diff --git a/.changeset/eleven-snakes-look.md b/.changeset/eleven-snakes-look.md new file mode 100644 index 0000000000..0abdacfa17 --- /dev/null +++ b/.changeset/eleven-snakes-look.md @@ -0,0 +1,5 @@ +--- +'@urql/exchange-graphcache': patch +--- + +Allow `offlineExchange` to once again issue all request policies, instead of mapping them to `cache-first`. When replaying operations after rehydrating it will now prioritise network policies, and before rehydrating receiving a network result will prevent a network request from being issued again. diff --git a/.changeset/two-ants-relate.md b/.changeset/two-ants-relate.md new file mode 100644 index 0000000000..67ecbdb842 --- /dev/null +++ b/.changeset/two-ants-relate.md @@ -0,0 +1,6 @@ +--- +'@urql/exchange-graphcache': patch +'@urql/core': patch +--- + +Add `OperationContext.optimistic` flag as an internal indication on whether a mutation triggered an optimistic update in `@urql/exchange-graphcache`'s `cacheExchange`. diff --git a/exchanges/graphcache/src/cacheExchange.ts b/exchanges/graphcache/src/cacheExchange.ts index 7a049ef9ec..a6df716236 100644 --- a/exchanges/graphcache/src/cacheExchange.ts +++ b/exchanges/graphcache/src/cacheExchange.ts @@ -4,6 +4,7 @@ import { makeOperation, Operation, OperationResult, + OperationContext, RequestPolicy, CacheOutcome, } from '@urql/core'; @@ -144,6 +145,7 @@ export const cacheExchange = // This registers queries with the data layer to ensure commutativity const prepareForwardedOperation = (operation: Operation) => { + let context: Partial | undefined; if (operation.kind === 'query') { // Pre-reserve the position of the result layer reserveLayer(store.data, operation.key); @@ -155,6 +157,7 @@ export const cacheExchange = reexecutingOperations.delete(operation.key); // Mark operation layer as done noopDataState(store.data, operation.key); + return operation; } else if ( operation.kind === 'mutation' && operation.context.requestPolicy !== 'network-only' @@ -175,6 +178,9 @@ export const cacheExchange = const pendingOperations: Operations = new Set(); collectPendingOperations(pendingOperations, dependencies); executePendingOperations(operation, pendingOperations, true); + + // Mark operation as optimistic + context = { optimistic: true }; } } @@ -190,7 +196,7 @@ export const cacheExchange = ) : operation.variables, }, - operation.context + { ...operation.context, ...context } ); }; diff --git a/exchanges/graphcache/src/offlineExchange.ts b/exchanges/graphcache/src/offlineExchange.ts index 05e07de73b..db138e5abb 100644 --- a/exchanges/graphcache/src/offlineExchange.ts +++ b/exchanges/graphcache/src/offlineExchange.ts @@ -1,5 +1,4 @@ import { pipe, share, merge, makeSubject, filter, onPush } from 'wonka'; -import { SelectionNode } from '@0no-co/graphql.web'; import { Operation, @@ -7,57 +6,23 @@ import { Exchange, ExchangeIO, CombinedError, + RequestPolicy, stringifyDocument, createRequest, makeOperation, } from '@urql/core'; -import { - getMainOperation, - getFragments, - isInlineFragment, - isFieldNode, - shouldInclude, - getSelectionSet, - getName, -} from './ast'; - -import { - SerializedRequest, - OptimisticMutationConfig, - Variables, - CacheExchangeOpts, - StorageAdapter, -} from './types'; +import { SerializedRequest, CacheExchangeOpts, StorageAdapter } from './types'; import { cacheExchange } from './cacheExchange'; import { toRequestPolicy } from './helpers/operation'; -/** Determines whether a given query contains an optimistic mutation field */ -const isOptimisticMutation = ( - config: T, - operation: Operation -) => { - const vars: Variables = operation.variables || {}; - const fragments = getFragments(operation.query); - const selections = [...getSelectionSet(getMainOperation(operation.query))]; - - let field: void | SelectionNode; - while ((field = selections.pop())) { - if (!shouldInclude(field, vars)) { - continue; - } else if (!isFieldNode(field)) { - const fragmentNode = !isInlineFragment(field) - ? fragments[getName(field)] - : field; - if (fragmentNode) selections.push(...getSelectionSet(fragmentNode)); - } else if (config[getName(field)]) { - return true; - } - } - - return false; -}; +const policyLevel = { + 'cache-only': 0, + 'cache-first': 1, + 'network-only': 2, + 'cache-and-network': 3, +} as const; /** Input parameters for the {@link offlineExchange}. * @remarks @@ -126,7 +91,6 @@ export const offlineExchange = ) { const { forward: outerForward, client, dispatchDebug } = input; const { source: reboundOps$, next } = makeSubject(); - const optimisticMutations = opts.optimistic || {}; const failedQueue: Operation[] = []; let hasRehydrated = false; let isFlushingQueue = false; @@ -148,23 +112,35 @@ export const offlineExchange = } }; + const filterQueue = (key: number) => { + for (let i = failedQueue.length - 1; i >= 0; i--) + if (failedQueue[i].key === key) failedQueue.splice(i, 1); + }; + const flushQueue = () => { if (!isFlushingQueue) { - isFlushingQueue = true; - const sent = new Set(); + isFlushingQueue = true; for (let i = 0; i < failedQueue.length; i++) { const operation = failedQueue[i]; if (operation.kind === 'mutation' || !sent.has(operation.key)) { - if (operation.kind !== 'subscription') - next(makeOperation('teardown', operation)); sent.add(operation.key); - next(toRequestPolicy(operation, 'cache-first')); + if (operation.kind !== 'subscription') { + next(makeOperation('teardown', operation)); + let overridePolicy: RequestPolicy = 'cache-first'; + for (let i = 0; i < failedQueue.length; i++) { + const { requestPolicy } = failedQueue[i].context; + if (policyLevel[requestPolicy] > policyLevel[overridePolicy]) + overridePolicy = requestPolicy; + } + next(toRequestPolicy(operation, overridePolicy)); + } else { + next(toRequestPolicy(operation, 'cache-first')); + } } } - - failedQueue.length = 0; isFlushingQueue = false; + failedQueue.length = 0; updateMetadata(); } }; @@ -176,8 +152,8 @@ export const offlineExchange = if ( hasRehydrated && res.operation.kind === 'mutation' && - isOfflineError(res.error, res) && - isOptimisticMutation(optimisticMutations, res.operation) + res.operation.context.optimistic && + isOfflineError(res.error, res) ) { failedQueue.push(res.operation); updateMetadata(); @@ -231,9 +207,7 @@ export const offlineExchange = if (operation.kind === 'query' && !hasRehydrated) { failedQueue.push(operation); } else if (operation.kind === 'teardown') { - for (let i = failedQueue.length - 1; i >= 0; i--) - if (failedQueue[i].key === operation.key) - failedQueue.splice(i, 1); + filterQueue(operation.key); } }) ), @@ -242,13 +216,14 @@ export const offlineExchange = return pipe( cacheResults$(opsAndRebound$), filter(res => { - if ( - res.operation.kind === 'query' && - isOfflineError(res.error, res) - ) { - next(toRequestPolicy(res.operation, 'cache-only')); - failedQueue.push(res.operation); - return false; + if (res.operation.kind === 'query') { + if (isOfflineError(res.error, res)) { + next(toRequestPolicy(res.operation, 'cache-only')); + failedQueue.push(res.operation); + return false; + } else if (!hasRehydrated) { + filterQueue(res.operation.key); + } } return true; }) diff --git a/exchanges/graphcache/src/test-utils/examples-1.test.ts b/exchanges/graphcache/src/test-utils/examples-1.test.ts index 7609e54748..c191f72220 100644 --- a/exchanges/graphcache/src/test-utils/examples-1.test.ts +++ b/exchanges/graphcache/src/test-utils/examples-1.test.ts @@ -534,7 +534,12 @@ it('correctly resolves optimistic updates on Relay schemas', () => { `; write(store, { query: getRoot }, queryData); - writeOptimistic(store, { query: updateItem, variables: { id: '2' } }, 1); + const { dependencies } = writeOptimistic( + store, + { query: updateItem, variables: { id: '2' } }, + 1 + ); + expect(dependencies.size).not.toBe(0); InMemoryData.noopDataState(store.data, 1); const queryRes = query(store, { query: getRoot }); @@ -542,6 +547,30 @@ it('correctly resolves optimistic updates on Relay schemas', () => { expect(queryRes.data).not.toBe(null); }); +it('skips non-optimistic mutation fields on writes', () => { + const store = new Store(); + + const updateItem = gql` + mutation UpdateItem($id: ID!) { + updateItem(id: $id) { + __typename + item { + __typename + id + name + } + } + } + `; + + const { dependencies } = writeOptimistic( + store, + { query: updateItem, variables: { id: '2' } }, + 1 + ); + expect(dependencies.size).toBe(0); +}); + it('allows cumulative optimistic updates', () => { let counter = 1; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1614a9226e..cd54627795 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -528,6 +528,13 @@ export interface OperationContext { * @see {@link https://beta.reactjs.org/blog/2022/03/29/react-v18#new-suspense-features} for more information on React Suspense. */ suspense?: boolean; + /** A metdata flag indicating whether this operation triggered optimistic updates. + * + * @remarks + * This configuration flag is reserved for `@urql/exchange-graphcache` and is flipped + * when an operation triggerd optimistic updates. + */ + optimistic?: boolean; [key: string]: any; }