Repository: urql-graphql/urql (ts)

Issue:
cache issues when using `@defer` directive and `@urql/exchange-graphcache` 
### Describe the bug

I’ve run into a pretty weird issue while using urql and I could really use some help.

The main problem is that when my app sends a query with defer or when it receives chunked data, it seems to be the last request that’s ever fired.

For example, when I open my app and navigate through random pages, everything works just fine. But when I go to a page that uses defer and then move to another random page, I get a bunch of runtime errors due to missing data. It looks like the query was resolved (cache hit), but an API request was never fired. When I refresh the page, everything works again.

After some debugging, I narrowed it down to have something to do with `@urql/exchange-graphcache` (if I remove the exchange it works fine). Based on the logs and urql devtools, there’s a cache hit for those queries when I there shouldn’t be. Also, when I remove defer usage, logs from the same flow show cache misses.

Do you have any suggestions what could be causing this?

Urql dev tools:
* There's no API call for the last four queries
* The fifth one (from the bottom) is the one with `defer` directive
<img width="859" alt="CleanShot 2023-04-18 at 17 01 19@2x" src="https://user-images.githubusercontent.com/9019397/232907214-8ef31d6e-5d4b-4f17-9f1a-fbb57ea868d0.png">

<img width="474" alt="CleanShot 2023-04-18 at 17 02 33@2x" src="https://user-images.githubusercontent.com/9019397/232907145-6642de26-261e-4a5f-9bff-b6d51d74972b.png">



Here's a PR to GraphQL Hive with adding defer to one of the queries (and necessary setup): https://github.com/kamilkisiela/graphql-hive/pull/1964

### Reproduction

https://github.com/kamilkisiela/graphql-hive/pull/1964

### Urql version

@urql/core: 4.0.4

@urql/exchange-graphcache: 6.0.1

### Validations

