UNPKG

16.9 kBJavaScriptView Raw
1import deepEqual from 'fast-deep-equal';
2import { useCallback, useContext, useEffect, useLayoutEffect, useReducer, useRef } from 'react';
3import defaultConfig, { cacheGet, cacheSet, CACHE_REVALIDATORS, CONCURRENT_PROMISES, CONCURRENT_PROMISES_TS, FOCUS_REVALIDATORS, MUTATION_TS } from './config';
4import hash from './libs/hash';
5import isDocumentVisible from './libs/is-document-visible';
6import isOnline from './libs/is-online';
7import throttle from './libs/throttle';
8import SWRConfigContext from './swr-config-context';
9const IS_SERVER = typeof window === 'undefined';
10// React currently throws a warning when using useLayoutEffect on the server.
11// To get around it, we can conditionally useEffect on the server (no-op) and
12// useLayoutEffect in the browser.
13const useIsomorphicLayoutEffect = IS_SERVER ? useEffect : useLayoutEffect;
14// TODO: introduce namepsace for the cache
15const getErrorKey = key => (key ? 'err@' + key : '');
16const getKeyArgs = key => {
17 let args = null;
18 if (typeof key === 'function') {
19 try {
20 key = key();
21 }
22 catch (err) {
23 // dependencies not ready
24 key = '';
25 }
26 }
27 if (Array.isArray(key)) {
28 // args array
29 args = key;
30 key = hash(key);
31 }
32 else {
33 // convert null to ''
34 key = String(key || '');
35 }
36 return [key, args];
37};
38const trigger = (_key, shouldRevalidate = true) => {
39 const [key] = getKeyArgs(_key);
40 if (!key)
41 return;
42 const updaters = CACHE_REVALIDATORS[key];
43 if (key && updaters) {
44 const currentData = cacheGet(key);
45 const currentError = cacheGet(getErrorKey(key));
46 for (let i = 0; i < updaters.length; ++i) {
47 updaters[i](shouldRevalidate, currentData, currentError, true);
48 }
49 }
50};
51const broadcastState = (key, data, error) => {
52 const updaters = CACHE_REVALIDATORS[key];
53 if (key && updaters) {
54 for (let i = 0; i < updaters.length; ++i) {
55 updaters[i](false, data, error);
56 }
57 }
58};
59const mutate = async (_key, _data, shouldRevalidate) => {
60 const [key] = getKeyArgs(_key);
61 if (!key)
62 return;
63 // update timestamp
64 MUTATION_TS[key] = Date.now() - 1;
65 let data, error;
66 if (_data && typeof _data.then === 'function') {
67 // `_data` is a promise
68 try {
69 data = await _data;
70 }
71 catch (err) {
72 error = err;
73 }
74 }
75 else {
76 data = _data;
77 if (typeof shouldRevalidate === 'undefined') {
78 // if it's a sync mutation, we trigger the revalidation by default
79 // because in most cases it's a local mutation
80 shouldRevalidate = true;
81 }
82 }
83 if (typeof data !== 'undefined') {
84 // update cached data
85 cacheSet(key, data);
86 }
87 // update existing SWR Hooks' state
88 const updaters = CACHE_REVALIDATORS[key];
89 if (updaters) {
90 for (let i = 0; i < updaters.length; ++i) {
91 updaters[i](!!shouldRevalidate, data, error, true);
92 }
93 }
94};
95function mergeState(state, payload) {
96 return { ...state, ...payload };
97}
98function useSWR(...args) {
99 let _key, fn, config = {};
100 if (args.length >= 1) {
101 _key = args[0];
102 }
103 if (args.length > 2) {
104 fn = args[1];
105 config = args[2];
106 }
107 else {
108 if (typeof args[1] === 'function') {
109 fn = args[1];
110 }
111 else if (typeof args[1] === 'object') {
112 config = args[1];
113 }
114 }
115 // we assume `key` as the identifier of the request
116 // `key` can change but `fn` shouldn't
117 // (because `revalidate` only depends on `key`)
118 const [key, fnArgs] = getKeyArgs(_key);
119 // `keyErr` is the cache key for error objects
120 const keyErr = getErrorKey(key);
121 config = Object.assign({}, defaultConfig, useContext(SWRConfigContext), config);
122 if (typeof fn === 'undefined') {
123 // use a global fetcher
124 fn = config.fetcher;
125 }
126 const initialData = cacheGet(key) || config.initialData;
127 const initialError = cacheGet(keyErr);
128 let [state, dispatch] = useReducer(mergeState, {
129 data: initialData,
130 error: initialError,
131 isValidating: false
132 });
133 // error ref inside revalidate (is last request errored?)
134 const unmountedRef = useRef(false);
135 const keyRef = useRef(key);
136 const dataRef = useRef(initialData);
137 const errorRef = useRef(initialError);
138 // start a revalidation
139 const revalidate = useCallback(async (revalidateOpts = {}) => {
140 if (!key || !fn)
141 return false;
142 if (unmountedRef.current)
143 return false;
144 revalidateOpts = Object.assign({ dedupe: false }, revalidateOpts);
145 let loading = true;
146 let shouldDeduping = typeof CONCURRENT_PROMISES[key] !== 'undefined' && revalidateOpts.dedupe;
147 // start fetching
148 try {
149 dispatch({
150 isValidating: true
151 });
152 let newData;
153 let startAt;
154 if (shouldDeduping) {
155 // there's already an ongoing request,
156 // this one needs to be deduplicated.
157 startAt = CONCURRENT_PROMISES_TS[key];
158 newData = await CONCURRENT_PROMISES[key];
159 }
160 else {
161 // if not deduping the request (hard revalidate) but
162 // there're other ongoing request(s) at the same time,
163 // we need to ignore the other result(s) to avoid
164 // possible race conditions:
165 // req1------------------>res1
166 // req2-------->res2
167 // in that case, the second response should not be overridden
168 // by the first one.
169 if (CONCURRENT_PROMISES[key]) {
170 // we can mark it as a mutation to ignore
171 // all requests which are fired before this one
172 MUTATION_TS[key] = Date.now() - 1;
173 }
174 // if no cache being rendered currently (it shows a blank page),
175 // we trigger the loading slow event.
176 if (config.loadingTimeout && !cacheGet(key)) {
177 setTimeout(() => {
178 if (loading)
179 config.onLoadingSlow(key, config);
180 }, config.loadingTimeout);
181 }
182 if (fnArgs !== null) {
183 CONCURRENT_PROMISES[key] = fn(...fnArgs);
184 }
185 else {
186 CONCURRENT_PROMISES[key] = fn(key);
187 }
188 CONCURRENT_PROMISES_TS[key] = startAt = Date.now();
189 setTimeout(() => {
190 delete CONCURRENT_PROMISES[key];
191 delete CONCURRENT_PROMISES_TS[key];
192 }, config.dedupingInterval);
193 newData = await CONCURRENT_PROMISES[key];
194 // trigger the success event,
195 // only do this for the original request.
196 config.onSuccess(newData, key, config);
197 }
198 // if the revalidation happened earlier than the local mutation,
199 // we have to ignore the result because it could override.
200 // meanwhile, a new revalidation should be triggered by the mutation.
201 if (MUTATION_TS[key] && startAt <= MUTATION_TS[key]) {
202 dispatch({ isValidating: false });
203 return false;
204 }
205 cacheSet(key, newData);
206 cacheSet(keyErr, undefined);
207 keyRef.current = key;
208 // new state for the reducer
209 const newState = {
210 isValidating: false
211 };
212 if (typeof errorRef.current !== 'undefined') {
213 // we don't have an error
214 newState.error = undefined;
215 errorRef.current = undefined;
216 }
217 if (deepEqual(dataRef.current, newData)) {
218 // deep compare to avoid extra re-render
219 // do nothing
220 }
221 else {
222 // data changed
223 newState.data = newData;
224 dataRef.current = newData;
225 }
226 // merge the new state
227 dispatch(newState);
228 if (!shouldDeduping) {
229 // also update other hooks
230 broadcastState(key, newData, undefined);
231 }
232 }
233 catch (err) {
234 delete CONCURRENT_PROMISES[key];
235 delete CONCURRENT_PROMISES_TS[key];
236 cacheSet(keyErr, err);
237 keyRef.current = key;
238 // get a new error
239 // don't use deep equal for errors
240 if (errorRef.current !== err) {
241 errorRef.current = err;
242 // we keep the stale data
243 dispatch({
244 isValidating: false,
245 error: err
246 });
247 if (!shouldDeduping) {
248 // also broadcast to update other hooks
249 broadcastState(key, undefined, err);
250 }
251 }
252 // events and retry
253 config.onError(err, key, config);
254 if (config.shouldRetryOnError) {
255 // when retrying, we always enable deduping
256 const retryCount = (revalidateOpts.retryCount || 0) + 1;
257 config.onErrorRetry(err, key, config, revalidate, Object.assign({ dedupe: true }, revalidateOpts, { retryCount }));
258 }
259 }
260 loading = false;
261 return true;
262 }, [key]);
263 // mounted (client side rendering)
264 useIsomorphicLayoutEffect(() => {
265 if (!key)
266 return undefined;
267 // after `key` updates, we need to mark it as mounted
268 unmountedRef.current = false;
269 // after the component is mounted (hydrated),
270 // we need to update the data from the cache
271 // and trigger a revalidation
272 const currentHookData = dataRef.current;
273 const latestKeyedData = cacheGet(key) || config.initialData;
274 // update the state if the key changed or cache updated
275 if (keyRef.current !== key ||
276 !deepEqual(currentHookData, latestKeyedData)) {
277 dispatch({ data: latestKeyedData });
278 dataRef.current = latestKeyedData;
279 keyRef.current = key;
280 }
281 // revalidate with deduping
282 const softRevalidate = () => revalidate({ dedupe: true });
283 // trigger a revalidation
284 if (typeof latestKeyedData !== 'undefined' &&
285 !IS_SERVER &&
286 window['requestIdleCallback']) {
287 // delay revalidate if there's cache
288 // to not block the rendering
289 window['requestIdleCallback'](softRevalidate);
290 }
291 else {
292 softRevalidate();
293 }
294 // whenever the window gets focused, revalidate
295 let onFocus;
296 if (config.revalidateOnFocus) {
297 // throttle: avoid being called twice from both listeners
298 // and tabs being switched quickly
299 onFocus = throttle(softRevalidate, config.focusThrottleInterval);
300 if (!FOCUS_REVALIDATORS[key]) {
301 FOCUS_REVALIDATORS[key] = [onFocus];
302 }
303 else {
304 FOCUS_REVALIDATORS[key].push(onFocus);
305 }
306 }
307 // register global cache update listener
308 const onUpdate = (shouldRevalidate = true, updatedData, updatedError, dedupe = true) => {
309 // update hook state
310 const newState = {};
311 if (typeof updatedData !== 'undefined' &&
312 !deepEqual(dataRef.current, updatedData)) {
313 newState.data = updatedData;
314 dataRef.current = updatedData;
315 }
316 // always update error
317 // because it can be `undefined`
318 if (errorRef.current !== updatedError) {
319 newState.error = updatedError;
320 errorRef.current = updatedError;
321 }
322 dispatch(newState);
323 keyRef.current = key;
324 if (shouldRevalidate) {
325 if (dedupe) {
326 return softRevalidate();
327 }
328 else {
329 return revalidate();
330 }
331 }
332 return false;
333 };
334 // add updater to listeners
335 if (!CACHE_REVALIDATORS[key]) {
336 CACHE_REVALIDATORS[key] = [onUpdate];
337 }
338 else {
339 CACHE_REVALIDATORS[key].push(onUpdate);
340 }
341 // set up polling
342 let timeout = null;
343 if (config.refreshInterval) {
344 const tick = async () => {
345 if (!errorRef.current &&
346 (config.refreshWhenHidden || isDocumentVisible()) &&
347 (!config.refreshWhenOffline && isOnline())) {
348 // only revalidate when the page is visible
349 // if API request errored, we stop polling in this round
350 // and let the error retry function handle it
351 await softRevalidate();
352 }
353 const interval = config.refreshInterval;
354 timeout = setTimeout(tick, interval);
355 };
356 timeout = setTimeout(tick, config.refreshInterval);
357 }
358 // set up reconnecting when the browser regains network connection
359 let reconnect = null;
360 if (config.revalidateOnReconnect) {
361 reconnect = addEventListener('online', softRevalidate);
362 }
363 return () => {
364 // cleanup
365 dispatch = () => null;
366 // mark it as unmounted
367 unmountedRef.current = true;
368 if (onFocus && FOCUS_REVALIDATORS[key]) {
369 const revalidators = FOCUS_REVALIDATORS[key];
370 const index = revalidators.indexOf(onFocus);
371 if (index >= 0) {
372 // 10x faster than splice
373 // https://jsperf.com/array-remove-by-index
374 revalidators[index] = revalidators[revalidators.length - 1];
375 revalidators.pop();
376 }
377 }
378 if (CACHE_REVALIDATORS[key]) {
379 const revalidators = CACHE_REVALIDATORS[key];
380 const index = revalidators.indexOf(onUpdate);
381 if (index >= 0) {
382 revalidators[index] = revalidators[revalidators.length - 1];
383 revalidators.pop();
384 }
385 }
386 if (timeout !== null) {
387 clearTimeout(timeout);
388 }
389 if (reconnect !== null) {
390 removeEventListener('online', reconnect);
391 }
392 };
393 }, [key, config.refreshInterval, revalidate]);
394 // suspense
395 if (config.suspense) {
396 if (IS_SERVER)
397 throw new Error('Suspense on server side is not yet supported!');
398 // in suspense mode, we can't return empty state
399 // (it should be suspended)
400 // try to get data and error from cache
401 let latestData = cacheGet(key);
402 let latestError = cacheGet(keyErr);
403 if (typeof latestData === 'undefined' &&
404 typeof latestError === 'undefined') {
405 // need to start the request if it hasn't
406 if (!CONCURRENT_PROMISES[key]) {
407 // trigger revalidate immediately
408 // to get the promise
409 revalidate();
410 }
411 if (CONCURRENT_PROMISES[key] &&
412 typeof CONCURRENT_PROMISES[key].then === 'function') {
413 // if it is a promise
414 throw CONCURRENT_PROMISES[key];
415 }
416 // it's a value, return it directly (override)
417 latestData = CONCURRENT_PROMISES[key];
418 }
419 if (typeof latestData === 'undefined' && latestError) {
420 // in suspense mode, throw error if there's no content
421 throw latestError;
422 }
423 // return the latest data / error from cache
424 // in case `key` has changed
425 return {
426 error: latestError,
427 data: latestData,
428 revalidate,
429 isValidating: state.isValidating
430 };
431 }
432 return {
433 // `key` might be changed in the upcoming hook re-render,
434 // but the previous state will stay
435 // so we need to match the latest key and data (fallback to `initialData`)
436 error: keyRef.current === key ? state.error : initialError,
437 data: keyRef.current === key ? state.data : initialData,
438 revalidate,
439 isValidating: state.isValidating
440 };
441}
442const SWRConfig = SWRConfigContext.Provider;
443export { trigger, mutate, SWRConfig };
444export default useSWR;