Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix autoRefresh option for usePromise #73

Merged
merged 6 commits into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -270,22 +268,23 @@ 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: {
seconds: 30;
}
```

#### keepValueWhileLoading
#### keepValueWhileLoading (only supported by `usePromise`)

Type: `boolean`\
Default: `true`
Expand Down Expand Up @@ -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`
Expand Down
90 changes: 90 additions & 0 deletions src/lib/ConsolidatedTimeout.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
61 changes: 61 additions & 0 deletions src/lib/ConsolidatedTimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { DateTime, Duration, DurationLikeObject } from "luxon";

export type RemoveTimeout = () => void;
type ExecutionCallback = () => void;
type Timeout = ReturnType<typeof setTimeout>;

export class ConsolidatedTimeout {
private readonly callback: ExecutionCallback;
private startTime: DateTime;
private timeoutMillis = new Set<number>();
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;
72 changes: 1 addition & 71 deletions src/resource/AsyncResource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
});
39 changes: 14 additions & 25 deletions src/resource/AsyncResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = unknown> {
public readonly loader: AsyncLoader<T>;
public loaderPromise: Promise<void> | undefined;
private loaderPromiseVersion = 0;

private readonly options: AsyncResourceOptions;
private autoRefreshTimeout: ConsolidatedTimeout;

public value = new ObservableValue<EventualValue<T>>(emptyValue);
public error = new ObservableValue<EventualValue<unknown>>(emptyValue);
public state = new ObservableValue<AsyncResourceState>("void");

private activeTtlTimeout: ReturnType<typeof setTimeout> | undefined;

public constructor(loader: AsyncLoader<T>, opts?: AsyncResourceOptions) {
public constructor(loader: AsyncLoader<T>) {
this.loader = loader;
this.options = opts ?? {};
this.autoRefreshTimeout = new ConsolidatedTimeout(() => this.refresh());
}

public refresh(): void {
Expand All @@ -40,6 +37,10 @@ export class AsyncResource<T = unknown> {
this.state.updateValue("void");
}

public addTTL(ttl: DurationLikeObject): RemoveTimeout {
return this.autoRefreshTimeout.addTimeout(ttl);
}

public async load(): Promise<void> {
if (this.value.value.isSet || this.error.value.isSet) {
return;
Expand All @@ -58,18 +59,6 @@ export class AsyncResource<T = unknown> {
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<void> {
const loaderPromiseVersion = ++this.loaderPromiseVersion;

Expand All @@ -95,9 +84,9 @@ export class AsyncResource<T = unknown> {
this.error.updateValue(error);
this.state.updateValue("error");
}

this.clearAfterTtl();
}

this.autoRefreshTimeout.start();
}

public watch<TOptions extends UseWatchResourceOptions>(
Expand Down
Loading