Repository: urql-graphql/urql (ts)

Issue:
Invalidate doesn't work as expected for some of the queries, or components not getting rerendered with new data
### Describe the bug

Basically, I invalidate a bunch of root queries on mutation. It goes through successfully, but then queries that got invalidated are not getting reexeucted from the network. Or it does, and one of the components (freshly rendered) is updated, but the rest of the subscribed to the same query components hold on to old data.

This is how it looks in the dev tools.

Some did refetch as you can see with the green thing, but some are instant weird reexecutions that lead to nothing.
<img width="694" alt="CleanShot 2023-06-07 at 22 03 59@2x" src="https://github.com/urql-graphql/urql/assets/3940079/7be44be8-a144-4ab6-9ab3-a2617462c230">

It looks like a series (15-20 of them) of teardown, execute, update, execute, update, teardown, execute, update.

It's hard to make a reproduction, but this is what my update function looks like.

```ts
  updates: {
    Mutation: {
      organizationCreateWithExistingSafe: (_parent, _args, cache, _info) => {
        cache.invalidate("Query", "organizations" as keyof Query)
        cache.invalidate("Query", "safes" as keyof Query)
      },

```
Maybe I don't understand something?

It's important to note that these two queries are shared extensively in UI through a hook like.
```
export const useSafesQuery = () => {
  return useQueryData({ query: UseSafes_SafesDocument })
}
```
And there are at least 2 consumer components on the page rendered at the point of invalidation.
Since there is no warning against such a thing in docs, it's quite a common pattern in the codebase for very reusable stuff like user info etc. that makes no sense to re-query all the time.

Also, it's important to note that network policy everywhere is the default one, so `cache-first` and not `cache-and-network` or `network-only`.

REPRO ATTACHED HERE IS NOT A REPRO. I don't have time to build a repro, especially since it DOES invalidate others correctly rather 90% of the time so to build one is virtually impossible.

I can hop on a call anytime to show it off to anyone interested if that would be easiest.

### Reproduction

https://codesandbox.io/s/urql-issue-template-client-iui0o

### Urql version

    "@urql/core": "~4.0.10",
    "@urql/devtools": "~2.0.3",
    "@urql/exchange-graphcache": "~6.1.1",
    "@urql/exchange-refocus": "~1.0.2",
    "urql": "~4.0.3",
    "wonka": "~6.3.2",

### 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:
fix(core): Force client.reexecuteOperation to dispatch
Resolves #3254

## Summary

Previously, when Graphcache issued an optimistic mutation that led to a cache miss, the cache-missed query operation would stall since it wasn't allowed to be issued again.

This patch prevents such “stalls” by forcing operations to be dispatched when `client.reexecuteOperation` is called. This allows Graphcache and related exchanges to force re-execution of operations, regardless of the deduplication behaviour.

## Set of changes

- Add forced dispatching to `client.reexecuteOperation`


