From 63d55ca6643f53e34a2ca60a4fb2360ebcbec7c8 Mon Sep 17 00:00:00 2001 From: Dan Train Date: Sat, 31 Jan 2026 23:12:29 +0000 Subject: [PATCH 1/6] feat: update to GraphQL incremental delivery format (incrementalSpec v0.2) - Upgrade graphql to 17.0.0-alpha.9 - Use native experimentalExecuteIncrementally in loader-query - Update Accept header to use incrementalSpec=v0.2 - Handle pending/incremental/completed arrays with id tracking - Merge parent id into deferred data for Relay normalization --- apps/counter-app/app/lib/relay-environment.ts | 119 ++++++++- apps/counter-app/package.json | 4 +- apps/counter-app/schema.graphql | 2 +- apps/movie-app/app/lib/relay-environment.ts | 118 ++++++++- apps/movie-app/package.json | 3 +- apps/movie-app/schema.graphql | 2 +- .../app/lib/relay-environment.tsx | 119 ++++++++- apps/trellix-relay/package.json | 4 +- apps/trellix-relay/schema.graphql | 2 +- docs/getting-started.md | 80 +++++- package.json | 2 +- packages/server/package.json | 5 +- packages/server/src/loader-query.ts | 117 +++++++-- pnpm-lock.yaml | 232 +++++++++--------- 14 files changed, 638 insertions(+), 171 deletions(-) diff --git a/apps/counter-app/app/lib/relay-environment.ts b/apps/counter-app/app/lib/relay-environment.ts index edb75f8..0149681 100644 --- a/apps/counter-app/app/lib/relay-environment.ts +++ b/apps/counter-app/app/lib/relay-environment.ts @@ -25,6 +25,46 @@ import { trackPromise } from "~/components/Progress"; const isServer = typeof document === "undefined"; const tabId = isServer ? null : createId(); +// Types for new June 2023 incremental delivery format +interface PendingResult { + id: string; + path: ReadonlyArray; + label?: string; +} + +interface IncrementalResult { + id: string; + data?: Record; + items?: unknown[]; + subPath?: ReadonlyArray; + errors?: unknown[]; +} + +interface IncrementalResponse { + data?: Record; + pending?: PendingResult[]; + incremental?: IncrementalResult[]; + completed?: { id: string }[]; + hasNext?: boolean; + errors?: { message: string }[]; + // Old format fields + path?: ReadonlyArray; + label?: string; +} + +// Helper to get an object at a given path in the data tree +function getAtPath( + data: Record, + path: ReadonlyArray, +): Record | undefined { + let current: unknown = data; + for (const key of path) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[key]; + } + return current as Record | undefined; +} + const fetchFn: FetchFunction = ( params: RequestParameters, variables: Variables, @@ -34,12 +74,17 @@ const fetchFn: FetchFunction = ( getCachedResponse(params, variables, cacheConfig) ?? Observable.create((sink) => { const fetchGraphQL = async () => { + // Track pending items by id for new format + const pendingById = new Map(); + // Store initial data for merging id into incremental results + let initialData: Record | undefined; + try { const response = await fetch("/graphql", { method: "POST", headers: { "Content-Type": "application/json", - Accept: "multipart/mixed; deferSpec=20220824, application/json", + Accept: "multipart/mixed; incrementalSpec=v0.2, application/json", }, body: JSON.stringify({ query: params.text, @@ -64,14 +109,74 @@ const fetchFn: FetchFunction = ( sink.next(result); } else { for await (const part of parts) { - if (part.body.errors) { - throw new Error(part.body.errors?.[0]?.message); + const body = part.body as IncrementalResponse; + + if (body.errors) { + throw new Error(body.errors?.[0]?.message); } - sink.next({ - ...part.body, - ...part.body?.incremental?.[0], - }); + // Check if this is new format (has pending array) + if (body.pending) { + // Track pending items for later path resolution + for (const pending of body.pending) { + pendingById.set(pending.id, pending); + } + } + + // Handle incremental results + if (body.incremental && body.incremental.length > 0) { + for (const inc of body.incremental) { + // Support both old format (path directly on inc) and new format (id + pending lookup) + type OldFormatInc = IncrementalResult & { + path?: ReadonlyArray; + label?: string; + }; + const oldInc = inc as OldFormatInc; + + let fullPath: ReadonlyArray; + let label: string | undefined; + + if (oldInc.path !== undefined) { + // Old format - path and label are directly on the incremental result + fullPath = oldInc.path; + label = oldInc.label; + } else { + // New format - look up path from pending by id + const pending = pendingById.get(inc.id); + const basePath = pending?.path ?? []; + const subPath = inc.subPath ?? []; + fullPath = [...basePath, ...subPath]; + label = pending?.label; + } + + // Relay needs the parent object's `id` to properly normalize deferred data. + // GraphQL-js de-duplicates fields, so `id` may only be in the initial response. + // We merge the parent's `id` from the initial data into the incremental result. + let mergedData = inc.data; + if (initialData && inc.data) { + const parentData = getAtPath(initialData, fullPath); + if (parentData?.id !== undefined && !("id" in inc.data)) { + mergedData = { id: parentData.id, ...inc.data }; + } + } + + // Transform to Relay-compatible format with path + sink.next({ + data: mergedData, + path: [...fullPath], + label, + hasNext: body.hasNext, + } as Parameters[0]); + } + } else if (body.data !== undefined) { + // Initial response - store data for later id merging + initialData = body.data; + // Pass through to Relay + sink.next(body as Parameters[0]); + } else if (body.path !== undefined) { + // Old format incremental result - pass through + sink.next(body as Parameters[0]); + } } } } catch (err) { diff --git a/apps/counter-app/package.json b/apps/counter-app/package.json index a74f310..4522e47 100644 --- a/apps/counter-app/package.json +++ b/apps/counter-app/package.json @@ -16,7 +16,7 @@ "write-graphql-schema": "tsx ./scripts/write-graphql-schema.ts" }, "dependencies": { - "@apollo/server": "5.0.0", + "@apollo/server": "^5.1.0", "@as-integrations/express5": "^1.1.2", "@paralleldrive/cuid2": "^3.3.0", "@pothos/core": "^4.12.0", @@ -34,7 +34,7 @@ "cookie-parser": "^1.4.7", "cors": "^2.8.6", "express": "^5.2.1", - "graphql": "17.0.0-alpha.2", + "graphql": "17.0.0-alpha.9", "graphql-relay": "^0.10.2", "graphql-subscriptions": "^3.0.0", "graphql-ws": "^6.0.7", diff --git a/apps/counter-app/schema.graphql b/apps/counter-app/schema.graphql index b367dec..4c9a58a 100644 --- a/apps/counter-app/schema.graphql +++ b/apps/counter-app/schema.graphql @@ -17,7 +17,7 @@ directive @stream( if: Boolean! = true """Number of items to return immediately""" - initialCount: Int = 0 + initialCount: Int! = 0 """Unique name""" label: String diff --git a/apps/movie-app/app/lib/relay-environment.ts b/apps/movie-app/app/lib/relay-environment.ts index 51c8b4c..90dcb4e 100644 --- a/apps/movie-app/app/lib/relay-environment.ts +++ b/apps/movie-app/app/lib/relay-environment.ts @@ -16,6 +16,45 @@ import { getCachedResponse } from "@remix-relay/react"; const isServer = typeof document === "undefined"; +// Types for new June 2023 incremental delivery format +interface PendingResult { + id: string; + path: ReadonlyArray; + label?: string; +} + +interface IncrementalResult { + id: string; + data?: Record; + items?: unknown[]; + subPath?: ReadonlyArray; + errors?: unknown[]; +} + +interface IncrementalResponse { + data?: Record; + pending?: PendingResult[]; + incremental?: IncrementalResult[]; + completed?: { id: string }[]; + hasNext?: boolean; + // Old format fields + path?: ReadonlyArray; + label?: string; +} + +// Helper to get an object at a given path in the data tree +function getAtPath( + data: Record, + path: ReadonlyArray, +): Record | undefined { + let current: unknown = data; + for (const key of path) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[key]; + } + return current as Record | undefined; +} + const fetchFn: FetchFunction = ( params: RequestParameters, variables: Variables, @@ -25,12 +64,17 @@ const fetchFn: FetchFunction = ( getCachedResponse(params, variables, cacheConfig) ?? Observable.create((sink) => { const fetchGraphQL = async () => { + // Track pending items by id for new format + const pendingById = new Map(); + // Store initial data for merging id into incremental results + let initialData: Record | undefined; + try { const response = await fetch("/graphql", { method: "POST", headers: { "Content-Type": "application/json", - Accept: "multipart/mixed; deferSpec=20220824, application/json", + Accept: "multipart/mixed; incrementalSpec=v0.2, application/json", }, body: JSON.stringify({ doc_id: params.id, @@ -48,14 +92,72 @@ const fetchFn: FetchFunction = ( json: boolean; body: unknown; }>) { - const data = part.json - ? { - ...(part.body as object), - ...((part.body as { incremental?: object[] }) - ?.incremental?.[0] ?? {}), + const body = part.json + ? (part.body as IncrementalResponse) + : (JSON.parse(part.body as string) as IncrementalResponse); + + // Check if this is new format (has pending array) + if (body.pending) { + // Track pending items for later path resolution + for (const pending of body.pending) { + pendingById.set(pending.id, pending); + } + } + + // Handle incremental results + if (body.incremental && body.incremental.length > 0) { + for (const inc of body.incremental) { + // Support both old format (path directly on inc) and new format (id + pending lookup) + type OldFormatInc = IncrementalResult & { + path?: ReadonlyArray; + label?: string; + }; + const oldInc = inc as OldFormatInc; + + let fullPath: ReadonlyArray; + let label: string | undefined; + + if (oldInc.path !== undefined) { + // Old format - path and label are directly on the incremental result + fullPath = oldInc.path; + label = oldInc.label; + } else { + // New format - look up path from pending by id + const pending = pendingById.get(inc.id); + const basePath = pending?.path ?? []; + const subPath = inc.subPath ?? []; + fullPath = [...basePath, ...subPath]; + label = pending?.label; + } + + // Relay needs the parent object's `id` to properly normalize deferred data. + // GraphQL-js de-duplicates fields, so `id` may only be in the initial response. + // We merge the parent's `id` from the initial data into the incremental result. + let mergedData = inc.data; + if (initialData && inc.data) { + const parentData = getAtPath(initialData, fullPath); + if (parentData?.id !== undefined && !("id" in inc.data)) { + mergedData = { id: parentData.id, ...inc.data }; + } } - : JSON.parse(part.body as string); - sink.next(data); + + // Transform to Relay-compatible format with path + sink.next({ + data: mergedData, + path: [...fullPath], + label, + hasNext: body.hasNext, + } as Parameters[0]); + } + } else if (body.data !== undefined) { + // Initial response - store data for later id merging + initialData = body.data; + // Pass through to Relay + sink.next(body as Parameters[0]); + } else if (body.path !== undefined) { + // Old format incremental result - pass through + sink.next(body as Parameters[0]); + } } } } catch (err) { diff --git a/apps/movie-app/package.json b/apps/movie-app/package.json index 310bf97..74009ad 100644 --- a/apps/movie-app/package.json +++ b/apps/movie-app/package.json @@ -19,7 +19,6 @@ "write-graphql-schema": "tsx ./scripts/write-graphql-schema.ts" }, "dependencies": { - "@graphql-tools/executor": "^1.5.1", "@graphql-yoga/plugin-defer-stream": "^3.18.0", "@graphql-yoga/plugin-persisted-operations": "^3.18.0", "@pothos/core": "^4.12.0", @@ -30,7 +29,7 @@ "@remix-relay/ui": "workspace:*", "class-variance-authority": "^0.7.1", "drizzle-orm": "^0.45.1", - "graphql": "17.0.0-alpha.2", + "graphql": "17.0.0-alpha.9", "graphql-yoga": "^5.18.0", "isbot": "^5.1.34", "lodash-es": "^4.17.23", diff --git a/apps/movie-app/schema.graphql b/apps/movie-app/schema.graphql index 7d199d9..d9497ec 100644 --- a/apps/movie-app/schema.graphql +++ b/apps/movie-app/schema.graphql @@ -17,7 +17,7 @@ directive @stream( if: Boolean! = true """Number of items to return immediately""" - initialCount: Int = 0 + initialCount: Int! = 0 """Unique name""" label: String diff --git a/apps/trellix-relay/app/lib/relay-environment.tsx b/apps/trellix-relay/app/lib/relay-environment.tsx index 57aa086..1957c35 100644 --- a/apps/trellix-relay/app/lib/relay-environment.tsx +++ b/apps/trellix-relay/app/lib/relay-environment.tsx @@ -26,6 +26,46 @@ import { trackPromise } from "~/components/Progress"; const isServer = typeof document === "undefined"; const tabId = isServer ? null : createId(); +// Types for new June 2023 incremental delivery format +interface PendingResult { + id: string; + path: ReadonlyArray; + label?: string; +} + +interface IncrementalResult { + id: string; + data?: Record; + items?: unknown[]; + subPath?: ReadonlyArray; + errors?: unknown[]; +} + +interface IncrementalResponse { + data?: Record; + pending?: PendingResult[]; + incremental?: IncrementalResult[]; + completed?: { id: string }[]; + hasNext?: boolean; + errors?: { message: string }[]; + // Old format fields + path?: ReadonlyArray; + label?: string; +} + +// Helper to get an object at a given path in the data tree +function getAtPath( + data: Record, + path: ReadonlyArray, +): Record | undefined { + let current: unknown = data; + for (const key of path) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[key]; + } + return current as Record | undefined; +} + const fetchFn: FetchFunction = ( params: RequestParameters, variables: Variables, @@ -35,12 +75,17 @@ const fetchFn: FetchFunction = ( getCachedResponse(params, variables, cacheConfig) ?? Observable.create((sink) => { const fetchGraphQL = async () => { + // Track pending items by id for new format + const pendingById = new Map(); + // Store initial data for merging id into incremental results + let initialData: Record | undefined; + try { const response = await fetch("/graphql", { method: "POST", headers: { "Content-Type": "application/json", - Accept: "multipart/mixed; deferSpec=20220824, application/json", + Accept: "multipart/mixed; incrementalSpec=v0.2, application/json", }, body: JSON.stringify({ query: params.text, @@ -65,14 +110,74 @@ const fetchFn: FetchFunction = ( sink.next(result); } else { for await (const part of parts) { - if (part.body.errors) { - throw new Error(part.body.errors?.[0]?.message); + const body = part.body as IncrementalResponse; + + if (body.errors) { + throw new Error(body.errors?.[0]?.message); } - sink.next({ - ...part.body, - ...part.body?.incremental?.[0], - }); + // Check if this is new format (has pending array) + if (body.pending) { + // Track pending items for later path resolution + for (const pending of body.pending) { + pendingById.set(pending.id, pending); + } + } + + // Handle incremental results + if (body.incremental && body.incremental.length > 0) { + for (const inc of body.incremental) { + // Support both old format (path directly on inc) and new format (id + pending lookup) + type OldFormatInc = IncrementalResult & { + path?: ReadonlyArray; + label?: string; + }; + const oldInc = inc as OldFormatInc; + + let fullPath: ReadonlyArray; + let label: string | undefined; + + if (oldInc.path !== undefined) { + // Old format - path and label are directly on the incremental result + fullPath = oldInc.path; + label = oldInc.label; + } else { + // New format - look up path from pending by id + const pending = pendingById.get(inc.id); + const basePath = pending?.path ?? []; + const subPath = inc.subPath ?? []; + fullPath = [...basePath, ...subPath]; + label = pending?.label; + } + + // Relay needs the parent object's `id` to properly normalize deferred data. + // GraphQL-js de-duplicates fields, so `id` may only be in the initial response. + // We merge the parent's `id` from the initial data into the incremental result. + let mergedData = inc.data; + if (initialData && inc.data) { + const parentData = getAtPath(initialData, fullPath); + if (parentData?.id !== undefined && !("id" in inc.data)) { + mergedData = { id: parentData.id, ...inc.data }; + } + } + + // Transform to Relay-compatible format with path + sink.next({ + data: mergedData, + path: [...fullPath], + label, + hasNext: body.hasNext, + } as Parameters[0]); + } + } else if (body.data !== undefined) { + // Initial response - store data for later id merging + initialData = body.data; + // Pass through to Relay + sink.next(body as Parameters[0]); + } else if (body.path !== undefined) { + // Old format incremental result - pass through + sink.next(body as Parameters[0]); + } } } } catch (err) { diff --git a/apps/trellix-relay/package.json b/apps/trellix-relay/package.json index 61a7c96..9b9edec 100644 --- a/apps/trellix-relay/package.json +++ b/apps/trellix-relay/package.json @@ -19,7 +19,7 @@ "write-graphql-schema": "tsx ./scripts/write-graphql-schema.ts" }, "dependencies": { - "@apollo/server": "5.0.0", + "@apollo/server": "^5.1.0", "@as-integrations/express5": "^1.1.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -46,7 +46,7 @@ "cors": "^2.8.6", "drizzle-orm": "^0.45.1", "express": "^5.2.1", - "graphql": "17.0.0-alpha.2", + "graphql": "17.0.0-alpha.9", "graphql-relay": "^0.10.2", "graphql-subscriptions": "^3.0.0", "graphql-ws": "^6.0.7", diff --git a/apps/trellix-relay/schema.graphql b/apps/trellix-relay/schema.graphql index 4e89208..cf04695 100644 --- a/apps/trellix-relay/schema.graphql +++ b/apps/trellix-relay/schema.graphql @@ -17,7 +17,7 @@ directive @stream( if: Boolean! = true """Number of items to return immediately""" - initialCount: Int = 0 + initialCount: Int! = 0 """Unique name""" label: String diff --git a/docs/getting-started.md b/docs/getting-started.md index fef7da2..c748718 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -27,7 +27,7 @@ pnpm create react-router@latest --template remix-run/react-router-templates/node For this guide we'll use [Pothos](https://pothos-graphql.dev/) to create the GraphQL schema. ```shell -pnpm add @pothos/core graphql@17.0.0-alpha.2 +pnpm add @pothos/core graphql@17.0.0-alpha.9 ``` > [!NOTE] @@ -204,6 +204,40 @@ import { } from "relay-runtime"; import { getCachedResponse } from "@remix-relay/react"; +// Types for incremental delivery format +interface PendingResult { + id: string; + path: ReadonlyArray; + label?: string; +} + +interface IncrementalResult { + id: string; + data?: Record; + subPath?: ReadonlyArray; +} + +interface IncrementalResponse { + data?: Record; + pending?: PendingResult[]; + incremental?: IncrementalResult[]; + completed?: { id: string }[]; + hasNext?: boolean; +} + +// Helper to get an object at a given path in the data tree +function getAtPath( + data: Record, + path: ReadonlyArray, +): Record | undefined { + let current: unknown = data; + for (const key of path) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[key]; + } + return current as Record | undefined; +} + const fetchFn: FetchFunction = ( params: RequestParameters, variables: Variables, @@ -213,12 +247,15 @@ const fetchFn: FetchFunction = ( getCachedResponse(params, variables, cacheConfig) ?? Observable.create((sink) => { const fetchGraphQL = async () => { + const pendingById = new Map(); + let initialData: Record | undefined; + try { const response = await fetch("/graphql", { method: "POST", headers: { "Content-Type": "application/json", - Accept: "multipart/mixed; deferSpec=20220824, application/json", + Accept: "multipart/mixed; incrementalSpec=v0.2, application/json", }, body: JSON.stringify({ query: params.text, @@ -232,10 +269,41 @@ const fetchFn: FetchFunction = ( sink.next(await parts.json()); } else { for await (const part of parts) { - sink.next({ - ...part.body, - ...part.body?.incremental?.[0], - }); + const body = part.body as IncrementalResponse; + + if (body.pending) { + for (const pending of body.pending) { + pendingById.set(pending.id, pending); + } + } + + if (body.incremental && body.incremental.length > 0) { + for (const inc of body.incremental) { + const pending = pendingById.get(inc.id); + const basePath = pending?.path ?? []; + const subPath = inc.subPath ?? []; + const fullPath = [...basePath, ...subPath]; + + // Merge parent's id for Relay normalization + let mergedData = inc.data; + if (initialData && inc.data) { + const parentData = getAtPath(initialData, fullPath); + if (parentData?.id !== undefined && !("id" in inc.data)) { + mergedData = { id: parentData.id, ...inc.data }; + } + } + + sink.next({ + data: mergedData, + path: fullPath, + label: pending?.label, + hasNext: body.hasNext, + } as Parameters[0]); + } + } else if (body.data !== undefined) { + initialData = body.data; + sink.next(body as Parameters[0]); + } } } } finally { diff --git a/package.json b/package.json index 6f4dbc0..3b2fb5f 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "pnpm": { "peerDependencyRules": { "allowedVersions": { - "graphql": "17.0.0-alpha.2" + "graphql": "17.0.0-alpha.9" } } } diff --git a/packages/server/package.json b/packages/server/package.json index 3894f76..2e5956f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -46,7 +46,6 @@ "typecheck": "tsc -b --noEmit" }, "dependencies": { - "@graphql-tools/executor": "^1.5.1", "tiny-invariant": "^1.3.3" }, "devDependencies": { @@ -56,14 +55,14 @@ "@types/node": "^25.1.0", "@types/relay-runtime": "^20.1.1", "eslint": "^9.39.0", - "graphql": "17.0.0-alpha.2", + "graphql": "17.0.0-alpha.9", "jiti": "^2.6.0", "relay-runtime": "^20.1.1", "tsup": "^8.5.1", "typescript": "~5.9.3" }, "peerDependencies": { - "graphql": "17.0.0-alpha.2", + "graphql": "17.0.0-alpha.9", "relay-runtime": ">=16" } } diff --git a/packages/server/src/loader-query.ts b/packages/server/src/loader-query.ts index b2c42e0..88750fd 100644 --- a/packages/server/src/loader-query.ts +++ b/packages/server/src/loader-query.ts @@ -1,9 +1,10 @@ -import { execute } from "@graphql-tools/executor"; import { - FormattedExecutionResult, + ExecutionResult, + experimentalExecuteIncrementally, + ExperimentalIncrementalExecutionResults, GraphQLSchema, InitialIncrementalExecutionResult, - SubsequentIncrementalExecutionResult, + IncrementalDeferResult, parse, } from "graphql"; import type { @@ -16,10 +17,38 @@ import type { import { PayloadExtensions } from "relay-runtime/lib/network/RelayNetworkTypes"; import invariant from "tiny-invariant"; +// PendingResult is not exported from graphql, so we define it here +interface PendingResult { + id: string; + path: ReadonlyArray; + label?: string; +} + +// Helper to get an object at a given path in the data tree +function getAtPath( + data: Record, + path: ReadonlyArray, +): Record | undefined { + let current: unknown = data; + for (const key of path) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[key]; + } + return current as Record | undefined; +} + function isConcreteRequest(node: GraphQLTaggedNode): node is ConcreteRequest { return (node as ConcreteRequest).params !== undefined; } +function isIncrementalResult( + result: + | ExecutionResult + | ExperimentalIncrementalExecutionResults, +): result is ExperimentalIncrementalExecutionResults { + return "initialResult" in result; +} + export type SerializablePreloadedQuery< TQuery extends OperationType, TResponse, @@ -34,13 +63,22 @@ export type LoaderQueryArgs = [ variables: VariablesOf, ]; +// Response format for deferred chunks - includes path for Relay compatibility +type DeferredResponse = { + hasNext: boolean; + data?: TData; + path?: ReadonlyArray; + label?: string; + extensions?: TExtensions; +}; + type LoaderQuery = ( ...args: LoaderQueryArgs ) => Promise< | { preloadedQuery: SerializablePreloadedQuery< TQuery, - FormattedExecutionResult + ExecutionResult >; deferredQueries: null; } @@ -52,10 +90,7 @@ type LoaderQuery = ( deferredQueries: Promise< SerializablePreloadedQuery< TQuery, - SubsequentIncrementalExecutionResult< - TQuery["response"], - PayloadExtensions - > + DeferredResponse >[] >; } @@ -79,14 +114,14 @@ export const getLoaderQuery = ( const document = parse(queryString); - const result = await execute({ + const result = await experimentalExecuteIncrementally({ schema, document, variableValues: variables, contextValue: context, }); - if (!("initialResult" in result)) { + if (!isIncrementalResult(result)) { if (result.errors?.length) { throw new Response(null, { status: 404, @@ -109,20 +144,74 @@ export const getLoaderQuery = ( response: result.initialResult, }; + // Track pending items by id to resolve paths + const pendingById = new Map(); + for (const pending of result.initialResult.pending) { + pendingById.set(pending.id, pending); + } + const deferredQueries = (async () => { - const chunks = []; + const chunks: DeferredResponse[] = + []; for await (const chunk of result.subsequentResults) { - chunks.push(chunk); + // Track any new pending items + if (chunk.pending) { + for (const pending of chunk.pending) { + pendingById.set(pending.id, pending); + } + } + + // Process incremental results + if (chunk.incremental) { + for (const inc of chunk.incremental) { + // Get the pending item to resolve the path + const pending = pendingById.get(inc.id); + const basePath = pending?.path ?? []; + const subPath = (inc as IncrementalDeferResult).subPath ?? []; + const fullPath = [...basePath, ...subPath]; + + // Get the incremental data + const incData = (inc as IncrementalDeferResult).data as Record< + string, + unknown + >; + + // Relay needs the parent object's `id` to properly normalize deferred data. + // GraphQL-js de-duplicates fields, so `id` may only be in the initial result. + // We merge the parent's `id` from the initial data into the incremental result. + const parentData = getAtPath( + result.initialResult.data as Record, + fullPath, + ); + const mergedData = + parentData?.id !== undefined + ? { id: parentData.id, ...incData } + : incData; + + // Transform to Relay-compatible format with path + const transformed: DeferredResponse< + TQuery["response"], + PayloadExtensions + > = { + hasNext: chunk.hasNext, + data: mergedData as TQuery["response"], + path: fullPath, + label: pending?.label, + }; + + chunks.push(transformed); + } + } } - return chunks.map(({ incremental, ...rest }, index) => ({ + return chunks.map((response, index) => ({ params: { ...node.params, cacheID: `${node.params.id ?? node.params.cacheID}-${index}`, }, variables, - response: { ...rest, ...incremental?.[0] }, + response, })); })(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89b698a..319535f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,23 +48,23 @@ importers: apps/counter-app: dependencies: '@apollo/server': - specifier: 5.0.0 - version: 5.0.0(graphql@17.0.0-alpha.2) + specifier: ^5.1.0 + version: 5.3.0(graphql@17.0.0-alpha.9) '@as-integrations/express5': specifier: ^1.1.2 - version: 1.1.2(@apollo/server@5.0.0(graphql@17.0.0-alpha.2))(express@5.2.1) + version: 1.1.2(@apollo/server@5.3.0(graphql@17.0.0-alpha.9))(express@5.2.1) '@paralleldrive/cuid2': specifier: ^3.3.0 version: 3.3.0 '@pothos/core': specifier: ^4.12.0 - version: 4.12.0(graphql@17.0.0-alpha.2) + version: 4.12.0(graphql@17.0.0-alpha.9) '@pothos/plugin-relay': specifier: ^4.6.2 - version: 4.6.2(@pothos/core@4.12.0(graphql@17.0.0-alpha.2))(graphql@17.0.0-alpha.2) + version: 4.6.2(@pothos/core@4.12.0(graphql@17.0.0-alpha.9))(graphql@17.0.0-alpha.9) '@pothos/plugin-zod': specifier: ^4.3.0 - version: 4.3.0(@pothos/core@4.12.0(graphql@17.0.0-alpha.2))(graphql@17.0.0-alpha.2)(zod@4.3.6) + version: 4.3.0(@pothos/core@4.12.0(graphql@17.0.0-alpha.9))(graphql@17.0.0-alpha.9)(zod@4.3.6) '@react-router/express': specifier: ^7.13.0 version: 7.13.0(express@5.2.1)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) @@ -102,17 +102,17 @@ importers: specifier: ^5.2.1 version: 5.2.1 graphql: - specifier: 17.0.0-alpha.2 - version: 17.0.0-alpha.2 + specifier: 17.0.0-alpha.9 + version: 17.0.0-alpha.9 graphql-relay: specifier: ^0.10.2 - version: 0.10.2(graphql@17.0.0-alpha.2) + version: 0.10.2(graphql@17.0.0-alpha.9) graphql-subscriptions: specifier: ^3.0.0 - version: 3.0.0(graphql@17.0.0-alpha.2) + version: 3.0.0(graphql@17.0.0-alpha.9) graphql-ws: specifier: ^6.0.7 - version: 6.0.7(graphql@17.0.0-alpha.2)(ws@8.19.0) + version: 6.0.7(graphql@17.0.0-alpha.9)(ws@8.19.0) isbot: specifier: ^5.1.34 version: 5.1.34 @@ -255,21 +255,18 @@ importers: apps/movie-app: dependencies: - '@graphql-tools/executor': - specifier: ^1.5.1 - version: 1.5.1(graphql@17.0.0-alpha.2) '@graphql-yoga/plugin-defer-stream': specifier: ^3.18.0 - version: 3.18.0(graphql-yoga@5.18.0(graphql@17.0.0-alpha.2))(graphql@17.0.0-alpha.2) + version: 3.18.0(graphql-yoga@5.18.0(graphql@17.0.0-alpha.9))(graphql@17.0.0-alpha.9) '@graphql-yoga/plugin-persisted-operations': specifier: ^3.18.0 - version: 3.18.0(graphql-yoga@5.18.0(graphql@17.0.0-alpha.2))(graphql@17.0.0-alpha.2) + version: 3.18.0(graphql-yoga@5.18.0(graphql@17.0.0-alpha.9))(graphql@17.0.0-alpha.9) '@pothos/core': specifier: ^4.12.0 - version: 4.12.0(graphql@17.0.0-alpha.2) + version: 4.12.0(graphql@17.0.0-alpha.9) '@pothos/plugin-relay': specifier: ^4.6.2 - version: 4.6.2(@pothos/core@4.12.0(graphql@17.0.0-alpha.2))(graphql@17.0.0-alpha.2) + version: 4.6.2(@pothos/core@4.12.0(graphql@17.0.0-alpha.9))(graphql@17.0.0-alpha.9) '@react-router/cloudflare': specifier: ^7.13.0 version: 7.13.0(@cloudflare/workers-types@4.20260131.0)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) @@ -289,11 +286,11 @@ importers: specifier: ^0.45.1 version: 0.45.1(@cloudflare/workers-types@4.20260131.0)(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.2)(postgres@3.4.8) graphql: - specifier: 17.0.0-alpha.2 - version: 17.0.0-alpha.2 + specifier: 17.0.0-alpha.9 + version: 17.0.0-alpha.9 graphql-yoga: specifier: ^5.18.0 - version: 5.18.0(graphql@17.0.0-alpha.2) + version: 5.18.0(graphql@17.0.0-alpha.9) isbot: specifier: ^5.1.34 version: 5.1.34 @@ -434,11 +431,11 @@ importers: apps/trellix-relay: dependencies: '@apollo/server': - specifier: 5.0.0 - version: 5.0.0(graphql@17.0.0-alpha.2) + specifier: ^5.1.0 + version: 5.3.0(graphql@17.0.0-alpha.9) '@as-integrations/express5': specifier: ^1.1.2 - version: 1.1.2(@apollo/server@5.0.0(graphql@17.0.0-alpha.2))(express@5.2.1) + version: 1.1.2(@apollo/server@5.3.0(graphql@17.0.0-alpha.9))(express@5.2.1) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -459,13 +456,13 @@ importers: version: 3.3.0 '@pothos/core': specifier: ^4.12.0 - version: 4.12.0(graphql@17.0.0-alpha.2) + version: 4.12.0(graphql@17.0.0-alpha.9) '@pothos/plugin-relay': specifier: ^4.6.2 - version: 4.6.2(@pothos/core@4.12.0(graphql@17.0.0-alpha.2))(graphql@17.0.0-alpha.2) + version: 4.6.2(@pothos/core@4.12.0(graphql@17.0.0-alpha.9))(graphql@17.0.0-alpha.9) '@pothos/plugin-zod': specifier: ^4.3.0 - version: 4.3.0(@pothos/core@4.12.0(graphql@17.0.0-alpha.2))(graphql@17.0.0-alpha.2)(zod@4.3.6) + version: 4.3.0(@pothos/core@4.12.0(graphql@17.0.0-alpha.9))(graphql@17.0.0-alpha.9)(zod@4.3.6) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -515,17 +512,17 @@ importers: specifier: ^5.2.1 version: 5.2.1 graphql: - specifier: 17.0.0-alpha.2 - version: 17.0.0-alpha.2 + specifier: 17.0.0-alpha.9 + version: 17.0.0-alpha.9 graphql-relay: specifier: ^0.10.2 - version: 0.10.2(graphql@17.0.0-alpha.2) + version: 0.10.2(graphql@17.0.0-alpha.9) graphql-subscriptions: specifier: ^3.0.0 - version: 3.0.0(graphql@17.0.0-alpha.2) + version: 3.0.0(graphql@17.0.0-alpha.9) graphql-ws: specifier: ^6.0.7 - version: 6.0.7(graphql@17.0.0-alpha.2)(ws@8.19.0) + version: 6.0.7(graphql@17.0.0-alpha.9)(ws@8.19.0) isbot: specifier: ^5.1.34 version: 5.1.34 @@ -816,9 +813,6 @@ importers: packages/server: dependencies: - '@graphql-tools/executor': - specifier: ^1.5.1 - version: 1.5.1(graphql@17.0.0-alpha.2) tiny-invariant: specifier: ^1.3.3 version: 1.3.3 @@ -842,8 +836,8 @@ importers: specifier: ^9.39.0 version: 9.39.2(jiti@2.6.1) graphql: - specifier: 17.0.0-alpha.2 - version: 17.0.0-alpha.2 + specifier: 17.0.0-alpha.9 + version: 17.0.0-alpha.9 jiti: specifier: ^2.6.0 version: 2.6.1 @@ -956,8 +950,8 @@ packages: peerDependencies: graphql: 14.x || 15.x || 16.x - '@apollo/server@5.0.0': - resolution: {integrity: sha512-PHopOm7pr69k7eDJvCBU4cZy9Z19qyCFKB9/luLnf2YCatu2WOYhoQPNr3dAoe//xv0RZFhxXbRcnK6IXIP7Nw==} + '@apollo/server@5.3.0': + resolution: {integrity: sha512-ixchCUA38gjB7k1eGU2fra3eUhGyvFhMsKAr72+DaCRl9NhzXf3V4EVlVdiyS6qrR8xWQ+IdZlj2lb52dkqj+A==} engines: {node: '>=20'} peerDependencies: graphql: ^16.11.0 @@ -5849,6 +5843,10 @@ packages: resolution: {integrity: sha512-aRAd/BQ5hSO0+l7x+sHBfJVUp2JUOjPTE/iwJ3BhtYNH/MC7n4gjlZbKvnBVFZZAczyMS3vezS4teEZivoqIzw==} engines: {node: ^14.19.0 || ^16.10.0 || >=18.0.0} + graphql@17.0.0-alpha.9: + resolution: {integrity: sha512-jVK1BsvX5pUIEpRDlEgeKJr80GAxl3B8ISsFDjXHtl2xAxMXVGTEFF4Q4R8NH0Gw7yMwcHDndkNjoNT5CbwHKA==} + engines: {node: ^16.19.0 || ^18.14.0 || >=19.7.0} + handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -8379,9 +8377,9 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - '@apollo/cache-control-types@1.0.3(graphql@17.0.0-alpha.2)': + '@apollo/cache-control-types@1.0.3(graphql@17.0.0-alpha.9)': dependencies: - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 '@apollo/protobufjs@1.2.7': dependencies: @@ -8398,32 +8396,32 @@ snapshots: '@types/long': 4.0.2 long: 4.0.0 - '@apollo/server-gateway-interface@2.0.0(graphql@17.0.0-alpha.2)': + '@apollo/server-gateway-interface@2.0.0(graphql@17.0.0-alpha.9)': dependencies: '@apollo/usage-reporting-protobuf': 4.1.1 '@apollo/utils.fetcher': 3.1.0 '@apollo/utils.keyvaluecache': 4.0.0 '@apollo/utils.logger': 3.0.0 - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 - '@apollo/server@5.0.0(graphql@17.0.0-alpha.2)': + '@apollo/server@5.3.0(graphql@17.0.0-alpha.9)': dependencies: - '@apollo/cache-control-types': 1.0.3(graphql@17.0.0-alpha.2) - '@apollo/server-gateway-interface': 2.0.0(graphql@17.0.0-alpha.2) + '@apollo/cache-control-types': 1.0.3(graphql@17.0.0-alpha.9) + '@apollo/server-gateway-interface': 2.0.0(graphql@17.0.0-alpha.9) '@apollo/usage-reporting-protobuf': 4.1.1 '@apollo/utils.createhash': 3.0.1 '@apollo/utils.fetcher': 3.1.0 '@apollo/utils.isnodelike': 3.0.0 '@apollo/utils.keyvaluecache': 4.0.0 '@apollo/utils.logger': 3.0.0 - '@apollo/utils.usagereporting': 2.1.0(graphql@17.0.0-alpha.2) + '@apollo/utils.usagereporting': 2.1.0(graphql@17.0.0-alpha.9) '@apollo/utils.withrequired': 3.0.0 - '@graphql-tools/schema': 10.0.25(graphql@17.0.0-alpha.2) + '@graphql-tools/schema': 10.0.25(graphql@17.0.0-alpha.9) async-retry: 1.3.3 body-parser: 2.2.2 cors: 2.8.6 finalhandler: 2.1.0 - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 loglevel: 1.9.2 lru-cache: 11.2.2 negotiator: 1.0.0 @@ -8441,9 +8439,9 @@ snapshots: '@apollo/utils.isnodelike': 3.0.0 sha.js: 2.4.12 - '@apollo/utils.dropunuseddefinitions@2.0.1(graphql@17.0.0-alpha.2)': + '@apollo/utils.dropunuseddefinitions@2.0.1(graphql@17.0.0-alpha.9)': dependencies: - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 '@apollo/utils.fetcher@3.1.0': {} @@ -8456,38 +8454,38 @@ snapshots: '@apollo/utils.logger@3.0.0': {} - '@apollo/utils.printwithreducedwhitespace@2.0.1(graphql@17.0.0-alpha.2)': + '@apollo/utils.printwithreducedwhitespace@2.0.1(graphql@17.0.0-alpha.9)': dependencies: - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 - '@apollo/utils.removealiases@2.0.1(graphql@17.0.0-alpha.2)': + '@apollo/utils.removealiases@2.0.1(graphql@17.0.0-alpha.9)': dependencies: - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 - '@apollo/utils.sortast@2.0.1(graphql@17.0.0-alpha.2)': + '@apollo/utils.sortast@2.0.1(graphql@17.0.0-alpha.9)': dependencies: - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 lodash.sortby: 4.7.0 - '@apollo/utils.stripsensitiveliterals@2.0.1(graphql@17.0.0-alpha.2)': + '@apollo/utils.stripsensitiveliterals@2.0.1(graphql@17.0.0-alpha.9)': dependencies: - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 - '@apollo/utils.usagereporting@2.1.0(graphql@17.0.0-alpha.2)': + '@apollo/utils.usagereporting@2.1.0(graphql@17.0.0-alpha.9)': dependencies: '@apollo/usage-reporting-protobuf': 4.1.1 - '@apollo/utils.dropunuseddefinitions': 2.0.1(graphql@17.0.0-alpha.2) - '@apollo/utils.printwithreducedwhitespace': 2.0.1(graphql@17.0.0-alpha.2) - '@apollo/utils.removealiases': 2.0.1(graphql@17.0.0-alpha.2) - '@apollo/utils.sortast': 2.0.1(graphql@17.0.0-alpha.2) - '@apollo/utils.stripsensitiveliterals': 2.0.1(graphql@17.0.0-alpha.2) - graphql: 17.0.0-alpha.2 + '@apollo/utils.dropunuseddefinitions': 2.0.1(graphql@17.0.0-alpha.9) + '@apollo/utils.printwithreducedwhitespace': 2.0.1(graphql@17.0.0-alpha.9) + '@apollo/utils.removealiases': 2.0.1(graphql@17.0.0-alpha.9) + '@apollo/utils.sortast': 2.0.1(graphql@17.0.0-alpha.9) + '@apollo/utils.stripsensitiveliterals': 2.0.1(graphql@17.0.0-alpha.9) + graphql: 17.0.0-alpha.9 '@apollo/utils.withrequired@3.0.0': {} - '@as-integrations/express5@1.1.2(@apollo/server@5.0.0(graphql@17.0.0-alpha.2))(express@5.2.1)': + '@as-integrations/express5@1.1.2(@apollo/server@5.3.0(graphql@17.0.0-alpha.9))(express@5.2.1)': dependencies: - '@apollo/server': 5.0.0(graphql@17.0.0-alpha.2) + '@apollo/server': 5.3.0(graphql@17.0.0-alpha.9) express: 5.2.1 '@ast-grep/napi-darwin-arm64@0.40.5': @@ -9681,73 +9679,73 @@ snapshots: dependencies: tslib: 2.8.1 - '@graphql-tools/executor@1.5.1(graphql@17.0.0-alpha.2)': + '@graphql-tools/executor@1.5.1(graphql@17.0.0-alpha.9)': dependencies: - '@graphql-tools/utils': 11.0.0(graphql@17.0.0-alpha.2) - '@graphql-typed-document-node/core': 3.2.0(graphql@17.0.0-alpha.2) + '@graphql-tools/utils': 11.0.0(graphql@17.0.0-alpha.9) + '@graphql-typed-document-node/core': 3.2.0(graphql@17.0.0-alpha.9) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 tslib: 2.8.1 - '@graphql-tools/merge@9.1.1(graphql@17.0.0-alpha.2)': + '@graphql-tools/merge@9.1.1(graphql@17.0.0-alpha.9)': dependencies: - '@graphql-tools/utils': 10.9.1(graphql@17.0.0-alpha.2) - graphql: 17.0.0-alpha.2 + '@graphql-tools/utils': 10.9.1(graphql@17.0.0-alpha.9) + graphql: 17.0.0-alpha.9 tslib: 2.8.1 - '@graphql-tools/schema@10.0.25(graphql@17.0.0-alpha.2)': + '@graphql-tools/schema@10.0.25(graphql@17.0.0-alpha.9)': dependencies: - '@graphql-tools/merge': 9.1.1(graphql@17.0.0-alpha.2) - '@graphql-tools/utils': 10.9.1(graphql@17.0.0-alpha.2) - graphql: 17.0.0-alpha.2 + '@graphql-tools/merge': 9.1.1(graphql@17.0.0-alpha.9) + '@graphql-tools/utils': 10.9.1(graphql@17.0.0-alpha.9) + graphql: 17.0.0-alpha.9 tslib: 2.8.1 - '@graphql-tools/utils@10.11.0(graphql@17.0.0-alpha.2)': + '@graphql-tools/utils@10.11.0(graphql@17.0.0-alpha.9)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@17.0.0-alpha.2) + '@graphql-typed-document-node/core': 3.2.0(graphql@17.0.0-alpha.9) '@whatwg-node/promise-helpers': 1.3.2 cross-inspect: 1.0.1 - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 tslib: 2.8.1 - '@graphql-tools/utils@10.9.1(graphql@17.0.0-alpha.2)': + '@graphql-tools/utils@10.9.1(graphql@17.0.0-alpha.9)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@17.0.0-alpha.2) + '@graphql-typed-document-node/core': 3.2.0(graphql@17.0.0-alpha.9) '@whatwg-node/promise-helpers': 1.3.2 cross-inspect: 1.0.1 dset: 3.1.4 - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 tslib: 2.8.1 - '@graphql-tools/utils@11.0.0(graphql@17.0.0-alpha.2)': + '@graphql-tools/utils@11.0.0(graphql@17.0.0-alpha.9)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@17.0.0-alpha.2) + '@graphql-typed-document-node/core': 3.2.0(graphql@17.0.0-alpha.9) '@whatwg-node/promise-helpers': 1.3.2 cross-inspect: 1.0.1 - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 tslib: 2.8.1 - '@graphql-typed-document-node/core@3.2.0(graphql@17.0.0-alpha.2)': + '@graphql-typed-document-node/core@3.2.0(graphql@17.0.0-alpha.9)': dependencies: - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 '@graphql-yoga/logger@2.0.1': dependencies: tslib: 2.8.1 - '@graphql-yoga/plugin-defer-stream@3.18.0(graphql-yoga@5.18.0(graphql@17.0.0-alpha.2))(graphql@17.0.0-alpha.2)': + '@graphql-yoga/plugin-defer-stream@3.18.0(graphql-yoga@5.18.0(graphql@17.0.0-alpha.9))(graphql@17.0.0-alpha.9)': dependencies: - '@graphql-tools/utils': 10.11.0(graphql@17.0.0-alpha.2) - graphql: 17.0.0-alpha.2 - graphql-yoga: 5.18.0(graphql@17.0.0-alpha.2) + '@graphql-tools/utils': 10.11.0(graphql@17.0.0-alpha.9) + graphql: 17.0.0-alpha.9 + graphql-yoga: 5.18.0(graphql@17.0.0-alpha.9) - '@graphql-yoga/plugin-persisted-operations@3.18.0(graphql-yoga@5.18.0(graphql@17.0.0-alpha.2))(graphql@17.0.0-alpha.2)': + '@graphql-yoga/plugin-persisted-operations@3.18.0(graphql-yoga@5.18.0(graphql@17.0.0-alpha.9))(graphql@17.0.0-alpha.9)': dependencies: '@whatwg-node/promise-helpers': 1.3.2 - graphql: 17.0.0-alpha.2 - graphql-yoga: 5.18.0(graphql@17.0.0-alpha.2) + graphql: 17.0.0-alpha.9 + graphql-yoga: 5.18.0(graphql@17.0.0-alpha.9) '@graphql-yoga/subscription@5.0.5': dependencies: @@ -10352,19 +10350,19 @@ snapshots: '@poppinss/exception@1.2.2': {} - '@pothos/core@4.12.0(graphql@17.0.0-alpha.2)': + '@pothos/core@4.12.0(graphql@17.0.0-alpha.9)': dependencies: - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 - '@pothos/plugin-relay@4.6.2(@pothos/core@4.12.0(graphql@17.0.0-alpha.2))(graphql@17.0.0-alpha.2)': + '@pothos/plugin-relay@4.6.2(@pothos/core@4.12.0(graphql@17.0.0-alpha.9))(graphql@17.0.0-alpha.9)': dependencies: - '@pothos/core': 4.12.0(graphql@17.0.0-alpha.2) - graphql: 17.0.0-alpha.2 + '@pothos/core': 4.12.0(graphql@17.0.0-alpha.9) + graphql: 17.0.0-alpha.9 - '@pothos/plugin-zod@4.3.0(@pothos/core@4.12.0(graphql@17.0.0-alpha.2))(graphql@17.0.0-alpha.2)(zod@4.3.6)': + '@pothos/plugin-zod@4.3.0(@pothos/core@4.12.0(graphql@17.0.0-alpha.9))(graphql@17.0.0-alpha.9)(zod@4.3.6)': dependencies: - '@pothos/core': 4.12.0(graphql@17.0.0-alpha.2) - graphql: 17.0.0-alpha.2 + '@pothos/core': 4.12.0(graphql@17.0.0-alpha.9) + graphql: 17.0.0-alpha.9 zod: 4.3.6 '@protobufjs/aspromise@1.1.2': {} @@ -14010,33 +14008,33 @@ snapshots: chalk: 4.1.2 tinygradient: 1.1.5 - graphql-relay@0.10.2(graphql@17.0.0-alpha.2): + graphql-relay@0.10.2(graphql@17.0.0-alpha.9): dependencies: - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 - graphql-subscriptions@3.0.0(graphql@17.0.0-alpha.2): + graphql-subscriptions@3.0.0(graphql@17.0.0-alpha.9): dependencies: - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 - graphql-ws@6.0.7(graphql@17.0.0-alpha.2)(ws@8.19.0): + graphql-ws@6.0.7(graphql@17.0.0-alpha.9)(ws@8.19.0): dependencies: - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 optionalDependencies: ws: 8.19.0 - graphql-yoga@5.18.0(graphql@17.0.0-alpha.2): + graphql-yoga@5.18.0(graphql@17.0.0-alpha.9): dependencies: '@envelop/core': 5.3.2 '@envelop/instrumentation': 1.0.0 - '@graphql-tools/executor': 1.5.1(graphql@17.0.0-alpha.2) - '@graphql-tools/schema': 10.0.25(graphql@17.0.0-alpha.2) - '@graphql-tools/utils': 10.11.0(graphql@17.0.0-alpha.2) + '@graphql-tools/executor': 1.5.1(graphql@17.0.0-alpha.9) + '@graphql-tools/schema': 10.0.25(graphql@17.0.0-alpha.9) + '@graphql-tools/utils': 10.11.0(graphql@17.0.0-alpha.9) '@graphql-yoga/logger': 2.0.1 '@graphql-yoga/subscription': 5.0.5 '@whatwg-node/fetch': 0.10.11 '@whatwg-node/promise-helpers': 1.3.2 '@whatwg-node/server': 0.10.18 - graphql: 17.0.0-alpha.2 + graphql: 17.0.0-alpha.9 lru-cache: 10.4.3 tslib: 2.8.1 @@ -14044,6 +14042,8 @@ snapshots: graphql@17.0.0-alpha.2: {} + graphql@17.0.0-alpha.9: {} + handlebars@4.7.8: dependencies: minimist: 1.2.8 From 779580f00a4cdf8d225e9272449d50d0fc8f26a3 Mon Sep 17 00:00:00 2001 From: Dan Train Date: Sat, 31 Jan 2026 23:33:51 +0000 Subject: [PATCH 2/6] feat(react): add processMultipartResponse utility for incremental delivery --- apps/counter-app/app/lib/relay-environment.ts | 130 +----------------- apps/movie-app/app/lib/relay-environment.ts | 126 +---------------- .../app/lib/relay-environment.tsx | 130 +----------------- docs/getting-started.md | 102 ++------------ packages/react/src/incremental-response.ts | 120 ++++++++++++++++ packages/react/src/index.ts | 1 + 6 files changed, 149 insertions(+), 460 deletions(-) create mode 100644 packages/react/src/incremental-response.ts diff --git a/apps/counter-app/app/lib/relay-environment.ts b/apps/counter-app/app/lib/relay-environment.ts index 0149681..e939336 100644 --- a/apps/counter-app/app/lib/relay-environment.ts +++ b/apps/counter-app/app/lib/relay-environment.ts @@ -19,52 +19,15 @@ import { import { PayloadExtensions } from "relay-runtime/lib/network/RelayNetworkTypes"; import { toast } from "sonner"; import invariant from "tiny-invariant"; -import { getCachedResponse } from "@remix-relay/react"; +import { + getCachedResponse, + processMultipartResponse, +} from "@remix-relay/react"; import { trackPromise } from "~/components/Progress"; const isServer = typeof document === "undefined"; const tabId = isServer ? null : createId(); -// Types for new June 2023 incremental delivery format -interface PendingResult { - id: string; - path: ReadonlyArray; - label?: string; -} - -interface IncrementalResult { - id: string; - data?: Record; - items?: unknown[]; - subPath?: ReadonlyArray; - errors?: unknown[]; -} - -interface IncrementalResponse { - data?: Record; - pending?: PendingResult[]; - incremental?: IncrementalResult[]; - completed?: { id: string }[]; - hasNext?: boolean; - errors?: { message: string }[]; - // Old format fields - path?: ReadonlyArray; - label?: string; -} - -// Helper to get an object at a given path in the data tree -function getAtPath( - data: Record, - path: ReadonlyArray, -): Record | undefined { - let current: unknown = data; - for (const key of path) { - if (current === null || current === undefined) return undefined; - current = (current as Record)[key]; - } - return current as Record | undefined; -} - const fetchFn: FetchFunction = ( params: RequestParameters, variables: Variables, @@ -74,11 +37,6 @@ const fetchFn: FetchFunction = ( getCachedResponse(params, variables, cacheConfig) ?? Observable.create((sink) => { const fetchGraphQL = async () => { - // Track pending items by id for new format - const pendingById = new Map(); - // Store initial data for merging id into incremental results - let initialData: Record | undefined; - try { const response = await fetch("/graphql", { method: "POST", @@ -100,84 +58,8 @@ const fetchFn: FetchFunction = ( const parts = await meros(response); - if (parts instanceof Response) { - const result = await parts.json(); - if (result.errors) { - throw new Error(result.errors?.[0]?.message); - } - - sink.next(result); - } else { - for await (const part of parts) { - const body = part.body as IncrementalResponse; - - if (body.errors) { - throw new Error(body.errors?.[0]?.message); - } - - // Check if this is new format (has pending array) - if (body.pending) { - // Track pending items for later path resolution - for (const pending of body.pending) { - pendingById.set(pending.id, pending); - } - } - - // Handle incremental results - if (body.incremental && body.incremental.length > 0) { - for (const inc of body.incremental) { - // Support both old format (path directly on inc) and new format (id + pending lookup) - type OldFormatInc = IncrementalResult & { - path?: ReadonlyArray; - label?: string; - }; - const oldInc = inc as OldFormatInc; - - let fullPath: ReadonlyArray; - let label: string | undefined; - - if (oldInc.path !== undefined) { - // Old format - path and label are directly on the incremental result - fullPath = oldInc.path; - label = oldInc.label; - } else { - // New format - look up path from pending by id - const pending = pendingById.get(inc.id); - const basePath = pending?.path ?? []; - const subPath = inc.subPath ?? []; - fullPath = [...basePath, ...subPath]; - label = pending?.label; - } - - // Relay needs the parent object's `id` to properly normalize deferred data. - // GraphQL-js de-duplicates fields, so `id` may only be in the initial response. - // We merge the parent's `id` from the initial data into the incremental result. - let mergedData = inc.data; - if (initialData && inc.data) { - const parentData = getAtPath(initialData, fullPath); - if (parentData?.id !== undefined && !("id" in inc.data)) { - mergedData = { id: parentData.id, ...inc.data }; - } - } - - // Transform to Relay-compatible format with path - sink.next({ - data: mergedData, - path: [...fullPath], - label, - hasNext: body.hasNext, - } as Parameters[0]); - } - } else if (body.data !== undefined) { - // Initial response - store data for later id merging - initialData = body.data; - // Pass through to Relay - sink.next(body as Parameters[0]); - } else if (body.path !== undefined) { - // Old format incremental result - pass through - sink.next(body as Parameters[0]); - } - } + for await (const payload of processMultipartResponse(parts)) { + sink.next(payload); } } catch (err) { if (!isServer) { diff --git a/apps/movie-app/app/lib/relay-environment.ts b/apps/movie-app/app/lib/relay-environment.ts index 90dcb4e..3fe7960 100644 --- a/apps/movie-app/app/lib/relay-environment.ts +++ b/apps/movie-app/app/lib/relay-environment.ts @@ -12,49 +12,13 @@ import { RecordSource, Store, } from "relay-runtime"; -import { getCachedResponse } from "@remix-relay/react"; +import { + getCachedResponse, + processMultipartResponse, +} from "@remix-relay/react"; const isServer = typeof document === "undefined"; -// Types for new June 2023 incremental delivery format -interface PendingResult { - id: string; - path: ReadonlyArray; - label?: string; -} - -interface IncrementalResult { - id: string; - data?: Record; - items?: unknown[]; - subPath?: ReadonlyArray; - errors?: unknown[]; -} - -interface IncrementalResponse { - data?: Record; - pending?: PendingResult[]; - incremental?: IncrementalResult[]; - completed?: { id: string }[]; - hasNext?: boolean; - // Old format fields - path?: ReadonlyArray; - label?: string; -} - -// Helper to get an object at a given path in the data tree -function getAtPath( - data: Record, - path: ReadonlyArray, -): Record | undefined { - let current: unknown = data; - for (const key of path) { - if (current === null || current === undefined) return undefined; - current = (current as Record)[key]; - } - return current as Record | undefined; -} - const fetchFn: FetchFunction = ( params: RequestParameters, variables: Variables, @@ -64,11 +28,6 @@ const fetchFn: FetchFunction = ( getCachedResponse(params, variables, cacheConfig) ?? Observable.create((sink) => { const fetchGraphQL = async () => { - // Track pending items by id for new format - const pendingById = new Map(); - // Store initial data for merging id into incremental results - let initialData: Record | undefined; - try { const response = await fetch("/graphql", { method: "POST", @@ -84,81 +43,8 @@ const fetchFn: FetchFunction = ( const parts = await meros(response); - // Check if it's a Response-like object (has .json method) - if (parts && typeof (parts as Response).json === "function") { - sink.next(await (parts as Response).json()); - } else { - for await (const part of parts as AsyncIterable<{ - json: boolean; - body: unknown; - }>) { - const body = part.json - ? (part.body as IncrementalResponse) - : (JSON.parse(part.body as string) as IncrementalResponse); - - // Check if this is new format (has pending array) - if (body.pending) { - // Track pending items for later path resolution - for (const pending of body.pending) { - pendingById.set(pending.id, pending); - } - } - - // Handle incremental results - if (body.incremental && body.incremental.length > 0) { - for (const inc of body.incremental) { - // Support both old format (path directly on inc) and new format (id + pending lookup) - type OldFormatInc = IncrementalResult & { - path?: ReadonlyArray; - label?: string; - }; - const oldInc = inc as OldFormatInc; - - let fullPath: ReadonlyArray; - let label: string | undefined; - - if (oldInc.path !== undefined) { - // Old format - path and label are directly on the incremental result - fullPath = oldInc.path; - label = oldInc.label; - } else { - // New format - look up path from pending by id - const pending = pendingById.get(inc.id); - const basePath = pending?.path ?? []; - const subPath = inc.subPath ?? []; - fullPath = [...basePath, ...subPath]; - label = pending?.label; - } - - // Relay needs the parent object's `id` to properly normalize deferred data. - // GraphQL-js de-duplicates fields, so `id` may only be in the initial response. - // We merge the parent's `id` from the initial data into the incremental result. - let mergedData = inc.data; - if (initialData && inc.data) { - const parentData = getAtPath(initialData, fullPath); - if (parentData?.id !== undefined && !("id" in inc.data)) { - mergedData = { id: parentData.id, ...inc.data }; - } - } - - // Transform to Relay-compatible format with path - sink.next({ - data: mergedData, - path: [...fullPath], - label, - hasNext: body.hasNext, - } as Parameters[0]); - } - } else if (body.data !== undefined) { - // Initial response - store data for later id merging - initialData = body.data; - // Pass through to Relay - sink.next(body as Parameters[0]); - } else if (body.path !== undefined) { - // Old format incremental result - pass through - sink.next(body as Parameters[0]); - } - } + for await (const payload of processMultipartResponse(parts)) { + sink.next(payload); } } catch (err) { if (!isServer) { diff --git a/apps/trellix-relay/app/lib/relay-environment.tsx b/apps/trellix-relay/app/lib/relay-environment.tsx index 1957c35..bc34ba6 100644 --- a/apps/trellix-relay/app/lib/relay-environment.tsx +++ b/apps/trellix-relay/app/lib/relay-environment.tsx @@ -20,52 +20,15 @@ import { import { PayloadExtensions } from "relay-runtime/lib/network/RelayNetworkTypes"; import { toast } from "sonner"; import invariant from "tiny-invariant"; -import { getCachedResponse } from "@remix-relay/react"; +import { + getCachedResponse, + processMultipartResponse, +} from "@remix-relay/react"; import { trackPromise } from "~/components/Progress"; const isServer = typeof document === "undefined"; const tabId = isServer ? null : createId(); -// Types for new June 2023 incremental delivery format -interface PendingResult { - id: string; - path: ReadonlyArray; - label?: string; -} - -interface IncrementalResult { - id: string; - data?: Record; - items?: unknown[]; - subPath?: ReadonlyArray; - errors?: unknown[]; -} - -interface IncrementalResponse { - data?: Record; - pending?: PendingResult[]; - incremental?: IncrementalResult[]; - completed?: { id: string }[]; - hasNext?: boolean; - errors?: { message: string }[]; - // Old format fields - path?: ReadonlyArray; - label?: string; -} - -// Helper to get an object at a given path in the data tree -function getAtPath( - data: Record, - path: ReadonlyArray, -): Record | undefined { - let current: unknown = data; - for (const key of path) { - if (current === null || current === undefined) return undefined; - current = (current as Record)[key]; - } - return current as Record | undefined; -} - const fetchFn: FetchFunction = ( params: RequestParameters, variables: Variables, @@ -75,11 +38,6 @@ const fetchFn: FetchFunction = ( getCachedResponse(params, variables, cacheConfig) ?? Observable.create((sink) => { const fetchGraphQL = async () => { - // Track pending items by id for new format - const pendingById = new Map(); - // Store initial data for merging id into incremental results - let initialData: Record | undefined; - try { const response = await fetch("/graphql", { method: "POST", @@ -101,84 +59,8 @@ const fetchFn: FetchFunction = ( const parts = await meros(response); - if (parts instanceof Response) { - const result = await parts.json(); - if (result.errors) { - throw new Error(result.errors?.[0]?.message); - } - - sink.next(result); - } else { - for await (const part of parts) { - const body = part.body as IncrementalResponse; - - if (body.errors) { - throw new Error(body.errors?.[0]?.message); - } - - // Check if this is new format (has pending array) - if (body.pending) { - // Track pending items for later path resolution - for (const pending of body.pending) { - pendingById.set(pending.id, pending); - } - } - - // Handle incremental results - if (body.incremental && body.incremental.length > 0) { - for (const inc of body.incremental) { - // Support both old format (path directly on inc) and new format (id + pending lookup) - type OldFormatInc = IncrementalResult & { - path?: ReadonlyArray; - label?: string; - }; - const oldInc = inc as OldFormatInc; - - let fullPath: ReadonlyArray; - let label: string | undefined; - - if (oldInc.path !== undefined) { - // Old format - path and label are directly on the incremental result - fullPath = oldInc.path; - label = oldInc.label; - } else { - // New format - look up path from pending by id - const pending = pendingById.get(inc.id); - const basePath = pending?.path ?? []; - const subPath = inc.subPath ?? []; - fullPath = [...basePath, ...subPath]; - label = pending?.label; - } - - // Relay needs the parent object's `id` to properly normalize deferred data. - // GraphQL-js de-duplicates fields, so `id` may only be in the initial response. - // We merge the parent's `id` from the initial data into the incremental result. - let mergedData = inc.data; - if (initialData && inc.data) { - const parentData = getAtPath(initialData, fullPath); - if (parentData?.id !== undefined && !("id" in inc.data)) { - mergedData = { id: parentData.id, ...inc.data }; - } - } - - // Transform to Relay-compatible format with path - sink.next({ - data: mergedData, - path: [...fullPath], - label, - hasNext: body.hasNext, - } as Parameters[0]); - } - } else if (body.data !== undefined) { - // Initial response - store data for later id merging - initialData = body.data; - // Pass through to Relay - sink.next(body as Parameters[0]); - } else if (body.path !== undefined) { - // Old format incremental result - pass through - sink.next(body as Parameters[0]); - } - } + for await (const payload of processMultipartResponse(parts)) { + sink.next(payload); } } catch (err) { if (!isServer) { diff --git a/docs/getting-started.md b/docs/getting-started.md index c748718..dbe4dc6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -191,65 +191,24 @@ Add an `app/lib/relay-environment.ts` file. ```typescript import { meros } from "meros/browser"; +import type { FetchFunction } from "relay-runtime"; import { - type CacheConfig, - type FetchFunction, - type RequestParameters, - type Variables, Environment, Network, Observable, RecordSource, Store, } from "relay-runtime"; -import { getCachedResponse } from "@remix-relay/react"; - -// Types for incremental delivery format -interface PendingResult { - id: string; - path: ReadonlyArray; - label?: string; -} - -interface IncrementalResult { - id: string; - data?: Record; - subPath?: ReadonlyArray; -} - -interface IncrementalResponse { - data?: Record; - pending?: PendingResult[]; - incremental?: IncrementalResult[]; - completed?: { id: string }[]; - hasNext?: boolean; -} - -// Helper to get an object at a given path in the data tree -function getAtPath( - data: Record, - path: ReadonlyArray, -): Record | undefined { - let current: unknown = data; - for (const key of path) { - if (current === null || current === undefined) return undefined; - current = (current as Record)[key]; - } - return current as Record | undefined; -} +import { + getCachedResponse, + processMultipartResponse, +} from "@remix-relay/react"; -const fetchFn: FetchFunction = ( - params: RequestParameters, - variables: Variables, - cacheConfig: CacheConfig, -) => { +const fetchFn: FetchFunction = (params, variables, cacheConfig) => { return ( getCachedResponse(params, variables, cacheConfig) ?? Observable.create((sink) => { const fetchGraphQL = async () => { - const pendingById = new Map(); - let initialData: Record | undefined; - try { const response = await fetch("/graphql", { method: "POST", @@ -257,54 +216,13 @@ const fetchFn: FetchFunction = ( "Content-Type": "application/json", Accept: "multipart/mixed; incrementalSpec=v0.2, application/json", }, - body: JSON.stringify({ - query: params.text, - variables, - }), + body: JSON.stringify({ query: params.text, variables }), }); const parts = await meros(response); - if (parts instanceof Response) { - sink.next(await parts.json()); - } else { - for await (const part of parts) { - const body = part.body as IncrementalResponse; - - if (body.pending) { - for (const pending of body.pending) { - pendingById.set(pending.id, pending); - } - } - - if (body.incremental && body.incremental.length > 0) { - for (const inc of body.incremental) { - const pending = pendingById.get(inc.id); - const basePath = pending?.path ?? []; - const subPath = inc.subPath ?? []; - const fullPath = [...basePath, ...subPath]; - - // Merge parent's id for Relay normalization - let mergedData = inc.data; - if (initialData && inc.data) { - const parentData = getAtPath(initialData, fullPath); - if (parentData?.id !== undefined && !("id" in inc.data)) { - mergedData = { id: parentData.id, ...inc.data }; - } - } - - sink.next({ - data: mergedData, - path: fullPath, - label: pending?.label, - hasNext: body.hasNext, - } as Parameters[0]); - } - } else if (body.data !== undefined) { - initialData = body.data; - sink.next(body as Parameters[0]); - } - } + for await (const payload of processMultipartResponse(parts)) { + sink.next(payload); } } finally { sink.complete(); @@ -333,7 +251,7 @@ export function getCurrentEnvironment() { } ``` -Note the use of `fetch` to request data, and the [meros](https://github.com/maraisr/meros) library to read the multipart response. This enables streaming of client requests. +Note the use of `fetch` to request data, and the [meros](https://github.com/maraisr/meros) library to read the multipart response. The `processMultipartResponse` utility from `@remix-relay/react` handles the incremental delivery format used by `@defer`. Add providers and a Suspense boundary to `app/root.tsx`. diff --git a/packages/react/src/incremental-response.ts b/packages/react/src/incremental-response.ts new file mode 100644 index 0000000..101deed --- /dev/null +++ b/packages/react/src/incremental-response.ts @@ -0,0 +1,120 @@ +import type { GraphQLResponse } from "relay-runtime"; + +interface PendingResult { + id: string; + path: ReadonlyArray; + label?: string; +} + +interface IncrementalResult { + id: string; + data?: Record; + items?: unknown[]; + subPath?: ReadonlyArray; + errors?: unknown[]; +} + +export interface IncrementalResponse { + data?: Record; + pending?: PendingResult[]; + incremental?: IncrementalResult[]; + completed?: { id: string }[]; + hasNext?: boolean; + errors?: { message: string }[]; + path?: ReadonlyArray; + label?: string; +} + +function getAtPath( + data: Record, + path: ReadonlyArray, +): Record | undefined { + let current: unknown = data; + for (const key of path) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[key]; + } + return current as Record | undefined; +} + +function checkErrors(body: IncrementalResponse) { + const firstError = body.errors?.[0]; + if (firstError) { + throw new Error(firstError.message); + } +} + +export async function* processMultipartResponse( + parts: AsyncIterable<{ body: unknown; json?: boolean }> | Response, +): AsyncGenerator { + // Handle non-multipart Response + if (parts && typeof (parts as Response).json === "function") { + const body = (await (parts as Response).json()) as IncrementalResponse; + checkErrors(body); + yield body as GraphQLResponse; + return; + } + + const pendingById = new Map(); + let initialData: Record | undefined; + + for await (const part of parts as AsyncIterable<{ + body: unknown; + json?: boolean; + }>) { + const body = ( + part.json !== false ? part.body : JSON.parse(part.body as string) + ) as IncrementalResponse; + + checkErrors(body); + + if (body.pending) { + for (const pending of body.pending) { + pendingById.set(pending.id, pending); + } + } + + if (body.incremental && body.incremental.length > 0) { + for (const inc of body.incremental) { + // Support old format (path on inc) and new format (id lookup) + type OldFormatInc = IncrementalResult & { + path?: ReadonlyArray; + label?: string; + }; + const oldInc = inc as OldFormatInc; + + let fullPath: ReadonlyArray; + let label: string | undefined; + + if (oldInc.path !== undefined) { + fullPath = oldInc.path; + label = oldInc.label; + } else { + const pending = pendingById.get(inc.id); + fullPath = [...(pending?.path ?? []), ...(inc.subPath ?? [])]; + label = pending?.label; + } + + let mergedData = inc.data; + if (initialData && inc.data) { + const parentData = getAtPath(initialData, fullPath); + if (parentData?.id !== undefined && !("id" in inc.data)) { + mergedData = { id: parentData.id, ...inc.data }; + } + } + + yield { + data: mergedData, + path: [...fullPath], + label, + hasNext: body.hasNext, + } as GraphQLResponse; + } + } else if (body.data !== undefined) { + initialData = body.data; + yield body as GraphQLResponse; + } else if (body.path !== undefined) { + yield body as GraphQLResponse; + } + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8a133c9..d235ed6 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -2,5 +2,6 @@ export { Deferred } from "./Deferred"; export { clientLoaderQuery, getClientLoaderQuery } from "./client-loader-query"; export { RemixRelayProvider } from "./deferred-query-context"; export { getCachedResponse } from "./get-cached-response"; +export { processMultipartResponse } from "./incremental-response"; export { metaQuery } from "./meta-query"; export { useLoaderQuery, useRouteLoaderQuery } from "./useLoaderQuery"; From cf23af20744a5e2071c95fe75c92444fa6be4873 Mon Sep 17 00:00:00 2001 From: Dan Train Date: Sat, 31 Jan 2026 23:35:29 +0000 Subject: [PATCH 3/6] chore: bump @remix-relay/react and @remix-relay/server to v3.0.0 --- docs/getting-started.md | 7 +++++-- packages/react/package.json | 2 +- packages/server/package.json | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index dbe4dc6..2628831 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -216,7 +216,10 @@ const fetchFn: FetchFunction = (params, variables, cacheConfig) => { "Content-Type": "application/json", Accept: "multipart/mixed; incrementalSpec=v0.2, application/json", }, - body: JSON.stringify({ query: params.text, variables }), + body: JSON.stringify({ + query: params.text, + variables, + }), }); const parts = await meros(response); @@ -251,7 +254,7 @@ export function getCurrentEnvironment() { } ``` -Note the use of `fetch` to request data, and the [meros](https://github.com/maraisr/meros) library to read the multipart response. The `processMultipartResponse` utility from `@remix-relay/react` handles the incremental delivery format used by `@defer`. +Note the use of `fetch` to request data, and the [meros](https://github.com/maraisr/meros) library to read the multipart response. Add providers and a Suspense boundary to `app/root.tsx`. diff --git a/packages/react/package.json b/packages/react/package.json index e91f884..14c9c29 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@remix-relay/react", - "version": "2.4.8", + "version": "3.0.0", "description": "Provides Relay integration with React Router (Framework)", "keywords": [ "Remix", diff --git a/packages/server/package.json b/packages/server/package.json index 2e5956f..472c12b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@remix-relay/server", - "version": "2.4.8", + "version": "3.0.0", "description": "Provides Relay integration with React Router (Framework)", "keywords": [ "Remix", From 29742e1de58c355d609ca964b9fe29b26893fad0 Mon Sep 17 00:00:00 2001 From: Dan Train Date: Sun, 1 Feb 2026 00:05:08 +0000 Subject: [PATCH 4/6] refactor: replace tiny-invariant with local helper --- packages/react/package.json | 5 +---- packages/react/src/invariant.ts | 8 ++++++++ packages/react/src/useLoaderQuery.ts | 2 +- packages/server/package.json | 3 --- packages/server/src/invariant.ts | 8 ++++++++ packages/server/src/loader-query.ts | 2 +- pnpm-lock.yaml | 18 ++---------------- 7 files changed, 21 insertions(+), 25 deletions(-) create mode 100644 packages/react/src/invariant.ts create mode 100644 packages/server/src/invariant.ts diff --git a/packages/react/package.json b/packages/react/package.json index 14c9c29..212d587 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -45,9 +45,6 @@ "lint": "eslint . --max-warnings 0", "typecheck": "tsc -b --noEmit" }, - "dependencies": { - "tiny-invariant": "^1.3.3" - }, "devDependencies": { "@remix-relay/eslint-config": "workspace:*", "@remix-relay/typescript-config": "workspace:*", @@ -58,7 +55,7 @@ "@types/react-relay": "^18.2.1", "@types/relay-runtime": "^20.1.1", "eslint": "^9.39.0", - "graphql": "17.0.0-alpha.2", + "graphql": "17.0.0-alpha.9", "jiti": "^2.6.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/packages/react/src/invariant.ts b/packages/react/src/invariant.ts new file mode 100644 index 0000000..8f25958 --- /dev/null +++ b/packages/react/src/invariant.ts @@ -0,0 +1,8 @@ +export function invariant( + condition: unknown, + message: string, +): asserts condition { + if (!condition) { + throw new Error(message); + } +} diff --git a/packages/react/src/useLoaderQuery.ts b/packages/react/src/useLoaderQuery.ts index 3999f0c..5fbefa2 100644 --- a/packages/react/src/useLoaderQuery.ts +++ b/packages/react/src/useLoaderQuery.ts @@ -15,9 +15,9 @@ import type { RequestParameters, VariablesOf, } from "relay-runtime"; -import invariant from "tiny-invariant"; import { SetDeferredQueryContext } from "./deferred-query-context"; import { responseCache } from "./get-cached-response"; +import { invariant } from "./invariant"; const { usePreloadedQuery, useQueryLoader, useRelayEnvironment } = relay; diff --git a/packages/server/package.json b/packages/server/package.json index 472c12b..7c0d693 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -45,9 +45,6 @@ "lint": "eslint . --max-warnings 0", "typecheck": "tsc -b --noEmit" }, - "dependencies": { - "tiny-invariant": "^1.3.3" - }, "devDependencies": { "@remix-relay/eslint-config": "workspace:*", "@remix-relay/typescript-config": "workspace:*", diff --git a/packages/server/src/invariant.ts b/packages/server/src/invariant.ts new file mode 100644 index 0000000..8f25958 --- /dev/null +++ b/packages/server/src/invariant.ts @@ -0,0 +1,8 @@ +export function invariant( + condition: unknown, + message: string, +): asserts condition { + if (!condition) { + throw new Error(message); + } +} diff --git a/packages/server/src/loader-query.ts b/packages/server/src/loader-query.ts index 88750fd..7dda1db 100644 --- a/packages/server/src/loader-query.ts +++ b/packages/server/src/loader-query.ts @@ -15,7 +15,7 @@ import type { VariablesOf, } from "relay-runtime"; import { PayloadExtensions } from "relay-runtime/lib/network/RelayNetworkTypes"; -import invariant from "tiny-invariant"; +import { invariant } from "./invariant"; // PendingResult is not exported from graphql, so we define it here interface PendingResult { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 319535f..feb34d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -751,10 +751,6 @@ importers: version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) packages/react: - dependencies: - tiny-invariant: - specifier: ^1.3.3 - version: 1.3.3 devDependencies: '@remix-relay/eslint-config': specifier: workspace:* @@ -784,8 +780,8 @@ importers: specifier: ^9.39.0 version: 9.39.2(jiti@2.6.1) graphql: - specifier: 17.0.0-alpha.2 - version: 17.0.0-alpha.2 + specifier: 17.0.0-alpha.9 + version: 17.0.0-alpha.9 jiti: specifier: ^2.6.0 version: 2.6.1 @@ -812,10 +808,6 @@ importers: version: 5.9.3 packages/server: - dependencies: - tiny-invariant: - specifier: ^1.3.3 - version: 1.3.3 devDependencies: '@remix-relay/eslint-config': specifier: workspace:* @@ -5839,10 +5831,6 @@ packages: resolution: {integrity: sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w==} engines: {node: '>= 10.x'} - graphql@17.0.0-alpha.2: - resolution: {integrity: sha512-aRAd/BQ5hSO0+l7x+sHBfJVUp2JUOjPTE/iwJ3BhtYNH/MC7n4gjlZbKvnBVFZZAczyMS3vezS4teEZivoqIzw==} - engines: {node: ^14.19.0 || ^16.10.0 || >=18.0.0} - graphql@17.0.0-alpha.9: resolution: {integrity: sha512-jVK1BsvX5pUIEpRDlEgeKJr80GAxl3B8ISsFDjXHtl2xAxMXVGTEFF4Q4R8NH0Gw7yMwcHDndkNjoNT5CbwHKA==} engines: {node: ^16.19.0 || ^18.14.0 || >=19.7.0} @@ -14040,8 +14028,6 @@ snapshots: graphql@15.3.0: {} - graphql@17.0.0-alpha.2: {} - graphql@17.0.0-alpha.9: {} handlebars@4.7.8: From f30427380268cecaf2c8493a29fcd69e011be081 Mon Sep 17 00:00:00 2001 From: Dan Train Date: Sun, 15 Feb 2026 13:18:33 +0000 Subject: [PATCH 5/6] revert: manual v3.0.0 version bump --- docs/getting-started.md | 7 ++----- packages/react/package.json | 2 +- packages/server/package.json | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 2628831..dbe4dc6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -216,10 +216,7 @@ const fetchFn: FetchFunction = (params, variables, cacheConfig) => { "Content-Type": "application/json", Accept: "multipart/mixed; incrementalSpec=v0.2, application/json", }, - body: JSON.stringify({ - query: params.text, - variables, - }), + body: JSON.stringify({ query: params.text, variables }), }); const parts = await meros(response); @@ -254,7 +251,7 @@ export function getCurrentEnvironment() { } ``` -Note the use of `fetch` to request data, and the [meros](https://github.com/maraisr/meros) library to read the multipart response. +Note the use of `fetch` to request data, and the [meros](https://github.com/maraisr/meros) library to read the multipart response. The `processMultipartResponse` utility from `@remix-relay/react` handles the incremental delivery format used by `@defer`. Add providers and a Suspense boundary to `app/root.tsx`. diff --git a/packages/react/package.json b/packages/react/package.json index 212d587..7117196 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@remix-relay/react", - "version": "3.0.0", + "version": "2.4.8", "description": "Provides Relay integration with React Router (Framework)", "keywords": [ "Remix", diff --git a/packages/server/package.json b/packages/server/package.json index 7c0d693..6a0ed3d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@remix-relay/server", - "version": "3.0.0", + "version": "2.4.8", "description": "Provides Relay integration with React Router (Framework)", "keywords": [ "Remix", From d2fb8cdedcee9e5f7c015f930c54b6a2b269fc5a Mon Sep 17 00:00:00 2001 From: Dan Train Date: Sun, 15 Feb 2026 13:18:46 +0000 Subject: [PATCH 6/6] chore: add changeset for v3.0.0 --- .changeset/new-response-format.md | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .changeset/new-response-format.md diff --git a/.changeset/new-response-format.md b/.changeset/new-response-format.md new file mode 100644 index 0000000..3ae5ec6 --- /dev/null +++ b/.changeset/new-response-format.md @@ -0,0 +1,57 @@ +--- +"@remix-relay/react": major +"@remix-relay/server": major +--- + +Update to GraphQL incremental delivery format (incrementalSpec v0.2) + +### Breaking changes + +- **`graphql` peer dependency updated from `17.0.0-alpha.2` to `17.0.0-alpha.9`** - Adopts the newer incremental delivery response format with `pending`/`incremental`/`completed` fields. + +- **Client fetch function must use `processMultipartResponse`** - The client-side relay environment fetch function must be updated to use the new `processMultipartResponse` utility exported from `@remix-relay/react`, which handles the new response format. The `Accept` header must also change from `deferSpec=20220824` to `incrementalSpec=v0.2`. + +### Migration + +Update the `graphql` dependency: + +```shell +pnpm add graphql@17.0.0-alpha.9 +``` + +Update the client relay environment fetch function to use `processMultipartResponse`: + +```typescript +import { + getCachedResponse, + processMultipartResponse, +} from "@remix-relay/react"; + +const fetchFn: FetchFunction = (params, variables, cacheConfig) => { + return ( + getCachedResponse(params, variables, cacheConfig) ?? + Observable.create((sink) => { + (async () => { + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "multipart/mixed; incrementalSpec=v0.2, application/json", + }, + body: JSON.stringify({ query: params.text, variables }), + }); + + const parts = await meros(response); + + for await (const payload of processMultipartResponse(parts)) { + sink.next(payload); + } + } finally { + sink.complete(); + } + })(); + }) + ); +}; +```