diff --git a/priem.d.ts b/priem.d.ts index 4dd0dcd..00f22b4 100644 --- a/priem.d.ts +++ b/priem.d.ts @@ -29,7 +29,6 @@ export declare interface Options { } export declare interface ResourceOptions { - maxSize?: number; ssrKey?: string; } diff --git a/src/Resource.ts b/src/Resource.ts index 385aa01..71e0ae1 100644 --- a/src/Resource.ts +++ b/src/Resource.ts @@ -1,5 +1,5 @@ import is, {TypeName} from '@sindresorhus/is'; -import {assertType, isBrowser, shallowEqual} from './utils'; +import {assertType, browserActivityState, isBrowser, shallowEqual} from './utils'; import {Cache, CacheItem, SerializableCacheItem, reduce} from './Cache'; const DEFAULT_MAX_SIZE = 50; @@ -40,6 +40,43 @@ export function toSerializableArray( }); } +const scheduledTimers = new Map void>(); + +browserActivityState.subscribe(() => { + if (browserActivityState.isActive()) { + scheduledTimers.forEach(handler => handler()); + scheduledTimers.clear(); + } +}); + +function setCacheItemTimeout(item: CacheItem, handler: () => void, timeout: number): void { + /* istanbul ignore else */ + if (isBrowser) { + /* istanbul ignore if */ + if (item.expireTimerId) { + window.clearTimeout(item.expireTimerId); + scheduledTimers.delete(item.expireTimerId); + } + const timerId = window.setTimeout(() => { + if (!browserActivityState.isActive() && timerId) { + scheduledTimers.set(timerId, handler); + } else { + handler(); + } + item.expireTimerId = undefined; + }, timeout); + item.expireTimerId = timerId; + } +} + +function clearCacheItemTimeout(item: CacheItem): void { + if (isBrowser && item.expireTimerId) { + window.clearTimeout(item.expireTimerId); + scheduledTimers.delete(item.expireTimerId); + item.expireTimerId = undefined; + } +} + // Only used during SSR for resources with `ssrKey` const resourceList = new Set>(); @@ -192,12 +229,16 @@ export class Resource> { if (shouldUpdate) { this.update(item, maxAge); - } else if (!item.expireTimerId && isBrowser && maxAge) { - item.expireTimerId = window.setTimeout(() => { - if (item && item.key) { - this.invalidate(item.key); - } - }, maxAge); + } else if (!item.expireTimerId && maxAge) { + setCacheItemTimeout( + item, + () => { + if (item && item.key) { + this.invalidate(item.key); + } + }, + maxAge, + ); } return item.value; @@ -217,7 +258,7 @@ export class Resource> { const timesUsed = this.onCacheChange(args, shouldCommit); if (timesUsed > 0) { - window.clearTimeout(item.expireTimerId); + clearCacheItemTimeout(item); item.isValid = false; // This will trigger an update } else { this.cache.remove(item); @@ -228,6 +269,7 @@ export class Resource> { /** @private */ update(item: CacheItem>, maxAge?: number): void { + clearCacheItemTimeout(item); Object.assign(item.value, {status: Status.PENDING, data: undefined, reason: undefined}); const promise = this.fn(item.key) @@ -242,13 +284,17 @@ export class Resource> { const timesUsed = this.onCacheChange(item.key); - if (timesUsed > 0 && isBrowser && maxAge) { - window.clearTimeout(item.expireTimerId); - item.expireTimerId = window.setTimeout(() => { - if (item.key) { - this.invalidate(item.key); - } - }, maxAge); + if (timesUsed > 0 && maxAge) { + setCacheItemTimeout( + item, + () => { + /* istanbul ignore else */ + if (item.key) { + this.invalidate(item.key); + } + }, + maxAge, + ); } }) .catch(error => { diff --git a/src/__tests__/Resource.ts b/src/__tests__/Resource.ts index e0672d2..378ccc8 100644 --- a/src/__tests__/Resource.ts +++ b/src/__tests__/Resource.ts @@ -233,7 +233,7 @@ it('should invalidate', async () => { expect(onCacheChange).toHaveBeenCalledTimes(3); }); -it('should not exceed the default max size of 50', async () => { +it('should not exceed the default max size of 50', () => { const resource = new Resource(({value}) => delay(200, {value})); for (let i = 0; i < 50; i++) { @@ -279,7 +279,7 @@ it('should not exceed the default max size of 50', async () => { expect(tail).toMatchInlineSnapshot(`CacheItem {}`); // `tail` was removed from the cache - expect(resource['cache'].findBy(item => shallowEqual(item ,{value: 0}))).toBe(undefined); + expect(resource['cache'].findBy(item => shallowEqual(item, {value: 0}))).toBe(undefined); }); it('should guard if promise resolves after item was removed', async () => { diff --git a/src/__tests__/createResource.tsx b/src/__tests__/createResource.tsx index 446f142..ac0f2e2 100644 --- a/src/__tests__/createResource.tsx +++ b/src/__tests__/createResource.tsx @@ -8,17 +8,55 @@ const readSpy = jest.spyOn(Resource.prototype, 'read'); const updateSpy = jest.spyOn(Resource.prototype, 'update'); const onCacheChangeSpy = jest.spyOn(Resource.prototype, 'onCacheChange'); -let timers: number[] = []; +let navigatorOnline = window.navigator.onLine; +Object.defineProperty(window.navigator, 'onLine', { + get() { + return navigatorOnline; + }, +}); + +function goOffline() { + navigatorOnline = false; + window.dispatchEvent(new window.Event('offline')); +} + +function goOnline() { + navigatorOnline = true; + window.dispatchEvent(new window.Event('online')); +} + +let documentHidden = window.document.hidden; +Object.defineProperty(window.document, 'hidden', { + get() { + return documentHidden; + }, +}); + +function hideWindow() { + documentHidden = true; + window.dispatchEvent(new window.Event('visibilitychange')); +} + +function showWindow() { + documentHidden = false; + window.dispatchEvent(new window.Event('visibilitychange')); +} + +let timerIds: number[] = []; const originalSetTimeout = window.setTimeout; // eslint-disable-next-line @typescript-eslint/ban-ts-ignore // @ts-ignore window.setTimeout = (handler, timeout, ...args) => { - timers.push(originalSetTimeout(handler, timeout, ...args)); + const timerId = originalSetTimeout(handler, timeout, ...args); + timerIds.push(timerId); + return timerId; }; afterEach(() => { - timers.filter(window.clearTimeout); - timers = []; + timerIds.filter(window.clearTimeout); + timerIds = []; + navigatorOnline = true; + documentHidden = false; readSpy.mockClear(); updateSpy.mockClear(); onCacheChangeSpy.mockClear(); @@ -142,18 +180,18 @@ it('should rerun promises when cache expires if `maxAge` is set', async () => { // expire (pending) expect(getLastReturn()).toBe('foo2'); - expect(useResourceSpy).toHaveBeenCalledTimes(6); + expect(useResourceSpy).toHaveBeenCalledTimes(5); expect(updateSpy).toHaveBeenCalledTimes(3); - expect(onCacheChangeSpy).toHaveBeenCalledTimes(5); + expect(onCacheChangeSpy).toHaveBeenCalledTimes(4); await act(() => delay(200)); // fulfilled expect(getLastReturn()).toBe('foo2'); - expect(useResourceSpy).toHaveBeenCalledTimes(7); + expect(useResourceSpy).toHaveBeenCalledTimes(6); expect(updateSpy).toHaveBeenCalledTimes(3); - expect(onCacheChangeSpy).toHaveBeenCalledTimes(6); + expect(onCacheChangeSpy).toHaveBeenCalledTimes(5); }); it('should have `invalidate` method', async () => { @@ -384,10 +422,10 @@ it('should debounce calls', async () => { return ret; }); - const Comp: React.FC<{arg: string}> = props => { + function Comp(props: {arg: string}) { useResourceSpy({value: props.arg}); return null; - }; + } const {rerender} = render(); rerender(); @@ -413,7 +451,7 @@ it('should debounce calls', async () => { rejected: false, }, ]); - expect(readSpy).toHaveBeenCalledTimes(3); + expect(readSpy).toHaveBeenCalledTimes(2); await act(() => delay(200)); @@ -426,7 +464,7 @@ it('should debounce calls', async () => { rejected: false, }, ]); - expect(readSpy).toHaveBeenCalledTimes(4); + expect(readSpy).toHaveBeenCalledTimes(3); await act(() => delay(300)); @@ -439,7 +477,7 @@ it('should debounce calls', async () => { rejected: false, }, ]); - expect(readSpy).toHaveBeenCalledTimes(4); + expect(readSpy).toHaveBeenCalledTimes(3); }); it('should invalidate on mount when `refreshOnMount` is set', async () => { @@ -489,6 +527,54 @@ it('should invalidate on mount when `refreshOnMount` is set', async () => { ]); }); +it('should schedule updates when browser is offline', async () => { + const useResource = createResource(() => delay(100, {value: 'foo'})); + const useResourceSpy = jest.fn(useResource); + + function Comp() { + useResourceSpy({}, {maxAge: 500}); + return null; + } + + render(); + expect(useResourceSpy).toHaveBeenCalledTimes(1); + await act(() => delay(100)); + expect(useResourceSpy).toHaveBeenCalledTimes(2); + + act(() => goOffline()); + await act(() => delay(700)); + expect(useResourceSpy).toHaveBeenCalledTimes(2); + + act(() => goOnline()); + expect(useResourceSpy).toHaveBeenCalledTimes(3); + await act(() => delay(100)); + expect(useResourceSpy).toHaveBeenCalledTimes(4); +}); + +it('should schedule updates when browser tab is not active', async () => { + const useResource = createResource(() => delay(100, {value: 'foo'})); + const useResourceSpy = jest.fn(useResource); + + function Comp() { + useResourceSpy({}, {maxAge: 500}); + return null; + } + + render(); + expect(useResourceSpy).toHaveBeenCalledTimes(1); + await act(() => delay(100)); + expect(useResourceSpy).toHaveBeenCalledTimes(2); + + act(() => hideWindow()); + await act(() => delay(700)); + expect(useResourceSpy).toHaveBeenCalledTimes(2); + + act(() => showWindow()); + expect(useResourceSpy).toHaveBeenCalledTimes(3); + await act(() => delay(100)); + expect(useResourceSpy).toHaveBeenCalledTimes(4); +}); + it('should hydrate data', async () => { hydrateStore([ [ diff --git a/src/createResource.ts b/src/createResource.ts index fa42aa0..b9187e5 100644 --- a/src/createResource.ts +++ b/src/createResource.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import {TypeName} from '@sindresorhus/is'; import {Resource, ResourceOptions, Subscriber, Status, MemoizedKey} from './Resource'; -import {assertType, shallowEqual, useForceUpdate, useLazyRef} from './utils'; +import {assertType, isBrowser, shallowEqual, useForceUpdate, useLazyRef} from './utils'; const DEFAULT_DEBOUNCE_MS = 150; @@ -85,7 +85,11 @@ export function createResource( * 4. The item is not in the cache. */ const shouldDebounce = - args !== undefined && !!prevResult && now - lastTimeCalled < DEFAULT_DEBOUNCE_MS && !resource.has(args); + isBrowser && + args !== undefined && + !!prevResult && + now - lastTimeCalled < DEFAULT_DEBOUNCE_MS && + !resource.has(args); // TODO: rework debounce React.useEffect(() => { diff --git a/src/utils.ts b/src/utils.ts index 3c74f03..909bae1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,65 @@ import * as React from 'react'; export const isBrowser: boolean = typeof window === 'object' && typeof document === 'object' && document.nodeType === 9; +type BrowserActivityStateSubscriber = () => void; + +class BrowserActivityState { + private isOnline: boolean; + private isVisible: boolean; + private initialized = false; + private readonly listeners: BrowserActivityStateSubscriber[] = []; + + constructor() { + if (isBrowser) { + this.isOnline = navigator.onLine; + this.isVisible = !document.hidden; + } else { + this.isOnline = false; + this.isVisible = false; + } + } + + isActive(): boolean { + return this.isOnline && this.isVisible; + } + + subscribe(fn: BrowserActivityStateSubscriber) { + this.lazyInitialize(); + this.listeners.push(fn); + } + + private updateOnlineStatus = () => { + /* istanbul ignore else */ + if (this.isOnline !== navigator.onLine) { + this.isOnline = navigator.onLine; + this.listeners.forEach(listener => listener()); + } + }; + + private updateVisibilityStatus = () => { + /* istanbul ignore else */ + if (this.isVisible !== !document.hidden) { + this.isVisible = !document.hidden; + this.listeners.forEach(listener => listener()); + } + }; + + private lazyInitialize() { + if (!isBrowser || this.initialized) { + return; + } + + this.initialized = true; + + window.addEventListener('online', this.updateOnlineStatus, false); + window.addEventListener('offline', this.updateOnlineStatus, false); + window.addEventListener('visibilitychange', this.updateVisibilityStatus, false); + window.addEventListener('focus', this.updateVisibilityStatus, false); + } +} + +export const browserActivityState = new BrowserActivityState(); + export function assertType(variable: unknown, types: readonly TypeName[], variableName = 'The value'): void | never { const typeOfVariable = is(variable);