Repository: urql-graphql/urql
Pull request #3693 (2024-10-19 14:54:30)

fix(graphcache): Replace selection iterator implementation for JSC memory reduction
Related to https://github.com/urql-graphql/urql/pull/3690
Related to https://github.com/0no-co/graphql.web/pull/33

## Summary

Attempt to get JSC memory usage under control by dropping the nested anonymous-function based iterator implementation with one with a manual stack. This has also been updated to use a `class` in the hopes of getting JSC to drop memory used by the iterator faster and reduce overall usage.
In testing, JSC seems to hang on to a large amount of scope memory due to the prior implementation.

> [!NOTE]
> Synthetic benchmarks are currently not present anymore (TBD). Browser benchmarks don't run anymore. Tests are currently broken for individual packages. In theory, however, we should be able to use Tachometer + Webdriver Safari to run the browser benchmarks on JSC to get comparative numbers.

## Set of changes

- Replace `makeSelectionIterator` with new stack-based implementation


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: e191b9ff910ae03e5cdc63533a0c9a0e587dbb60

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name                      | Type  |
| ------------------------- | ----- |
| @urql/exchange-graphcache | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/urql-graphql/urql/new/fix/jsc-selection-iterator-stack?filename=.changeset/seven-countries-rhyme.md&value=---%0A%22%40urql%2Fexchange-graphcache%22%3A%20patch%0A---%0A%0Afix(graphcache)%3A%20Replace%20selection%20iterator%20implementation%20for%20JSC%20memory%20reduction%0A)



