diff --git a/README.md b/README.md index 76fbc72..93e2de2 100644 --- a/README.md +++ b/README.md @@ -184,9 +184,7 @@ const userResource = getAsyncResource(getUser, [123]); // 3. With options const userResource = getAsyncResource(getUser, [123], { - autoRefresh: { - seconds: 30, - }, + tags: ["api"], }); // 4. Usage in factory function @@ -270,14 +268,15 @@ const Score = ({ matchId }) => { You can configure `usePromise` and `getAsyncResource` with the following options: -#### autoRefresh +#### autoRefresh (only supported by `usePromise`) Type: [Duration like object](https://moment.github.io/luxon/api-docs/index.html#durationfromobject)\ Default: `undefined` When a duration is configured, the resource will automatically be refreshed in -the provided interval. +the provided interval. If the same resource has multiple auto-refresh intervals, +the shortest interval will be used. ```javascript autoRefresh: { @@ -285,7 +284,7 @@ autoRefresh: { } ``` -#### keepValueWhileLoading +#### keepValueWhileLoading (only supported by `usePromise`) Type: `boolean`\ Default: `true` @@ -321,7 +320,7 @@ the "same code" issue (see ["Caveats of default storage key generation"](#caveats-of-default-storage-key-generation)), you can set an explicit loader ID, that identifies the loader function. -#### useSuspense +#### useSuspense (only supported by `usePromise`) Type: `boolean`\ Default: `true` diff --git a/src/lib/ConsolidatedTimeout.test.ts b/src/lib/ConsolidatedTimeout.test.ts new file mode 100644 index 0000000..2d308e4 --- /dev/null +++ b/src/lib/ConsolidatedTimeout.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, jest } from "@jest/globals"; +import { ConsolidatedTimeout } from "./ConsolidatedTimeout.js"; + +const callback = jest.fn(); + +let timeout: ConsolidatedTimeout; + +beforeEach((): void => { + jest.resetAllMocks(); + jest.useFakeTimers(); + timeout = new ConsolidatedTimeout(callback); +}); + +const testCallbackIsCalledAfter = (ms: number): void => { + const beforeCalls = callback.mock.calls.length; + jest.advanceTimersByTime(ms - 1); + expect(callback).toHaveBeenCalledTimes(beforeCalls); + jest.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledTimes(beforeCalls + 1); +}; + +const testCallbackIsNotCalledAfter = (ms: number): void => { + const beforeCalls = callback.mock.calls.length; + jest.advanceTimersByTime(ms - 1); + expect(callback).toHaveBeenCalledTimes(beforeCalls); + jest.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledTimes(beforeCalls); +}; + +test("Callback is not triggered after start when there is no timeout added", (): void => { + timeout.start(); + testCallbackIsNotCalledAfter(Number.MAX_SAFE_INTEGER); +}); + +test("Callback is triggered (only once) after timeout added after start", (): void => { + timeout.start(); + timeout.addTimeout({ milliseconds: 1000 }); + + testCallbackIsCalledAfter(1000); + testCallbackIsNotCalledAfter(Number.MAX_SAFE_INTEGER); +}); + +test("Callback is triggered after timeout added before start", (): void => { + timeout.addTimeout({ milliseconds: 1000 }); + timeout.start(); + testCallbackIsCalledAfter(1000); +}); + +test("Callback is triggered at minimum timeout", (): void => { + timeout.start(); + timeout.addTimeout({ milliseconds: 1000 }); + timeout.addTimeout({ milliseconds: 500 }); + testCallbackIsCalledAfter(500); +}); + +test("Consecutive start call restarts the timeout", (): void => { + timeout.start(); + timeout.addTimeout({ milliseconds: 1000 }); + testCallbackIsNotCalledAfter(999); + + timeout.start(); + testCallbackIsNotCalledAfter(500); + testCallbackIsCalledAfter(500); +}); + +test("Callback is triggered when adding timeout while already running", (): void => { + timeout.start(); + + timeout.addTimeout({ milliseconds: 1000 }); + testCallbackIsNotCalledAfter(499); + + timeout.addTimeout({ milliseconds: 500 }); + testCallbackIsCalledAfter(1); +}); + +test("Callback is triggered instantly when adding due timeout while already running", (): void => { + timeout.start(); + + timeout.addTimeout({ milliseconds: 1000 }); + testCallbackIsNotCalledAfter(501); + timeout.addTimeout({ milliseconds: 500 }); + expect(callback).toHaveBeenCalledTimes(1); +}); + +test("Removing last timeout will not trigger callback", (): void => { + timeout.start(); + const removeTimeout = timeout.addTimeout({ milliseconds: 1000 }); + removeTimeout(); + testCallbackIsNotCalledAfter(1000); +}); diff --git a/src/lib/ConsolidatedTimeout.ts b/src/lib/ConsolidatedTimeout.ts new file mode 100644 index 0000000..116a494 --- /dev/null +++ b/src/lib/ConsolidatedTimeout.ts @@ -0,0 +1,61 @@ +import { DateTime, Duration, DurationLikeObject } from "luxon"; + +export type RemoveTimeout = () => void; +type ExecutionCallback = () => void; +type Timeout = ReturnType; + +export class ConsolidatedTimeout { + private readonly callback: ExecutionCallback; + private startTime: DateTime; + private timeoutMillis = new Set(); + private runningTimeout?: Timeout; + + public constructor(callback: ExecutionCallback) { + this.startTime = DateTime.now(); + this.callback = callback; + } + + public start(): void { + this.startTime = DateTime.now(); + this.startNextTimeout(); + } + + private clear(): void { + if (this.runningTimeout) { + clearTimeout(this.runningTimeout); + this.runningTimeout = undefined; + } + } + + public addTimeout(timeout: DurationLikeObject): RemoveTimeout { + const timeoutMs = Duration.fromDurationLike(timeout).toMillis(); + + this.timeoutMillis.add(timeoutMs); + this.startNextTimeout(); + + return () => { + this.timeoutMillis.delete(timeoutMs); + this.startNextTimeout(); + }; + } + + private startNextTimeout(): void { + this.clear(); + + if (this.timeoutMillis.size === 0) { + return; + } + + const shortestTimeout = Math.min(...this.timeoutMillis); + const elapsedTime = this.startTime.diffNow().negate().toMillis(); + const ms = shortestTimeout - elapsedTime; + + if (ms <= 0) { + this.callback(); + } else { + this.runningTimeout = setTimeout(() => this.callback(), ms); + } + } +} + +export default ConsolidatedTimeout; diff --git a/src/resource/AsyncResource.test.ts b/src/resource/AsyncResource.test.ts index 3489d8e..b92694a 100644 --- a/src/resource/AsyncResource.test.ts +++ b/src/resource/AsyncResource.test.ts @@ -59,25 +59,6 @@ describe("calling load()", () => { expect(loader).toHaveBeenCalledTimes(1); }); - test("twice does trigger loader twice when TTL is reached", async () => { - const resource = new AsyncResource(loader, { - ttl: { milliseconds: 100 }, - }); - // load - void resource.load(); - expect(loader).toHaveBeenCalledTimes(1); - // wait for load - await jest.advanceTimersByTimeAsync(loadingTime); - // wait for TTL-1 -> not loading again - await jest.advanceTimersByTimeAsync(99); - void resource.load(); - expect(loader).toHaveBeenCalledTimes(1); - // wait for TTL rest -> loading again - await jest.advanceTimersByTimeAsync(1); - void resource.load(); - expect(loader).toHaveBeenCalledTimes(2); - }); - test("can be superseded by another load() call if cleared in between", async () => { const resource = new AsyncResource(loader); // #1 load for 50ms @@ -138,22 +119,6 @@ describe(".value", () => { expect(resource.value.value.isSet).toBe(false); }); - test("is empty when becoming stale", async () => { - const resource = new AsyncResource(loader, { - ttl: { - milliseconds: 100, - }, - }); - // load - void resource.load(); - // wait for load - await jest.advanceTimersByTimeAsync(loadingTime); - expect(resource.value.value.isSet).toBe(true); - // after TTL - jest.advanceTimersByTime(100); - expect(resource.value.value.isSet).toBe(false); - }); - test("is updated after loading again when cleared", async () => { const resource = new AsyncResource(loader); // load @@ -201,19 +166,12 @@ describe(".error", () => { }); test("is empty when becoming stale", async () => { - const resource = new AsyncResource(errorLoader, { - ttl: { - milliseconds: 100, - }, - }); + const resource = new AsyncResource(errorLoader); // load void resource.load(); // wait for load await jest.advanceTimersByTimeAsync(loadingTime); expect(resource.error.value.isSet).toBe(true); - // after TTL - jest.advanceTimersByTime(100); - expect(resource.error.value.isSet).toBe(false); }); test("is updated after loading again when cleared", async () => { @@ -235,31 +193,3 @@ describe(".error", () => { expect(resource.error.value.isSet).toBe(false); }); }); - -test("TTL is 'restarted' on reload", async () => { - const ttl = loadingTime * 4; - const resource = new AsyncResource(loader, { - ttl: { - milliseconds: ttl, - }, - }); - - // load - void resource.load(); - // wait for load - await jest.advanceTimersByTimeAsync(loadingTime); - // wait for TTL/2 - await jest.advanceTimersByTimeAsync(ttl / 2); - // reload resource - resource.refresh(); - // load - void resource.load(); - // wait for load - await jest.advanceTimersByTimeAsync(loadingTime); - // wait for TTL/2 -> resource not cleared - await jest.advanceTimersByTimeAsync(ttl / 2); - expect(resource.value.value.isSet).toBe(true); - // wait for another TTL/2 -> resource finally cleared due to TTL - await jest.advanceTimersByTimeAsync(ttl / 2); - expect(resource.value.value.isSet).toBe(false); -}); diff --git a/src/resource/AsyncResource.ts b/src/resource/AsyncResource.ts index e611707..9d58fdb 100644 --- a/src/resource/AsyncResource.ts +++ b/src/resource/AsyncResource.ts @@ -5,31 +5,28 @@ import { UseWatchResourceOptions, UseWatchResourceResult, } from "./types.js"; -import { Duration, DurationLikeObject } from "luxon"; import { ObservableValue } from "../observable-value/ObservableValue.js"; import { useWatchResourceValue } from "./useWatchResourceValue.js"; import { useWatchObservableValue } from "../observable-value/useWatchObservableValue.js"; - -export interface AsyncResourceOptions { - ttl?: DurationLikeObject; -} +import { DurationLikeObject } from "luxon"; +import { + ConsolidatedTimeout, + RemoveTimeout, +} from "../lib/ConsolidatedTimeout.js"; export class AsyncResource { public readonly loader: AsyncLoader; public loaderPromise: Promise | undefined; private loaderPromiseVersion = 0; - - private readonly options: AsyncResourceOptions; + private autoRefreshTimeout: ConsolidatedTimeout; public value = new ObservableValue>(emptyValue); public error = new ObservableValue>(emptyValue); public state = new ObservableValue("void"); - private activeTtlTimeout: ReturnType | undefined; - - public constructor(loader: AsyncLoader, opts?: AsyncResourceOptions) { + public constructor(loader: AsyncLoader) { this.loader = loader; - this.options = opts ?? {}; + this.autoRefreshTimeout = new ConsolidatedTimeout(() => this.refresh()); } public refresh(): void { @@ -40,6 +37,10 @@ export class AsyncResource { this.state.updateValue("void"); } + public addTTL(ttl: DurationLikeObject): RemoveTimeout { + return this.autoRefreshTimeout.addTimeout(ttl); + } + public async load(): Promise { if (this.value.value.isSet || this.error.value.isSet) { return; @@ -58,18 +59,6 @@ export class AsyncResource { return error === true || this.error.value.value === error; } - private clearAfterTtl(): void { - const ttl = this.options.ttl; - - if (ttl !== undefined) { - clearTimeout(this.activeTtlTimeout); - - this.activeTtlTimeout = setTimeout(() => { - this.refresh(); - }, Duration.fromDurationLike(ttl).toMillis()); - } - } - private async handleLoading(): Promise { const loaderPromiseVersion = ++this.loaderPromiseVersion; @@ -95,9 +84,9 @@ export class AsyncResource { this.error.updateValue(error); this.state.updateValue("error"); } - - this.clearAfterTtl(); } + + this.autoRefreshTimeout.start(); } public watch( diff --git a/src/resource/getAsyncResource.ts b/src/resource/getAsyncResource.ts index 16bdafe..21643d5 100644 --- a/src/resource/getAsyncResource.ts +++ b/src/resource/getAsyncResource.ts @@ -1,7 +1,7 @@ import { AsyncFn, FnParameters, GetAsyncResourceOptions } from "./types.js"; import { defaultStorageKeyBuilder } from "../store/defaultStorageKeyBuilder.js"; import { Store } from "../store/Store.js"; -import { AsyncResource, AsyncResourceOptions } from "./AsyncResource.js"; +import { AsyncResource } from "./AsyncResource.js"; const emptyResource = new AsyncResource(() => Promise.resolve(undefined), @@ -25,11 +25,7 @@ export function getAsyncResource( parameters: TParams | null, options: GetAsyncResourceOptions = {}, ): AsyncResource { - const { loaderId, tags, autoRefresh } = options; - - const asyncResourceOptions: AsyncResourceOptions = { - ttl: autoRefresh, - }; + const { loaderId, tags } = options; if (parameters === null) { return emptyResource; @@ -43,8 +39,7 @@ export function getAsyncResource( const asyncResourceLoader = () => asyncFn(...parameters); - const resourceBuilder = () => - new AsyncResource(asyncResourceLoader, asyncResourceOptions); + const resourceBuilder = () => new AsyncResource(asyncResourceLoader); return Store.default.getOrSet(storageKey, resourceBuilder, { tags: tags, diff --git a/src/resource/types.ts b/src/resource/types.ts index 6ec35a1..0d7f0ab 100644 --- a/src/resource/types.ts +++ b/src/resource/types.ts @@ -16,13 +16,13 @@ export type AsyncResourceState = "void" | "loading" | "loaded" | "error"; export type GetAsyncResourceOptions = { loaderId?: string; tags?: Tags; - autoRefresh?: DurationLikeObject; }; // useWatchResource types export type UseWatchResourceOptions = { keepValueWhileLoading?: boolean; useSuspense?: boolean; + autoRefresh?: DurationLikeObject; } & GetAsyncResourceOptions; export type NoSuspenseReturnType = Readonly< diff --git a/src/resource/useWatchResourceValue.ts b/src/resource/useWatchResourceValue.ts index cb4970c..244c3ec 100644 --- a/src/resource/useWatchResourceValue.ts +++ b/src/resource/useWatchResourceValue.ts @@ -1,7 +1,8 @@ import { AsyncResource } from "./AsyncResource.js"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { useWatchObservableValue } from "../observable-value/useWatchObservableValue.js"; import { UseWatchResourceOptions, UseWatchResourceResult } from "./types.js"; +import { hash } from "object-code"; export const useWatchResourceValue = < T, @@ -12,12 +13,22 @@ export const useWatchResourceValue = < ): UseWatchResourceResult => { type Result = UseWatchResourceResult; - const { keepValueWhileLoading = true, useSuspense = true } = options; + const { + keepValueWhileLoading = true, + useSuspense = true, + autoRefresh, + } = options; const observedValue = useWatchObservableValue(resource.value); const error = useWatchObservableValue(resource.error); const previousValue = useRef(observedValue); + useEffect(() => { + if (autoRefresh) { + return resource.addTTL(autoRefresh); + } + }, [hash(autoRefresh)]); + void resource.load(); if (observedValue.isSet) {