Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.

fix(graphcache): Re-enable offlineExchange issuing non-cache request policies (#3308)

authored by kitten.sh and committed by

GitHub 7ddccc13 76ad6191

+93 -65
+5
.changeset/eleven-snakes-look.md
··· 1 + --- 2 + '@urql/exchange-graphcache': patch 3 + --- 4 + 5 + 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.
+6
.changeset/two-ants-relate.md
··· 1 + --- 2 + '@urql/exchange-graphcache': patch 3 + '@urql/core': patch 4 + --- 5 + 6 + Add `OperationContext.optimistic` flag as an internal indication on whether a mutation triggered an optimistic update in `@urql/exchange-graphcache`'s `cacheExchange`.
+7 -1
exchanges/graphcache/src/cacheExchange.ts
··· 4 4 makeOperation, 5 5 Operation, 6 6 OperationResult, 7 + OperationContext, 7 8 RequestPolicy, 8 9 CacheOutcome, 9 10 } from '@urql/core'; ··· 144 145 145 146 // This registers queries with the data layer to ensure commutativity 146 147 const prepareForwardedOperation = (operation: Operation) => { 148 + let context: Partial<OperationContext> | undefined; 147 149 if (operation.kind === 'query') { 148 150 // Pre-reserve the position of the result layer 149 151 reserveLayer(store.data, operation.key); ··· 155 157 reexecutingOperations.delete(operation.key); 156 158 // Mark operation layer as done 157 159 noopDataState(store.data, operation.key); 160 + return operation; 158 161 } else if ( 159 162 operation.kind === 'mutation' && 160 163 operation.context.requestPolicy !== 'network-only' ··· 175 178 const pendingOperations: Operations = new Set(); 176 179 collectPendingOperations(pendingOperations, dependencies); 177 180 executePendingOperations(operation, pendingOperations, true); 181 + 182 + // Mark operation as optimistic 183 + context = { optimistic: true }; 178 184 } 179 185 } 180 186 ··· 190 196 ) 191 197 : operation.variables, 192 198 }, 193 - operation.context 199 + { ...operation.context, ...context } 194 200 ); 195 201 }; 196 202
+38 -63
exchanges/graphcache/src/offlineExchange.ts
··· 1 1 import { pipe, share, merge, makeSubject, filter, onPush } from 'wonka'; 2 - import { SelectionNode } from '@0no-co/graphql.web'; 3 2 4 3 import { 5 4 Operation, ··· 7 6 Exchange, 8 7 ExchangeIO, 9 8 CombinedError, 9 + RequestPolicy, 10 10 stringifyDocument, 11 11 createRequest, 12 12 makeOperation, 13 13 } from '@urql/core'; 14 14 15 - import { 16 - getMainOperation, 17 - getFragments, 18 - isInlineFragment, 19 - isFieldNode, 20 - shouldInclude, 21 - getSelectionSet, 22 - getName, 23 - } from './ast'; 24 - 25 - import { 26 - SerializedRequest, 27 - OptimisticMutationConfig, 28 - Variables, 29 - CacheExchangeOpts, 30 - StorageAdapter, 31 - } from './types'; 15 + import { SerializedRequest, CacheExchangeOpts, StorageAdapter } from './types'; 32 16 33 17 import { cacheExchange } from './cacheExchange'; 34 18 import { toRequestPolicy } from './helpers/operation'; 35 19 36 - /** Determines whether a given query contains an optimistic mutation field */ 37 - const isOptimisticMutation = <T extends OptimisticMutationConfig>( 38 - config: T, 39 - operation: Operation 40 - ) => { 41 - const vars: Variables = operation.variables || {}; 42 - const fragments = getFragments(operation.query); 43 - const selections = [...getSelectionSet(getMainOperation(operation.query))]; 44 - 45 - let field: void | SelectionNode; 46 - while ((field = selections.pop())) { 47 - if (!shouldInclude(field, vars)) { 48 - continue; 49 - } else if (!isFieldNode(field)) { 50 - const fragmentNode = !isInlineFragment(field) 51 - ? fragments[getName(field)] 52 - : field; 53 - if (fragmentNode) selections.push(...getSelectionSet(fragmentNode)); 54 - } else if (config[getName(field)]) { 55 - return true; 56 - } 57 - } 58 - 59 - return false; 60 - }; 20 + const policyLevel = { 21 + 'cache-only': 0, 22 + 'cache-first': 1, 23 + 'network-only': 2, 24 + 'cache-and-network': 3, 25 + } as const; 61 26 62 27 /** Input parameters for the {@link offlineExchange}. 63 28 * @remarks ··· 126 91 ) { 127 92 const { forward: outerForward, client, dispatchDebug } = input; 128 93 const { source: reboundOps$, next } = makeSubject<Operation>(); 129 - const optimisticMutations = opts.optimistic || {}; 130 94 const failedQueue: Operation[] = []; 131 95 let hasRehydrated = false; 132 96 let isFlushingQueue = false; ··· 148 112 } 149 113 }; 150 114 115 + const filterQueue = (key: number) => { 116 + for (let i = failedQueue.length - 1; i >= 0; i--) 117 + if (failedQueue[i].key === key) failedQueue.splice(i, 1); 118 + }; 119 + 151 120 const flushQueue = () => { 152 121 if (!isFlushingQueue) { 153 - isFlushingQueue = true; 154 - 155 122 const sent = new Set<number>(); 123 + isFlushingQueue = true; 156 124 for (let i = 0; i < failedQueue.length; i++) { 157 125 const operation = failedQueue[i]; 158 126 if (operation.kind === 'mutation' || !sent.has(operation.key)) { 159 - if (operation.kind !== 'subscription') 127 + sent.add(operation.key); 128 + if (operation.kind !== 'subscription') { 160 129 next(makeOperation('teardown', operation)); 161 - sent.add(operation.key); 162 - next(toRequestPolicy(operation, 'cache-first')); 130 + let overridePolicy: RequestPolicy = 'cache-first'; 131 + for (let i = 0; i < failedQueue.length; i++) { 132 + const { requestPolicy } = failedQueue[i].context; 133 + if (policyLevel[requestPolicy] > policyLevel[overridePolicy]) 134 + overridePolicy = requestPolicy; 135 + } 136 + next(toRequestPolicy(operation, overridePolicy)); 137 + } else { 138 + next(toRequestPolicy(operation, 'cache-first')); 139 + } 163 140 } 164 141 } 165 - 166 - failedQueue.length = 0; 167 142 isFlushingQueue = false; 143 + failedQueue.length = 0; 168 144 updateMetadata(); 169 145 } 170 146 }; ··· 176 152 if ( 177 153 hasRehydrated && 178 154 res.operation.kind === 'mutation' && 179 - isOfflineError(res.error, res) && 180 - isOptimisticMutation(optimisticMutations, res.operation) 155 + res.operation.context.optimistic && 156 + isOfflineError(res.error, res) 181 157 ) { 182 158 failedQueue.push(res.operation); 183 159 updateMetadata(); ··· 231 207 if (operation.kind === 'query' && !hasRehydrated) { 232 208 failedQueue.push(operation); 233 209 } else if (operation.kind === 'teardown') { 234 - for (let i = failedQueue.length - 1; i >= 0; i--) 235 - if (failedQueue[i].key === operation.key) 236 - failedQueue.splice(i, 1); 210 + filterQueue(operation.key); 237 211 } 238 212 }) 239 213 ), ··· 242 216 return pipe( 243 217 cacheResults$(opsAndRebound$), 244 218 filter(res => { 245 - if ( 246 - res.operation.kind === 'query' && 247 - isOfflineError(res.error, res) 248 - ) { 249 - next(toRequestPolicy(res.operation, 'cache-only')); 250 - failedQueue.push(res.operation); 251 - return false; 219 + if (res.operation.kind === 'query') { 220 + if (isOfflineError(res.error, res)) { 221 + next(toRequestPolicy(res.operation, 'cache-only')); 222 + failedQueue.push(res.operation); 223 + return false; 224 + } else if (!hasRehydrated) { 225 + filterQueue(res.operation.key); 226 + } 252 227 } 253 228 return true; 254 229 })
+30 -1
exchanges/graphcache/src/test-utils/examples-1.test.ts
··· 534 534 `; 535 535 536 536 write(store, { query: getRoot }, queryData); 537 - writeOptimistic(store, { query: updateItem, variables: { id: '2' } }, 1); 537 + const { dependencies } = writeOptimistic( 538 + store, 539 + { query: updateItem, variables: { id: '2' } }, 540 + 1 541 + ); 542 + expect(dependencies.size).not.toBe(0); 538 543 InMemoryData.noopDataState(store.data, 1); 539 544 const queryRes = query(store, { query: getRoot }); 540 545 541 546 expect(queryRes.partial).toBe(false); 542 547 expect(queryRes.data).not.toBe(null); 548 + }); 549 + 550 + it('skips non-optimistic mutation fields on writes', () => { 551 + const store = new Store(); 552 + 553 + const updateItem = gql` 554 + mutation UpdateItem($id: ID!) { 555 + updateItem(id: $id) { 556 + __typename 557 + item { 558 + __typename 559 + id 560 + name 561 + } 562 + } 563 + } 564 + `; 565 + 566 + const { dependencies } = writeOptimistic( 567 + store, 568 + { query: updateItem, variables: { id: '2' } }, 569 + 1 570 + ); 571 + expect(dependencies.size).toBe(0); 543 572 }); 544 573 545 574 it('allows cumulative optimistic updates', () => {
+7
packages/core/src/types.ts
··· 528 528 * @see {@link https://beta.reactjs.org/blog/2022/03/29/react-v18#new-suspense-features} for more information on React Suspense. 529 529 */ 530 530 suspense?: boolean; 531 + /** A metdata flag indicating whether this operation triggered optimistic updates. 532 + * 533 + * @remarks 534 + * This configuration flag is reserved for `@urql/exchange-graphcache` and is flipped 535 + * when an operation triggerd optimistic updates. 536 + */ 537 + optimistic?: boolean; 531 538 [key: string]: any; 532 539 } 533 540