UNPKG

18.4 kBJavaScriptView Raw
1import deepEqual from 'fast-deep-equal';
2import { useCallback, useContext, useEffect, useLayoutEffect, useState, useRef, useMemo } 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 useSWR(...args) {
96 let _key, fn, config = {};
97 if (args.length >= 1) {
98 _key = args[0];
99 }
100 if (args.length > 2) {
101 fn = args[1];
102 config = args[2];
103 }
104 else {
105 if (typeof args[1] === 'function') {
106 fn = args[1];
107 }
108 else if (typeof args[1] === 'object') {
109 config = args[1];
110 }
111 }
112 // we assume `key` as the identifier of the request
113 // `key` can change but `fn` shouldn't
114 // (because `revalidate` only depends on `key`)
115 const [key, fnArgs] = getKeyArgs(_key);
116 // `keyErr` is the cache key for error objects
117 const keyErr = getErrorKey(key);
118 config = Object.assign({}, defaultConfig, useContext(SWRConfigContext), config);
119 if (typeof fn === 'undefined') {
120 // use a global fetcher
121 fn = config.fetcher;
122 }
123 const initialData = cacheGet(key) || config.initialData;
124 const initialError = cacheGet(keyErr);
125 // if a state is accessed (data, error or isValidating),
126 // we add the state to dependencies so if the state is
127 // updated in the future, we can trigger a rerender
128 const stateDependencies = useRef({
129 data: false,
130 error: false,
131 isValidating: false
132 });
133 const stateRef = useRef({
134 data: initialData,
135 error: initialError,
136 isValidating: false
137 });
138 const rerender = useState(null)[1];
139 let dispatch = useCallback(payload => {
140 let shouldUpdateState = false;
141 for (let k in payload) {
142 stateRef.current[k] = payload[k];
143 if (stateDependencies.current[k]) {
144 shouldUpdateState = true;
145 }
146 }
147 if (shouldUpdateState || config.suspense) {
148 rerender({});
149 }
150 }, []);
151 // error ref inside revalidate (is last request errored?)
152 const unmountedRef = useRef(false);
153 const keyRef = useRef(key);
154 // start a revalidation
155 const revalidate = useCallback(async (revalidateOpts = {}) => {
156 if (!key || !fn)
157 return false;
158 if (unmountedRef.current)
159 return false;
160 revalidateOpts = Object.assign({ dedupe: false }, revalidateOpts);
161 let loading = true;
162 let shouldDeduping = typeof CONCURRENT_PROMISES[key] !== 'undefined' && revalidateOpts.dedupe;
163 // start fetching
164 try {
165 dispatch({
166 isValidating: true
167 });
168 let newData;
169 let startAt;
170 if (shouldDeduping) {
171 // there's already an ongoing request,
172 // this one needs to be deduplicated.
173 startAt = CONCURRENT_PROMISES_TS[key];
174 newData = await CONCURRENT_PROMISES[key];
175 }
176 else {
177 // if not deduping the request (hard revalidate) but
178 // there're other ongoing request(s) at the same time,
179 // we need to ignore the other result(s) to avoid
180 // possible race conditions:
181 // req1------------------>res1
182 // req2-------->res2
183 // in that case, the second response should not be overridden
184 // by the first one.
185 if (CONCURRENT_PROMISES[key]) {
186 // we can mark it as a mutation to ignore
187 // all requests which are fired before this one
188 MUTATION_TS[key] = Date.now() - 1;
189 }
190 // if no cache being rendered currently (it shows a blank page),
191 // we trigger the loading slow event.
192 if (config.loadingTimeout && !cacheGet(key)) {
193 setTimeout(() => {
194 if (loading)
195 config.onLoadingSlow(key, config);
196 }, config.loadingTimeout);
197 }
198 if (fnArgs !== null) {
199 CONCURRENT_PROMISES[key] = fn(...fnArgs);
200 }
201 else {
202 CONCURRENT_PROMISES[key] = fn(key);
203 }
204 CONCURRENT_PROMISES_TS[key] = startAt = Date.now();
205 setTimeout(() => {
206 delete CONCURRENT_PROMISES[key];
207 delete CONCURRENT_PROMISES_TS[key];
208 }, config.dedupingInterval);
209 newData = await CONCURRENT_PROMISES[key];
210 // trigger the success event,
211 // only do this for the original request.
212 config.onSuccess(newData, key, config);
213 }
214 // if the revalidation happened earlier than the local mutation,
215 // we have to ignore the result because it could override.
216 // meanwhile, a new revalidation should be triggered by the mutation.
217 if (MUTATION_TS[key] && startAt <= MUTATION_TS[key]) {
218 dispatch({ isValidating: false });
219 return false;
220 }
221 cacheSet(key, newData);
222 cacheSet(keyErr, undefined);
223 keyRef.current = key;
224 // new state for the reducer
225 const newState = {
226 isValidating: false
227 };
228 if (typeof stateRef.current.error !== 'undefined') {
229 // we don't have an error
230 newState.error = undefined;
231 }
232 if (deepEqual(stateRef.current.data, newData)) {
233 // deep compare to avoid extra re-render
234 // do nothing
235 }
236 else {
237 // data changed
238 newState.data = newData;
239 }
240 // merge the new state
241 dispatch(newState);
242 if (!shouldDeduping) {
243 // also update other hooks
244 broadcastState(key, newData, undefined);
245 }
246 }
247 catch (err) {
248 delete CONCURRENT_PROMISES[key];
249 delete CONCURRENT_PROMISES_TS[key];
250 cacheSet(keyErr, err);
251 keyRef.current = key;
252 // get a new error
253 // don't use deep equal for errors
254 if (stateRef.current.error !== err) {
255 // we keep the stale data
256 dispatch({
257 isValidating: false,
258 error: err
259 });
260 if (!shouldDeduping) {
261 // also broadcast to update other hooks
262 broadcastState(key, undefined, err);
263 }
264 }
265 // events and retry
266 config.onError(err, key, config);
267 if (config.shouldRetryOnError) {
268 // when retrying, we always enable deduping
269 const retryCount = (revalidateOpts.retryCount || 0) + 1;
270 config.onErrorRetry(err, key, config, revalidate, Object.assign({ dedupe: true }, revalidateOpts, { retryCount }));
271 }
272 }
273 loading = false;
274 return true;
275 }, [key]);
276 // mounted (client side rendering)
277 useIsomorphicLayoutEffect(() => {
278 if (!key)
279 return undefined;
280 // after `key` updates, we need to mark it as mounted
281 unmountedRef.current = false;
282 // after the component is mounted (hydrated),
283 // we need to update the data from the cache
284 // and trigger a revalidation
285 const currentHookData = stateRef.current.data;
286 const latestKeyedData = cacheGet(key) || config.initialData;
287 // update the state if the key changed or cache updated
288 if (keyRef.current !== key ||
289 !deepEqual(currentHookData, latestKeyedData)) {
290 dispatch({ data: latestKeyedData });
291 keyRef.current = key;
292 }
293 // revalidate with deduping
294 const softRevalidate = () => revalidate({ dedupe: true });
295 // trigger a revalidation
296 if (!config.initialData) {
297 if (typeof latestKeyedData !== 'undefined' &&
298 !IS_SERVER &&
299 window['requestIdleCallback']) {
300 // delay revalidate if there's cache
301 // to not block the rendering
302 window['requestIdleCallback'](softRevalidate);
303 }
304 else {
305 softRevalidate();
306 }
307 }
308 // whenever the window gets focused, revalidate
309 let onFocus;
310 if (config.revalidateOnFocus) {
311 // throttle: avoid being called twice from both listeners
312 // and tabs being switched quickly
313 onFocus = throttle(softRevalidate, config.focusThrottleInterval);
314 if (!FOCUS_REVALIDATORS[key]) {
315 FOCUS_REVALIDATORS[key] = [onFocus];
316 }
317 else {
318 FOCUS_REVALIDATORS[key].push(onFocus);
319 }
320 }
321 // register global cache update listener
322 const onUpdate = (shouldRevalidate = true, updatedData, updatedError, dedupe = true) => {
323 // update hook state
324 const newState = {};
325 let needUpdate = false;
326 if (typeof updatedData !== 'undefined' &&
327 !deepEqual(stateRef.current.data, updatedData)) {
328 newState.data = updatedData;
329 needUpdate = true;
330 }
331 // always update error
332 // because it can be `undefined`
333 if (stateRef.current.error !== updatedError) {
334 newState.error = updatedError;
335 needUpdate = true;
336 }
337 if (needUpdate) {
338 dispatch(newState);
339 }
340 keyRef.current = key;
341 if (shouldRevalidate) {
342 if (dedupe) {
343 return softRevalidate();
344 }
345 else {
346 return revalidate();
347 }
348 }
349 return false;
350 };
351 // add updater to listeners
352 if (!CACHE_REVALIDATORS[key]) {
353 CACHE_REVALIDATORS[key] = [onUpdate];
354 }
355 else {
356 CACHE_REVALIDATORS[key].push(onUpdate);
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 (reconnect !== null) {
387 removeEventListener('online', reconnect);
388 }
389 };
390 }, [key, revalidate]);
391 // set up polling
392 useIsomorphicLayoutEffect(() => {
393 let timer = null;
394 const tick = async () => {
395 if (!stateRef.current.error &&
396 (config.refreshWhenHidden || isDocumentVisible()) &&
397 (!config.refreshWhenOffline && isOnline())) {
398 // only revalidate when the page is visible
399 // if API request errored, we stop polling in this round
400 // and let the error retry function handle it
401 await revalidate({ dedupe: true });
402 }
403 if (config.refreshInterval) {
404 timer = setTimeout(tick, config.refreshInterval);
405 }
406 };
407 if (config.refreshInterval) {
408 timer = setTimeout(tick, config.refreshInterval);
409 }
410 return () => {
411 if (timer)
412 clearTimeout(timer);
413 };
414 }, [
415 config.refreshInterval,
416 config.refreshWhenHidden,
417 config.refreshWhenOffline,
418 revalidate
419 ]);
420 // suspense
421 if (config.suspense) {
422 if (IS_SERVER)
423 throw new Error('Suspense on server side is not yet supported!');
424 // in suspense mode, we can't return empty state
425 // (it should be suspended)
426 // try to get data and error from cache
427 let latestData = cacheGet(key);
428 let latestError = cacheGet(keyErr);
429 if (typeof latestData === 'undefined' &&
430 typeof latestError === 'undefined') {
431 // need to start the request if it hasn't
432 if (!CONCURRENT_PROMISES[key]) {
433 // trigger revalidate immediately
434 // to get the promise
435 revalidate();
436 }
437 if (CONCURRENT_PROMISES[key] &&
438 typeof CONCURRENT_PROMISES[key].then === 'function') {
439 // if it is a promise
440 throw CONCURRENT_PROMISES[key];
441 }
442 // it's a value, return it directly (override)
443 latestData = CONCURRENT_PROMISES[key];
444 }
445 if (typeof latestData === 'undefined' && latestError) {
446 // in suspense mode, throw error if there's no content
447 throw latestError;
448 }
449 // return the latest data / error from cache
450 // in case `key` has changed
451 return {
452 error: latestError,
453 data: latestData,
454 revalidate,
455 isValidating: stateRef.current.isValidating
456 };
457 }
458 // define returned state
459 // can be memorized since the state is a ref
460 return useMemo(() => {
461 const state = { revalidate };
462 Object.defineProperties(state, {
463 error: {
464 // `key` might be changed in the upcoming hook re-render,
465 // but the previous state will stay
466 // so we need to match the latest key and data (fallback to `initialData`)
467 get: function () {
468 stateDependencies.current.error = true;
469 return keyRef.current === key ? stateRef.current.error : initialError;
470 }
471 },
472 data: {
473 get: function () {
474 stateDependencies.current.data = true;
475 return keyRef.current === key ? stateRef.current.data : initialData;
476 }
477 },
478 isValidating: {
479 get: function () {
480 stateDependencies.current.isValidating = true;
481 return stateRef.current.isValidating;
482 }
483 }
484 });
485 return state;
486 }, [revalidate]);
487}
488const SWRConfig = SWRConfigContext.Provider;
489export { trigger, mutate, SWRConfig };
490export default useSWR;