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',