Patch:
diff --git a/.changeset/wicked-seahorses-smash.md b/.changeset/wicked-seahorses-smash.md
new file mode 100644
index 00000000..208dd471
--- /dev/null
+++ b/.changeset/wicked-seahorses-smash.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': patch
+---
+
+Explicitly unblock `client.reexecuteOperation` calls to allow stalled operations from continuing and re-executing. Previously, this could cause `@urql/exchange-graphcache` to stall if an optimistic mutation led to a cache miss.
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 0ad6586f..fecd3d0c 100755
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -729,6 +729,10 @@ export const Client: new (opts: ClientOptions) => Client = function Client(
       if (operation.kind === 'teardown') {
         dispatchOperation(operation);
       } else if (operation.kind === 'mutation' || active.has(operation.key)) {
+        let queued = false;
+        for (let i = 0; i < queue.length; i++)
+          queued = queued || queue[i].key === operation.key;
+        if (!queued) dispatched.delete(operation.key);
         queue.push(operation);
         Promise.resolve().then(dispatchOperation);
       }


Test patch:
diff --git a/exchanges/graphcache/src/cacheExchange.test.ts b/exchanges/graphcache/src/cacheExchange.test.ts
index 9c2313e6..c31d2d27 100644
--- a/exchanges/graphcache/src/cacheExchange.test.ts
+++ b/exchanges/graphcache/src/cacheExchange.test.ts
@@ -1257,6 +1257,145 @@ describe('optimistic updates', () => {
     expect(updates.Mutation.addAuthor).toHaveBeenCalledTimes(2);
     expect(response).toHaveBeenCalledTimes(2);
     expect(result).toHaveBeenCalledTimes(4);
+    expect(reexec).toHaveBeenCalledTimes(2);
+
+    next(opOne);
+    vi.runAllTimers();
+    expect(result).toHaveBeenCalledTimes(5);
+  });
+
+  it('does not block subsequent query operations', () => {
+    vi.useFakeTimers();
+
+    const authorsQuery = gql`
+      query {
+        authors {
+          id
+          name
+        }
+      }
+    `;
+
+    const authorsQueryData = {
+      __typename: 'Query',
+      authors: [
+        {
+          __typename: 'Author',
+          id: '123',
+          name: 'Author',
+        },
+      ],
+    };
+
+    const mutation = gql`
+      mutation {
+        deleteAuthor {
+          id
+          name
+        }
+      }
+    `;
+
+    const optimisticMutationData = {
+      __typename: 'Mutation',
+      deleteAuthor: {
+        __typename: 'Author',
+        id: '123',
+        name: '[REDACTED OFFLINE]',
+      },
+    };
+
+    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: authorsQuery,
+      variables: undefined,
+    });
+
+    const opMutation = client.createRequestOperation('mutation', {
+      key: 2,
+      query: mutation,
+      variables: undefined,
+    });
+
+    const response = vi.fn((forwardOp: Operation): OperationResult => {
+      if (forwardOp.key === 1) {
+        return { ...queryResponse, operation: opOne, data: authorsQueryData };
+      } else if (forwardOp.key === 2) {
+        return {
+          ...queryResponse,
+          operation: opMutation,
+          data: {
+            __typename: 'Mutation',
+            deleteAuthor: optimisticMutationData.deleteAuthor,
+          },
+        };
+      }
+
+      return undefined as any;
+    });
+
+    const result = vi.fn();
+    const forward: ExchangeIO = ops$ =>
+      pipe(ops$, delay(1), map(response), share);
+
+    const optimistic = {
+      deleteAuthor: vi.fn(() => optimisticMutationData.deleteAuthor) as any,
+    };
+
+    const updates = {
+      Mutation: {
+        deleteAuthor: vi.fn((_data, _, cache) => {
+          cache.invalidate({
+            __typename: 'Author',
+            id: optimisticMutationData.deleteAuthor.id,
+          });
+        }),
+      },
+    };
+
+    pipe(
+      cacheExchange({ optimistic, updates })({
+        forward,
+        client,
+        dispatchDebug,
+      })(ops$),
+      tap(result),
+      publish
+    );
+
+    next(opOne);
+    vi.runAllTimers();
+    expect(response).toHaveBeenCalledTimes(1);
+    expect(result).toHaveBeenCalledTimes(1);
+
+    next(opMutation);
+    expect(response).toHaveBeenCalledTimes(1);
+    expect(optimistic.deleteAuthor).toHaveBeenCalledTimes(1);
+    expect(updates.Mutation.deleteAuthor).toHaveBeenCalledTimes(1);
+    expect(reexec).toHaveBeenCalledTimes(1);
+    expect(result).toHaveBeenCalledTimes(1);
+
+    vi.runAllTimers();
+
+    expect(updates.Mutation.deleteAuthor).toHaveBeenCalledTimes(2);
+    expect(response).toHaveBeenCalledTimes(2);
+    expect(result).toHaveBeenCalledTimes(2);
+    expect(reexec).toHaveBeenCalledTimes(2);
+    expect(reexec.mock.calls[1][0]).toMatchObject(opOne);
+
+    next(opOne);
+    vi.runAllTimers();
+    expect(result).toHaveBeenCalledTimes(3);
   });
 });
 
diff --git a/exchanges/graphcache/src/store/data.test.ts b/exchanges/graphcache/src/store/data.test.ts
index de004652..03f5441e 100644
--- a/exchanges/graphcache/src/store/data.test.ts
+++ b/exchanges/graphcache/src/store/data.test.ts
@@ -13,6 +13,7 @@ describe('garbage collection', () => {
   it('erases orphaned entities', () => {
     InMemoryData.writeRecord('Todo:1', '__typename', 'Todo');
     InMemoryData.writeRecord('Todo:1', 'id', '1');
+    InMemoryData.writeRecord('Todo:2', '__typename', 'Todo');
     InMemoryData.writeRecord('Query', '__typename', 'Query');
     InMemoryData.writeLink('Query', 'todo', 'Todo:1');
 
@@ -27,7 +28,7 @@ describe('garbage collection', () => {
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined);
 
     expect(InMemoryData.getCurrentDependencies()).toEqual(
-      new Set(['Todo:1', 'Query.todo'])
+      new Set(['Todo:1', 'Todo:2', 'Query.todo'])
     );
   });
 
diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts
index dd14d6aa..67c379e5 100755
--- a/packages/core/src/client.test.ts
+++ b/packages/core/src/client.test.ts
@@ -700,6 +700,46 @@ describe('deduplication behavior', () => {
 
     expect(onOperation).toHaveBeenCalledTimes(2);
   });
+
+  it('unblocks operations on call to reexecuteOperation', async () => {
+    const onOperation = vi.fn();
+    const onResult = vi.fn();
+
+    let hasSent = false;
+    const exchange: Exchange = () => ops$ =>
+      pipe(
+        ops$,
+        onPush(onOperation),
+        map(op => ({
+          hasNext: false,
+          stale: false,
+          data: 'test',
+          operation: op,
+        })),
+        filter(() => hasSent || !(hasSent = true))
+      );
+
+    const client = createClient({
+      url: 'test',
+      exchanges: [exchange],
+    });
+
+    const operation = makeOperation('query', queryOperation, {
+      ...queryOperation.context,
+      requestPolicy: 'cache-first',
+    });
+
+    pipe(client.executeRequestOperation(operation), subscribe(onResult));
+
+    expect(onOperation).toHaveBeenCalledTimes(1);
+    expect(onResult).toHaveBeenCalledTimes(0);
+
+    client.reexecuteOperation(operation);
+    await Promise.resolve();
+
+    expect(onOperation).toHaveBeenCalledTimes(2);
+    expect(onResult).toHaveBeenCalledTimes(1);
+  });
 });
 
 describe('shared sources behavior', () => {