From d48f4ef56578dad7ec90f33458353791e463ef7b Mon Sep 17 00:00:00 2001 From: Thomas Heyenbrock Date: Tue, 7 May 2024 18:16:20 +0200 Subject: [PATCH] implement correct merging of incremental respones (@defer/@stream) (#3580) * implement correct merging of incremental respones (@defer/@stream) * fix lint error * fix error for real --- .changeset/clean-pets-dance.md | 5 ++ packages/graphiql-react/src/execution.tsx | 102 ++++++++++++++-------- 2 files changed, 71 insertions(+), 36 deletions(-) create mode 100644 .changeset/clean-pets-dance.md diff --git a/.changeset/clean-pets-dance.md b/.changeset/clean-pets-dance.md new file mode 100644 index 00000000000..c37ceaeb48f --- /dev/null +++ b/.changeset/clean-pets-dance.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Implement correct merging of incremental responses (@defer/@stream) diff --git a/packages/graphiql-react/src/execution.tsx b/packages/graphiql-react/src/execution.tsx index d66b4eea78b..be1b8bcaced 100644 --- a/packages/graphiql-react/src/execution.tsx +++ b/packages/graphiql-react/src/execution.tsx @@ -1,13 +1,17 @@ import { Fetcher, - FetcherResultPayload, formatError, formatResult, isAsyncIterable, isObservable, Unsubscribable, } from '@graphiql/toolkit'; -import { ExecutionResult, FragmentDefinitionNode, print } from 'graphql'; +import { + ExecutionResult, + FragmentDefinitionNode, + GraphQLError, + print, +} from 'graphql'; import { getFragmentDependenciesForAST } from 'graphql-language-service'; import { ReactNode, useCallback, useMemo, useRef, useState } from 'react'; import setValue from 'set-value'; @@ -183,7 +187,7 @@ export function ExecutionContextProvider({ }); try { - let fullResponse: FetcherResultPayload = { data: {} }; + const fullResponse: ExecutionResult = {}; const handleResponse = (result: ExecutionResult) => { // A different query was dispatched in the meantime, so don't // show the results of this one. @@ -202,40 +206,8 @@ export function ExecutionContextProvider({ } if (maybeMultipart) { - const payload: FetcherResultPayload = { - data: fullResponse.data, - }; - const maybeErrors = [ - ...(fullResponse?.errors || []), - ...maybeMultipart.flatMap(i => i.errors).filter(Boolean), - ]; - - if (maybeErrors.length) { - payload.errors = maybeErrors; - } - for (const part of maybeMultipart) { - // We pull out errors here, so we dont include it later - const { path, data, errors, ...rest } = part; - if (path) { - if (!data) { - throw new Error( - `Expected part to contain a data property, but got ${part}`, - ); - } - - setValue(payload.data, path, data, { merge: true }); - } else if (data) { - // If there is no path, we don't know what to do with the payload, - // so we just set it. - payload.data = data; - } - - // Ensures we also bring extensions and alike along for the ride - fullResponse = { - ...payload, - ...rest, - }; + mergeIncrementalResult(fullResponse, part); } setIsFetching(false); @@ -361,3 +333,61 @@ function tryParseJsonObject({ } return parsed; } + +type IncrementalResult = { + data?: Record | null; + errors?: ReadonlyArray; + extensions?: Record; + hasNext?: boolean; + path?: ReadonlyArray; + incremental?: ReadonlyArray; + label?: string; + items?: ReadonlyArray> | null; +}; + +/** + * @param executionResult The complete execution result object which will be + * mutated by merging the contents of the incremental result. + * @param incrementalResult The incremental result that will be merged into the + * complete execution result. + */ +function mergeIncrementalResult( + executionResult: ExecutionResult, + incrementalResult: IncrementalResult, +): void { + const path = ['data', ...(incrementalResult.path ?? [])]; + + if (incrementalResult.items) { + for (const item of incrementalResult.items) { + setValue(executionResult, path.join('.'), item); + // Increment the last path segment (the array index) to merge the next item at the next index + // eslint-disable-next-line unicorn/prefer-at -- cannot mutate the array using Array.at() + (path[path.length - 1] as number)++; + } + } + + if (incrementalResult.data) { + setValue(executionResult, path.join('.'), incrementalResult.data, { + merge: true, + }); + } + + if (incrementalResult.errors) { + executionResult.errors ||= []; + (executionResult.errors as GraphQLError[]).push( + ...incrementalResult.errors, + ); + } + + if (incrementalResult.extensions) { + setValue(executionResult, 'extensions', incrementalResult.extensions, { + merge: true, + }); + } + + if (incrementalResult.incremental) { + for (const incrementalSubResult of incrementalResult.incremental) { + mergeIncrementalResult(executionResult, incrementalSubResult); + } + } +}