Repository: urql-graphql/urql (ts)

Issue:
Using `graphcache@5.0.0` or 6 the cache in the default storage is not loaded anymore on the first page load
After upgrading from 4 to 5 (or 6) `@urql/exchange-graphcache` doing some tests with a fake delay on the backend I discovered that when I refresh the page (F5 or Ctrl+R) the cache present in the IndexedDB is not used.

**The REPL** (with SvelteKit too) is here: https://codesandbox.io/p/sandbox/romantic-feather-xmc826

**Steps**:

1. Go to https://xmc826-5173.csb.app/todos

1. the todos are downloaded, showed and stored in the IndexedDB after 3 seconds of [fake delay on the server resolver](https://codesandbox.io/p/sandbox/apollo-server-urql-svelte-crud-h1pcl?file=%2Fschema.js&selection=%5B%7B%22endColumn%22%3A25%2C%22endLineNumber%22%3A132%2C%22startColumn%22%3A7%2C%22startLineNumber%22%3A132%7D%5D)

1. Click on the reload button in the navbar (or the browser's one)

1. The "Loading listStore..." message appears during the 3 fake delay seconds

1. I expect instead to see the todos immediately (during the `cache-and-network` call which is still happening)

If I rollback only `"@urql/exchange-graphcache"` to `"4.4.3"` this "cold cache" behavior works again.

**What could be causing the problem?**

Thank you in advance.

### Urql version

[package.json](https://codesandbox.io/p/sandbox/romantic-feather-xmc826?selection=%5B%7B%22endColumn%22%3A13%2C%22endLineNumber%22%3A19%2C%22startColumn%22%3A13%2C%22startLineNumber%22%3A19%7D%5D&file=%2Fpackage.json)


### 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): Add missing retries after offline rehydration
Resolves #3093

## Summary

Adds missing retries of query operations when offline rehydration has completed and fix the timing of flushing the failed queue of operations after rehydration.

The former can lead to operations only awaiting network requests even after finishing storage rehydration, since no additional operations would be issued.

The latter can lead to stalled operations when the device is offline on load.

## Set of changes

- Add query operations to `failedQueue` while rehydrating
- Delay timing of `flushQueue` to after storage rehydration
- Move `onOnline` register to `flushQueue`


Patch:
diff --git a/.changeset/wet-crabs-dream.md b/.changeset/wet-crabs-dream.md
new file mode 100644
index 00000000..15d1868b
--- /dev/null
+++ b/.changeset/wet-crabs-dream.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Retry operations against offline cache and stabilize timing of flushing failed operations queue after rehydrating the storage data.
diff --git a/exchanges/graphcache/src/cacheExchange.ts b/exchanges/graphcache/src/cacheExchange.ts
index 9d461e69..54591c73 100644
--- a/exchanges/graphcache/src/cacheExchange.ts
+++ b/exchanges/graphcache/src/cacheExchange.ts
@@ -71,6 +71,7 @@ export const cacheExchange =
     const store = new Store<C>(opts);
 
     if (opts && opts.storage) {
+      store.data.hydrating = true;
       opts.storage.readData().then(entries => {
         hydrateData(store.data, opts!.storage!, entries);
       });
diff --git a/exchanges/graphcache/src/offlineExchange.ts b/exchanges/graphcache/src/offlineExchange.ts
index 6a801534..f0d9a832 100644
--- a/exchanges/graphcache/src/offlineExchange.ts
+++ b/exchanges/graphcache/src/offlineExchange.ts
@@ -1,4 +1,4 @@
-import { pipe, share, merge, makeSubject, filter } from 'wonka';
+import { pipe, share, merge, makeSubject, filter, onPush } from 'wonka';
 import { SelectionNode } from '@0no-co/graphql.web';
 
 import {
@@ -128,37 +128,36 @@ export const offlineExchange =
       const { source: reboundOps$, next } = makeSubject<Operation>();
       const optimisticMutations = opts.optimistic || {};
       const failedQueue: Operation[] = [];
+      let hasRehydrated = false;
+      let isFlushingQueue = false;
 
       const updateMetadata = () => {
-        const requests: SerializedRequest[] = [];
-        for (let i = 0; i < failedQueue.length; i++) {
-          const operation = failedQueue[i];
-          if (operation.kind === 'mutation') {
-            requests.push({
-              query: stringifyDocument(operation.query),
-              variables: operation.variables,
-              extensions: operation.extensions,
-            });
+        if (hasRehydrated) {
+          const requests: SerializedRequest[] = [];
+          for (let i = 0; i < failedQueue.length; i++) {
+            const operation = failedQueue[i];
+            if (operation.kind === 'mutation') {
+              requests.push({
+                query: stringifyDocument(operation.query),
+                variables: operation.variables,
+                extensions: operation.extensions,
+              });
+            }
           }
+          storage.writeMetadata!(requests);
         }
-        storage.writeMetadata!(requests);
       };
 
-      let isFlushingQueue = false;
       const flushQueue = () => {
         if (!isFlushingQueue) {
           isFlushingQueue = true;
-
           for (let i = 0; i < failedQueue.length; i++) {
             const operation = failedQueue[i];
-            if (operation.kind === 'mutation') {
+            if (operation.kind === 'mutation')
               next(makeOperation('teardown', operation));
-            }
+            next(operation);
           }
 
-          for (let i = 0; i < failedQueue.length; i++)
-            client.reexecuteOperation(failedQueue[i]);
-
           failedQueue.length = 0;
           isFlushingQueue = false;
           updateMetadata();
@@ -170,6 +169,7 @@ export const offlineExchange =
           outerForward(ops$),
           filter(res => {
             if (
+              hasRehydrated &&
               res.operation.kind === 'mutation' &&
               isOfflineError(res.error, res) &&
               isOptimisticMutation(optimisticMutations, res.operation)
@@ -185,31 +185,30 @@ export const offlineExchange =
         );
       };
 
-      storage
-        .readMetadata()
-        .then(mutations => {
-          if (mutations) {
-            for (let i = 0; i < mutations.length; i++) {
-              failedQueue.push(
-                client.createRequestOperation(
-                  'mutation',
-                  createRequest(mutations[i].query, mutations[i].variables),
-                  mutations[i].extensions
-                )
-              );
-            }
-
-            flushQueue();
-          }
-        })
-        .finally(() => storage.onOnline!(flushQueue));
-
       const cacheResults$ = cacheExchange({
         ...opts,
         storage: {
           ...storage,
           readData() {
-            return storage.readData().finally(flushQueue);
+            const hydrate = storage.readData();
+            return {
+              async then(onEntries) {
+                const mutations = await storage.readMetadata!();
+                for (let i = 0; mutations && i < mutations.length; i++) {
+                  failedQueue.push(
+                    client.createRequestOperation(
+                      'mutation',
+                      createRequest(mutations[i].query, mutations[i].variables),
+                      mutations[i].extensions
+                    )
+                  );
+                }
+                onEntries!(await hydrate);
+                storage.onOnline!(flushQueue);
+                hasRehydrated = true;
+                flushQueue();
+              },
+            };
           },
         },
       })({
@@ -219,7 +218,17 @@ export const offlineExchange =
       });
 
       return operations$ => {
-        const opsAndRebound$ = merge([reboundOps$, operations$]);
+        const opsAndRebound$ = merge([
+          reboundOps$,
+          pipe(
+            operations$,
+            onPush(operation => {
+              if (operation.kind === 'query' && !hasRehydrated) {
+                failedQueue.push(operation);
+              }
+            })
+          ),
+        ]);
 
         return pipe(
           cacheResults$(opsAndRebound$),
@@ -232,7 +241,6 @@ export const offlineExchange =
               failedQueue.push(res.operation);
               return false;
             }
-
             return true;
           })
         );
diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts
index 1be859d0..6d936303 100644
--- a/exchanges/graphcache/src/store/data.ts
+++ b/exchanges/graphcache/src/store/data.ts
@@ -31,6 +31,8 @@ interface NodeMap<T> {
 }
 
 export interface InMemoryData {
+  /** Flag for whether the data is waiting for hydration */
+  hydrating: boolean;
   /** Flag for whether deferred tasks have been scheduled yet */
   defer: boolean;
   /** A list of entities that have been flagged for gargabe collection since no references to them are left */
@@ -109,7 +111,11 @@ export const initDataState = (
     // We don't create new layers for read operations and instead simply
     // apply the currently available layer, if any
     currentOptimisticKey = layerKey;
-  } else if (isOptimistic || data.optimisticOrder.length > 1) {
+  } else if (
+    isOptimistic ||
+    data.hydrating ||
+    data.optimisticOrder.length > 1
+  ) {
     // If this operation isn't optimistic and we see it for the first time,
     // then it must've been optimistic in the past, so we can proactively
     // clear the optimistic data before writing
@@ -155,7 +161,11 @@ export const clearDataState = () => {
   currentOptimisticKey = null;
 
   // Determine whether the current operation has been a commutative layer
-  if (layerKey && data.optimisticOrder.indexOf(layerKey) > -1) {
+  if (
+    !data.hydrating &&
+    layerKey &&
+    data.optimisticOrder.indexOf(layerKey) > -1
+  ) {
     // Squash all layers in reverse order (low priority upwards) that have
     // been written already
     let i = data.optimisticOrder.length;
@@ -217,6 +227,7 @@ export const getCurrentDependencies = (): Dependencies => {
 };
 
 export const make = (queryRootKey: string): InMemoryData => ({
+  hydrating: false,
   defer: false,
   gc: new Set(),
   persist: new Set(),
@@ -634,6 +645,7 @@ export const hydrateData = (
     }
   }
 
-  clearDataState();
   data.storage = storage;
+  data.hydrating = false;
+  clearDataState();
 };


Test patch:
diff --git a/exchanges/graphcache/src/offlineExchange.test.ts b/exchanges/graphcache/src/offlineExchange.test.ts
index f988b9f6..c4b13dbc 100644
--- a/exchanges/graphcache/src/offlineExchange.test.ts
+++ b/exchanges/graphcache/src/offlineExchange.test.ts
@@ -5,7 +5,6 @@ import {
   Operation,
   OperationResult,
 } from '@urql/core';
-import { print } from 'graphql';
 import { vi, expect, it, describe, beforeAll } from 'vitest';
 
 import { pipe, share, map, makeSubject, tap, publish } from 'wonka';
@@ -105,6 +104,7 @@ describe('storage', () => {
 
 describe('offline', () => {
   beforeAll(() => {
+    vi.resetAllMocks();
     global.navigator = { onLine: true } as any;
   });
 
@@ -167,26 +167,10 @@ describe('offline', () => {
     expect(queryOneData).toMatchObject(result.mock.calls[0][0].data);
 
     next(mutationOp);
-    expect(result).toBeCalledTimes(1);
-    expect(storage.writeMetadata).toHaveBeenCalled();
-    expect(storage.writeMetadata).toHaveBeenCalledWith([
-      {
-        query: `mutation {
-  updateAuthor {
-    id
-    name
-    __typename
-  }
-}`,
-        variables: {},
-      },
-    ]);
+    expect(result).toBeCalledTimes(2);
 
     next(queryOp);
-    expect(result).toBeCalledTimes(2);
-    expect(result.mock.calls[1][0].data).toMatchObject({
-      authors: [{ id: '123', name: 'URQL', __typename: 'Author' }],
-    });
+    expect(result).toBeCalledTimes(3);
   });
 
   it('should intercept errored queries', async () => {
@@ -261,9 +245,6 @@ describe('offline', () => {
       url: 'http://0.0.0.0',
       exchanges: [],
     });
-    const reexecuteOperation = vi
-      .spyOn(client, 'reexecuteOperation')
-      .mockImplementation(() => undefined);
 
     const mutationOp = client.createRequestOperation('mutation', {
       key: 1,
@@ -300,35 +281,9 @@ describe('offline', () => {
     );
 
     next(mutationOp);
-    expect(storage.writeMetadata).toHaveBeenCalled();
-    expect(storage.writeMetadata).toHaveBeenCalledWith([
-      {
-        query: `mutation {
-  updateAuthor {
-    id
-    name
-    __typename
-  }
-}`,
-        variables: {},
-      },
-    ]);
 
     await onOnlineCalled;
 
     flush!();
-    expect(reexecuteOperation).toHaveBeenCalled();
-    expect((reexecuteOperation.mock.calls[0][0] as any).key).toEqual(1);
-    expect(print((reexecuteOperation.mock.calls[0][0] as any).query)).toEqual(
-      print(gql`
-        mutation {
-          updateAuthor {
-            id
-            name
-            __typename
-          }
-        }
-      `)
-    );
   });
 });