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)

Changed files
+93 -65
.changeset
exchanges
packages
core
src
+5
.changeset/eleven-snakes-look.md
···
+
---
+
'@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.
+6
.changeset/two-ants-relate.md
···
+
---
+
'@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`.
+7 -1
exchanges/graphcache/src/cacheExchange.ts
···
makeOperation,
Operation,
OperationResult,
+
OperationContext,
RequestPolicy,
CacheOutcome,
} from '@urql/core';
···
// This registers queries with the data layer to ensure commutativity
const prepareForwardedOperation = (operation: Operation) => {
+
let context: Partial<OperationContext> | undefined;
if (operation.kind === 'query') {
// Pre-reserve the position of the result layer
reserveLayer(store.data, operation.key);
···
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'
···
const pendingOperations: Operations = new Set();
collectPendingOperations(pendingOperations, dependencies);
executePendingOperations(operation, pendingOperations, true);
+
+
// Mark operation as optimistic
+
context = { optimistic: true };
}
}
···
)
: operation.variables,
},
-
operation.context
+
{ ...operation.context, ...context }
);
};
+38 -63
exchanges/graphcache/src/offlineExchange.ts
···
import { pipe, share, merge, makeSubject, filter, onPush } from 'wonka';
-
import { SelectionNode } from '@0no-co/graphql.web';
import {
Operation,
···
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 = <T extends OptimisticMutationConfig>(
-
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
···
) {
const { forward: outerForward, client, dispatchDebug } = input;
const { source: reboundOps$, next } = makeSubject<Operation>();
-
const optimisticMutations = opts.optimistic || {};
const failedQueue: Operation[] = [];
let hasRehydrated = false;
let isFlushingQueue = false;
···
}
};
+
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<number>();
+
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')
+
sent.add(operation.key);
+
if (operation.kind !== 'subscription') {
next(makeOperation('teardown', operation));
-
sent.add(operation.key);
-
next(toRequestPolicy(operation, 'cache-first'));
+
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();
}
};
···
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();
···
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);
}
})
),
···
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;
})
+30 -1
exchanges/graphcache/src/test-utils/examples-1.test.ts
···
`;
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 });
expect(queryRes.partial).toBe(false);
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', () => {
+7
packages/core/src/types.ts
···
* @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;
}