diff --git a/README.md b/README.md
index 20e927b..4286737 100644
--- a/README.md
+++ b/README.md
@@ -12,24 +12,18 @@
-Fetch data, run async tasks, and keep your UI in sync — without the boilerplate. React Executor replaces ad-hoc
-`useState`/`useEffect` data-fetching patterns with a composable, plugin-driven model that works with Suspense and SSR
-out of the box.
+Asynchronous task execution and state management for React.
- TypeScript first.
- Expressive and concise API with strict typings.
- Works great with SSR and Suspense.
- [Extensible with plugins.](#plugins)
- [First class devtools.](#devtools)
-- [Just 5 kB gzipped.](https://pkg-size.dev/react-executor)
+- [Just 3 kB gzipped.](https://bundlephobia.com/package/react-executor)
- Check out the [Cookbook](#cookbook) for real-life examples!
-> [!TIP]
-> New here? Skip straight to the [Cookbook](#cookbook) for real-world patterns including polling, optimistic updates,
-> pagination, and dependent tasks.
-
```sh
@@ -42,11 +36,6 @@ npm install --save-prod react-executor
-- [API docs](https://smikhalevski.github.io/react-executor/)
-- [TODO app example](https://stackblitz.com/edit/react-executor-todo-app?file=README.md)
-- [Streaming SSR example](https://codesandbox.io/p/devbox/react-executor-ssr-streaming-example-mwrmrs)
-- [Next.js integration example](https://codesandbox.io/p/devbox/react-executor-next-example-whsj4v)
-
🔰 [**Introduction**](#introduction)
- [Executor keys](#executor-keys)
@@ -57,6 +46,7 @@ npm install --save-prod react-executor
- [Retry the latest task](#retry-the-latest-task)
- [Settle an executor](#settle-an-executor)
- [Clear an executor](#clear-an-executor)
+- [Optimistic updates](#optimistic-updates)
📢 [**Events and lifecycle**](#events-and-lifecycle)
@@ -110,7 +100,6 @@ npm install --save-prod react-executor
🍪 **Cookbook**
-- [Optimistic updates](#optimistic-updates)
- [Dependent tasks](#dependent-tasks)
- [Derived executors](#derived-executors)
- [Pagination](#pagination)
@@ -120,6 +109,13 @@ npm install --save-prod react-executor
- [Storage state versioning](#storage-state-versioning)
- [Global loading indicator](#global-loading-indicator)
+🔎 **Resources**
+
+- [API docs](https://smikhalevski.github.io/react-executor/)
+- [TODO app example](https://stackblitz.com/edit/react-executor-todo-app?file=README.md)
+- [Streaming SSR example](https://codesandbox.io/p/devbox/react-executor-ssr-streaming-example-mwrmrs)
+- [Next.js integration example](https://codesandbox.io/p/devbox/react-executor-next-example-whsj4v)
+
@@ -260,13 +256,13 @@ manager.get(rookyKey) !== manager.getOrCreate({ id: 123 });
Let's execute a new task:
```ts
-import { ExecutorManager, ExecutorTask } from 'react-executor';
+import { ExecutorManager, ExecutorTaskCallback } from 'react-executor';
const manager = new ExecutorManager();
const rookyExecutor = manager.getOrCreate('rooky');
-const helloTask: ExecutorTask = async (signal, executor) => 'Hello';
+const helloTask: ExecutorTaskCallback = async (signal, executor) => 'Hello';
const helloPromise = rookyExecutor.execute(task);
// ⮕ AbortablePromise
@@ -418,7 +414,7 @@ For example, if you're fetching data from the server inside a task, you can pass
a [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch#signal) option:
```ts
-const byeTask: ExecutorTask = async (signal, executor) => {
+const byeTask: ExecutorTaskCallback = async (signal, executor) => {
const response = await fetch('/bye', { signal });
return response.json();
@@ -575,6 +571,44 @@ Clearing an executor removes the stored value and reason, but _doesn't_ affect t
the [latest task](https://smikhalevski.github.io/react-executor/interfaces/react-executor.Executor.html#task)
that was executed.
+## Optimistic updates
+
+Optimistic updates are a UX technique where the UI reflects the result of an action instantly, before the server has
+confirmed it. If the server succeeds, nothing changes visually. If the server fails, the UI rolls back to the state it
+was in before the action.
+
+In React Executor, optimistic updates are first-class citizens and can be achieved by providing
+[`pendingValue`](https://smikhalevski.github.io/react-executor/interfaces/react-executor.ExecutorTask.html#pendingxalue)
+in executor task. When a task is submitted with a `pendingValue`, the executor resolves to that value instantly
+(before the task callback runs) and stores a checkpoint of the previous state. If the task fails, the checkpoint is
+restored automatically.
+
+```ts
+const executor = useExecutor('meaningOfLife');
+
+const handleClick = () => {
+ executor.execute({
+ callback: async signal => await getTheMeaningOfLife(signal),
+
+ // 🟡 Executor is resolved with this value before running callback
+ pendingValue: 42,
+ });
+};
+```
+
+The UI reflects `42` the moment the button is clicked. If `getTheMeaningOfLife` rejects, the executor rolls back
+to the value it held before execute was called — no manual snapshot or error handling required.
+
+The rollback covers three cases depending on what state the executor was in before the optimistic update was applied:
+
+- If previously _fulfilled_, then the executor is resolved back to the previous value.
+
+- If previously _rejected_, then the executor is rejected with the previous reason.
+
+- If previously _unsettled_, then the executor is cleared back to its initial state.
+
+This means `pendingValue` is safe to use regardless of whether the executor has ever successfully fetched data.
+
# Events and lifecycle
Executors publish various events when their state changes. To subscribe to executor events use the
@@ -1329,10 +1363,10 @@ Combine this plugin with [`invalidateByPeers`](#invalidatebypeers) to automatica
executor on which it depends becomes invalid:
```ts
-import { ExecutorTask, useExecutor } from 'react-executor';
+import { ExecutorTaskCallback, useExecutor } from 'react-executor';
import invalidateByPeers from 'react-executor/plugin/invalidateByPeers';
-const fetchCheese: ExecutorTask = async (signal, executor) => {
+const fetchCheese: ExecutorTaskCallback = async (signal, executor) => {
// Wait for the breadExecutor to be created
const breadExecutor = await executor.manager.getOrAwait('bread');
@@ -2042,31 +2076,6 @@ the [react-executor-devtools](https://github.com/smikhalevski/react-executor-dev
# Cookbook
-## Optimistic updates
-
-To implement optimistic updates, [resolve the executor](#settle-an-executor) with the expected value and then
-execute a server request.
-
-For example, if you want to instantly show to a user that a flag was enabled:
-
-```ts
-const executor = useExecutor('flag', false);
-
-const handleEnableClick = () => {
- // 1️⃣ Optimistically resolve an executor
- executor.resolve(true);
-
- // 2️⃣ Synchronize state with the server
- executor.execute(async signal => {
- const response = await fetch('/flag', { signal });
-
- const data = await response.json();
-
- return data.isEnabled;
- });
-};
-```
-
## Dependent tasks
Pause a task until another executor is settled:
diff --git a/src/main/ExecutorImpl.ts b/src/main/ExecutorImpl.ts
index b3adf1b..007afbd 100644
--- a/src/main/ExecutorImpl.ts
+++ b/src/main/ExecutorImpl.ts
@@ -1,6 +1,13 @@
import { AbortablePromise, PubSub } from 'parallel-universe';
import type { ExecutorManager } from './ExecutorManager.js';
-import type { Executor, ExecutorEvent, ExecutorState, ExecutorTask, PartialExecutorEvent } from './types.js';
+import {
+ Executor,
+ ExecutorEvent,
+ ExecutorState,
+ ExecutorTask,
+ ExecutorTaskCallback,
+ PartialExecutorEvent,
+} from './types.js';
import { AbortError, isPromiseLike, preventUnhandledRejection } from './utils.js';
/**
@@ -29,6 +36,11 @@ export class ExecutorImpl implements Executor {
*/
_pubSub = new PubSub();
+ /**
+ * Snapshot captured before task execution, if the execution can be rolled back.
+ */
+ _rollbackSnapshot: ExecutorState | null = null;
+
get isRejected(): boolean {
return this.isSettled && !this.isFulfilled;
}
@@ -98,20 +110,31 @@ export class ExecutorImpl implements Executor {
});
};
- execute = (task: ExecutorTask): AbortablePromise => {
- const handleAbort = (): void => {
+ execute = (task: ExecutorTask | ExecutorTaskCallback): AbortablePromise => {
+ if (typeof task === 'function') {
+ task = { callback: task };
+ }
+
+ const { callback, pendingValue, preserveLatestTask } = task;
+
+ const handleAbort = () => {
if (this.promise === promise) {
+ this._rollback();
this.promise = null;
this.version++;
}
+
this.publish({ type: 'aborted' });
};
+ // Rollback pending execution if any
+ this._rollback();
+
const promise = new AbortablePromise((resolve, reject, signal) => {
signal.addEventListener('abort', handleAbort);
new Promise(resolve => {
- const value = task(signal, this);
+ const value = callback(signal, this);
resolve(value instanceof AbortablePromise ? value.withSignal(signal) : value);
}).then(
value => {
@@ -149,11 +172,25 @@ export class ExecutorImpl implements Executor {
this.version++;
}
- if (this.promise === promise) {
+ if (this.promise !== promise) {
+ // Task was replaced midflight
+ return promise;
+ }
+
+ if (!preserveLatestTask) {
this.task = task;
- this.publish({ type: 'pending' });
}
+ if (pendingValue !== undefined) {
+ this._rollbackSnapshot = this.getStateSnapshot();
+ this.isFulfilled = true;
+ this.value = pendingValue;
+ this.settledAt = Date.now();
+ this.invalidatedAt = 0;
+ }
+
+ this.publish({ type: 'pending', payload: { task } });
+
return promise;
};
@@ -187,10 +224,12 @@ export class ExecutorImpl implements Executor {
resolve = (value: PromiseLike | Value, settledAt = Date.now()): void => {
if (isPromiseLike(value)) {
- this.execute(() => value);
+ this.execute({ callback: () => value, preserveLatestTask: true });
return;
}
+ this._rollback();
+
const prevPromise = this.promise;
this.promise = null;
@@ -206,6 +245,8 @@ export class ExecutorImpl implements Executor {
};
reject = (reason: any, settledAt = Date.now()): void => {
+ this._rollback();
+
const prevPromise = this.promise;
this.promise = null;
@@ -263,4 +304,11 @@ export class ExecutorImpl implements Executor {
invalidatedAt: this.invalidatedAt,
};
};
+
+ _rollback(): void {
+ if (this._rollbackSnapshot !== null) {
+ Object.assign(this, this._rollbackSnapshot);
+ this._rollbackSnapshot = null;
+ }
+ }
}
diff --git a/src/main/ExecutorManager.ts b/src/main/ExecutorManager.ts
index a253e55..2a3c94e 100644
--- a/src/main/ExecutorManager.ts
+++ b/src/main/ExecutorManager.ts
@@ -5,7 +5,7 @@ import type {
ExecutorEvent,
ExecutorPlugin,
ExecutorState,
- ExecutorTask,
+ ExecutorTaskCallback,
NoInfer,
Observable,
} from './types.js';
@@ -146,7 +146,7 @@ export class ExecutorManager implements Iterable, Observable(
key: unknown,
- initialValue?: ExecutorTask | PromiseLike | Value,
+ initialValue?: ExecutorTaskCallback | PromiseLike | Value,
plugins?: Array> | null | undefined>
): Executor;
@@ -163,7 +163,7 @@ export class ExecutorManager implements Iterable, Observable {
@@ -194,7 +194,7 @@ export class ExecutorManager implements Iterable, Observable(task: ExecutorTask): ExecutorPlugin {
+export default function lazyTask(
+ task: ExecutorTask | ExecutorTaskCallback
+): ExecutorPlugin {
return executor => {
+ if (typeof task === 'function') {
+ task = { callback: task };
+ }
+
executor.task = task;
executor.publish({
diff --git a/src/main/ssr/SSRExecutorManager.ts b/src/main/ssr/SSRExecutorManager.ts
index 1a784b8..5c75e1f 100644
--- a/src/main/ssr/SSRExecutorManager.ts
+++ b/src/main/ssr/SSRExecutorManager.ts
@@ -77,7 +77,7 @@ export class SSRExecutorManager extends ExecutorManager {
* Increase this if your SSR data-fetching involves many sequential async steps.
*
* @throws {Error} If pending executors are still found after `maxRetries` attempts. This usually indicates
- * a plugin that continuously re-executes tasks during SSR, such as {@link retryFulfilled} with a short delay.
+ * a plugin that continuously re-executes tasks during SSR.
*/
hasChanges(maxRetries = 10): Promise {
const getVersion = () => Array.from(this).reduce((version, executor) => version + executor.version, 0);
diff --git a/src/main/types.ts b/src/main/types.ts
index e7e35c3..1ee76d0 100644
--- a/src/main/types.ts
+++ b/src/main/types.ts
@@ -134,14 +134,48 @@ export interface PartialExecutorEvent {
export type ExecutorPlugin = (executor: Executor) => void;
/**
- * The task that can be executed by an {@link Executor}.
+ * The task callback that can be executed by an {@link Executor}.
*
* @param signal The {@link AbortSignal} that is aborted if task was discarded.
* @param executor The executor that executes the task.
* @returns The value that the executor must be fulfilled with.
* @template Value The value stored by the executor.
*/
-export type ExecutorTask = (signal: AbortSignal, executor: Executor) => PromiseLike | Value;
+export type ExecutorTaskCallback = (
+ signal: AbortSignal,
+ executor: Executor
+) => PromiseLike | Value;
+
+/**
+ * The task that can be executed by an {@link Executor}.
+ *
+ * @template Value The value stored by the executor.
+ */
+export interface ExecutorTask {
+ /**
+ * The task callback that can be executed by an {@link Executor}.
+ */
+ callback: ExecutorTaskCallback;
+
+ /**
+ * The value to resolve the executor with immediately while {@link callback} is pending.
+ * Acts as an optimistic result — the UI can render with this value before the task settles.
+ * Replaced by the real result on fulfillment, or discarded and rolled back on rejection.
+ *
+ * If `undefined`, the executor remains in its current state while the task is pending.
+ */
+ pendingValue?: Value;
+
+ /**
+ * If `true`, {@link Executor.task} is not overwritten by this task upon execution.
+ *
+ * Useful when the task is the canonical source of truth for the executor and should not be silently replaced by
+ * a one-off execution — for example, a polling task or a task submitted by a plugin.
+ *
+ * @default false
+ */
+ preserveLatestTask?: boolean;
+}
/**
* The minimal serializable state that is required to hydrate the {@link Executor} instance.
@@ -318,10 +352,10 @@ export interface Executor extends ReadonlyExecutor {
* If a new task is executed before the returned promise is fulfilled then the signal is aborted and the result is
* ignored.
*
- * @param task The task callback that returns the new result for the executor to store.
+ * @param task The task object or the task callback that returns the new result for the executor to store.
* @returns The promise that is resolved with the result of the task.
*/
- execute(task: ExecutorTask): AbortablePromise;
+ execute(task: ExecutorTask | ExecutorTaskCallback): AbortablePromise;
/**
* If the executor is not {@link isPending pending}, the {@link task latest task} is {@link execute executed}
diff --git a/src/main/useExecutor.ts b/src/main/useExecutor.ts
index 1f77cce..9217cee 100644
--- a/src/main/useExecutor.ts
+++ b/src/main/useExecutor.ts
@@ -1,4 +1,4 @@
-import type { Executor, ExecutorPlugin, ExecutorTask, NoInfer } from './types.js';
+import type { Executor, ExecutorPlugin, ExecutorTaskCallback, NoInfer } from './types.js';
import { useExecutorManager } from './useExecutorManager.js';
import { useExecutorSubscription } from './useExecutorSubscription.js';
@@ -38,7 +38,7 @@ export function useExecutor(
*/
export function useExecutor(
key: unknown,
- initialValue?: ExecutorTask | PromiseLike | Value,
+ initialValue?: ExecutorTaskCallback | PromiseLike | Value,
plugins?: Array> | null | undefined>
): Executor;
diff --git a/src/test/ExecutorImpl.test.ts b/src/test/ExecutorImpl.test.ts
index af8cd4c..303bd03 100644
--- a/src/test/ExecutorImpl.test.ts
+++ b/src/test/ExecutorImpl.test.ts
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, Mock, test, vi } from 'vitest';
import { AbortablePromise } from 'parallel-universe';
import { ExecutorImpl } from '../main/ExecutorImpl.js';
import { AbortError, noop } from '../main/utils.js';
-import { ExecutorEvent, ExecutorState } from '../main/index.js';
+import { ExecutorEvent, ExecutorState, ExecutorTask } from '../main/index.js';
vi.useFakeTimers();
vi.setSystemTime(50);
@@ -73,24 +73,62 @@ describe('getOrDefault', () => {
});
describe('execute', () => {
- test('executes a task', async () => {
- const taskMock = vi.fn((_signal, _executor) => 'aaa');
- const promise = executor.execute(taskMock);
+ test('executes a task callback', async () => {
+ const callbackMock = vi.fn((_signal, _executor) => 'aaa');
+ const promise = executor.execute(callbackMock);
- expect(executor.task).toBe(taskMock);
+ expect(executor.task).toStrictEqual({ callback: callbackMock });
- expect(taskMock).toHaveBeenCalledTimes(1);
- expect(taskMock.mock.calls[0][0].aborted).toBe(false);
- expect(taskMock.mock.calls[0][1]).toBe(executor);
+ expect(callbackMock).toHaveBeenCalledTimes(1);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(false);
+ expect(callbackMock.mock.calls[0][1]).toBe(executor);
expect(listenerMock).toHaveBeenCalledTimes(1);
expect(listenerMock).toHaveBeenNthCalledWith(1, {
type: 'pending',
target: executor,
version: 1,
+ payload: { task: { callback: callbackMock } },
+ } satisfies ExecutorEvent);
+
+ expect(executor.promise).toBe(promise);
+
+ await expect(promise).resolves.toEqual('aaa');
+
+ expect(listenerMock).toHaveBeenCalledTimes(2);
+ expect(listenerMock).toHaveBeenNthCalledWith(2, {
+ type: 'fulfilled',
+ target: executor,
+ version: 2,
payload: undefined,
} satisfies ExecutorEvent);
+ expect(executor.isFulfilled).toBe(true);
+ expect(executor.isRejected).toBe(false);
+ expect(executor.value).toBe('aaa');
+ expect(executor.promise).toBeNull();
+ });
+
+ test('executes a task object', async () => {
+ const callbackMock = vi.fn((_signal, _executor) => 'aaa');
+ const task: ExecutorTask = { callback: callbackMock };
+ const promise = executor.execute(task);
+
+ expect(executor.task).toBe(task);
+
+ expect(callbackMock).toHaveBeenCalledTimes(1);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(false);
+ expect(callbackMock.mock.calls[0][1]).toBe(executor);
+
+ expect(listenerMock).toHaveBeenCalledTimes(1);
+ expect(listenerMock).toHaveBeenNthCalledWith(1, {
+ type: 'pending',
+ target: executor,
+ version: 1,
+ payload: { task },
+ } satisfies ExecutorEvent);
+ expect(listenerMock.mock.calls[0][0].payload.task).toBe(task);
+
expect(executor.promise).toBe(promise);
await expect(promise).resolves.toEqual('aaa');
@@ -110,23 +148,23 @@ describe('execute', () => {
});
test('aborts the pending task if a new task is submitted', async () => {
- const taskMock1 = vi.fn(_signal => 'aaa');
- const taskMock2 = vi.fn(_signal => 'bbb');
+ const callbackMock1 = vi.fn(_signal => 'aaa');
+ const callbackMock2 = vi.fn(_signal => 'bbb');
- const promise1 = executor.execute(taskMock1);
+ const promise1 = executor.execute(callbackMock1);
- const promise2 = executor.execute(taskMock2);
+ const promise2 = executor.execute(callbackMock2);
- expect(executor.task).toBe(taskMock2);
- expect(taskMock1.mock.calls[0][0].aborted).toBe(true);
- expect(taskMock2.mock.calls[0][0].aborted).toBe(false);
+ expect(executor.task).toStrictEqual({ callback: callbackMock2 });
+ expect(callbackMock1.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock2.mock.calls[0][0].aborted).toBe(false);
expect(listenerMock).toHaveBeenCalledTimes(3);
expect(listenerMock).toHaveBeenNthCalledWith(1, {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock1 } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(2, {
type: 'aborted',
@@ -138,7 +176,7 @@ describe('execute', () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock2 } },
} satisfies ExecutorEvent);
expect(executor.isFulfilled).toBe(false);
@@ -165,17 +203,17 @@ describe('execute', () => {
});
test('rejects if a task throws an error', async () => {
- const taskMock = vi.fn(() => {
+ const callbackMock = vi.fn(() => {
throw expectedReason;
});
- const promise = executor.execute(taskMock);
+ const promise = executor.execute(callbackMock);
expect(listenerMock).toHaveBeenCalledTimes(1);
expect(listenerMock).toHaveBeenNthCalledWith(1, {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(executor.promise).toBe(promise);
@@ -198,24 +236,24 @@ describe('execute', () => {
});
test('task promise can be aborted', () => {
- const taskMock = vi.fn((_signal, _executor) => 'aaa');
+ const callbackMock = vi.fn((_signal, _executor) => 'aaa');
- const promise = executor.execute(taskMock);
+ const promise = executor.execute(callbackMock);
promise.abort();
- expect(executor.task).toBe(taskMock);
+ expect(executor.task).toStrictEqual({ callback: callbackMock });
- expect(taskMock).toHaveBeenCalledTimes(1);
- expect(taskMock.mock.calls[0][0].aborted).toBe(true);
- expect(taskMock.mock.calls[0][1]).toBe(executor);
+ expect(callbackMock).toHaveBeenCalledTimes(1);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock.mock.calls[0][1]).toBe(executor);
expect(listenerMock).toHaveBeenCalledTimes(2);
expect(listenerMock).toHaveBeenNthCalledWith(1, {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(2, {
type: 'aborted',
@@ -232,32 +270,32 @@ describe('execute', () => {
});
test('a new task can be executed from abort event handler if previous task is aborted manually', async () => {
- const taskMock1 = vi.fn(_signal => 'aaa');
- const taskMock2 = vi.fn(_signal => 'bbb');
+ const callbackMock1 = vi.fn(_signal => 'aaa');
+ const callbackMock2 = vi.fn(_signal => 'bbb');
executor.subscribe(event => {
if (event.type === 'aborted') {
- executor.execute(taskMock2);
+ executor.execute(callbackMock2);
}
});
- const promise = executor.execute(taskMock1);
+ const promise = executor.execute(callbackMock1);
promise.abort();
- expect(executor.task).toBe(taskMock2);
+ expect(executor.task).toStrictEqual({ callback: callbackMock2 });
expect(executor.promise).not.toBeNull();
expect(executor.promise).not.toBe(promise);
- expect(taskMock1).toHaveBeenCalledTimes(1);
- expect(taskMock1.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock1).toHaveBeenCalledTimes(1);
+ expect(callbackMock1.mock.calls[0][0].aborted).toBe(true);
expect(listenerMock).toHaveBeenCalledTimes(3);
expect(listenerMock).toHaveBeenNthCalledWith(1, {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock1 } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(2, {
type: 'aborted',
@@ -269,7 +307,7 @@ describe('execute', () => {
type: 'pending',
target: executor,
version: 3,
- payload: undefined,
+ payload: { task: { callback: callbackMock2 } },
} satisfies ExecutorEvent);
await expect(executor.promise).resolves.toBe('bbb');
@@ -281,36 +319,36 @@ describe('execute', () => {
});
test('a new task can be executed from abort event handler if a task is replaced', async () => {
- const taskMock1 = vi.fn(_signal => 'aaa');
- const taskMock2 = vi.fn(_signal => 'bbb');
- const taskMock3 = vi.fn(_signal => 'ccc');
+ const callbackMock1 = vi.fn(_signal => 'aaa');
+ const callbackMock2 = vi.fn(_signal => 'bbb');
+ const callbackMock3 = vi.fn(_signal => 'ccc');
listenerMock.mockImplementationOnce(noop).mockImplementationOnce(event => {
if (event.type === 'aborted') {
- executor.execute(taskMock3);
+ executor.execute(callbackMock3);
}
});
- const promise1 = executor.execute(taskMock1);
+ const promise1 = executor.execute(callbackMock1);
- const promise2 = executor.execute(taskMock2);
+ const promise2 = executor.execute(callbackMock2);
- expect(executor.task).toBe(taskMock3);
+ expect(executor.task).toStrictEqual({ callback: callbackMock3 });
expect(executor.promise).not.toBeNull();
expect(executor.promise).not.toBe(promise1);
expect(executor.promise).not.toBe(promise2);
- expect(taskMock1).toHaveBeenCalledTimes(1);
- expect(taskMock2).toHaveBeenCalledTimes(1);
- expect(taskMock1.mock.calls[0][0].aborted).toBe(true);
- expect(taskMock2.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock1).toHaveBeenCalledTimes(1);
+ expect(callbackMock2).toHaveBeenCalledTimes(1);
+ expect(callbackMock1.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock2.mock.calls[0][0].aborted).toBe(true);
expect(listenerMock).toHaveBeenCalledTimes(4);
expect(listenerMock).toHaveBeenNthCalledWith(1, {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock1 } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(2, {
type: 'aborted',
@@ -328,7 +366,7 @@ describe('execute', () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock3 } },
} satisfies ExecutorEvent);
await expect(executor.promise).resolves.toBe('ccc');
@@ -340,21 +378,21 @@ describe('execute', () => {
});
test('an AbortablePromise returned from a task is aborted when a task is replaced', async () => {
- const taskMock1 = vi.fn(
+ const callbackMock1 = vi.fn(
_signal =>
new AbortablePromise(resolve => {
resolve('aaa');
})
);
- const taskMock2 = vi.fn(_signal => 'bbb');
+ const callbackMock2 = vi.fn(_signal => 'bbb');
- const promise1 = executor.execute(taskMock1);
- const promise2 = executor.execute(taskMock2);
+ const promise1 = executor.execute(callbackMock1);
+ const promise2 = executor.execute(callbackMock2);
- expect(executor.task).toBe(taskMock2);
+ expect(executor.task).toStrictEqual({ callback: callbackMock2 });
- expect(taskMock1).toHaveBeenCalledTimes(1);
- expect(taskMock1.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock1).toHaveBeenCalledTimes(1);
+ expect(callbackMock1.mock.calls[0][0].aborted).toBe(true);
await expect(promise1).rejects.toEqual(AbortError('The task was replaced'));
await expect(promise2).resolves.toBe('bbb');
@@ -402,6 +440,165 @@ describe('execute', () => {
expect(executor.value).toBe('bbb');
expect(executor.promise).toBeNull();
});
+
+ test('applies pending value when task is pending', async () => {
+ const callbackMock = vi.fn((_signal, _executor) => 'aaa');
+
+ const task: ExecutorTask = { callback: callbackMock, pendingValue: 'xxx' };
+
+ const promise = executor.execute(task);
+
+ expect(executor.task).toBe(task);
+ expect(executor.isFulfilled).toBe(true);
+ expect(executor.value).toBe('xxx');
+ expect(executor.settledAt).toBe(50);
+ expect(executor.invalidatedAt).toBe(0);
+ expect(executor.isPending).toBe(true);
+ expect(executor._rollbackSnapshot).toStrictEqual({
+ annotations: {},
+ invalidatedAt: 0,
+ isFulfilled: false,
+ reason: undefined,
+ settledAt: 0,
+ value: undefined,
+ });
+
+ expect(callbackMock).toHaveBeenCalledTimes(1);
+
+ expect(listenerMock).toHaveBeenCalledTimes(1);
+ expect(listenerMock).toHaveBeenNthCalledWith(1, {
+ type: 'pending',
+ target: executor,
+ version: 1,
+ payload: { task },
+ } satisfies ExecutorEvent);
+
+ expect(executor.promise).toBe(promise);
+
+ await expect(promise).resolves.toEqual('aaa');
+
+ expect(listenerMock).toHaveBeenCalledTimes(2);
+ expect(listenerMock).toHaveBeenNthCalledWith(2, {
+ type: 'fulfilled',
+ target: executor,
+ version: 2,
+ payload: undefined,
+ } satisfies ExecutorEvent);
+
+ expect(executor.isFulfilled).toBe(true);
+ expect(executor.isRejected).toBe(false);
+ expect(executor.value).toBe('aaa');
+ expect(executor.promise).toBeNull();
+ expect(executor._rollbackSnapshot).toBeNull();
+ });
+
+ test('rollbacks pending value if task fails', async () => {
+ executor.resolve('kkk');
+
+ expect(executor.value).toBe('kkk');
+
+ await expect(
+ executor.execute({
+ callback() {
+ throw new Error('expected');
+ },
+ pendingValue: 'xxx',
+ })
+ ).rejects.toEqual(new Error('expected'));
+
+ expect(listenerMock).toHaveBeenCalledTimes(3);
+ expect(listenerMock).toHaveBeenNthCalledWith(3, {
+ type: 'rejected',
+ target: executor,
+ version: 3,
+ payload: undefined,
+ } satisfies ExecutorEvent);
+
+ expect(executor.isFulfilled).toBe(false);
+ expect(executor.isRejected).toBe(true);
+ expect(executor.value).toBe('kkk');
+ expect(executor.promise).toBeNull();
+ expect(executor._rollbackSnapshot).toBeNull();
+ });
+
+ test('rollbacks pending value if task is aborted', async () => {
+ executor.resolve('kkk');
+
+ expect(executor.value).toBe('kkk');
+
+ const promise = executor.execute({ callback: () => 'aaa', pendingValue: 'xxx' });
+
+ promise.abort();
+
+ await promise.catch(noop);
+
+ expect(executor.value).toBe('kkk');
+ expect(executor._rollbackSnapshot).toBeNull();
+ });
+
+ test('rollbacks pending value when task is replaced', async () => {
+ executor.resolve('kkk');
+
+ expect(executor.value).toBe('kkk');
+
+ executor.execute({ callback: () => 'aaa', pendingValue: 'xxx' }).catch(noop);
+
+ expect(executor.value).toBe('xxx');
+
+ const promise = executor.execute({ callback: () => 'bbb', pendingValue: 'yyy' });
+
+ expect(executor.value).toBe('yyy');
+
+ expect(executor._rollbackSnapshot).toStrictEqual({
+ annotations: {},
+ invalidatedAt: 0,
+ isFulfilled: true,
+ reason: undefined,
+ settledAt: 50,
+ value: 'kkk',
+ });
+
+ await expect(promise).resolves.toEqual('bbb');
+
+ expect(executor.value).toBe('bbb');
+ expect(executor.promise).toBeNull();
+ expect(executor._rollbackSnapshot).toBeNull();
+ });
+
+ test('rollbacks pending value when replacement task fails', async () => {
+ executor.resolve('kkk');
+
+ expect(executor.value).toBe('kkk');
+
+ executor.execute({ callback: () => 'aaa', pendingValue: 'xxx' }).catch(noop);
+
+ expect(executor.value).toBe('xxx');
+
+ const promise = executor.execute({
+ callback() {
+ throw new Error('expected');
+ },
+ pendingValue: 'yyy',
+ });
+
+ expect(executor.value).toBe('yyy');
+
+ expect(executor._rollbackSnapshot).toStrictEqual({
+ annotations: {},
+ invalidatedAt: 0,
+ isFulfilled: true,
+ reason: undefined,
+ settledAt: 50,
+ value: 'kkk',
+ });
+
+ await expect(promise).rejects.toEqual(new Error('expected'));
+
+ expect(executor.value).toBe('kkk');
+ expect(executor.isRejected).toBe(true);
+ expect(executor.promise).toBeNull();
+ expect(executor._rollbackSnapshot).toBeNull();
+ });
});
describe('resolve', () => {
@@ -425,21 +622,21 @@ describe('resolve', () => {
});
test('aborts pending task and preserves it as the latest task', () => {
- const taskMock = vi.fn((_signal, _executor) => 'aaa');
+ const callbackMock = vi.fn((_signal, _executor) => 'aaa');
- executor.execute(taskMock);
+ executor.execute(callbackMock);
executor.resolve('bbb');
- expect(taskMock).toHaveBeenCalledTimes(1);
- expect(taskMock.mock.calls[0][0].aborted).toBe(true);
- expect(taskMock.mock.calls[0][1]).toBe(executor);
+ expect(callbackMock).toHaveBeenCalledTimes(1);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock.mock.calls[0][1]).toBe(executor);
expect(executor.isFulfilled).toBe(true);
expect(executor.isRejected).toBe(false);
expect(executor.isInvalidated).toBe(false);
expect(executor.value).toBe('bbb');
expect(executor.reason).toBeUndefined();
- expect(executor.task).toBe(taskMock);
+ expect(executor.task).toStrictEqual({ callback: callbackMock });
expect(executor.promise).toBeNull();
expect(listenerMock).toHaveBeenCalledTimes(3);
@@ -447,7 +644,7 @@ describe('resolve', () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(2, {
type: 'aborted',
@@ -482,7 +679,12 @@ describe('resolve', () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: {
+ task: {
+ callback: expect.any(Function),
+ preserveLatestTask: true,
+ },
+ },
} satisfies ExecutorEvent);
expect(executor.isFulfilled).toBe(false);
@@ -490,7 +692,7 @@ describe('resolve', () => {
expect(executor.isInvalidated).toBe(false);
expect(executor.value).toBeUndefined();
expect(executor.reason).toBeUndefined();
- expect(executor.task).not.toBeNull();
+ expect(executor.task).toBeNull();
expect(executor.promise).not.toBeNull();
await executor.promise;
@@ -505,7 +707,7 @@ describe('resolve', () => {
expect(executor.value).toBe('aaa');
expect(executor.reason).toBeUndefined();
- expect(executor.task).not.toBeNull();
+ expect(executor.task).toBeNull();
expect(executor.promise).toBeNull();
});
});
@@ -531,21 +733,21 @@ describe('reject', () => {
});
test('aborts pending task and preserves it as the latest task', () => {
- const taskMock = vi.fn((_signal, _executor) => 'aaa');
+ const callbackMock = vi.fn((_signal, _executor) => 'aaa');
- executor.execute(taskMock);
+ executor.execute(callbackMock);
executor.reject('bbb');
- expect(taskMock).toHaveBeenCalledTimes(1);
- expect(taskMock.mock.calls[0][0].aborted).toBe(true);
- expect(taskMock.mock.calls[0][1]).toBe(executor);
+ expect(callbackMock).toHaveBeenCalledTimes(1);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock.mock.calls[0][1]).toBe(executor);
expect(executor.isFulfilled).toBe(false);
expect(executor.isRejected).toBe(true);
expect(executor.isInvalidated).toBe(false);
expect(executor.value).toBeUndefined();
expect(executor.reason).toBe('bbb');
- expect(executor.task).toBe(taskMock);
+ expect(executor.task).toStrictEqual({ callback: callbackMock });
expect(executor.promise).toBeNull();
expect(listenerMock).toHaveBeenCalledTimes(3);
@@ -553,7 +755,7 @@ describe('reject', () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(2, {
type: 'aborted',
@@ -589,25 +791,25 @@ describe('retry', () => {
});
test('no-op if there is a pending task', () => {
- const task = () => 'aaa';
- const promise = executor.execute(task);
+ const callback = () => 'aaa';
+ const promise = executor.execute(callback);
executor.retry();
- expect(executor.task).toBe(task);
+ expect(executor.task).toStrictEqual({ callback });
expect(executor.promise).toBe(promise);
});
test('executes the latest task', async () => {
- const taskMock = vi.fn(() => 'aaa');
+ const callbackMock = vi.fn(() => 'aaa');
- await executor.execute(taskMock);
+ await executor.execute(callbackMock);
- expect(taskMock).toHaveBeenCalledTimes(1);
+ expect(callbackMock).toHaveBeenCalledTimes(1);
executor.retry();
- expect(taskMock).toHaveBeenCalledTimes(2);
+ expect(callbackMock).toHaveBeenCalledTimes(2);
});
});
@@ -722,7 +924,7 @@ describe('abort', () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: expect.any(Function) } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(2, {
type: 'fulfilled',
@@ -746,7 +948,7 @@ describe('abort', () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: expect.any(Function) } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(2, {
type: 'rejected',
@@ -757,22 +959,22 @@ describe('abort', () => {
});
test('aborts the pending task', async () => {
- const taskMock = vi.fn(_signal => 'aaa');
+ const callbackMock = vi.fn(_signal => 'aaa');
- executor.execute(taskMock);
+ executor.execute(callbackMock);
executor.abort('bbb');
- expect(executor.task).toBe(taskMock);
+ expect(executor.task).toStrictEqual({ callback: callbackMock });
- expect(taskMock).toHaveBeenCalledTimes(1);
- expect(taskMock.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock).toHaveBeenCalledTimes(1);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(true);
expect(listenerMock).toHaveBeenCalledTimes(2);
expect(listenerMock).toHaveBeenNthCalledWith(1, {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(2, {
type: 'aborted',
diff --git a/src/test/ExecutorManager.test.ts b/src/test/ExecutorManager.test.ts
index ad13d5a..d18aeab 100644
--- a/src/test/ExecutorManager.test.ts
+++ b/src/test/ExecutorManager.test.ts
@@ -86,22 +86,27 @@ describe('getOrCreate', () => {
});
test('applies the initial task only once', async () => {
- const taskMock = vi.fn(() => 111);
+ const callbackMock = vi.fn(() => 111);
- manager.getOrCreate('aaa', taskMock);
+ manager.getOrCreate('aaa', callbackMock);
- const executor = manager.getOrCreate('aaa', taskMock);
+ const executor = manager.getOrCreate('aaa', callbackMock);
expect(executor.isPending).toBe(true);
expect(executor.isFulfilled).toBe(false);
expect(executor.isRejected).toBe(false);
expect(executor.value).toBeUndefined();
expect(executor.reason).toBeUndefined();
- expect(taskMock).toHaveBeenCalledTimes(1);
+ expect(callbackMock).toHaveBeenCalledTimes(1);
expect(listenerMock).toHaveBeenCalledTimes(2);
expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'attached', target: executor, version: 0 });
- expect(listenerMock).toHaveBeenNthCalledWith(2, { type: 'pending', target: executor, version: 1 });
+ expect(listenerMock).toHaveBeenNthCalledWith(2, {
+ type: 'pending',
+ target: executor,
+ version: 1,
+ payload: { task: { callback: callbackMock } },
+ });
await expect(executor.getOrAwait()).resolves.toBe(111);
@@ -116,9 +121,9 @@ describe('getOrCreate', () => {
});
test('does not apply initial value if executor was resolved from a plugin', () => {
- const taskMock = vi.fn(() => 111);
+ const callbackMock = vi.fn(() => 111);
- const executor = manager.getOrCreate('aaa', taskMock, [
+ const executor = manager.getOrCreate('aaa', callbackMock, [
executor => {
executor.resolve(222);
},
@@ -130,7 +135,7 @@ describe('getOrCreate', () => {
expect(executor.value).toBe(222);
expect(executor.reason).toBeUndefined();
- expect(taskMock).toHaveBeenCalledTimes(0);
+ expect(callbackMock).toHaveBeenCalledTimes(0);
expect(listenerMock).toHaveBeenCalledTimes(2);
expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'fulfilled', target: executor, version: 1 });
@@ -138,9 +143,9 @@ describe('getOrCreate', () => {
});
test('does not apply initial value if a task execution was started form a plugin', async () => {
- const taskMock = vi.fn(() => 111);
+ const callbackMock = vi.fn(() => 111);
- const executor = manager.getOrCreate('aaa', taskMock, [
+ const executor = manager.getOrCreate('aaa', callbackMock, [
executor => {
executor.execute(() => 222);
},
@@ -152,10 +157,15 @@ describe('getOrCreate', () => {
expect(executor.value).toBeUndefined();
expect(executor.reason).toBeUndefined();
- expect(taskMock).toHaveBeenCalledTimes(0);
+ expect(callbackMock).toHaveBeenCalledTimes(0);
expect(listenerMock).toHaveBeenCalledTimes(2);
- expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'pending', target: executor, version: 1 });
+ expect(listenerMock).toHaveBeenNthCalledWith(1, {
+ type: 'pending',
+ target: executor,
+ version: 1,
+ payload: { task: { callback: expect.any(Function) } },
+ });
expect(listenerMock).toHaveBeenNthCalledWith(2, { type: 'attached', target: executor, version: 1 });
await executor.getOrAwait();
@@ -163,7 +173,12 @@ describe('getOrCreate', () => {
expect(executor.value).toBe(222);
expect(listenerMock).toHaveBeenCalledTimes(3);
- expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'pending', target: executor, version: 1 });
+ expect(listenerMock).toHaveBeenNthCalledWith(1, {
+ type: 'pending',
+ target: executor,
+ version: 1,
+ payload: { task: { callback: expect.any(Function) } },
+ });
expect(listenerMock).toHaveBeenNthCalledWith(2, { type: 'attached', target: executor, version: 1 });
expect(listenerMock).toHaveBeenNthCalledWith(3, { type: 'fulfilled', target: executor, version: 2 });
});
@@ -350,11 +365,11 @@ describe('hydrate', () => {
})
).toBe(true);
- const task = () => 222;
- const executor = manager.getOrCreate('xxx', task);
+ const callback = () => 222;
+ const executor = manager.getOrCreate('xxx', callback);
expect(executor.value).toBe(111);
expect(executor.settledAt).toBe(50);
- expect(executor.task).toBe(task);
+ expect(executor.task).toStrictEqual({ callback });
});
});
diff --git a/src/test/plugin/abortDeactivated.test.ts b/src/test/plugin/abortDeactivated.test.ts
index c71ba6e..dbbfff6 100644
--- a/src/test/plugin/abortDeactivated.test.ts
+++ b/src/test/plugin/abortDeactivated.test.ts
@@ -17,14 +17,14 @@ beforeEach(() => {
test('aborts a deactivated executor', async () => {
const executor = manager.getOrCreate('xxx', undefined, [abortDeactivated({ delay: 0 })]);
const deactivate = executor.activate();
- const taskMock = vi.fn(_signal => delay(100));
- const promise = executor.execute(taskMock);
+ const callbackMock = vi.fn(_signal => delay(100));
+ const promise = executor.execute(callbackMock);
deactivate();
await expect(promise).rejects.toEqual(AbortError('The executor was aborted'));
- expect(taskMock.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(true);
expect(listenerMock).toHaveBeenCalledTimes(6);
expect(listenerMock).toHaveBeenNthCalledWith(1, {
@@ -49,7 +49,7 @@ test('aborts a deactivated executor', async () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(5, {
type: 'deactivated',
@@ -68,15 +68,15 @@ test('aborts a deactivated executor', async () => {
test('cancels abortion of an activated executor', async () => {
const executor = manager.getOrCreate('xxx', undefined, [abortDeactivated({ delay: 0 })]);
const deactivate = executor.activate();
- const taskMock = vi.fn(_signal => delay(100, 'aaa'));
- const promise = executor.execute(taskMock);
+ const callbackMock = vi.fn(_signal => delay(100, 'aaa'));
+ const promise = executor.execute(callbackMock);
deactivate();
executor.activate();
await expect(promise).resolves.toBe('aaa');
- expect(taskMock.mock.calls[0][0].aborted).toBe(false);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(false);
expect(listenerMock).toHaveBeenCalledTimes(7);
expect(listenerMock).toHaveBeenNthCalledWith(1, {
@@ -101,7 +101,7 @@ test('cancels abortion of an activated executor', async () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(5, {
type: 'deactivated',
diff --git a/src/test/plugin/abortWhen.test.ts b/src/test/plugin/abortWhen.test.ts
index ff964a7..c7847c8 100644
--- a/src/test/plugin/abortWhen.test.ts
+++ b/src/test/plugin/abortWhen.test.ts
@@ -15,21 +15,21 @@ beforeEach(() => {
test('aborts the pending task', () => {
const pubSub = new PubSub();
- const taskMock = vi.fn(_signal => new Promise(noop));
+ const callbackMock = vi.fn(_signal => new Promise(noop));
- manager.getOrCreate('xxx', taskMock, [abortWhen(pubSub)]);
+ manager.getOrCreate('xxx', callbackMock, [abortWhen(pubSub)]);
pubSub.publish(true);
vi.advanceTimersByTime(5_000);
- expect(taskMock.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(true);
});
test('does not abort the executed task by default', () => {
const pubSub = new PubSub();
- const taskMock = vi.fn();
+ const callbackMock = vi.fn();
const executor = manager.getOrCreate('xxx', undefined, [abortWhen(pubSub)]);
@@ -37,15 +37,15 @@ test('does not abort the executed task by default', () => {
vi.runAllTimers();
- executor.execute(taskMock);
+ executor.execute(callbackMock);
- expect(taskMock.mock.calls[0][0].aborted).toBe(false);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(false);
});
test('aborts the executed task if isSustained is true', () => {
const pubSub = new PubSub();
- const taskMock = vi.fn();
+ const callbackMock = vi.fn();
const executor = manager.getOrCreate('xxx', undefined, [abortWhen(pubSub, { isSustained: true })]);
@@ -53,19 +53,19 @@ test('aborts the executed task if isSustained is true', () => {
vi.runAllTimers();
- executor.execute(taskMock);
+ executor.execute(callbackMock);
- expect(taskMock.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(true);
});
test('does not abort if the observable has pushed false before the timeout', async () => {
const pubSub = new PubSub();
- const taskMock = vi.fn(_signal => delay(15_000, 'aaa'));
+ const callbackMock = vi.fn(_signal => delay(15_000, 'aaa'));
const executor = manager.getOrCreate('xxx', undefined, [abortWhen(pubSub, { delay: 10_000 })]);
- executor.execute(taskMock);
+ executor.execute(callbackMock);
pubSub.publish(true);
@@ -77,17 +77,17 @@ test('does not abort if the observable has pushed false before the timeout', asy
await expect(executor.getOrAwait()).resolves.toBe('aaa');
- expect(taskMock.mock.calls[0][0].aborted).toBe(false);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(false);
});
test('does not abort if true pushed twice', () => {
const pubSub = new PubSub();
- const taskMock = vi.fn(_signal => delay(15_000, 'aaa'));
+ const callbackMock = vi.fn(_signal => delay(15_000, 'aaa'));
const executor = manager.getOrCreate('xxx', undefined, [abortWhen(pubSub)]);
- executor.execute(taskMock);
+ executor.execute(callbackMock);
pubSub.publish(true);
pubSub.publish(true);
@@ -95,5 +95,5 @@ test('does not abort if true pushed twice', () => {
vi.advanceTimersToNextTimer();
- expect(taskMock.mock.calls[0][0].aborted).toBe(false);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(false);
});
diff --git a/src/test/plugin/detachDeactivated.test.ts b/src/test/plugin/detachDeactivated.test.ts
index 155e347..018a7c8 100644
--- a/src/test/plugin/detachDeactivated.test.ts
+++ b/src/test/plugin/detachDeactivated.test.ts
@@ -16,8 +16,8 @@ beforeEach(() => {
test('detaches a deactivated executor', async () => {
const executor = manager.getOrCreate('xxx', undefined, [detachDeactivated({ delay: 0 })]);
const deactivate = executor.activate();
- const taskMock = vi.fn(_signal => delay(100, 'aaa'));
- const promise = executor.execute(taskMock);
+ const callbackMock = vi.fn(_signal => delay(100, 'aaa'));
+ const promise = executor.execute(callbackMock);
deactivate();
@@ -46,7 +46,7 @@ test('detaches a deactivated executor', async () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(5, {
type: 'deactivated',
@@ -65,8 +65,8 @@ test('detaches a deactivated executor', async () => {
test('cancels deactivation of an activated executor', async () => {
const executor = manager.getOrCreate('xxx', undefined, [detachDeactivated({ delay: 0 })]);
const deactivate = executor.activate();
- const taskMock = vi.fn(_signal => delay(100, 'aaa'));
- const promise = executor.execute(taskMock);
+ const callbackMock = vi.fn(_signal => delay(100, 'aaa'));
+ const promise = executor.execute(callbackMock);
deactivate();
executor.activate();
@@ -96,7 +96,7 @@ test('cancels deactivation of an activated executor', async () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(5, {
type: 'deactivated',
diff --git a/src/test/plugin/lazyTask.test.ts b/src/test/plugin/lazyTask.test.ts
index cdf4c38..9ed3a30 100644
--- a/src/test/plugin/lazyTask.test.ts
+++ b/src/test/plugin/lazyTask.test.ts
@@ -3,10 +3,10 @@ import { ExecutorManager } from '../../main/index.js';
import lazyTask from '../../main/plugin/lazyTask.js';
test('sets a task and does not execute it', async () => {
- const taskMock = vi.fn(() => 'aaa');
+ const callbackMock = vi.fn(() => 'aaa');
- const executor = new ExecutorManager().getOrCreate('xxx', undefined, [lazyTask(taskMock)]);
+ const executor = new ExecutorManager().getOrCreate('xxx', undefined, [lazyTask(callbackMock)]);
- expect(taskMock).not.toHaveBeenCalled();
- expect(executor.task).toBe(taskMock);
+ expect(callbackMock).not.toHaveBeenCalled();
+ expect(executor.task).toStrictEqual({ callback: callbackMock });
});
diff --git a/src/test/plugin/retryActivated.test.ts b/src/test/plugin/retryActivated.test.ts
index ca3e67e..799b3af 100644
--- a/src/test/plugin/retryActivated.test.ts
+++ b/src/test/plugin/retryActivated.test.ts
@@ -15,8 +15,8 @@ beforeEach(() => {
});
test('retries an activated executor', async () => {
- const taskMock = vi.fn();
- const executor = manager.getOrCreate('xxx', taskMock, [retryActivated()]);
+ const callbackMock = vi.fn();
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryActivated()]);
await executor.getOrAwait();
@@ -28,8 +28,8 @@ test('retries an activated executor', async () => {
});
test('does not retry if executor is not stale yet', async () => {
- const taskMock = vi.fn();
- const executor = manager.getOrCreate('xxx', taskMock, [retryActivated({ delayAfterSettled: 5_000 })]);
+ const callbackMock = vi.fn();
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryActivated({ delayAfterSettled: 5_000 })]);
await executor.getOrAwait();
diff --git a/src/test/plugin/retryFulfilled.test.ts b/src/test/plugin/retryFulfilled.test.ts
index 8bb7f3e..2817db0 100644
--- a/src/test/plugin/retryFulfilled.test.ts
+++ b/src/test/plugin/retryFulfilled.test.ts
@@ -15,8 +15,8 @@ beforeEach(() => {
});
test('retries a fulfilled executor', async () => {
- const taskMock = vi.fn();
- const executor = manager.getOrCreate('xxx', taskMock, [retryFulfilled({ count: 2, delay: 0 })]);
+ const callbackMock = vi.fn();
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryFulfilled({ count: 2, delay: 0 })]);
executor.activate();
expect(executor.isPending).toBe(true);
@@ -39,12 +39,12 @@ test('retries a fulfilled executor', async () => {
vi.runAllTimers();
expect(executor.isPending).toBe(false);
- expect(taskMock).toHaveBeenCalledTimes(3);
+ expect(callbackMock).toHaveBeenCalledTimes(3);
});
test('stops retrying if an executor is aborted', async () => {
- const taskMock = vi.fn();
- const executor = manager.getOrCreate('xxx', taskMock, [retryFulfilled({ count: 2, delay: 0 })]);
+ const callbackMock = vi.fn();
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryFulfilled({ count: 2, delay: 0 })]);
executor.activate();
expect(executor.isPending).toBe(true);
@@ -61,12 +61,12 @@ test('stops retrying if an executor is aborted', async () => {
vi.runAllTimers();
expect(executor.isPending).toBe(false);
- expect(taskMock).toHaveBeenCalledTimes(2);
+ expect(callbackMock).toHaveBeenCalledTimes(2);
});
test('stops retrying if an executor is rejected', async () => {
- const taskMock = vi.fn();
- const executor = manager.getOrCreate('xxx', taskMock, [retryFulfilled({ count: 2, delay: 0 })]);
+ const callbackMock = vi.fn();
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryFulfilled({ count: 2, delay: 0 })]);
executor.activate();
expect(executor.isPending).toBe(true);
@@ -83,12 +83,12 @@ test('stops retrying if an executor is rejected', async () => {
vi.runAllTimers();
expect(executor.isPending).toBe(false);
- expect(taskMock).toHaveBeenCalledTimes(2);
+ expect(callbackMock).toHaveBeenCalledTimes(2);
});
test('stops retrying if an executor is deactivated', async () => {
- const taskMock = vi.fn();
- const executor = manager.getOrCreate('xxx', taskMock, [retryFulfilled({ count: 2, delay: 0 })]);
+ const callbackMock = vi.fn();
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryFulfilled({ count: 2, delay: 0 })]);
const deactivate = executor.activate();
expect(executor.isPending).toBe(true);
@@ -106,5 +106,5 @@ test('stops retrying if an executor is deactivated', async () => {
vi.runAllTimers();
expect(executor.isPending).toBe(false);
- expect(taskMock).toHaveBeenCalledTimes(2);
+ expect(callbackMock).toHaveBeenCalledTimes(2);
});
diff --git a/src/test/plugin/retryInvalidated.test.ts b/src/test/plugin/retryInvalidated.test.ts
index 7a56f18..6938f8c 100644
--- a/src/test/plugin/retryInvalidated.test.ts
+++ b/src/test/plugin/retryInvalidated.test.ts
@@ -12,8 +12,8 @@ beforeEach(() => {
});
test('retries the invalidated active executor', async () => {
- const taskMock = vi.fn().mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
- const executor = manager.getOrCreate('xxx', taskMock, [retryInvalidated()]);
+ const callbackMock = vi.fn().mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryInvalidated()]);
executor.activate();
@@ -52,7 +52,7 @@ test('retries the invalidated active executor', async () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(4, {
type: 'activated',
@@ -76,7 +76,7 @@ test('retries the invalidated active executor', async () => {
type: 'pending',
target: executor,
version: 4,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(8, {
type: 'fulfilled',
@@ -87,8 +87,8 @@ test('retries the invalidated active executor', async () => {
});
test('retries the activated and invalidated executor', async () => {
- const taskMock = vi.fn().mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
- const executor = manager.getOrCreate('xxx', taskMock, [retryInvalidated()]);
+ const callbackMock = vi.fn().mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryInvalidated()]);
await executor.getOrAwait();
@@ -126,7 +126,7 @@ test('retries the activated and invalidated executor', async () => {
type: 'pending',
target: executor,
version: 1,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(4, {
type: 'fulfilled',
@@ -150,7 +150,7 @@ test('retries the activated and invalidated executor', async () => {
type: 'pending',
target: executor,
version: 4,
- payload: undefined,
+ payload: { task: { callback: callbackMock } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(8, {
type: 'fulfilled',
diff --git a/src/test/plugin/retryRejected.test.ts b/src/test/plugin/retryRejected.test.ts
index 12d7ff6..1315a19 100644
--- a/src/test/plugin/retryRejected.test.ts
+++ b/src/test/plugin/retryRejected.test.ts
@@ -18,11 +18,11 @@ beforeEach(() => {
});
test('retries a rejected executor', async () => {
- const taskMock = vi.fn(() => {
+ const callbackMock = vi.fn(() => {
throw expectedReason;
});
- const executor = manager.getOrCreate('xxx', taskMock, [retryRejected({ count: 2, delay: 0 })]);
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryRejected({ count: 2, delay: 0 })]);
executor.activate();
expect(executor.isPending).toBe(true);
@@ -45,15 +45,15 @@ test('retries a rejected executor', async () => {
vi.runAllTimers();
expect(executor.isPending).toBe(false);
- expect(taskMock).toHaveBeenCalledTimes(3);
+ expect(callbackMock).toHaveBeenCalledTimes(3);
});
test('stops retrying if an executor is aborted', async () => {
- const taskMock = vi.fn(() => {
+ const callbackMock = vi.fn(() => {
throw expectedReason;
});
- const executor = manager.getOrCreate('xxx', taskMock, [retryRejected({ count: 2, delay: 0 })]);
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryRejected({ count: 2, delay: 0 })]);
executor.activate();
expect(executor.isPending).toBe(true);
@@ -70,15 +70,15 @@ test('stops retrying if an executor is aborted', async () => {
vi.runAllTimers();
expect(executor.isPending).toBe(false);
- expect(taskMock).toHaveBeenCalledTimes(2);
+ expect(callbackMock).toHaveBeenCalledTimes(2);
});
test('stops retrying if an executor is fulfilled', async () => {
- const taskMock = vi.fn(() => {
+ const callbackMock = vi.fn(() => {
throw expectedReason;
});
- const executor = manager.getOrCreate('xxx', taskMock, [retryRejected({ count: 2, delay: 0 })]);
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryRejected({ count: 2, delay: 0 })]);
executor.activate();
expect(executor.isPending).toBe(true);
@@ -95,15 +95,15 @@ test('stops retrying if an executor is fulfilled', async () => {
vi.runAllTimers();
expect(executor.isPending).toBe(false);
- expect(taskMock).toHaveBeenCalledTimes(2);
+ expect(callbackMock).toHaveBeenCalledTimes(2);
});
test('stops retrying if an executor is deactivated', async () => {
- const taskMock = vi.fn(() => {
+ const callbackMock = vi.fn(() => {
throw expectedReason;
});
- const executor = manager.getOrCreate('xxx', taskMock, [retryRejected({ count: 2, delay: 0 })]);
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryRejected({ count: 2, delay: 0 })]);
const deactivate = executor.activate();
expect(executor.isPending).toBe(true);
@@ -122,5 +122,5 @@ test('stops retrying if an executor is deactivated', async () => {
vi.runAllTimers();
expect(executor.isPending).toBe(false);
- expect(taskMock).toHaveBeenCalledTimes(2);
+ expect(callbackMock).toHaveBeenCalledTimes(2);
});
diff --git a/src/test/plugin/retryWhen.test.ts b/src/test/plugin/retryWhen.test.ts
index 898d6b8..f898352 100644
--- a/src/test/plugin/retryWhen.test.ts
+++ b/src/test/plugin/retryWhen.test.ts
@@ -14,9 +14,9 @@ beforeEach(() => {
test('retries an active executor', async () => {
const pubSub = new PubSub();
- const taskMock = vi.fn().mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
+ const callbackMock = vi.fn().mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
- const executor = manager.getOrCreate('xxx', taskMock, [retryWhen(pubSub)]);
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryWhen(pubSub)]);
executor.activate();
@@ -37,15 +37,15 @@ test('retries an active executor', async () => {
await expect(executor.getOrAwait()).resolves.toBe('bbb');
- expect(taskMock).toHaveBeenCalledTimes(2);
+ expect(callbackMock).toHaveBeenCalledTimes(2);
});
test('does not retry a non-active executor', async () => {
const pubSub = new PubSub();
- const taskMock = vi.fn().mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
+ const callbackMock = vi.fn().mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
- const executor = manager.getOrCreate('xxx', taskMock, [retryWhen(pubSub)]);
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryWhen(pubSub)]);
await expect(executor.getOrAwait()).resolves.toBe('aaa');
@@ -61,15 +61,15 @@ test('does not retry a non-active executor', async () => {
expect(executor.isPending).toBe(false);
- expect(taskMock).toHaveBeenCalledTimes(1);
+ expect(callbackMock).toHaveBeenCalledTimes(1);
});
test('does not retry if observable has pushed false before timeout', async () => {
const pubSub = new PubSub();
- const taskMock = vi.fn().mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
+ const callbackMock = vi.fn().mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
- const executor = manager.getOrCreate('xxx', taskMock, [retryWhen(pubSub, { delay: 10_000 })]);
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryWhen(pubSub, { delay: 10_000 })]);
executor.activate();
@@ -87,15 +87,15 @@ test('does not retry if observable has pushed false before timeout', async () =>
expect(executor.isPending).toBe(false);
- expect(taskMock).toHaveBeenCalledTimes(1);
+ expect(callbackMock).toHaveBeenCalledTimes(1);
});
test('retries if observable has pushed true after timeout', async () => {
const pubSub = new PubSub();
- const taskMock = vi.fn().mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
+ const callbackMock = vi.fn().mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
- const executor = manager.getOrCreate('xxx', taskMock, [retryWhen(pubSub, { delay: 5_000 })]);
+ const executor = manager.getOrCreate('xxx', callbackMock, [retryWhen(pubSub, { delay: 5_000 })]);
executor.activate();
@@ -113,5 +113,5 @@ test('retries if observable has pushed true after timeout', async () => {
expect(executor.isPending).toBe(true);
- expect(taskMock).toHaveBeenCalledTimes(2);
+ expect(callbackMock).toHaveBeenCalledTimes(2);
});
diff --git a/src/test/plugin/syncBrowserStorage.test.ts b/src/test/plugin/syncBrowserStorage.test.ts
index aebb2bb..0cbea8b 100644
--- a/src/test/plugin/syncBrowserStorage.test.ts
+++ b/src/test/plugin/syncBrowserStorage.test.ts
@@ -282,17 +282,17 @@ test('sets storage item if executor was resolved from a preceding plugin', () =>
});
test('initial task is not called if storage item exists', () => {
- const taskMock = vi.fn(() => 'bbb');
+ const callbackMock = vi.fn(() => 'bbb');
localStorage.setItem(
'"xxx"',
'{"value":"aaa","isFulfilled":true,"settledAt":20,"invalidatedAt":30,"annotations":{}}'
);
- executor = manager.getOrCreate('xxx', taskMock, [syncBrowserStorage()]);
+ executor = manager.getOrCreate('xxx', callbackMock, [syncBrowserStorage()]);
expect(executor.value).toBe('aaa');
- expect(taskMock).not.toHaveBeenCalled();
+ expect(callbackMock).not.toHaveBeenCalled();
});
test('does not set storage item or resolve an executor if an executor is pending', async () => {
@@ -588,7 +588,7 @@ test('syncs state if task is aborted', () => {
type: 'pending',
target: executor,
version: 2,
- payload: undefined,
+ payload: { task: { callback: expect.any(Function) } },
} satisfies ExecutorEvent);
executor.abort();
diff --git a/src/test/ssr/SSRExecutorManager.test.ts b/src/test/ssr/SSRExecutorManager.test.ts
index 096cf85..dada7e6 100644
--- a/src/test/ssr/SSRExecutorManager.test.ts
+++ b/src/test/ssr/SSRExecutorManager.test.ts
@@ -213,15 +213,15 @@ describe('abort', () => {
test('aborts executors', () => {
const manager = new SSRExecutorManager();
- const taskMock = vi.fn(_signal => 111);
+ const callbackMock = vi.fn(_signal => 111);
const executor = manager.getOrCreate('xxx');
- executor.execute(taskMock);
+ executor.execute(callbackMock);
manager.abort();
expect(executor.isPending).toBe(false);
- expect(taskMock.mock.calls[0][0].aborted).toBe(true);
+ expect(callbackMock.mock.calls[0][0].aborted).toBe(true);
});
});
diff --git a/src/test/useExecutor.test.tsx b/src/test/useExecutor.test.tsx
index ae5ec16..35af7d0 100644
--- a/src/test/useExecutor.test.tsx
+++ b/src/test/useExecutor.test.tsx
@@ -102,8 +102,8 @@ test('creates an executor with the initial value', () => {
});
test('creates an executor with the initial task', async () => {
- const taskMock = vi.fn(() => 'aaa');
- const renderMock = vi.fn(() => useExecutor(executorKey, taskMock));
+ const callbackMock = vi.fn(() => 'aaa');
+ const renderMock = vi.fn(() => useExecutor(executorKey, callbackMock));
const hook = renderHook(renderMock, { wrapper: StrictMode });
const executor = hook.result.current;
@@ -114,9 +114,9 @@ test('creates an executor with the initial task', async () => {
expect(executor.isRejected).toBe(false);
expect(executor.value).toBeUndefined();
expect(executor.reason).toBeUndefined();
- expect(executor.task).toBe(taskMock);
+ expect(executor.task).toStrictEqual({ callback: callbackMock });
- expect(taskMock).toHaveBeenCalledTimes(1);
+ expect(callbackMock).toHaveBeenCalledTimes(1);
expect(renderMock).toHaveBeenCalledTimes(2);
await act(() => executor.getOrAwait());
@@ -126,7 +126,7 @@ test('creates an executor with the initial task', async () => {
expect(executor.isRejected).toBe(false);
expect(executor.value).toBe('aaa');
expect(executor.reason).toBeUndefined();
- expect(executor.task).toBe(taskMock);
+ expect(executor.task).toStrictEqual({ callback: callbackMock });
expect(renderMock).toHaveBeenCalledTimes(4);
});
@@ -150,19 +150,19 @@ test('re-renders after reject', () => {
});
test('re-renders after task execute', async () => {
- const task = () => 'aaa';
+ const callback = () => 'aaa';
const renderMock = vi.fn(() => useExecutor(executorKey));
const hook = renderHook(renderMock, { wrapper: StrictMode });
const executor = hook.result.current;
- await act(() => executor.execute(task));
+ await act(() => executor.execute(callback));
expect(executor.isPending).toBe(false);
expect(executor.isFulfilled).toBe(true);
expect(executor.isRejected).toBe(false);
expect(executor.value).toBe('aaa');
expect(executor.reason).toBeUndefined();
- expect(executor.task).toBe(task);
+ expect(executor.task).toStrictEqual({ callback });
expect(renderMock).toHaveBeenCalledTimes(6);
});
@@ -201,7 +201,7 @@ test('deactivates an executor when key changes after parent component render', a
type: 'pending',
target: manager.get('xxx')!,
version: 1,
- payload: undefined,
+ payload: { task: { callback: expect.any(Function) } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(3, {
type: 'activated',
@@ -236,7 +236,7 @@ test('deactivates an executor when key changes after parent component render', a
type: 'pending',
target: manager.get('yyy')!,
version: 1,
- payload: undefined,
+ payload: { task: { callback: expect.any(Function) } },
} satisfies ExecutorEvent);
expect(listenerMock).toHaveBeenNthCalledWith(7, {
type: 'deactivated',