diff --git a/biome.json b/biome.json index 17a9d15..20b4762 100644 --- a/biome.json +++ b/biome.json @@ -6,10 +6,10 @@ "rules": { "recommended": true }, - "includes": ["**", "!**/dist", "!**/*.bs.js", "!**/lib"] + "includes": ["**", "!**/dist", "!**/*.bs.js", "!**/lib", "!**/__generated__"] }, "formatter": { "enabled": true, - "includes": ["**", "!**/dist", "!**/*.bs.js", "!**/lib"] + "includes": ["**", "!**/dist", "!**/*.bs.js", "!**/lib", "!**/__generated__"] } } diff --git a/bun.lockb b/bun.lockb index c0ffb41..b339a3c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/mock-server/index.js b/mock-server/index.js new file mode 100644 index 0000000..2ca1c0c --- /dev/null +++ b/mock-server/index.js @@ -0,0 +1,94 @@ +import { createSchema, createYoga } from "graphql-yoga"; +import { createServer } from "node:http"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const typeDefs = readFileSync(join(__dirname, "schema.graphql"), "utf-8"); + +const users = [ + { + id: "user-1", + name: "Alice Chen", + email: "alice@example.com", + avatarUrl: null, + }, + { + id: "user-2", + name: "Bob Martinez", + email: "bob@example.com", + avatarUrl: null, + }, +]; + +let posts = [ + { + id: "post-1", + title: "Getting Started with ReScript", + body: "ReScript is a robustly typed language that compiles to efficient and human-readable JavaScript. It comes with a lightning fast compiler toolchain that scales to any codebase size.", + createdAt: "2026-04-10T10:00:00Z", + authorId: "user-1", + }, + { + id: "post-2", + title: "Building with Rspack", + body: "Rspack is a high performance JavaScript bundler written in Rust. It offers strong compatibility with the webpack ecosystem while providing significantly better build performance.", + createdAt: "2026-04-11T14:30:00Z", + authorId: "user-2", + }, + { + id: "post-3", + title: "Emotion CSS-in-JS Patterns", + body: "Emotion is a performant and flexible CSS-in-JS library. It allows you to style applications with string or object styles and has a predictable composition model.", + createdAt: "2026-04-12T09:15:00Z", + authorId: "user-1", + }, +]; + +let nextPostId = 4; + +const resolvers = { + Query: { + posts: () => posts, + post: (_, { id }) => posts.find((p) => p.id === id) || null, + me: () => users[0], + }, + Mutation: { + createPost: (_, { title, body }) => { + const post = { + id: `post-${nextPostId++}`, + title, + body, + createdAt: new Date().toISOString(), + authorId: "user-1", + }; + posts.unshift(post); + return post; + }, + }, + Post: { + author: (post) => users.find((u) => u.id === post.authorId), + }, + Node: { + __resolveType: (obj) => { + if (obj.email) return "User"; + if (obj.title) return "Post"; + return null; + }, + }, +}; + +const yoga = createYoga({ + schema: createSchema({ typeDefs, resolvers }), + cors: { + origin: "http://localhost:8080", + credentials: true, + }, +}); + +const server = createServer(yoga); + +server.listen(4000, () => { + console.log("GraphQL server running at http://localhost:4000/graphql"); +}); diff --git a/mock-server/schema.graphql b/mock-server/schema.graphql new file mode 100644 index 0000000..2939c32 --- /dev/null +++ b/mock-server/schema.graphql @@ -0,0 +1,28 @@ +type Query { + posts: [Post!]! + post(id: ID!): Post + me: User +} + +type Mutation { + createPost(title: String!, body: String!): Post! +} + +type User implements Node { + id: ID! + name: String! + email: String! + avatarUrl: String +} + +type Post implements Node { + id: ID! + title: String! + body: String! + createdAt: String! + author: User! +} + +interface Node { + id: ID! +} diff --git a/package.json b/package.json index cea072a..d73cdd9 100644 --- a/package.json +++ b/package.json @@ -1,57 +1,68 @@ { - "name": "rspack-rescript-react", - "private": true, - "version": "1.0.0", - "scripts": { - "dev": "concurrently \"bun run res:dev\" \"rspack serve\"", - "build": "cross-env NODE_ENV=production rspack build", - "res:build": "rescript build", - "res:dev": "rescript watch", - "res:clean": "rescript clean", -"prepare": "husky", - "commit": "git-cz", - "format:check": "biome check .", - "format:write": "biome format . --write", - "lint:check": "biome lint .", - "lint": "biome lint --write .", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "@emotion/css": "^11.13.5", - "@emotion/server": "^11.11.0", - "@rescript/core": "1.6.1", - "@rescript/react": "0.14.2", - "react": "^19.2.5", - "react-dom": "^19.2.5", - "rescript": "12.2.0" - }, - "devDependencies": { - "@biomejs/biome": "^2.4.11", - "@commitlint/cli": "^20.5.0", - "@commitlint/config-conventional": "^20.5.0", - "@commitlint/cz-commitlint": "^20.5.1", - "@rspack/cli": "1.7.11", - "@rspack/core": "^1.7.11", - "@rspack/plugin-react-refresh": "1.6.2", - "@svgr/webpack": "^8.1.0", - "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "commitizen": "^4.3.1", - "concurrently": "^9.2.1", - "cross-env": "^10.1.0", - "husky": "^9.1.7", - "inquirer": "^13.4.1", - "jsdom": "^29.0.2", - "typescript": "^6.0.2", - "vitest": "^4.1.4" - }, - "config": { - "commitizen": { - "path": "@commitlint/cz-commitlint" - } - } + "name": "rspack-rescript-react", + "private": true, + "version": "1.0.0", + "scripts": { + "dev": "concurrently \"bun run res:dev\" \"rspack serve\" \"bun run dev:server\"", + "dev:server": "node mock-server/index.js", + "build": "cross-env NODE_ENV=production rspack build", + "relay": "rescript-relay-compiler", + "relay:watch": "rescript-relay-compiler --watch", + "res:build": "rescript build", + "res:dev": "rescript watch", + "res:clean": "rescript clean", + "prepare": "husky", + "commit": "git-cz", + "format:check": "biome check .", + "format:write": "biome format . --write", + "lint:check": "biome lint .", + "lint": "biome lint --write .", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@emotion/css": "^11.13.5", + "@emotion/server": "^11.11.0", + "@rescript/core": "1.6.1", + "@rescript/react": "0.14.2", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-relay": "20.1.1", + "relay-runtime": "20.1.1", + "rescript": "12.2.0", + "rescript-relay": "4.4.1" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@commitlint/cli": "^20.5.0", + "@commitlint/config-conventional": "^20.5.0", + "@commitlint/cz-commitlint": "^20.5.1", + "@rspack/cli": "1.7.11", + "@rspack/core": "^1.7.11", + "@rspack/plugin-react-refresh": "1.6.2", + "@svgr/webpack": "^8.1.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "commitizen": "^4.3.1", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "graphql": "16.11.0", + "graphql-yoga": "5.12.0", + "husky": "^9.1.7", + "inquirer": "^13.4.1", + "jsdom": "^29.0.2", + "typescript": "^6.0.2", + "vitest": "^4.1.4" + }, + "config": { + "commitizen": { + "path": "@commitlint/cz-commitlint" + } + }, + "trustedDependencies": [ + "rescript-relay" + ] } diff --git a/relay.config.js b/relay.config.js new file mode 100644 index 0000000..b232fb1 --- /dev/null +++ b/relay.config.js @@ -0,0 +1,6 @@ +module.exports = { + src: "./src", + schema: "./mock-server/schema.graphql", + artifactDirectory: "./src/__generated__", + language: "rescript", +}; diff --git a/rescript.json b/rescript.json index dea9f76..8bb2d9a 100644 --- a/rescript.json +++ b/rescript.json @@ -13,7 +13,8 @@ } ], "suffix": ".bs.js", - "dependencies": ["@rescript/core", "@rescript/react"], + "dependencies": ["@rescript/core", "@rescript/react", "rescript-relay"], + "ppx-flags": ["rescript-relay/ppx"], "jsx": { "version": 4, "mode": "automatic" diff --git a/rspack.config.js b/rspack.config.js index f39caee..9eaaea0 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -12,6 +12,9 @@ module.exports = { "process.env.NODE_ENV": JSON.stringify( isProduction ? "production" : "development", ), + "process.env.GRAPHQL_ENDPOINT": JSON.stringify( + process.env.GRAPHQL_ENDPOINT || "http://localhost:4000/graphql", + ), }), !isProduction && new ReactRefreshPlugin(), ].filter(Boolean), diff --git a/src/App.res b/src/App.res index 27f4431..0b5b344 100644 --- a/src/App.res +++ b/src/App.res @@ -60,22 +60,24 @@ Emotion.Css.injectGlobal(` module App = { @react.component let make = () => { -
- -
- -
- -
+ +
+ +
+ +
+ +
+
} } diff --git a/src/__generated__/PostsPageQuery_graphql.res b/src/__generated__/PostsPageQuery_graphql.res new file mode 100644 index 0000000..88dbeb2 --- /dev/null +++ b/src/__generated__/PostsPageQuery_graphql.res @@ -0,0 +1,252 @@ +/* @sourceLoc PostsPage.res */ +/* @generated */ +%%raw("/* @generated */") +module Types = { + @@warning("-30") + + type rec response_posts_author = { + name: string, + } + and response_posts = { + author: response_posts_author, + body: string, + createdAt: string, + @live id: string, + title: string, + } + type response = { + posts: array, + } + @live + type rawResponse = response + @live + type variables = unit + @live + type refetchVariables = unit + @live let makeRefetchVariables = () => () +} + + +type queryRef + +module Internal = { + @live + let variablesConverter: dict>> = %raw( + json`{}` + ) + @live + let variablesConverterMap = () + @live + let convertVariables = v => v->RescriptRelay.convertObj( + variablesConverter, + variablesConverterMap, + None + ) + @live + type wrapResponseRaw + @live + let wrapResponseConverter: dict>> = %raw( + json`{}` + ) + @live + let wrapResponseConverterMap = () + @live + let convertWrapResponse = v => v->RescriptRelay.convertObj( + wrapResponseConverter, + wrapResponseConverterMap, + null + ) + @live + type responseRaw + @live + let responseConverter: dict>> = %raw( + json`{}` + ) + @live + let responseConverterMap = () + @live + let convertResponse = v => v->RescriptRelay.convertObj( + responseConverter, + responseConverterMap, + None + ) + type wrapRawResponseRaw = wrapResponseRaw + @live + let convertWrapRawResponse = convertWrapResponse + type rawResponseRaw = responseRaw + @live + let convertRawResponse = convertResponse + type rawPreloadToken<'response> = {source: Nullable.t>} + external tokenToRaw: queryRef => rawPreloadToken = "%identity" +} +module Utils = { + @@warning("-33") + open Types +} + +type relayOperationNode +type operationType = RescriptRelay.queryNode + + +let node: operationType = %raw(json` (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v1 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "title", + "storageKey": null +}, +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "body", + "storageKey": null +}, +v3 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null +}, +v4 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "PostsPageQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Post", + "kind": "LinkedField", + "name": "posts", + "plural": true, + "selections": [ + (v0/*: any*/), + (v1/*: any*/), + (v2/*: any*/), + (v3/*: any*/), + { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "author", + "plural": false, + "selections": [ + (v4/*: any*/) + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "PostsPageQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Post", + "kind": "LinkedField", + "name": "posts", + "plural": true, + "selections": [ + (v0/*: any*/), + (v1/*: any*/), + (v2/*: any*/), + (v3/*: any*/), + { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "author", + "plural": false, + "selections": [ + (v4/*: any*/), + (v0/*: any*/) + ], + "storageKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "140268a5af4c6d335c5ea434cf85de18", + "id": null, + "metadata": {}, + "name": "PostsPageQuery", + "operationKind": "query", + "text": "query PostsPageQuery {\n posts {\n id\n title\n body\n createdAt\n author {\n name\n id\n }\n }\n}\n" + } +}; +})() `) + +@live let load: ( + ~environment: RescriptRelay.Environment.t, + ~variables: Types.variables, + ~fetchPolicy: RescriptRelay.fetchPolicy=?, + ~fetchKey: string=?, + ~networkCacheConfig: RescriptRelay.cacheConfig=?, +) => queryRef = ( + ~environment, + ~variables, + ~fetchPolicy=?, + ~fetchKey=?, + ~networkCacheConfig=?, +) => + RescriptRelayReact.loadQuery( + environment, + node, + variables->Internal.convertVariables, + { + fetchKey, + fetchPolicy, + networkCacheConfig, + }, + ) + +@live +let queryRefToObservable = token => { + let raw = token->Internal.tokenToRaw + raw.source->Nullable.toOption +} + +@live +let queryRefToPromise = token => { + Promise.make((resolve, _reject) => { + switch token->queryRefToObservable { + | None => resolve(Error()) + | Some(o) => + open RescriptRelay.Observable + let _: subscription = o->subscribe(makeObserver(~complete=() => resolve(Ok()))) + } + }) +} diff --git a/src/__generated__/RelaySchemaAssets_graphql.res b/src/__generated__/RelaySchemaAssets_graphql.res new file mode 100644 index 0000000..49198ff --- /dev/null +++ b/src/__generated__/RelaySchemaAssets_graphql.res @@ -0,0 +1,31 @@ +/* @generated */ +@@warning("-30") + +@live @unboxed +type enum_RequiredFieldAction = + | NONE + | LOG + | THROW + | FutureAddedValue(string) + + +@live @unboxed +type enum_RequiredFieldAction_input = + | NONE + | LOG + | THROW + + +@live @unboxed +type enum_CatchFieldTo = + | NULL + | RESULT + | FutureAddedValue(string) + + +@live @unboxed +type enum_CatchFieldTo_input = + | NULL + | RESULT + + diff --git a/src/pages/PostsPage.res b/src/pages/PostsPage.res index 72f9b89..4843fa0 100644 --- a/src/pages/PostsPage.res +++ b/src/pages/PostsPage.res @@ -1,4 +1,101 @@ open Emotion.Css +open Emotion.Utils + +module PostsQuery = %relay(` + query PostsPageQuery { + posts { + id + title + body + createdAt + author { + name + } + } + } +`) + +module PostCard = { + module Typography = Typography.Typography + module Card = Card.Card + + @react.component + let make = (~title, ~body, ~authorName, ~createdAt) => { + + + {React.string(title)} + + + {React.string(body)} +
+ {React.string(authorName)} + {React.string(createdAt)} +
+
+
+ } +} + +module PostsList = { + module Typography = Typography.Typography + + @react.component + let make = () => { + let data = PostsQuery.use(~variables=()) + +
+ {data.posts + ->Array.map(post => { + + }) + ->React.array} +
+ } +} + +module ServerUnavailable = { + module Typography = Typography.Typography + module Card = Card.Card + + @react.component + let make = () => { + + + + {React.string("GraphQL Server Not Running")} + + + {React.string( + "The Posts page requires the mock GraphQL server. Start it locally with:", + )} + + + {React.string("bun run dev:server")} + + + + } +} module Typography = Typography.Typography @@ -8,14 +105,19 @@ let make = () => { "padding": "4rem 1rem", "maxWidth": "800px", "margin": "0 auto", - "textAlign": "center", })
{React.string("Posts")} - {React.string("Data fetching with Relay will be added here.")} + className={css({"marginTop": "0.5rem", "marginBottom": "2rem", "color": Color.textSecondary})}> + {React.string("Data fetched from the mock GraphQL server via Relay.")} + }> + {React.string("Loading posts...")} }> + + +
} diff --git a/src/relay/RelayEnv.res b/src/relay/RelayEnv.res new file mode 100644 index 0000000..2dac3f0 --- /dev/null +++ b/src/relay/RelayEnv.res @@ -0,0 +1,36 @@ +@val external fetch: (string, {..}) => promise<{..}> = "fetch" +@val external graphqlEndpoint: string = "process.env.GRAPHQL_ENDPOINT" + +let fetchQuery: RescriptRelay.Network.fetchFunctionPromise = async ( + operation, + variables, + _cacheConfig, + _uploadables, +) => { + let body = JSON.stringifyAny({ + "query": operation.text, + "variables": variables, + })->Option.getOr("{}") + + let response = await fetch( + graphqlEndpoint, + { + "method": "POST", + "headers": { + "content-type": "application/json", + "accept": "application/json", + }, + "body": body, + }, + ) + await response["json"]() +} + +let network = RescriptRelay.Network.makePromiseBased(~fetchFunction=fetchQuery) + +let environment = RescriptRelay.Environment.make( + ~network, + ~store=RescriptRelay.Store.make( + ~source=RescriptRelay.RecordSource.make(), + ), +)