Patch:
diff --git a/.changeset/tender-moons-watch.md b/.changeset/tender-moons-watch.md
new file mode 100644
index 00000000..65976c58
--- /dev/null
+++ b/.changeset/tender-moons-watch.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Update selection iterator implementation for JSC memory reduction
diff --git a/exchanges/graphcache/src/operations/query.ts b/exchanges/graphcache/src/operations/query.ts
index 3ad90a2e..f8ed04c9 100644
--- a/exchanges/graphcache/src/operations/query.ts
+++ b/exchanges/graphcache/src/operations/query.ts
@@ -37,7 +37,7 @@ import { warn, pushDebugNode, popDebugNode } from '../helpers/help';
 
 import type { Context } from './shared';
 import {
-  makeSelectionIterator,
+  SelectionIterator,
   ensureData,
   makeContext,
   updateContext,
@@ -142,7 +142,7 @@ const readRoot = (
     return input;
   }
 
-  const iterate = makeSelectionIterator(
+  const selection = new SelectionIterator(
     entityKey,
     entityKey,
     false,
@@ -154,7 +154,7 @@ const readRoot = (
   let node: FormattedNode<FieldNode> | void;
   let hasChanged = InMemoryData.currentForeignData;
   const output = InMemoryData.makeData(input);
-  while ((node = iterate())) {
+  while ((node = selection.next())) {
     const fieldAlias = getFieldAlias(node);
     const fieldValue = input[fieldAlias];
     // Add the current alias to the walked path before processing the field's value
@@ -387,7 +387,7 @@ const readSelection = (
     return;
   }
 
-  const iterate = makeSelectionIterator(
+  const selection = new SelectionIterator(
     typename,
     entityKey,
     false,
@@ -402,7 +402,7 @@ const readSelection = (
   let node: FormattedNode<FieldNode> | void;
   const hasPartials = ctx.partial;
   const output = InMemoryData.makeData(input);
-  while ((node = iterate()) !== undefined) {
+  while ((node = selection.next()) !== undefined) {
     // Derive the needed data from our node.
     const fieldName = getName(node);
     const fieldArgs = getFieldArguments(node, ctx.variables);
diff --git a/exchanges/graphcache/src/operations/shared.ts b/exchanges/graphcache/src/operations/shared.ts
index bb1893a3..3bfa1560 100644
--- a/exchanges/graphcache/src/operations/shared.ts
+++ b/exchanges/graphcache/src/operations/shared.ts
@@ -1,7 +1,6 @@
 import type { CombinedError, ErrorLike, FormattedNode } from '@urql/core';
 
 import type {
-  FieldNode,
   InlineFragmentNode,
   FragmentDefinitionNode,
 } from '@0no-co/graphql.web';
@@ -161,114 +160,133 @@ const isFragmentHeuristicallyMatching = (
   });
 };
 
-interface SelectionIterator {
-  (): FormattedNode<FieldNode> | undefined;
-}
+export class SelectionIterator {
+  typename: undefined | string;
+  entityKey: string;
+  ctx: Context;
+  stack: {
+    selectionSet: FormattedNode<SelectionSet>;
+    index: number;
+    defer: boolean;
+    optional: boolean | undefined;
+  }[];
 
-// NOTE: Outside of this file, we expect `_defer` to always be reset to `false`
-export function makeSelectionIterator(
-  typename: undefined | string,
-  entityKey: string,
-  _defer: false,
-  _optional: undefined,
-  selectionSet: FormattedNode<SelectionSet>,
-  ctx: Context
-): SelectionIterator;
-// NOTE: Inside this file we expect the state to be recursively passed on
-export function makeSelectionIterator(
-  typename: undefined | string,
-  entityKey: string,
-  _defer: boolean,
-  _optional: undefined | boolean,
-  selectionSet: FormattedNode<SelectionSet>,
-  ctx: Context
-): SelectionIterator;
+  // NOTE: Outside of this file, we expect `_defer` to always be reset to `false`
+  constructor(
+    typename: undefined | string,
+    entityKey: string,
+    _defer: false,
+    _optional: undefined,
+    selectionSet: FormattedNode<SelectionSet>,
+    ctx: Context
+  );
+  // NOTE: Inside this file we expect the state to be recursively passed on
+  constructor(
+    typename: undefined | string,
+    entityKey: string,
+    _defer: boolean,
+    _optional: undefined | boolean,
+    selectionSet: FormattedNode<SelectionSet>,
+    ctx: Context
+  );
 
-export function makeSelectionIterator(
-  typename: undefined | string,
-  entityKey: string,
-  _defer: boolean,
-  _optional: boolean | undefined,
-  selectionSet: FormattedNode<SelectionSet>,
-  ctx: Context
-): SelectionIterator {
-  let child: SelectionIterator | void;
-  let index = 0;
+  constructor(
+    typename: undefined | string,
+    entityKey: string,
+    _defer: boolean,
+    _optional: boolean | undefined,
+    selectionSet: FormattedNode<SelectionSet>,
+    ctx: Context
+  ) {
+    this.typename = typename;
+    this.entityKey = entityKey;
+    this.ctx = ctx;
+    this.stack = [
+      {
+        selectionSet,
+        index: 0,
+        defer: _defer,
+        optional: _optional,
+      },
+    ];
+  }
 
-  return function next() {
-    let node: FormattedNode<FieldNode> | undefined;
-    while (child || index < selectionSet.length) {
-      node = undefined;
-      deferRef = _defer;
-      optionalRef = _optional;
-      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)) {
+  next() {
+    while (this.stack.length > 0) {
+      let state = this.stack[this.stack.length - 1];
+      while (state.index < state.selectionSet.length) {
+        const select = state.selectionSet[state.index++];
+        if (!shouldInclude(select, this.ctx.variables)) {
           /*noop*/
         } else if (select.kind !== Kind.FIELD) {
           // A fragment is either referred to by FragmentSpread or inline
           const fragment =
             select.kind !== Kind.INLINE_FRAGMENT
-              ? ctx.fragments[getName(select)]
+              ? this.ctx.fragments[getName(select)]
               : select;
           if (fragment) {
             const isMatching =
               !fragment.typeCondition ||
-              (ctx.store.schema
-                ? isInterfaceOfType(ctx.store.schema, fragment, typename)
+              (this.ctx.store.schema
+                ? isInterfaceOfType(
+                    this.ctx.store.schema,
+                    fragment,
+                    this.typename
+                  )
                 : (currentOperation === 'read' &&
                     isFragmentMatching(
                       fragment.typeCondition.name.value,
-                      typename
+                      this.typename
                     )) ||
                   isFragmentHeuristicallyMatching(
                     fragment,
-                    typename,
-                    entityKey,
-                    ctx.variables,
-                    ctx.store.logger
+                    this.typename,
+                    this.entityKey,
+                    this.ctx.variables,
+                    this.ctx.store.logger
                   ));
-
             if (
               isMatching ||
-              (currentOperation === 'write' && !ctx.store.schema)
+              (currentOperation === 'write' && !this.ctx.store.schema)
             ) {
               if (process.env.NODE_ENV !== 'production')
-                pushDebugNode(typename, fragment);
+                pushDebugNode(this.typename, fragment);
               const isFragmentOptional = isOptional(select);
               if (
                 isMatching &&
                 fragment.typeCondition &&
-                typename !== fragment.typeCondition.name.value
+                this.typename !== fragment.typeCondition.name.value
               ) {
-                writeConcreteType(fragment.typeCondition.name.value, typename!);
+                writeConcreteType(
+                  fragment.typeCondition.name.value,
+                  this.typename!
+                );
               }
 
-              child = makeSelectionIterator(
-                typename,
-                entityKey,
-                _defer || isDeferred(select, ctx.variables),
-                isFragmentOptional !== undefined
-                  ? isFragmentOptional
-                  : _optional,
-                getSelectionSet(fragment),
-                ctx
+              this.stack.push(
+                (state = {
+                  selectionSet: getSelectionSet(fragment),
+                  index: 0,
+                  defer: state.defer || isDeferred(select, this.ctx.variables),
+                  optional:
+                    isFragmentOptional !== undefined
+                      ? isFragmentOptional
+                      : state.optional,
+                })
               );
             }
           }
         } else if (currentOperation === 'write' || !select._generated) {
+          deferRef = state.defer;
+          optionalRef = state.optional;
           return select;
         }
       }
+      this.stack.pop();
+      if (process.env.NODE_ENV !== 'production') popDebugNode();
     }
-  };
+    return undefined;
+  }
 }
 
 const isFragmentMatching = (typeCondition: string, typename: string | void) => {
diff --git a/exchanges/graphcache/src/operations/write.ts b/exchanges/graphcache/src/operations/write.ts
index 2f6fc744..1e49fbd9 100644
--- a/exchanges/graphcache/src/operations/write.ts
+++ b/exchanges/graphcache/src/operations/write.ts
@@ -39,7 +39,7 @@ import * as InMemoryData from '../store/data';
 
 import type { Context } from './shared';
 import {
-  makeSelectionIterator,
+  SelectionIterator,
   ensureData,
   makeContext,
   updateContext,
@@ -237,7 +237,7 @@ const writeSelection = (
   }
 
   const updates = ctx.store.updates[typename];
-  const iterate = makeSelectionIterator(
+  const selection = new SelectionIterator(
     typename,
     entityKey || typename,
     false,
@@ -247,7 +247,7 @@ const writeSelection = (
   );
 
   let node: FormattedNode<FieldNode> | void;
-  while ((node = iterate())) {
+  while ((node = selection.next())) {
     const fieldName = getName(node);
     const fieldArgs = getFieldArguments(node, ctx.variables);
     const fieldKey = keyOfField(fieldName, fieldArgs);


Test patch:
diff --git a/exchanges/graphcache/src/operations/shared.test.ts b/exchanges/graphcache/src/operations/shared.test.ts
index 6b331949..711d9593 100644
--- a/exchanges/graphcache/src/operations/shared.test.ts
+++ b/exchanges/graphcache/src/operations/shared.test.ts
@@ -7,7 +7,7 @@ import {
 } from '@urql/core';
 import { FieldNode } from '@0no-co/graphql.web';
 
-import { makeSelectionIterator, deferRef } from './shared';
+import { SelectionIterator, deferRef } from './shared';
 import { SelectionSet } from '../ast';
 
 const selectionOfDocument = (
@@ -21,7 +21,7 @@ const selectionOfDocument = (
 
 const ctx = {} as any;
 
-describe('makeSelectionIterator', () => {
+describe('SelectionIterator', () => {
   it('emits all fields', () => {
     const selection = selectionOfDocument(gql`
       {
@@ -30,7 +30,7 @@ describe('makeSelectionIterator', () => {
         c
       }
     `);
-    const iterate = makeSelectionIterator(
+    const iterate = new SelectionIterator(
       'Query',
       'Query',
       false,
@@ -41,7 +41,7 @@ describe('makeSelectionIterator', () => {
     const result: FieldNode[] = [];
 
     let node: FieldNode | void;
-    while ((node = iterate())) result.push(node);
+    while ((node = iterate.next())) result.push(node);
 
     expect(result).toMatchInlineSnapshot(`
       [
@@ -90,7 +90,7 @@ describe('makeSelectionIterator', () => {
       }
     `);
 
-    const iterate = makeSelectionIterator(
+    const iterate = new SelectionIterator(
       'Query',
       'Query',
       false,
@@ -101,7 +101,7 @@ describe('makeSelectionIterator', () => {
     const result: FieldNode[] = [];
 
     let node: FieldNode | void;
-    while ((node = iterate())) result.push(node);
+    while ((node = iterate.next())) result.push(node);
 
     expect(result).toMatchInlineSnapshot('[]');
   });
@@ -121,7 +121,7 @@ describe('makeSelectionIterator', () => {
       }
     `);
 
-    const iterate = makeSelectionIterator(
+    const iterate = new SelectionIterator(
       'Query',
       'Query',
       false,
@@ -132,7 +132,7 @@ describe('makeSelectionIterator', () => {
     const result: FieldNode[] = [];
 
     let node: FieldNode | void;
-    while ((node = iterate())) result.push(node);
+    while ((node = iterate.next())) result.push(node);
 
     expect(result).toMatchInlineSnapshot(`
       [
@@ -207,7 +207,7 @@ describe('makeSelectionIterator', () => {
       }
     `);
 
-    const iterate = makeSelectionIterator(
+    const iterate = new SelectionIterator(
       'Query',
       'Query',
       false,
@@ -217,7 +217,7 @@ describe('makeSelectionIterator', () => {
     );
 
     const deferred: boolean[] = [];
-    while (iterate()) deferred.push(deferRef);
+    while (iterate.next()) deferred.push(deferRef);
     expect(deferred).toEqual([
       false, // a
       true, // b
@@ -243,7 +243,7 @@ describe('makeSelectionIterator', () => {
       }
     `);
 
-    const iterate = makeSelectionIterator(
+    const iterate = new SelectionIterator(
       'Query',
       'Query',
       true,
@@ -253,7 +253,7 @@ describe('makeSelectionIterator', () => {
     );
 
     const deferred: boolean[] = [];
-    while (iterate()) deferred.push(deferRef);
+    while (iterate.next()) deferred.push(deferRef);
     expect(deferred).toEqual([true, true, true]);
   });
 });