diff --git a/.changeset/wet-crabs-dream.md b/.changeset/wet-crabs-dream.md new file mode 100644 index 0000000000..15d1868b62 --- /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 9d461e6961..54591c73c1 100644 --- a/exchanges/graphcache/src/cacheExchange.ts +++ b/exchanges/graphcache/src/cacheExchange.ts @@ -71,6 +71,7 @@ export const cacheExchange = const store = new Store(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.test.ts b/exchanges/graphcache/src/offlineExchange.test.ts index f988b9f6ad..c4b13dbc09 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 - } - } - `) - ); }); }); diff --git a/exchanges/graphcache/src/offlineExchange.ts b/exchanges/graphcache/src/offlineExchange.ts index 6a801534be..f0d9a8322d 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(); 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 1be859d0e5..6d93630322 100644 --- a/exchanges/graphcache/src/store/data.ts +++ b/exchanges/graphcache/src/store/data.ts @@ -31,6 +31,8 @@ interface NodeMap { } 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(); };