Repository: urql-graphql/urql
Pull request #3564 (2024-04-17 06:36:52)

feat(graphcache): only add dependency if we are changing the value
## Summary

This is kind of an optimisation, say we have three operations that are all watching different fields of `Todo`, only one of these operations watches `Todo.completed`. When a mutation changes `Todo.completed` we should only invoke that one operation, this is slightly more performant. Not sure if worth it but it could also enable us to create a granular re-rendering mechanism with a first-class `fragment` citizen.


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

Latest commit: b5d415e148c867778f348d51d93b4e69201996f0

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/urql-graphql/urql/new/granular-dependency-aggregation?filename=.changeset/three-points-guess.md&value=---%0A%22%40urql%2Fexchange-graphcache%22%3A%20patch%0A---%0A%0Afeat(graphcache)%3A%20only%20add%20dependency%20if%20we%20are%20changing%20the%20value%0A)



Patch:
diff --git a/.changeset/nine-walls-behave.md b/.changeset/nine-walls-behave.md
new file mode 100644
index 00000000..8b9e96ee
--- /dev/null
+++ b/.changeset/nine-walls-behave.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Only record dependencies that are changing data, this will reduce the amount of operations we re-invoke due to network-only/cache-and-network queries and mutations
diff --git a/exchanges/graphcache/src/operations/write.ts b/exchanges/graphcache/src/operations/write.ts
index 56b7af62..2f6fc744 100644
--- a/exchanges/graphcache/src/operations/write.ts
+++ b/exchanges/graphcache/src/operations/write.ts
@@ -343,6 +343,7 @@ const writeSelection = (
             ? InMemoryData.readLink(entityKey || typename, fieldKey)
             : undefined
         );
+
         InMemoryData.writeLink(entityKey || typename, fieldKey, link);
       } else {
         writeField(ctx, getSelectionSet(node), ensureData(fieldValue));
diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts
index ee695bc1..32ac1c46 100644
--- a/exchanges/graphcache/src/store/data.ts
+++ b/exchanges/graphcache/src/store/data.ts
@@ -458,7 +458,9 @@ export const readRecord = (
   entityKey: string,
   fieldKey: string
 ): EntityField => {
-  updateDependencies(entityKey, fieldKey);
+  if (currentOperation === 'read') {
+    updateDependencies(entityKey, fieldKey);
+  }
   return getNode(currentData!.records, entityKey, fieldKey);
 };
 
@@ -467,7 +469,9 @@ export const readLink = (
   entityKey: string,
   fieldKey: string
 ): Link | undefined => {
-  updateDependencies(entityKey, fieldKey);
+  if (currentOperation === 'read') {
+    updateDependencies(entityKey, fieldKey);
+  }
   return getNode(currentData!.links, entityKey, fieldKey);
 };
 
@@ -508,8 +512,12 @@ export const writeRecord = (
   fieldKey: string,
   value?: EntityField
 ) => {
-  updateDependencies(entityKey, fieldKey);
-  updatePersist(entityKey, fieldKey);
+  const existing = getNode(currentData!.records, entityKey, fieldKey);
+  if (!isEqualLinkOrScalar(existing, value)) {
+    updateDependencies(entityKey, fieldKey);
+    updatePersist(entityKey, fieldKey);
+  }
+
   setNode(currentData!.records, entityKey, fieldKey, value);
 };
 
@@ -533,9 +541,12 @@ export const writeLink = (
     updateRCForLink(entityLinks && entityLinks[fieldKey], -1);
     updateRCForLink(link, 1);
   }
-  // Update persistence batch and dependencies
-  updateDependencies(entityKey, fieldKey);
-  updatePersist(entityKey, fieldKey);
+  const existing = getNode(currentData!.links, entityKey, fieldKey);
+  if (!isEqualLinkOrScalar(existing, link)) {
+    updateDependencies(entityKey, fieldKey);
+    updatePersist(entityKey, fieldKey);
+  }
+
   // Update the link
   setNode(currentData!.links, entityKey, fieldKey, link);
 };
@@ -629,8 +640,9 @@ const squashLayer = (layerKey: number) => {
     for (const entry of links.entries()) {
       const entityKey = entry[0];
       const keyMap = entry[1];
-      for (const fieldKey in keyMap)
+      for (const fieldKey in keyMap) {
         writeLink(entityKey, fieldKey, keyMap[fieldKey]);
+      }
     }
   }
 
@@ -639,8 +651,9 @@ const squashLayer = (layerKey: number) => {
     for (const entry of records.entries()) {
       const entityKey = entry[0];
       const keyMap = entry[1];
-      for (const fieldKey in keyMap)
+      for (const fieldKey in keyMap) {
         writeRecord(entityKey, fieldKey, keyMap[fieldKey]);
+      }
     }
   }
 
@@ -710,3 +723,17 @@ export const hydrateData = (
   data.hydrating = false;
   clearDataState();
 };
+
+function isEqualLinkOrScalar(
+  a: Link | EntityField | undefined,
+  b: Link | EntityField | undefined
+) {
+  if (typeof a !== typeof b) return false;
+  if (a !== b) return false;
+  if (Array.isArray(a) && Array.isArray(b)) {
+    if (a.length !== b.length) return false;
+    return !a.some((el, index) => el !== b[index]);
+  }
+
+  return true;
+}


Test patch:
diff --git a/exchanges/graphcache/src/cacheExchange.test.ts b/exchanges/graphcache/src/cacheExchange.test.ts
index 1ba13805..fcb5b2bb 100644
--- a/exchanges/graphcache/src/cacheExchange.test.ts
+++ b/exchanges/graphcache/src/cacheExchange.test.ts
@@ -480,6 +480,167 @@ describe('data dependencies', () => {
     );
   });
 
