Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
at main 7.7 kB view raw
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 };