From 989fc867eaa3d2c422e852e44f969c7137895427 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 17 Jun 2025 19:09:11 +0200 Subject: [PATCH 01/12] rework multipart response parsing, remove incremental references --- .api-reports/api-report-core.api.md | 15 --- .api-reports/api-report-incremental.api.md | 2 +- .api-reports/api-report-link.api.md | 54 +------- .api-reports/api-report.api.md | 54 +------- .changeset/fuzzy-plants-approve.md | 11 ++ .changeset/lazy-baboons-rescue.md | 7 + .size-limits.json | 8 +- src/core/index.ts | 5 - .../handlers/__tests__/defer20220824.test.ts | 96 ++++++++++++++ src/incremental/handlers/defer20220824.ts | 15 ++- src/link/core/types.ts | 53 +------- src/link/http/__tests__/HttpLink.ts | 4 + src/link/http/__tests__/responseIterator.ts | 23 ++-- .../responseIteratorNoAsyncIterator.ts | 4 + src/link/http/parseAndCheckHttpResponse.ts | 122 ++++++++---------- src/link/index.ts | 5 - src/testing/internal/incremental.ts | 18 ++- 17 files changed, 216 insertions(+), 280 deletions(-) create mode 100644 .changeset/fuzzy-plants-approve.md create mode 100644 .changeset/lazy-baboons-rescue.md diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index b7626743a51..791b47c5b54 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -33,9 +33,6 @@ import { DocumentTransformCacheKey } from '@apollo/client/utilities'; import { empty } from '@apollo/client/link'; import { enableExperimentalFragmentVariables } from 'graphql-tag'; import { execute } from '@apollo/client/link'; -import { ExecutionPatchIncrementalResult } from '@apollo/client/link'; -import { ExecutionPatchInitialResult } from '@apollo/client/link'; -import { ExecutionPatchResult } from '@apollo/client/link'; import { fallbackHttpConfig } from '@apollo/client/link/http'; import { FetchResult } from '@apollo/client/link'; import { FieldFunctionOptions } from '@apollo/client/cache'; @@ -53,7 +50,6 @@ import { HttpLink } from '@apollo/client/link/http'; import { IdGetter } from '@apollo/client/cache'; import { IdGetterObj } from '@apollo/client/cache'; import type { Incremental } from '@apollo/client/incremental'; -import { IncrementalPayload } from '@apollo/client/link'; import { InMemoryCache } from '@apollo/client/cache'; import { InMemoryCacheConfig } from '@apollo/client/cache'; import type { InteropObservable } from 'rxjs'; @@ -83,7 +79,6 @@ import { OperationContext } from '@apollo/client/link'; import { OperationTypeNode } from 'graphql'; import { OptimisticStoreItem } from '@apollo/client/cache'; import { parseAndCheckHttpResponse } from '@apollo/client/link/http'; -import { Path } from '@apollo/client/link'; import { PossibleTypesMap } from '@apollo/client/cache'; import { ReactiveVar } from '@apollo/client/cache'; import { ReadMergeModifyContext } from '@apollo/client/cache'; @@ -304,12 +299,6 @@ export type ErrorPolicy = "none" | "ignore" | "all"; export { execute } -export { ExecutionPatchIncrementalResult } - -export { ExecutionPatchInitialResult } - -export { ExecutionPatchResult } - export { fallbackHttpConfig } // @public (undocumented) @@ -370,8 +359,6 @@ interface IgnoreModifier { // @public (undocumented) const _ignoreModifier: unique symbol; -export { IncrementalPayload } - export { InMemoryCache } export { InMemoryCacheConfig } @@ -672,8 +659,6 @@ namespace OverridableTypes { export { parseAndCheckHttpResponse } -export { Path } - export { PossibleTypesMap } // @public (undocumented) diff --git a/.api-reports/api-report-incremental.api.md b/.api-reports/api-report-incremental.api.md index 65edcb3e3dc..22db4729cd4 100644 --- a/.api-reports/api-report-incremental.api.md +++ b/.api-reports/api-report-incremental.api.md @@ -44,7 +44,7 @@ namespace Defer20220824Handler { }; } -// @public (undocumented) +// @public class Defer20220824Handler implements Incremental.Handler { // (undocumented) extractErrors(result: FetchResult): GraphQLFormattedError[] | undefined; diff --git a/.api-reports/api-report-link.api.md b/.api-reports/api-report-link.api.md index 258a52c7b9f..1156bd870d9 100644 --- a/.api-reports/api-report-link.api.md +++ b/.api-reports/api-report-link.api.md @@ -47,7 +47,7 @@ export interface ApolloPayloadResult, TExtensions = // (undocumented) errors?: ReadonlyArray; // (undocumented) - payload: FormattedExecutionResult | ExecutionPatchResult | null; + payload: FormattedExecutionResult | null; } // @public (undocumented) @@ -67,41 +67,6 @@ export interface ExecuteContext { client: ApolloClient; } -// Warning: (ae-forgotten-export) The symbol "ExecutionPatchResultBase" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export interface ExecutionPatchIncrementalResult, TExtensions = Record> extends ExecutionPatchResultBase { - // (undocumented) - data?: never; - // (undocumented) - errors?: never; - // (undocumented) - extensions?: never; - // (undocumented) - incremental?: IncrementalPayload[]; -} - -// @public (undocumented) -export interface ExecutionPatchInitialResult, TExtensions = Record> extends ExecutionPatchResultBase { - // (undocumented) - data: TData | null | undefined; - // (undocumented) - errors?: ReadonlyArray; - // (undocumented) - extensions?: TExtensions; - // (undocumented) - incremental?: never; -} - -// @public (undocumented) -export type ExecutionPatchResult, TExtensions = Record> = ExecutionPatchInitialResult | ExecutionPatchIncrementalResult; - -// @public (undocumented) -interface ExecutionPatchResultBase { - // (undocumented) - hasNext?: boolean; -} - // @public (undocumented) export type FetchResult, TExtensions = Record> = FormattedExecutionResult | AdditionalFetchResultTypes[keyof AdditionalFetchResultTypes]; @@ -122,20 +87,6 @@ export interface GraphQLRequest> { variables?: TVariables; } -// @public (undocumented) -export interface IncrementalPayload { - // (undocumented) - data: TData | null; - // (undocumented) - errors?: ReadonlyArray; - // (undocumented) - extensions?: TExtensions; - // (undocumented) - label?: string; - // (undocumented) - path: Path; -} - // @public (undocumented) export type NextLink = (operation: Operation) => Observable; @@ -164,9 +115,6 @@ export interface Operation { export interface OperationContext extends DefaultContext { } -// @public (undocumented) -export type Path = ReadonlyArray; - // @public (undocumented) export type RequestHandler = (operation: Operation, forward: NextLink) => Observable | null; diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index 3a3ffd75f43..e5bd6f85069 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -210,7 +210,7 @@ export interface ApolloPayloadResult, TExtensions = // (undocumented) errors?: ReadonlyArray; // (undocumented) - payload: FormattedExecutionResult | ExecutionPatchResult | null; + payload: FormattedExecutionResult | null; } // @public (undocumented) @@ -818,41 +818,6 @@ interface ExecuteContext { client: ApolloClient; } -// Warning: (ae-forgotten-export) The symbol "ExecutionPatchResultBase" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export interface ExecutionPatchIncrementalResult, TExtensions = Record> extends ExecutionPatchResultBase { - // (undocumented) - data?: never; - // (undocumented) - errors?: never; - // (undocumented) - extensions?: never; - // (undocumented) - incremental?: IncrementalPayload[]; -} - -// @public (undocumented) -export interface ExecutionPatchInitialResult, TExtensions = Record> extends ExecutionPatchResultBase { - // (undocumented) - data: TData | null | undefined; - // (undocumented) - errors?: ReadonlyArray; - // (undocumented) - extensions?: TExtensions; - // (undocumented) - incremental?: never; -} - -// @public (undocumented) -export type ExecutionPatchResult, TExtensions = Record> = ExecutionPatchInitialResult | ExecutionPatchIncrementalResult; - -// @public (undocumented) -interface ExecutionPatchResultBase { - // (undocumented) - hasNext?: boolean; -} - // @public type ExtractByMatchingTypeNames; } -// @public (undocumented) -export interface IncrementalPayload { - // (undocumented) - data: TData | null; - // (undocumented) - errors?: ReadonlyArray; - // (undocumented) - extensions?: TExtensions; - // (undocumented) - label?: string; - // (undocumented) - path: Path; -} - // @public (undocumented) type InferContextValueFromResolvers = TResolvers extends { [typename: string]: infer TFieldResolvers; @@ -2054,9 +2005,6 @@ namespace OverridableTypes { // @public (undocumented) export function parseAndCheckHttpResponse(operations: Operation | Operation[]): (response: Response) => Promise; -// @public (undocumented) -export type Path = ReadonlyArray; - // @public (undocumented) class Policies { constructor(config: { diff --git a/.changeset/fuzzy-plants-approve.md b/.changeset/fuzzy-plants-approve.md new file mode 100644 index 00000000000..f4632dd6a9a --- /dev/null +++ b/.changeset/fuzzy-plants-approve.md @@ -0,0 +1,11 @@ +--- +"@apollo/client": patch +--- + +Remove these incremental-format-specific types: + +* `ExecutionPatchIncrementalResult` +* `ExecutionPatchInitialResult` +* `ExecutionPatchResult` +* `IncrementalPayload` +* `Path` diff --git a/.changeset/lazy-baboons-rescue.md b/.changeset/lazy-baboons-rescue.md new file mode 100644 index 00000000000..c47e6b712cd --- /dev/null +++ b/.changeset/lazy-baboons-rescue.md @@ -0,0 +1,7 @@ +--- +"@apollo/client": patch +--- + +Slightly rework multipart response parsing. + +This removes last incremental-protocol-specific details from `HttpLink` and `BatchHttpLink`. diff --git a/.size-limits.json b/.size-limits.json index 91581360598..0a02c5b9be7 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,6 +1,6 @@ { - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43678, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38629, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33368, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27606 + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43583, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38595, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33294, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27610 } diff --git a/src/core/index.ts b/src/core/index.ts index 5d98ba3b3b4..4621f3d48d1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -116,16 +116,11 @@ export { export type { ApolloPayloadResult, DocumentNode, - ExecutionPatchIncrementalResult, - ExecutionPatchInitialResult, - ExecutionPatchResult, FetchResult, GraphQLRequest, - IncrementalPayload, NextLink, Operation, OperationContext, - Path, RequestHandler, } from "@apollo/client/link"; diff --git a/src/incremental/handlers/__tests__/defer20220824.test.ts b/src/incremental/handlers/__tests__/defer20220824.test.ts index e67253db56a..15ea3f252d3 100644 --- a/src/incremental/handlers/__tests__/defer20220824.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824.test.ts @@ -1028,3 +1028,99 @@ test("stream that returns an error but continues to stream", async () => { partial: false, }); }); + +test("handles final chunk of { hasNext: false } correctly in usage with Apollo Client", async () => { + const stream = mockDeferStream(); + const client = new ApolloClient({ + link: stream.httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query ProductsQuery { + allProducts { + id + nonNullErrorField + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + stream.enqueueInitialChunk({ + data: { + allProducts: [null, null, null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + ], + hasNext: true, + }); + + stream.enqueueSubsequentChunk({ + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + allProducts: [null, null, null], + }), + error: new CombinedGraphQLErrors({ + data: { + allProducts: [null, null, null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + // we are in an error state, should this be "complete" or "partial"/stay "streaming"? + // how would the types hold up to that? + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }), + }); + await expect(observableStream).not.toEmitAnything(); +}); diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index be7d70c0072..5909d807fab 100644 --- a/src/incremental/handlers/defer20220824.ts +++ b/src/incremental/handlers/defer20220824.ts @@ -1,11 +1,7 @@ import type { DocumentNode, GraphQLFormattedError } from "graphql"; import type { FormattedExecutionResult } from "graphql"; -import type { - FetchResult, - GraphQLRequest, - IncrementalPayload, -} from "@apollo/client"; +import type { FetchResult, GraphQLRequest } from "@apollo/client"; import type { DeepPartial } from "@apollo/client/utilities"; import { DeepMerger, @@ -107,6 +103,10 @@ class DeferRequest } } +/** + * This handler implements the `@defer` directive as specified in this historical commit: + * https://github.com/graphql/graphql-spec/tree/48cf7263a71a683fab03d45d309fd42d8d9a6659/spec + */ export class Defer20220824Handler implements Incremental.Handler { @@ -117,7 +117,8 @@ export class Defer20220824Handler | Defer20220824Handler.InitialResult { return ( isIncrementalInitialResult(result) || - isIncrementalSubsequentResult(result) + isIncrementalSubsequentResult(result) || + (Object.keys(result).length === 1 && "hasNext" in result) // hasNext-only chunk ); } @@ -171,5 +172,5 @@ export function isIncrementalSubsequentResult( export function isIncrementalInitialResult( result: Record ): result is Defer20220824Handler.InitialResult { - return "hasNext" in result && "data" in result; + return "hasNext" in result && ("data" in result || "errors" in result); } diff --git a/src/link/core/types.ts b/src/link/core/types.ts index db73efe9078..1d99181eddf 100644 --- a/src/link/core/types.ts +++ b/src/link/core/types.ts @@ -6,67 +6,16 @@ import type { ApolloClient, DefaultContext } from "@apollo/client"; export type { DocumentNode }; -export type Path = ReadonlyArray; - -interface ExecutionPatchResultBase { - hasNext?: boolean; -} - -export interface ExecutionPatchInitialResult< - TData = Record, - TExtensions = Record, -> extends ExecutionPatchResultBase { - // if data is present, incremental is not - data: TData | null | undefined; - incremental?: never; - errors?: ReadonlyArray; - extensions?: TExtensions; -} - -export interface IncrementalPayload { - // data and path must both be present - // https://github.com/graphql/graphql-spec/pull/742/files#diff-98d0cd153b72b63c417ad4238e8cc0d3385691ccbde7f7674bc0d2a718b896ecR288-R293 - data: TData | null; - label?: string; - path: Path; - errors?: ReadonlyArray; - extensions?: TExtensions; -} - -export interface ExecutionPatchIncrementalResult< - TData = Record, - TExtensions = Record, -> extends ExecutionPatchResultBase { - // the reverse is also true: if incremental is present, - // data (and errors and extensions) are not - incremental?: IncrementalPayload[]; - data?: never; - // Errors only exist for chunks, not at the top level - // https://github.com/robrichard/defer-stream-wg/discussions/50#discussioncomment-3466739 - errors?: never; - extensions?: never; -} - export interface ApolloPayloadResult< TData = Record, TExtensions = Record, > { - payload: - | FormattedExecutionResult - | ExecutionPatchResult - | null; + payload: FormattedExecutionResult | null; // Transport layer errors (as distinct from GraphQL or NetworkErrors), // these are fatal errors that will include done: true. errors?: ReadonlyArray; } -export type ExecutionPatchResult< - TData = Record, - TExtensions = Record, -> = - | ExecutionPatchInitialResult - | ExecutionPatchIncrementalResult; - export interface GraphQLRequest> { query: DocumentNode; variables?: TVariables; diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts index 4f1d99cacb8..3509e5037a3 100644 --- a/src/link/http/__tests__/HttpLink.ts +++ b/src/link/http/__tests__/HttpLink.ts @@ -1466,6 +1466,10 @@ describe("HttpLink", () => { hasNext: true, }); + await expect(observableStream).toEmitTypedValue({ + hasNext: false, + }); + // the second chunk contains only hasNext: false which is not emitted as // a `next` event so the link completes. diff --git a/src/link/http/__tests__/responseIterator.ts b/src/link/http/__tests__/responseIterator.ts index f021365e60b..793a638bfd0 100644 --- a/src/link/http/__tests__/responseIterator.ts +++ b/src/link/http/__tests__/responseIterator.ts @@ -140,13 +140,18 @@ describe("multipart responses", () => { }, }); - const fetch = jest.fn(async () => ({ - status: 200, - body: stream, - headers: new Headers({ - "content-type": `multipart/mixed; boundary=${BOUNDARY}`, - }), - })); + const fetch = jest.fn(async () => { + const val = { + status: 200, + body: stream, + headers: new Headers({ + "content-type": `multipart/mixed; boundary=${BOUNDARY}`, + }), + }; + + console.log(val); + return val; + }); const link = new HttpLink({ fetch: fetch as any, @@ -239,9 +244,7 @@ describe("multipart responses", () => { }); const observable = execute(link, { query: sampleDeferredQuery }); const mockError = { - throws: new Error( - "TextDecoder must be defined in the environment: please import a polyfill." - ), + throws: new TypeError("TextDecoder is not a constructor"), }; const observableStream = new ObservableStream(observable); diff --git a/src/link/http/__tests__/responseIteratorNoAsyncIterator.ts b/src/link/http/__tests__/responseIteratorNoAsyncIterator.ts index 08c6b80679d..cdbef67adde 100644 --- a/src/link/http/__tests__/responseIteratorNoAsyncIterator.ts +++ b/src/link/http/__tests__/responseIteratorNoAsyncIterator.ts @@ -276,6 +276,10 @@ describe("multipart responses", () => { await expect(observableStream).toEmitTypedValue(result); } + await expect(observableStream).toEmitTypedValue({ + hasNext: false, + }); + await expect(observableStream).toComplete(); }); }); diff --git a/src/link/http/parseAndCheckHttpResponse.ts b/src/link/http/parseAndCheckHttpResponse.ts index 37f47eb7751..f3f08d818fe 100644 --- a/src/link/http/parseAndCheckHttpResponse.ts +++ b/src/link/http/parseAndCheckHttpResponse.ts @@ -19,45 +19,46 @@ function isApolloPayloadResult(value: unknown): value is ApolloPayloadResult { return isNonNullObject(value) && "payload" in value; } -export async function readMultipartBody< - T extends object = Record, ->(response: Response, nextValue: (value: T) => void) { - if (TextDecoder === undefined) { - throw new Error( - "TextDecoder must be defined in the environment: please import a polyfill." - ); - } +export async function* consumeMultipartBody( + response: Response +): AsyncGenerator { const decoder = new TextDecoder("utf-8"); const contentType = response.headers?.get("content-type"); - const delimiter = "boundary="; // parse boundary value and ignore any subsequent name/value pairs after ; // https://www.rfc-editor.org/rfc/rfc9110.html#name-parameters // e.g. multipart/mixed;boundary="graphql";deferSpec=20220824 // if no boundary is specified, default to - - const boundaryVal = - contentType?.includes(delimiter) ? - contentType - ?.substring(contentType?.indexOf(delimiter) + delimiter.length) - .replace(/['"]/g, "") - .replace(/\;(.*)/gm, "") - .trim() - : "-"; - - const boundary = `\r\n--${boundaryVal}`; + const match = contentType?.match( + /* + ;\s*boundary= # Match the boundary parameter + (?: # either + '([^']*)' # a string starting with ' doesn't contain ', ends with ' + | # or + "([^"]*)" # a string starting with " doesn't contain ", ends with " + | # or + ([^"'].*?) # a string that doesn't start with ' or ", parsed non-greedily + ) # end of the group + \s* # optional whitespace + (?;;|$) # match a semicolon or end of string + */ + /;\s*boundary=(?:'([^']+)'|"([^"]+)"|([^"'].+?))\s*(?:;|$)/i + ); + const boundary = "\r\n--" + (match?.findLast((val) => !!val) || "-"); let buffer = ""; invariant( response.body && typeof response.body.getReader === "function", "Unknown type for `response.body`. Please use a `fetch` implementation that is WhatWG-compliant and that uses WhatWG ReadableStreams for `body`." ); - const iterator = response.body.getReader(); - let running = true; - while (running) { - const { value, done } = await iterator.read(); + const reader = response.body.getReader(); + let done = false; + let value: Uint8Array | string | undefined; + + while (!done) { + ({ value, done } = await reader.read()); const chunk = typeof value === "string" ? value : decoder.decode(value); const searchFrom = buffer.length - boundary.length + 1; - running = !done; buffer += chunk; let bi = buffer.indexOf(boundary, searchFrom); @@ -83,50 +84,41 @@ export async function readMultipartBody< const body = message.slice(i); if (body) { - const result = parseJsonEncoding(response, body); - if ( - Object.keys(result).length > 1 || - "data" in result || - "incremental" in result || - "errors" in result || - "payload" in result - ) { - if (isApolloPayloadResult(result)) { - let next = {}; - if ("payload" in result) { - if (Object.keys(result).length === 1 && result.payload === null) { - return; - } - next = { ...result.payload }; - } - if ("errors" in result) { - next = { - ...next, - extensions: { - ...("extensions" in next ? next.extensions : (null as any)), - [PROTOCOL_ERRORS_SYMBOL]: new CombinedProtocolErrors( - result.errors ?? [] - ), - }, - }; - } - nextValue(next as T); - } else { - // for the last chunk with only `hasNext: false` - // we don't need to call observer.next as there is no data/errors - nextValue(result); - } - } else if ( - // If the chunk contains only a "hasNext: false", we can call - // observer.complete() immediately. - Object.keys(result).length === 1 && - "hasNext" in result && - !result.hasNext - ) { + yield body; + } + bi = buffer.indexOf(boundary); + } + } +} + +export async function readMultipartBody< + T extends object = Record, +>(response: Response, nextValue: (value: T) => void) { + for await (const body of consumeMultipartBody(response)) { + const result = parseJsonEncoding(response, body); + if (Object.keys(result).length == 0) continue; + if (isApolloPayloadResult(result)) { + let next = {}; + if ("payload" in result) { + if (Object.keys(result).length === 1 && result.payload === null) { return; } + next = { ...result.payload }; } - bi = buffer.indexOf(boundary); + if ("errors" in result) { + next = { + ...next, + extensions: { + ...("extensions" in next ? next.extensions : (null as any)), + [PROTOCOL_ERRORS_SYMBOL]: new CombinedProtocolErrors( + result.errors ?? [] + ), + }, + }; + } + nextValue(next as T); + } else { + nextValue(result); } } } diff --git a/src/link/index.ts b/src/link/index.ts index ed33ae17b82..c6696aea929 100644 --- a/src/link/index.ts +++ b/src/link/index.ts @@ -10,15 +10,10 @@ export type { ApolloPayloadResult, DocumentNode, ExecuteContext, - ExecutionPatchIncrementalResult, - ExecutionPatchInitialResult, - ExecutionPatchResult, FetchResult, GraphQLRequest, - IncrementalPayload, NextLink, Operation, OperationContext, - Path, RequestHandler, } from "./core/types.js"; diff --git a/src/testing/internal/incremental.ts b/src/testing/internal/incremental.ts index 8f475b7437b..a457b2189ff 100644 --- a/src/testing/internal/incremental.ts +++ b/src/testing/internal/incremental.ts @@ -4,11 +4,10 @@ import { TransformStream, } from "node:stream/web"; -import type { GraphQLError } from "graphql"; import type { + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, GraphQLFormattedError, - InitialIncrementalExecutionResult, - SubsequentIncrementalExecutionResult, } from "graphql-17-alpha2"; import type { ApolloPayloadResult } from "@apollo/client"; @@ -115,8 +114,8 @@ export function mockDeferStream< TExtensions = Record, >() { const { httpLink, enqueue } = mockIncrementalStream< - | InitialIncrementalExecutionResult - | SubsequentIncrementalExecutionResult + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult >({ responseHeaders: new Headers({ "Content-Type": 'multipart/mixed; boundary="-"; deferSpec=20220824', @@ -125,12 +124,12 @@ export function mockDeferStream< return { httpLink, enqueueInitialChunk( - chunk: InitialIncrementalExecutionResult + chunk: FormattedInitialIncrementalExecutionResult ) { enqueue(chunk, chunk.hasNext); }, enqueueSubsequentChunk( - chunk: SubsequentIncrementalExecutionResult + chunk: FormattedSubsequentIncrementalExecutionResult ) { enqueue(chunk, chunk.hasNext); }, @@ -140,11 +139,10 @@ export function mockDeferStream< hasNext: true, incremental: [ { - // eslint-disable-next-line @typescript-eslint/no-restricted-types - errors: errors as GraphQLError[], + errors, }, ], - } satisfies SubsequentIncrementalExecutionResult, + }, true ); }, From 3eb1605cc28c14a6292f5beb71e7152d29114fd1 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 17 Jun 2025 19:11:21 +0200 Subject: [PATCH 02/12] remove export keyword --- src/link/http/parseAndCheckHttpResponse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/link/http/parseAndCheckHttpResponse.ts b/src/link/http/parseAndCheckHttpResponse.ts index f3f08d818fe..5da02d3d33f 100644 --- a/src/link/http/parseAndCheckHttpResponse.ts +++ b/src/link/http/parseAndCheckHttpResponse.ts @@ -19,7 +19,7 @@ function isApolloPayloadResult(value: unknown): value is ApolloPayloadResult { return isNonNullObject(value) && "payload" in value; } -export async function* consumeMultipartBody( +async function* consumeMultipartBody( response: Response ): AsyncGenerator { const decoder = new TextDecoder("utf-8"); From ca7e5a8b9d25f76b6f80c45985d52eb265f01501 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 18 Jun 2025 12:45:49 +0200 Subject: [PATCH 03/12] Update src/link/http/__tests__/responseIterator.ts Co-authored-by: Jerel Miller --- src/link/http/__tests__/responseIterator.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/link/http/__tests__/responseIterator.ts b/src/link/http/__tests__/responseIterator.ts index 793a638bfd0..b489fe3076e 100644 --- a/src/link/http/__tests__/responseIterator.ts +++ b/src/link/http/__tests__/responseIterator.ts @@ -149,7 +149,6 @@ describe("multipart responses", () => { }), }; - console.log(val); return val; }); From 75d86b659009983668c355f5fb8bf6c7f3ca8ef7 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 18 Jun 2025 12:46:01 +0200 Subject: [PATCH 04/12] Update src/incremental/handlers/__tests__/defer20220824.test.ts Co-authored-by: Jerel Miller --- src/incremental/handlers/__tests__/defer20220824.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/incremental/handlers/__tests__/defer20220824.test.ts b/src/incremental/handlers/__tests__/defer20220824.test.ts index 15ea3f252d3..8e0b68dccde 100644 --- a/src/incremental/handlers/__tests__/defer20220824.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824.test.ts @@ -1114,8 +1114,6 @@ test("handles final chunk of { hasNext: false } correctly in usage with Apollo C await expect(observableStream).toEmitSimilarValue({ expected: (previous) => ({ ...previous, - // we are in an error state, should this be "complete" or "partial"/stay "streaming"? - // how would the types hold up to that? dataState: "complete", loading: false, networkStatus: NetworkStatus.error, From 2e977a404b0f740e29e3a832f405e70025501c25 Mon Sep 17 00:00:00 2001 From: phryneas <4282439+phryneas@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:48:13 +0000 Subject: [PATCH 05/12] Clean up Prettier, Size-limit, and Api-Extractor --- .size-limits.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.size-limits.json b/.size-limits.json index 0a02c5b9be7..df68a673adb 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,6 +1,6 @@ { - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43583, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38595, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33294, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27610 + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43630, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38590, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33306, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27562 } From 05ac9512685f6c993cf183cc9a849ccdf99945c4 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 18 Jun 2025 14:29:52 +0200 Subject: [PATCH 06/12] simplify incremental logic --- src/core/QueryInfo.ts | 20 +-- .../handlers/__tests__/defer20220824.test.ts | 60 +++------ src/incremental/handlers/defer20220824.ts | 123 +++++++++--------- src/incremental/types.ts | 2 +- 4 files changed, 93 insertions(+), 112 deletions(-) diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index 4c80828358d..dc17ca2d197 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -18,9 +18,10 @@ import type { ObservableQuery } from "./ObservableQuery.js"; import type { QueryManager } from "./QueryManager.js"; import type { DefaultContext, - NormalizedExecutionResult, InternalRefetchQueriesInclude, + MutationQueryReducer, MutationUpdaterFunction, + NormalizedExecutionResult, OnQueryUpdated, OperationVariables, Streaming, @@ -91,7 +92,7 @@ const queryInfoIds = new WeakMap(); // It is responsible for reporting results to the cache, merging and in a no-cache // scenario accumulating the response. export class QueryInfo< - TData, + TData extends Record, TVariables extends OperationVariables = OperationVariables, TCache extends ApolloCache = ApolloCache, > { @@ -182,7 +183,7 @@ export class QueryInfo< const { incrementalHandler } = this.queryManager; if (incrementalHandler.isIncrementalResult(incoming)) { - this.incremental ||= incrementalHandler.startRequest({ + this.incremental ||= incrementalHandler.startRequest({ query, }); @@ -402,11 +403,14 @@ export class QueryInfo< if (complete && currentQueryResult) { // Run our reducer using the current query result and the mutation result. - const nextQueryResult = updater(currentQueryResult, { - mutationResult: getResultWithDataState(), - queryName: (document && getOperationName(document)) || void 0, - queryVariables: variables!, - }); + const nextQueryResult = (updater as MutationQueryReducer)( + currentQueryResult, + { + mutationResult: getResultWithDataState(), + queryName: (document && getOperationName(document)) || void 0, + queryVariables: variables!, + } + ); // Write the modified result back into the store if we got a new result. if (nextQueryResult) { diff --git a/src/incremental/handlers/__tests__/defer20220824.test.ts b/src/incremental/handlers/__tests__/defer20220824.test.ts index 8e0b68dccde..8c6d10f0b7b 100644 --- a/src/incremental/handlers/__tests__/defer20220824.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824.test.ts @@ -33,8 +33,7 @@ import { } from "@apollo/client/testing/internal"; import { - isIncrementalInitialResult, - isIncrementalSubsequentResult, + hasIncrementalChunks, // eslint-disable-next-line local-rules/no-relative-imports } from "../defer20220824.js"; @@ -168,8 +167,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(true); - expect(isIncrementalSubsequentResult(chunk)).toBe(false); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { @@ -185,8 +183,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(false); - expect(isIncrementalSubsequentResult(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { @@ -218,8 +215,7 @@ describe("graphql-js test cases", () => { const { value: chunk } = (await incoming.next())!; assert(chunk); expect(handler.isIncrementalResult(chunk)).toBe(false); - expect(isIncrementalInitialResult(chunk)).toBe(false); - expect(isIncrementalSubsequentResult(chunk)).toBe(false); + expect(hasIncrementalChunks(chunk)).toBe(false); }); it.skip("Does not disable defer with null if argument", async () => { // test is not interesting from a client perspective @@ -243,8 +239,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(true); - expect(isIncrementalSubsequentResult(chunk)).toBe(false); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: {}, errors: [], @@ -256,8 +251,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(false); - expect(isIncrementalSubsequentResult(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { @@ -290,8 +284,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(true); - expect(isIncrementalSubsequentResult(chunk)).toBe(false); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: {}, errors: [], @@ -303,8 +296,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(false); - expect(isIncrementalSubsequentResult(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { @@ -349,8 +341,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(true); - expect(isIncrementalSubsequentResult(chunk)).toBe(false); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { @@ -367,8 +358,7 @@ describe("graphql-js test cases", () => { assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(false); - expect(isIncrementalSubsequentResult(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { @@ -406,8 +396,7 @@ describe("graphql-js test cases", () => { assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(true); - expect(isIncrementalSubsequentResult(chunk)).toBe(false); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1", name: "Luke" }, @@ -422,8 +411,7 @@ describe("graphql-js test cases", () => { assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(false); - expect(isIncrementalSubsequentResult(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { @@ -465,8 +453,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(true); - expect(isIncrementalSubsequentResult(chunk)).toBe(false); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1" } }, errors: [], @@ -478,8 +465,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(false); - expect(isIncrementalSubsequentResult(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { @@ -519,8 +505,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(true); - expect(isIncrementalSubsequentResult(chunk)).toBe(false); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1" } }, errors: [], @@ -532,8 +517,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(false); - expect(isIncrementalSubsequentResult(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { @@ -573,8 +557,7 @@ describe("graphql-js test cases", () => { const { value: chunk } = (await incoming.next())!; assert(chunk); expect(handler.isIncrementalResult(chunk)).toBe(false); - expect(isIncrementalInitialResult(chunk)).toBe(false); - expect(isIncrementalSubsequentResult(chunk)).toBe(false); + expect(hasIncrementalChunks(chunk)).toBe(false); }); it.skip("Handles async non-nullable errors thrown in deferred fragments", async () => { // from client perspective, a repeat of a previous one @@ -606,8 +589,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(true); - expect(isIncrementalSubsequentResult(chunk)).toBe(false); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1" } }, errors: [], @@ -619,8 +601,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(false); - expect(isIncrementalSubsequentResult(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { @@ -638,8 +619,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); expect(handler.isIncrementalResult(chunk)).toBe(true); - expect(isIncrementalInitialResult(chunk)).toBe(false); - expect(isIncrementalSubsequentResult(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index 5909d807fab..86aa607eada 100644 --- a/src/incremental/handlers/defer20220824.ts +++ b/src/incremental/handlers/defer20220824.ts @@ -5,7 +5,6 @@ import type { FetchResult, GraphQLRequest } from "@apollo/client"; import type { DeepPartial } from "@apollo/client/utilities"; import { DeepMerger, - getGraphQLErrorsFromResult, hasDirectives, isNonEmptyArray, } from "@apollo/client/utilities/internal"; @@ -16,39 +15,40 @@ export declare namespace Defer20220824Handler { export type InitialResult> = { data?: TData | null | undefined; errors?: ReadonlyArray; - extensions?: Record; + extensions?: Record; hasNext: boolean; }; export type SubsequentResult> = { + data?: TData | null | undefined; + errors?: ReadonlyArray; + extensions?: Record; hasNext: boolean; - incremental?: Array>; + incremental?: Array>; }; - export type Chunk> = + export type Chunk> = | InitialResult | SubsequentResult; - export type IncrementalPayload> = - IncrementalDeferPayload; - - export interface IncrementalDeferPayload> { - data?: TData | null; + export type IncrementalDeferPayload> = { + data?: TData | null | undefined; errors?: ReadonlyArray; extensions?: Record; path?: Incremental.Path; label?: string; - } + }; } declare module "@apollo/client/link" { export interface AdditionalFetchResultTypes { - Defer20220824Handler: Defer20220824Handler.Chunk; + Defer20220824Handler: Defer20220824Handler.Chunk>; } } -class DeferRequest - implements Incremental.IncrementalRequest +class DeferRequest> + implements + Incremental.IncrementalRequest, TData> { public hasNext = true; @@ -56,46 +56,52 @@ class DeferRequest private extensions: Record = {}; private data: any = {}; + private mergeIn( + normalized: FormattedExecutionResult, + merger: DeepMerger + ) { + if (normalized.data !== undefined) { + this.data = merger.merge(this.data, normalized.data); + } + if (normalized.errors) { + this.errors.push(...normalized.errors); + } + Object.assign(this.extensions, normalized.extensions); + } + handle( // we'll get `undefined` here in case of a `no-cache` fetch policy, // so we'll continue with the last value this request had accumulated cacheData: TData | DeepPartial | null | undefined = this.data, - chunk: - | Defer20220824Handler.InitialResult - | Defer20220824Handler.SubsequentResult + chunk: Defer20220824Handler.Chunk ): FormattedExecutionResult { this.hasNext = chunk.hasNext; this.data = cacheData; - if (isIncrementalSubsequentResult(chunk)) { - if (isNonEmptyArray(chunk.incremental)) { - const merger = new DeepMerger(); - for (const incremental of chunk.incremental) { - let { data, path } = incremental; - if (data && path) { - for (let i = path.length - 1; i >= 0; --i) { - const key = path[i]; - const isNumericKey = !isNaN(+key); - const parent: Record = - isNumericKey ? [] : {}; - parent[key] = data; - data = parent as typeof data; - } - this.data = merger.merge(this.data, data); + + this.mergeIn(chunk, new DeepMerger()); + + if (hasIncrementalChunks(chunk)) { + const merger = new DeepMerger(); + for (const incremental of chunk.incremental) { + let { data, path, errors, extensions } = incremental; + if (data && path) { + for (let i = path.length - 1; i >= 0; --i) { + const key = path[i]; + const isNumericKey = !isNaN(+key); + const parent: Record = isNumericKey ? [] : {}; + parent[key] = data; + data = parent as typeof data; } - this.errors.push(...getGraphQLErrorsFromResult(incremental)); - Object.assign(this.extensions, incremental.extensions); } + this.mergeIn( + { + errors, + extensions, + data: data ? (data as TData) : undefined, + }, + merger + ); } - - // Detect the first chunk of a deferred query and merge it with existing - // cache data. This ensures a `cache-first` fetch policy that returns - // partial cache data or a `cache-and-network` fetch policy that already - // has full data in the cache does not complain when trying to merge the - // initial deferred server data with existing cache data. - } else if (isIncrementalInitialResult(chunk)) { - this.data = new DeepMerger().merge(this.data, chunk.data); - this.errors = getGraphQLErrorsFromResult(chunk); - this.extensions = { ...chunk.extensions }; } const { data, errors, extensions } = this; @@ -108,18 +114,14 @@ class DeferRequest * https://github.com/graphql/graphql-spec/tree/48cf7263a71a683fab03d45d309fd42d8d9a6659/spec */ export class Defer20220824Handler - implements Incremental.Handler + implements Incremental.Handler> { isIncrementalResult( result: Record ): result is | Defer20220824Handler.SubsequentResult | Defer20220824Handler.InitialResult { - return ( - isIncrementalInitialResult(result) || - isIncrementalSubsequentResult(result) || - (Object.keys(result).length === 1 && "hasNext" in result) // hasNext-only chunk - ); + return "hasNext" in result; } extractErrors(result: FetchResult) { @@ -133,11 +135,11 @@ export class Defer20220824Handler acc.push(...errors); } }; - if (isIncrementalInitialResult(result)) { + if (this.isIncrementalResult(result)) { push(result); - } - if (isIncrementalSubsequentResult(result)) { - result.incremental.forEach(push); + if (hasIncrementalChunks(result)) { + result.incremental.forEach(push); + } } if (acc.length) { return acc; @@ -156,21 +158,16 @@ export class Defer20220824Handler return request; } - startRequest(_: { query: DocumentNode }) { + startRequest>(_: { + query: DocumentNode; + }) { return new DeferRequest(); } } // only exported for use in tests -export function isIncrementalSubsequentResult( +export function hasIncrementalChunks( result: Record ): result is Required { - return Array.isArray(result.incremental); -} - -// only exported for use in tests -export function isIncrementalInitialResult( - result: Record -): result is Defer20220824Handler.InitialResult { - return "hasNext" in result && ("data" in result || "errors" in result); + return isNonEmptyArray(result.incremental); } diff --git a/src/incremental/types.ts b/src/incremental/types.ts index 777d7b4d57b..e2085b9805f 100644 --- a/src/incremental/types.ts +++ b/src/incremental/types.ts @@ -19,7 +19,7 @@ export declare namespace Incremental { extractErrors: ( result: FetchResult ) => readonly GraphQLFormattedError[] | undefined | void; - startRequest: (request: { + startRequest: >(request: { query: DocumentNode; }) => IncrementalRequest; } From 13f43927984e2937c15981f457e5dab6dba0387e Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 18 Jun 2025 14:31:22 +0200 Subject: [PATCH 07/12] Update src/link/http/parseAndCheckHttpResponse.ts Co-authored-by: Jerel Miller --- src/link/http/parseAndCheckHttpResponse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/link/http/parseAndCheckHttpResponse.ts b/src/link/http/parseAndCheckHttpResponse.ts index 5da02d3d33f..ca7c15fb2d3 100644 --- a/src/link/http/parseAndCheckHttpResponse.ts +++ b/src/link/http/parseAndCheckHttpResponse.ts @@ -40,7 +40,7 @@ async function* consumeMultipartBody( ([^"'].*?) # a string that doesn't start with ' or ", parsed non-greedily ) # end of the group \s* # optional whitespace - (?;;|$) # match a semicolon or end of string + (?:;|$) # match a semicolon or end of string */ /;\s*boundary=(?:'([^']+)'|"([^"]+)"|([^"'].+?))\s*(?:;|$)/i ); From 5bf05bdfc2d0ab9411e9f8b3e1f2ed4659cf28da Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 18 Jun 2025 14:32:25 +0200 Subject: [PATCH 08/12] Update src/link/http/parseAndCheckHttpResponse.ts Co-authored-by: Jerel Miller --- src/link/http/parseAndCheckHttpResponse.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/link/http/parseAndCheckHttpResponse.ts b/src/link/http/parseAndCheckHttpResponse.ts index ca7c15fb2d3..9a383ce6156 100644 --- a/src/link/http/parseAndCheckHttpResponse.ts +++ b/src/link/http/parseAndCheckHttpResponse.ts @@ -98,13 +98,10 @@ export async function readMultipartBody< const result = parseJsonEncoding(response, body); if (Object.keys(result).length == 0) continue; if (isApolloPayloadResult(result)) { - let next = {}; - if ("payload" in result) { - if (Object.keys(result).length === 1 && result.payload === null) { - return; - } - next = { ...result.payload }; + if (Object.keys(result).length === 1 && result.payload === null) { + return; } + let next = { ...result.payload }; if ("errors" in result) { next = { ...next, From d7235d107eedc1f770649bdc7a7a767d5d25840d Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 18 Jun 2025 14:33:41 +0200 Subject: [PATCH 09/12] Update src/link/http/parseAndCheckHttpResponse.ts Co-authored-by: Jerel Miller --- src/link/http/parseAndCheckHttpResponse.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/link/http/parseAndCheckHttpResponse.ts b/src/link/http/parseAndCheckHttpResponse.ts index 9a383ce6156..140573be0ed 100644 --- a/src/link/http/parseAndCheckHttpResponse.ts +++ b/src/link/http/parseAndCheckHttpResponse.ts @@ -103,14 +103,11 @@ export async function readMultipartBody< } let next = { ...result.payload }; if ("errors" in result) { - next = { - ...next, - extensions: { - ...("extensions" in next ? next.extensions : (null as any)), - [PROTOCOL_ERRORS_SYMBOL]: new CombinedProtocolErrors( - result.errors ?? [] - ), - }, + next.extensions = { + ...next.extensions, + [PROTOCOL_ERRORS_SYMBOL]: new CombinedProtocolErrors( + result.errors ?? [] + ), }; } nextValue(next as T); From 130d2424d576148de41d661e2b0ffb2726e0d238 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 18 Jun 2025 14:46:55 +0200 Subject: [PATCH 10/12] finangle types --- src/core/QueryInfo.ts | 11 ++++++++--- src/link/error/__tests__/index.ts | 4 ++-- src/link/http/__tests__/HttpLink.ts | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index dc17ca2d197..90c8e333f0e 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -92,7 +92,7 @@ const queryInfoIds = new WeakMap(); // It is responsible for reporting results to the cache, merging and in a no-cache // scenario accumulating the response. export class QueryInfo< - TData extends Record, + TData, TVariables extends OperationVariables = OperationVariables, TCache extends ApolloCache = ApolloCache, > { @@ -183,9 +183,14 @@ export class QueryInfo< const { incrementalHandler } = this.queryManager; if (incrementalHandler.isIncrementalResult(incoming)) { - this.incremental ||= incrementalHandler.startRequest({ + this.incremental ||= incrementalHandler.startRequest< + TData & Record + >({ query, - }); + }) as Incremental.IncrementalRequest< + Record, + TData | Streaming + >; return this.incremental.handle(cacheData, incoming); } diff --git a/src/link/error/__tests__/index.ts b/src/link/error/__tests__/index.ts index 725a758d652..37c29b16985 100644 --- a/src/link/error/__tests__/index.ts +++ b/src/link/error/__tests__/index.ts @@ -320,7 +320,7 @@ describe("error handling", () => { message: "Error field", }, ]), - }, + } as Record, }); expect(callback).toHaveBeenCalledTimes(1); @@ -672,7 +672,7 @@ describe("error handling with class", () => { message: "Error field", }, ]), - }, + } as Record, }); expect(callback).toHaveBeenCalledTimes(1); diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts index 3509e5037a3..d7717d4dc19 100644 --- a/src/link/http/__tests__/HttpLink.ts +++ b/src/link/http/__tests__/HttpLink.ts @@ -1762,7 +1762,7 @@ describe("HttpLink", () => { message: "Error field", }, ]), - }, + } as Record, }); await expect(observableStream).toComplete(); From 0908a4d9a530879ba852655696bca3a338fd2779 Mon Sep 17 00:00:00 2001 From: phryneas <4282439+phryneas@users.noreply.github.com> Date: Wed, 18 Jun 2025 12:49:33 +0000 Subject: [PATCH 11/12] Clean up Prettier, Size-limit, and Api-Extractor --- .api-reports/api-report-incremental.api.md | 34 ++++++++++------------ .api-reports/api-report.api.md | 2 +- .size-limits.json | 8 ++--- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/.api-reports/api-report-incremental.api.md b/.api-reports/api-report-incremental.api.md index 22db4729cd4..7f24be1cfb5 100644 --- a/.api-reports/api-report-incremental.api.md +++ b/.api-reports/api-report-incremental.api.md @@ -14,38 +14,34 @@ import type { GraphQLRequest } from '@apollo/client'; // @public (undocumented) namespace Defer20220824Handler { // (undocumented) - type Chunk> = InitialResult | SubsequentResult; + type Chunk> = InitialResult | SubsequentResult; // (undocumented) - interface IncrementalDeferPayload> { - // (undocumented) - data?: TData | null; - // (undocumented) + type IncrementalDeferPayload> = { + data?: TData | null | undefined; errors?: ReadonlyArray; - // (undocumented) extensions?: Record; - // (undocumented) - label?: string; - // (undocumented) path?: Incremental.Path; - } - // (undocumented) - type IncrementalPayload> = IncrementalDeferPayload; + label?: string; + }; // (undocumented) type InitialResult> = { data?: TData | null | undefined; errors?: ReadonlyArray; - extensions?: Record; + extensions?: Record; hasNext: boolean; }; // (undocumented) type SubsequentResult> = { + data?: TData | null | undefined; + errors?: ReadonlyArray; + extensions?: Record; hasNext: boolean; - incremental?: Array>; + incremental?: Array>; }; } // @public -class Defer20220824Handler implements Incremental.Handler { +class Defer20220824Handler implements Incremental.Handler> { // (undocumented) extractErrors(result: FetchResult): GraphQLFormattedError[] | undefined; // (undocumented) @@ -55,7 +51,7 @@ class Defer20220824Handler implements Incremental.Handler(_: { + startRequest>(_: { query: DocumentNode; }): DeferRequest; } @@ -63,9 +59,9 @@ export { Defer20220824Handler } export { Defer20220824Handler as GraphQL17Alpha2Handler } // @public (undocumented) -class DeferRequest implements Incremental.IncrementalRequest { +class DeferRequest> implements Incremental.IncrementalRequest, TData> { // (undocumented) - handle(cacheData: TData | DeepPartial | null | undefined, chunk: Defer20220824Handler.InitialResult | Defer20220824Handler.SubsequentResult): FormattedExecutionResult; + handle(cacheData: TData | DeepPartial | null | undefined, chunk: Defer20220824Handler.Chunk): FormattedExecutionResult; // (undocumented) hasNext: boolean; } @@ -81,7 +77,7 @@ export namespace Incremental { // (undocumented) prepareRequest: (request: GraphQLRequest) => GraphQLRequest; // (undocumented) - startRequest: (request: { + startRequest: >(request: { query: DocumentNode; }) => IncrementalRequest; } diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index e5bd6f85069..f485b835572 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -1249,7 +1249,7 @@ namespace Incremental { // Warning: (ae-forgotten-export) The symbol "Incremental" needs to be exported by the entry point index.d.ts // // (undocumented) - startRequest: (request: { + startRequest: >(request: { query: DocumentNode; }) => IncrementalRequest; } diff --git a/.size-limits.json b/.size-limits.json index df68a673adb..d585ef03f57 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,6 +1,6 @@ { - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43630, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38590, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33306, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27562 + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43494, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38514, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33279, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27582 } From 4d77aac9234072e8232c65d81d7748d18151ff9f Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 18 Jun 2025 17:39:40 +0200 Subject: [PATCH 12/12] chores --- .size-limits.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.size-limits.json b/.size-limits.json index d585ef03f57..767f099d391 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,6 +1,6 @@ { - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43494, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38514, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33279, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27582 + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43524, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38516, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33302, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27599 }