Repository: urql-graphql/urql
Pull request #3389 (2023-09-26 10:40:25)

feat(core): Update defer/stream protocol as per spec
## Summary

In the [new June 2023 spec](https://github.com/graphql/defer-stream-wg/discussions/69) we get the introduction of `pending` on both incremental and execution results, pending contains an array of id and path.

The id will be used in incremental payloads, we can then do a lookup in `pending` and find a path, the path indicates where the fragment got spread starting at the root.

Another addition here is the `subPath` which can be present on an incremental payload and builds on the path we can retrieve through `id`, when deferred selection-sets are mixed with the non-deferred one this subPath will point at where we have to start merging.


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 2da5e0a5b9dea4cf8fd1618f074ca0b58aee0fae

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/JoviDeCroock/urql/new/new-defer-stream-spec?filename=.changeset/sharp-chairs-kneel.md&value=---%0A%22%40urql%2Fcore%22%3A%20patch%0A---%0A%0Afeat(core)%3A%20implement%20new%20defer%2Fstream%20spec%0A)



Patch:
diff --git a/.changeset/fast-jeans-pay.md b/.changeset/fast-jeans-pay.md
new file mode 100644
index 00000000..68829fc6
--- /dev/null
+++ b/.changeset/fast-jeans-pay.md
@@ -0,0 +1,5 @@
+---
+"@urql/core": patch
+---
+
+Implement new `@defer` / `@stream` transport protocol spec changes.
diff --git a/packages/core/src/internal/fetchSource.ts b/packages/core/src/internal/fetchSource.ts
index 28e87e30..a29d46f2 100644
--- a/packages/core/src/internal/fetchSource.ts
+++ b/packages/core/src/internal/fetchSource.ts
@@ -175,9 +175,15 @@ async function* fetchOperation(
       throw new Error(await response.text());
     }
 