- [X] I can confirm that this is a bug report, and not a feature request, RFC, question, or discussion, for which [GitHub Discussions](https://github.com/urql-graphql/urql/discussions) should be used
- [X] Read the [docs](https://formidable.com/open-source/urql/docs/).
- [X] Follow our [Code of Conduct](https://github.com/urql-graphql/urql/blob/main/CODE_OF_CONDUCT.md)

Pull request description:
fix(graphcache): Fix defer field state becoming sticky and affecting future fields
Resolves #3161

**Note:** This will need a rebase after #3165 is merged and should be merged on top.

## Summary

A rather old regression would cause `@defer` directives to manipulate the `deferRef` state and then become “sticky”, meaning that all future fields would also be considered deferred.

Instead, we need to make sure that this field resets properly and that the state returns to normal.

On a side-note, I tried to convert `makeSelectionIterator` to a generator function, since this would make it more elegant, but an initial rough implementation decreased overall cache read/write performance by 15–20%, so we'll have to investigate whether there's more we can do here. In general, traversal is the most expensive part of the cache operations, so if we can instead make it faster we'd profit from that quite a lot.

## Set of changes

- Refactor `makeSelectionIterator` to reset `deferRef`
- Add missing test cases for `makeSelectionIterator`


Patch:
diff --git a/.changeset/real-queens-crash.md b/.changeset/real-queens-crash.md
new file mode 100644
index 00000000..9f21a5bf
--- /dev/null
+++ b/.changeset/real-queens-crash.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Fix regression which caused `@defer` directives from becoming “sticky” and causing every subsequent cache read to be treated as if the field was deferred.
diff --git a/exchanges/graphcache/src/operations/query.ts b/exchanges/graphcache/src/operations/query.ts
index 80dcb2ca..83f3cf69 100644
--- a/exchanges/graphcache/src/operations/query.ts
+++ b/exchanges/graphcache/src/operations/query.ts
@@ -137,7 +137,13 @@ const readRoot = (
     return input;
   }
 
-  const iterate = makeSelectionIterator(entityKey, entityKey, select, ctx);
+  const iterate = makeSelectionIterator(
+    entityKey,
+    entityKey,
+    deferRef,
+    select,
+    ctx
+  );
 
   let node: FieldNode | void;
   let hasChanged = InMemoryData.currentForeignData;
@@ -334,7 +340,13 @@ const readSelection = (
   }
 
   const resolvers = store.resolvers[typename];
-  const iterate = makeSelectionIterator(typename, entityKey, select, ctx);
+  const iterate = makeSelectionIterator(
+    typename,
+    entityKey,
+    deferRef,
+    select,
+    ctx
+  );
 
   let hasFields = false;
   let hasPartials = false;
diff --git a/exchanges/graphcache/src/operations/shared.ts b/exchanges/graphcache/src/operations/shared.ts
index 28f9dd15..97681956 100644
--- a/exchanges/graphcache/src/operations/shared.ts
+++ b/exchanges/graphcache/src/operations/shared.ts
@@ -160,66 +160,60 @@ interface SelectionIterator {
 export const makeSelectionIterator = (
   typename: void | string,
   entityKey: string,
-  select: SelectionSet,
+  defer: boolean,
+  selectionSet: SelectionSet,
   ctx: Context
 ): SelectionIterator => {
-  let childDeferred = false;
-  let childIterator: SelectionIterator | void;
+  let child: SelectionIterator | void;
   let index = 0;
 
   return function next() {
-    if (!deferRef && childDeferred) deferRef = childDeferred;
-
-    if (childIterator) {
-      const node = childIterator();
-      if (node != null) {
-        return node;
-      }
-
-      childIterator = undefined;
-      childDeferred = false;
-      if (process.env.NODE_ENV !== 'production') {
-        popDebugNode();
-      }
-    }
-
-    while (index < select.length) {
-      const node = select[index++];
-      if (!shouldInclude(node, ctx.variables)) {
-        continue;
-      } else if (!isFieldNode(node)) {
-        // A fragment is either referred to by FragmentSpread or inline
-        const fragmentNode = !isInlineFragment(node)
-          ? ctx.fragments[getName(node)]
-          : node;
-
-        if (fragmentNode !== undefined) {
-          const isMatching = ctx.store.schema
-            ? isInterfaceOfType(ctx.store.schema, fragmentNode, typename)
-            : isFragmentHeuristicallyMatching(
-                fragmentNode,
+    let node: FieldNode | undefined;
+    while (child || index < selectionSet.length) {
+      node = undefined;
+      deferRef = defer;
+      if (child) {
+        if ((node = child())) {
+          return node;
+        } else {
+          child = undefined;
+          if (process.env.NODE_ENV !== 'production') popDebugNode();
+        }
+      } else {
+        const select = selectionSet[index++];
+        if (!shouldInclude(select, ctx.variables)) {
+          /*noop*/
+        } else if (!isFieldNode(select)) {
+          // A fragment is either referred to by FragmentSpread or inline
+          const fragment = !isInlineFragment(select)
+            ? ctx.fragments[getName(select)]
+            : select;
+          if (fragment) {
+            const isMatching =
+              !fragment.typeCondition ||
+              (ctx.store.schema
+                ? isInterfaceOfType(ctx.store.schema, fragment, typename)
+                : isFragmentHeuristicallyMatching(
+                    fragment,
+                    typename,
+                    entityKey,
+                    ctx.variables
+                  ));
+            if (isMatching) {
+              if (process.env.NODE_ENV !== 'production')
+                pushDebugNode(typename, fragment);
+              child = makeSelectionIterator(
                 typename,
                 entityKey,
-                ctx.variables
+                defer || isDeferred(select, ctx.variables),
+                getSelectionSet(fragment),
+                ctx
               );
-          if (isMatching) {
-            if (process.env.NODE_ENV !== 'production') {
-              pushDebugNode(typename, fragmentNode);
             }
-
-            childDeferred = !!isDeferred(node, ctx.variables);
-            if (!deferRef && childDeferred) deferRef = childDeferred;
-
-            return (childIterator = makeSelectionIterator(
-              typename,
-              entityKey,
-              getSelectionSet(fragmentNode)!,
-              ctx
-            ))();
           }
+        } else {
+          return select;
         }
-      } else {
-        return node;
       }
     }
   };
diff --git a/exchanges/graphcache/src/operations/write.ts b/exchanges/graphcache/src/operations/write.ts
index e2871fca..33b552e1 100644
--- a/exchanges/graphcache/src/operations/write.ts
+++ b/exchanges/graphcache/src/operations/write.ts
@@ -226,6 +226,7 @@ const writeSelection = (
   const iterate = makeSelectionIterator(
     typename,
     entityKey || typename,
+    deferRef,
     select,
     ctx
   );


Test patch:
diff --git a/exchanges/graphcache/src/operations/shared.test.ts b/exchanges/graphcache/src/operations/shared.test.ts
new file mode 100644
index 00000000..fdfb7111
--- /dev/null
+++ b/exchanges/graphcache/src/operations/shared.test.ts
@@ -0,0 +1,249 @@
+import { describe, it, expect } from 'vitest';
+import { TypedDocumentNode, gql } from '@urql/core';
+import { FieldNode } from '@0no-co/graphql.web';
+
+import { makeSelectionIterator, deferRef } from './shared';
+import { SelectionSet } from '../ast';
+
+const selectionOfDocument = (doc: TypedDocumentNode): SelectionSet => {
+  for (const definition of doc.definitions)
+    if (definition.kind === 'OperationDefinition')
+      return definition.selectionSet.selections;
+  return [];
+};
+
+const ctx = {} as any;
+
+describe('makeSelectionIterator', () => {
+  it('emits all fields', () => {
+    const selection = selectionOfDocument(
+      gql`
+        {
+          a
+          b
+          c
+        }
+      `
+    );
+    const iterate = makeSelectionIterator(
+      'Query',
+      'Query',
+      false,
+      selection,
+      ctx
+    );
+    const result: FieldNode[] = [];
+
+    let node: FieldNode | void;
+    while ((node = iterate())) result.push(node);
+
+    expect(result).toMatchInlineSnapshot(`
+      [
+        {
+          "alias": undefined,
+          "arguments": [],
+          "directives": [],
+          "kind": "Field",
+          "name": {
+            "kind": "Name",
+            "value": "a",
+          },
+          "selectionSet": undefined,
+        },
+        {
+          "alias": undefined,
+          "arguments": [],
+          "directives": [],
+          "kind": "Field",
+          "name": {
+            "kind": "Name",
+            "value": "b",
+          },
+          "selectionSet": undefined,
+        },
+        {
+          "alias": undefined,
+          "arguments": [],
+          "directives": [],
+          "kind": "Field",
+          "name": {
+            "kind": "Name",
+            "value": "c",
+          },
+          "selectionSet": undefined,
+        },
+      ]
+    `);
+  });
+
+  it('skips fields that are skipped or not included', () => {
+    const selection = selectionOfDocument(gql`
+      {
+        a @skip(if: true)
+        b @include(if: false)
+      }
+    `);
+
+    const iterate = makeSelectionIterator(
+      'Query',
+      'Query',
+      false,
+      selection,
+      ctx
+    );
+    const result: FieldNode[] = [];
+
+    let node: FieldNode | void;
+    while ((node = iterate())) result.push(node);
+
+    expect(result).toMatchInlineSnapshot('[]');
+  });
+
+  it('processes fragments', () => {
+    const selection = selectionOfDocument(gql`
+      {
+        a
+        ... {
+          b
+        }
+        ... {
+          ... {
+            c
+          }
+        }
+      }
+    `);
+
+    const iterate = makeSelectionIterator(
+      'Query',
+      'Query',
+      false,
+      selection,
+      ctx
+    );
+    const result: FieldNode[] = [];
+
+    let node: FieldNode | void;
+    while ((node = iterate())) result.push(node);
+
+    expect(result).toMatchInlineSnapshot(`
+      [
+        {
+          "alias": undefined,
+          "arguments": [],
+          "directives": [],
+          "kind": "Field",
+          "name": {
+            "kind": "Name",
+            "value": "a",
+          },
+          "selectionSet": undefined,
+        },
+        {
+          "alias": undefined,
+          "arguments": [],
+          "directives": [],
+          "kind": "Field",
+          "name": {
+            "kind": "Name",
+            "value": "b",
+          },
+          "selectionSet": undefined,
+        },
+        {
+          "alias": undefined,
+          "arguments": [],
+          "directives": [],
+          "kind": "Field",
+          "name": {
+            "kind": "Name",
+            "value": "c",
+          },
+          "selectionSet": undefined,
+        },
+      ]
+    `);
+  });
+
+  it('updates deferred state as needed', () => {
+    const selection = selectionOfDocument(gql`
+      {
+        a
+        ... @defer {
+          b
+        }
+        ... {
+          ... @defer {
+            c
+          }
+        }
+        ... {
+          ... {
+            d
+          }
+        }
+        ... @defer {
+          ... {
+            e
+          }
+        }
+        ... {
+          ... {
+            f
+          }
+        }
+        ... {
+          g
+        }
+        h
+      }
+    `);
+
+    const iterate = makeSelectionIterator(
+      'Query',
+      'Query',
+      false,
+      selection,
+      ctx
+    );
+
+    const deferred: boolean[] = [];
+    while (iterate()) deferred.push(deferRef);
+    expect(deferred).toEqual([
+      false, // a
+      true, // b
+      true, // c
+      false, // d
+      true, // e
+      false, // f
+      false, // g
+      false, // h
+    ]);
+  });
+
+  it('applies the parent’s defer state if needed', () => {
+    const selection = selectionOfDocument(gql`
+      {
+        a
+        ... @defer {
+          b
+        }
+        ... {
+          c
+        }
+      }
+    `);
+
+    const iterate = makeSelectionIterator(
+      'Query',
+      'Query',
+      true,
+      selection,
+      ctx
+    );
+
+    const deferred: boolean[] = [];
+    while (iterate()) deferred.push(deferRef);
+    expect(deferred).toEqual([true, true, true]);
+  });
+});