1import { pipe, share, merge, makeSubject, filter, onPush } from 'wonka';
2
3import type {
4 Operation,
5 OperationResult,
6 Exchange,
7 ExchangeIO,
8 CombinedError,
9 RequestPolicy,
10} from '@urql/core';
11import { stringifyDocument, createRequest, makeOperation } from '@urql/core';
12
13import type {
14 SerializedRequest,
15 CacheExchangeOpts,
16 StorageAdapter,
17} from './types';
18import { cacheExchange } from './cacheExchange';
19import { toRequestPolicy } from './helpers/operation';
20
21const policyLevel = {
22 'cache-only': 0,
23 'cache-first': 1,
24 'network-only': 2,
25 'cache-and-network': 3,
26} as const;
27
28/** Input parameters for the {@link offlineExchange}.
29 * @remarks
30 * This configuration object extends the {@link CacheExchangeOpts}
31 * as the `offlineExchange` extends the regular {@link cacheExchange}.
32 */
33export interface OfflineExchangeOpts extends CacheExchangeOpts {
34 /** Configures an offline storage adapter for Graphcache.
35 *
36 * @remarks
37 * A {@link StorageAdapter} allows Graphcache to write data to an external,
38 * asynchronous storage, and hydrate data from it when it first loads.
39 * This allows you to preserve normalized data between restarts/reloads.
40 *
41 * @see {@link https://urql.dev/goto/docs/graphcache/offline} for the full Offline Support docs.
42 */
43 storage: StorageAdapter;
44 /** Predicate function to determine whether a {@link CombinedError} hints at a network error.
45 *
46 * @remarks
47 * Not ever {@link CombinedError} means that the device is offline and by default
48 * the `offlineExchange` will check for common network error messages and check
49 * `navigator.onLine`. However, when `isOfflineError` is passed it can replace
50 * the default offline detection.
51 */
52 isOfflineError?(
53 error: undefined | CombinedError,
54 result: OperationResult
55 ): boolean;
56}
57
58/** Exchange factory that creates a normalized cache exchange in Offline Support mode.
59 *
60 * @param opts - A {@link OfflineExchangeOpts} configuration object.
61 * @returns the created normalized, offline cache {@link Exchange}.
62 *
63 * @remarks
64 * The `offlineExchange` is a wrapper around the regular {@link cacheExchange}
65 * which adds logic via the {@link OfflineExchangeOpts.storage} adapter to
66 * recognize when it’s offline, when to retry failed mutations, and how
67 * to handle longer periods of being offline.
68 *
69 * @see {@link https://urql.dev/goto/docs/graphcache/offline} for the full Offline Support docs.
70 */
71export const offlineExchange =
72 <C extends OfflineExchangeOpts>(opts: C): Exchange =>
73 input => {
74 const { storage } = opts;
75
76 const isOfflineError =
77 opts.isOfflineError ||
78 ((error: undefined | CombinedError) =>
79 error &&
80 error.networkError &&
81 !error.response &&
82 ((typeof navigator !== 'undefined' && navigator.onLine === false) ||
83 /request failed|failed to fetch|network\s?error/i.test(
84 error.networkError.message
85 )));
86
87 if (
88 storage &&
89 storage.onOnline &&
90 storage.readMetadata &&
91 storage.writeMetadata
92 ) {
93 const { forward: outerForward, client, dispatchDebug } = input;
94 const { source: reboundOps$, next } = makeSubject<Operation>();
95 const failedQueue: Operation[] = [];
96 let hasRehydrated = false;
97 let isFlushingQueue = false;
98
99 const updateMetadata = () => {
100 if (hasRehydrated) {
101 const requests: SerializedRequest[] = [];
102 for (let i = 0; i < failedQueue.length; i++) {
103 const operation = failedQueue[i];
104 if (operation.kind === 'mutation') {
105 requests.push({
106 query: stringifyDocument(operation.query),
107 variables: operation.variables,
108 extensions: operation.extensions,
109 });
110 }
111 }
112 storage.writeMetadata!(requests);
113 }
114 };
115
116 const filterQueue = (key: number) => {
117 for (let i = failedQueue.length - 1; i >= 0; i--)
118 if (failedQueue[i].key === key) failedQueue.splice(i, 1);
119 };
120
121 const flushQueue = () => {
122 if (!isFlushingQueue) {
123 const sent = new Set<number>();
124 isFlushingQueue = true;
125 for (let i = 0; i < failedQueue.length; i++) {
126 const operation = failedQueue[i];
127 if (operation.kind === 'mutation' || !sent.has(operation.key)) {
128 sent.add(operation.key);
129 if (operation.kind !== 'subscription') {
130 next(makeOperation('teardown', operation));
131 let overridePolicy: RequestPolicy = 'cache-first';
132 for (let i = 0; i < failedQueue.length; i++) {
133 const { requestPolicy } = failedQueue[i].context;
134 if (policyLevel[requestPolicy] > policyLevel[overridePolicy])
135 overridePolicy = requestPolicy;
136 }
137 next(toRequestPolicy(operation, overridePolicy));
138 } else {
139 next(toRequestPolicy(operation, 'cache-first'));
140 }
141 }
142 }
143 isFlushingQueue = false;
144 failedQueue.length = 0;
145 updateMetadata();
146 }
147 };
148
149 const forward: ExchangeIO = ops$ => {
150 return pipe(
151 outerForward(ops$),
152 filter(res => {
153 if (
154 hasRehydrated &&
155 res.operation.kind === 'mutation' &&
156 res.operation.context.optimistic &&
157 isOfflineError(res.error, res)
158 ) {
159 failedQueue.push(res.operation);
160 updateMetadata();
161 return false;
162 }
163
164 return true;
165 }),
166 share
167 );
168 };
169
170 const cacheResults$ = cacheExchange({
171 ...opts,
172 storage: {
173 ...storage,
174 readData() {
175 const hydrate = storage.readData();
176 return {
177 async then(onEntries) {
178 const mutations = await storage.readMetadata!();
179 for (let i = 0; mutations && i < mutations.length; i++) {
180 failedQueue.push(
181 client.createRequestOperation(
182 'mutation',
183 createRequest(mutations[i].query, mutations[i].variables),
184 mutations[i].extensions
185 )
186 );
187 }
188 onEntries!(await hydrate);
189 storage.onOnline!(flushQueue);
190 hasRehydrated = true;
191 flushQueue();
192 },
193 };
194 },
195 },
196 })({
197 client,
198 dispatchDebug,
199 forward,
200 });
201
202 return operations$ => {
203 const opsAndRebound$ = merge([
204 reboundOps$,
205 pipe(
206 operations$,
207 onPush(operation => {
208 if (operation.kind === 'query' && !hasRehydrated) {
209 failedQueue.push(operation);
210 } else if (operation.kind === 'teardown') {
211 filterQueue(operation.key);
212 }
213 })
214 ),
215 ]);
216
217 return pipe(
218 cacheResults$(opsAndRebound$),
219 filter(res => {
220 if (res.operation.kind === 'query') {
221 if (isOfflineError(res.error, res)) {
222 next(toRequestPolicy(res.operation, 'cache-only'));
223 failedQueue.push(res.operation);
224 return false;
225 } else if (!hasRehydrated) {
226 filterQueue(res.operation.key);
227 }
228 }
229 return true;
230 })
231 );
232 };
233 }
234
235 return cacheExchange(opts)(input);
236 };