+  it('does not notify related queries when a mutation update does not change the data', () => {
+    vi.useFakeTimers();
+
+    const balanceFragment = gql`
+      fragment BalanceFragment on Author {
+        id
+        balance {
+          amount
+        }
+      }
+    `;
+
+    const queryById = gql`
+      query ($id: ID!) {
+        author(id: $id) {
+          id
+          name
+          ...BalanceFragment
+        }
+      }
+
+      ${balanceFragment}
+    `;
+
+    const queryByIdDataA = {
+      __typename: 'Query',
+      author: {
+        __typename: 'Author',
+        id: '1',
+        name: 'Author 1',
+        balance: {
+          __typename: 'Balance',
+          amount: 100,
+        },
+      },
+    };
+
+    const queryByIdDataB = {
+      __typename: 'Query',
+      author: {
+        __typename: 'Author',
+        id: '2',
+        name: 'Author 2',
+        balance: {
+          __typename: 'Balance',
+          amount: 200,
+        },
+      },
+    };
+
+    const mutation = gql`
+      mutation ($userId: ID!, $amount: Int!) {
+        updateBalance(userId: $userId, amount: $amount) {
+          userId
+          balance {
+            amount
+          }
+        }
+      }
+    `;
+
+    const mutationData = {
+      __typename: 'Mutation',
+      updateBalance: {
+        __typename: 'UpdateBalanceResult',
+        userId: '1',
+        balance: {
+          __typename: 'Balance',
+          amount: 100,
+        },
+      },
+    };
+
+    const client = createClient({
+      url: 'http://0.0.0.0',
+      exchanges: [],
+    });
+    const { source: ops$, next } = makeSubject<Operation>();
+
+    const reexec = vi
+      .spyOn(client, 'reexecuteOperation')
+      .mockImplementation(next);
+
+    const opOne = client.createRequestOperation('query', {
+      key: 1,
+      query: queryById,
+      variables: { id: 1 },
+    });
+
+    const opTwo = client.createRequestOperation('query', {
+      key: 2,
+      query: queryById,
+      variables: { id: 2 },
+    });
+
+    const opMutation = client.createRequestOperation('mutation', {
+      key: 3,
+      query: mutation,
+      variables: { userId: '1', amount: 1000 },
+    });
+
+    const response = vi.fn((forwardOp: Operation): OperationResult => {
+      if (forwardOp.key === 1) {
+        return { ...queryResponse, operation: opOne, data: queryByIdDataA };
+      } else if (forwardOp.key === 2) {
+        return { ...queryResponse, operation: opTwo, data: queryByIdDataB };
+      } else if (forwardOp.key === 3) {
+        return {
+          ...queryResponse,
+          operation: opMutation,
+          data: mutationData,
+        };
+      }
+
+      return undefined as any;
+    });
+
+    const result = vi.fn();
+    const forward: ExchangeIO = ops$ =>
+      pipe(ops$, delay(1), map(response), share);
+
+    const updates = {
+      Mutation: {
+        updateBalance: vi.fn((result, _args, cache) => {
+          const {
+            updateBalance: { userId, balance },
+          } = result;
+          cache.writeFragment(balanceFragment, { id: userId, balance });
+        }),
+      },
+    };
+
+    const keys = {
+      Balance: () => null,
+    };
+
+    pipe(
+      cacheExchange({ updates, keys })({ forward, client, dispatchDebug })(
+        ops$
+      ),
+      tap(result),
+      publish
+    );
+
+    next(opTwo);
+    vi.runAllTimers();
+    expect(response).toHaveBeenCalledTimes(1);
+
+    next(opOne);
+    vi.runAllTimers();
+    expect(response).toHaveBeenCalledTimes(2);
+
+    next(opMutation);
+    vi.runAllTimers();
+
+    expect(response).toHaveBeenCalledTimes(3);
+    expect(updates.Mutation.updateBalance).toHaveBeenCalledTimes(1);
+
+    expect(reexec).toHaveBeenCalledTimes(0);
+  });
+
   it('does nothing when no related queries have changed', () => {
     const queryUnrelated = gql`
       {
@@ -1448,15 +1609,17 @@ describe('optimistic updates', () => {
 
     expect(response).toHaveBeenCalledTimes(1);
     expect(optimistic.concealAuthor).toHaveBeenCalledTimes(2);
-    expect(reexec).toHaveBeenCalledTimes(2);
+    expect(reexec).toHaveBeenCalledTimes(1);
     expect(result).toHaveBeenCalledTimes(0);
 
     vi.advanceTimersByTime(2);
     expect(response).toHaveBeenCalledTimes(2);
-    expect(result).toHaveBeenCalledTimes(0);
+    expect(reexec).toHaveBeenCalledTimes(2);
+    expect(result).toHaveBeenCalledTimes(1);
 
     vi.runAllTimers();
     expect(response).toHaveBeenCalledTimes(3);
+    expect(reexec).toHaveBeenCalledTimes(2);
     expect(result).toHaveBeenCalledTimes(2);
   });
 
diff --git a/exchanges/graphcache/src/store/data.test.ts b/exchanges/graphcache/src/store/data.test.ts
index a7dcad93..49351b3f 100644
--- a/exchanges/graphcache/src/store/data.test.ts
+++ b/exchanges/graphcache/src/store/data.test.ts
@@ -98,8 +98,10 @@ describe('garbage collection', () => {
     InMemoryData.gc();
 
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe('1');
+    // TODO: is it a problem that this fails, we are reading from Todo
+    // but we are not updating anything
     expect(InMemoryData.getCurrentDependencies()).toEqual(
-      new Set(['Query.todo', 'Todo:1'])
+      new Set(['Query.todo'])
     );
   });