+    let pending: ExecutionResult['pending'];
     for await (const payload of results) {
+      if (payload.pending && !result) {
+        pending = payload.pending;
+      } else if (payload.pending) {
+        pending = [...pending!, ...payload.pending];
+      }
       result = result
-        ? mergeResultPatch(result, payload, response)
+        ? mergeResultPatch(result, payload, response, pending)
         : makeResult(operation, payload, response);
       networkMode = false;
       yield result;
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 06e80ea7..33ab727f 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -114,6 +114,8 @@ export interface RequestExtensions {
   [extension: string]: any;
 }
 
+type Path = readonly (string | number)[];
+
 /** Incremental Payloads sent as part of "Incremental Delivery" patching prior result data.
  *
  * @remarks
@@ -139,7 +141,16 @@ export interface IncrementalPayload {
    * entry of the `path` will be an index number at which to start setting the range of
    * items.
    */
-  path: readonly (string | number)[];
+  path?: Path;
+  /** An id pointing at an entry in the "pending" set of deferred results
+   *
+   * @remarks
+   * When we resolve this id it will give us the path to the deferred Fragment, this
+   * can be afterwards combined with the subPath to get the eventual location of the data.
+   */
+  id?: string;
+  /** A path array from the defer/stream fragment to the location of our data. */
+  subPath?: Path;
   /** Data to patch into the result data at the given `path`.
    *
    * @remarks
@@ -172,7 +183,21 @@ export interface IncrementalPayload {
   extensions?: Extensions;
 }
 
+type PendingIncrementalResult = {
+  path: Path;
+  id: string;
+  label?: string;
+};
+
 export interface ExecutionResult {
+  /** Payloads we are still waiting for from the server.
+   *
+   * @remarks
+   * This was nely introduced in the defer/stream spec iteration of June 2023 https://github.com/graphql/defer-stream-wg/discussions/69
+   * Pending can be present on both Incremental as well as normal execution results, the presence of pending on an incremental
+   * result points at a nested deferred/streamed fragment.
+   */
+  pending?: readonly PendingIncrementalResult[];
   /** Incremental patches to be applied to a previous result as part of "Incremental Delivery".
    *
    * @remarks
diff --git a/packages/core/src/utils/result.ts b/packages/core/src/utils/result.ts
index d6319474..01170da6 100644
--- a/packages/core/src/utils/result.ts
+++ b/packages/core/src/utils/result.ts
@@ -85,7 +85,8 @@ const deepMerge = (target: any, source: any) => {
 export const mergeResultPatch = (
   prevResult: OperationResult,
   nextResult: ExecutionResult,
-  response?: any
+  response?: any,
+  pending?: ExecutionResult['pending']
 ): OperationResult => {
   let errors = prevResult.error ? prevResult.error.graphQLErrors : [];
   let hasExtensions = !!prevResult.extensions || !!nextResult.extensions;
@@ -112,7 +113,19 @@ export const mergeResultPatch = (
 
       let prop: string | number = 'data';
       let part: Record<string, any> | Array<any> = withData;
-      for (let i = 0, l = patch.path.length; i < l; prop = patch.path[i++]) {
+      let path: readonly (string | number)[] = [];
+      if (patch.path) {
+        path = patch.path;
+      } else if (pending) {
+        const res = pending.find(pendingRes => pendingRes.id === patch.id);
+        if (patch.subPath) {
+          path = [...res!.path, ...patch.subPath];
+        } else {
+          path = res!.path;
+        }
+      }
+
+      for (let i = 0, l = path.length; i < l; prop = path[i++]) {
         part = part[prop] = Array.isArray(part[prop])
           ? [...part[prop]]
           : { ...part[prop] };


Test patch:
diff --git a/packages/core/src/utils/result.test.ts b/packages/core/src/utils/result.test.ts
index 9670b6e9..4d53d2cb 100644
--- a/packages/core/src/utils/result.test.ts
+++ b/packages/core/src/utils/result.test.ts
@@ -38,7 +38,125 @@ describe('makeResult', () => {
   });
 });
 
-describe('mergeResultPatch', () => {
+describe('mergeResultPatch (defer/stream latest', () => {
+  it('should read pending and append the result', () => {
+    const pending = [{ id: '0', path: [] }];
+    const prevResult: OperationResult = {
+      operation: queryOperation,
+      stale: false,
+      hasNext: true,
+      data: {
+        f2: {
+          a: 'a',
+          b: 'b',
+          c: {
+            d: 'd',
+            e: 'e',
+            f: { h: 'h', i: 'i' },
+          },
+        },
+      },
+    };
+
+    const merged = mergeResultPatch(
+      prevResult,
+      {
+        incremental: [
+          { id: '0', data: { MyFragment: 'Query' } },
+          { id: '0', subPath: ['f2', 'c', 'f'], data: { j: 'j' } },
+        ],
+        // TODO: not sure if we need this but it's part of the spec
+        // completed: [{ id: '0' }],
+        hasNext: false,
+      },
+      undefined,
+      pending
+    );
+
+    expect(merged.data).toEqual({
+      MyFragment: 'Query',
+      f2: {
+        a: 'a',
+        b: 'b',
+        c: {
+          d: 'd',
+          e: 'e',
+          f: { h: 'h', i: 'i', j: 'j' },
+        },
+      },
+    });
+  });
+
+  it('should read pending and append the result w/ overlapping fields', () => {
+    const pending = [
+      { id: '0', path: [], label: 'D1' },
+      { id: '1', path: ['f2', 'c', 'f'], label: 'D2' },
+    ];
+    const prevResult: OperationResult = {
+      operation: queryOperation,
+      stale: false,
+      hasNext: true,
+      data: {
+        f2: {
+          a: 'A',
+          b: 'B',
+          c: {
+            d: 'D',
+            e: 'E',
+            f: {
+              h: 'H',
+              i: 'I',
+            },
+          },
+        },
+      },
+    };
+
+    const merged = mergeResultPatch(
+      prevResult,
+      {
+        incremental: [
+          { id: '0', subPath: ['f2', 'c', 'f'], data: { j: 'J', k: 'K' } },
+        ],
+        pending: [{ id: '1', path: ['f2', 'c', 'f'], label: 'D2' }],
+        hasNext: true,
+      },
+      undefined,
+      pending
+    );
+
+    const merged2 = mergeResultPatch(
+      merged,
+      {
+        incremental: [{ id: '1', data: { l: 'L', m: 'M' } }],
+        hasNext: false,
+      },
+      undefined,
+      pending
+    );
+
+    expect(merged2.data).toEqual({
+      f2: {
+        a: 'A',
+        b: 'B',
+        c: {
+          d: 'D',
+          e: 'E',
+          f: {
+            h: 'H',
+            i: 'I',
+            j: 'J',
+            k: 'K',
+            l: 'L',
+            m: 'M',
+          },
+        },
+      },
+    });
+  });
+});
+
+describe('mergeResultPatch (defer/stream pre June-2023)', () => {
   it('should default hasNext to true if the last result was set to true', () => {
     const prevResult: OperationResult = {
       operation: subscriptionOperation,