From 4eba9eb851b430bcca9e56dee07731ca458166fb Mon Sep 17 00:00:00 2001 From: Savva Mikhalevski Date: Tue, 2 Jul 2024 18:02:11 +0300 Subject: [PATCH] Added pendingPromise (#26) --- README.md | 2 +- src/main/ExecutorImpl.ts | 56 +++++++++------------ src/main/ssr/SSRExecutorManager.ts | 2 +- src/main/types.ts | 31 ++++++++++++ src/test/ExecutorImpl.test.ts | 70 +++++++++++++------------- src/test/plugin/retryFulfilled.test.ts | 5 +- src/test/plugin/retryRejected.test.ts | 19 ++++--- 7 files changed, 102 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index f5a6180..8a2268e 100644 --- a/README.md +++ b/README.md @@ -1321,7 +1321,7 @@ hook: const accountExecutor = useExecutorManager().getOrCreate('account'); ``` -You can execute a task in response a user action, for example when user clicks a button: +You can execute a task in response to a user action, for example when user clicks a button: ```tsx const executor = useExecutor('test'); diff --git a/src/main/ExecutorImpl.ts b/src/main/ExecutorImpl.ts index a5aed06..28e84e9 100644 --- a/src/main/ExecutorImpl.ts +++ b/src/main/ExecutorImpl.ts @@ -17,11 +17,7 @@ export class ExecutorImpl implements Executor { isFulfilled = false; annotations: ExecutorAnnotations = Object.create(null); version = 0; - - /** - * The promise of the pending task execution, or `null` if there's no pending task execution. - */ - _taskPromise: AbortablePromise | null = null; + pendingPromise: AbortablePromise | null = null; /** * The number of consumers that activated the executor. @@ -46,7 +42,7 @@ export class ExecutorImpl implements Executor { } get isPending(): boolean { - return this._taskPromise !== null; + return this.pendingPromise !== null; } get isInvalidated(): boolean { @@ -65,8 +61,8 @@ export class ExecutorImpl implements Executor { throw this.isSettled ? this.reason : new Error('The executor is not settled'); } - getOrDefault(defaultValue: DefaultValue): Value | DefaultValue { - return this.isFulfilled ? this.value! : defaultValue; + getOrDefault(defaultValue?: DefaultValue): Value | DefaultValue | undefined { + return this.isFulfilled ? this.value : defaultValue; } getOrAwait(): AbortablePromise { @@ -103,10 +99,10 @@ export class ExecutorImpl implements Executor { } execute(task: ExecutorTask): AbortablePromise { - const taskPromise = new AbortablePromise((resolve, reject, signal) => { + const promise = new AbortablePromise((resolve, reject, signal) => { signal.addEventListener('abort', () => { - if (this._taskPromise === taskPromise) { - this._taskPromise = null; + if (this.pendingPromise === promise) { + this.pendingPromise = null; this.version++; } this.publish('aborted'); @@ -120,7 +116,7 @@ export class ExecutorImpl implements Executor { if (signal.aborted) { return; } - this._taskPromise = null; + this.pendingPromise = null; this.resolve(value); resolve(value); }, @@ -129,30 +125,30 @@ export class ExecutorImpl implements Executor { if (signal.aborted) { return; } - this._taskPromise = null; + this.pendingPromise = null; this.reject(reason); reject(reason); } ); }); - taskPromise.catch(noop); + promise.catch(noop); - const prevTaskPromise = this._taskPromise; - this._taskPromise = taskPromise; + const { pendingPromise } = this; + this.pendingPromise = promise; - if (prevTaskPromise !== null) { - prevTaskPromise.abort(AbortError('The task was replaced')); + if (pendingPromise !== null) { + pendingPromise.abort(AbortError('The task was replaced')); } else { this.version++; } - if (this._taskPromise === taskPromise) { + if (this.pendingPromise === promise) { this.task = task; this.publish('pending'); } - return taskPromise; + return promise; } retry(): void { @@ -172,9 +168,7 @@ export class ExecutorImpl implements Executor { } abort(reason: unknown = AbortError('The executor was aborted')): void { - if (this._taskPromise !== null) { - this._taskPromise.abort(reason); - } + this.pendingPromise?.abort(reason); } invalidate(invalidatedAt = Date.now()): void { @@ -191,12 +185,10 @@ export class ExecutorImpl implements Executor { return; } - const taskPromise = this._taskPromise; - this._taskPromise = null; + const { pendingPromise } = this; + this.pendingPromise = null; - if (taskPromise !== null) { - taskPromise.abort(); - } + pendingPromise?.abort(); this.isFulfilled = true; this.value = value; @@ -208,12 +200,10 @@ export class ExecutorImpl implements Executor { } reject(reason: any, settledAt = Date.now()): void { - const taskPromise = this._taskPromise; - this._taskPromise = null; + const { pendingPromise } = this; + this.pendingPromise = null; - if (taskPromise !== null) { - taskPromise.abort(); - } + pendingPromise?.abort(); this.isFulfilled = false; this.reason = reason; diff --git a/src/main/ssr/SSRExecutorManager.ts b/src/main/ssr/SSRExecutorManager.ts index 584101c..db02988 100644 --- a/src/main/ssr/SSRExecutorManager.ts +++ b/src/main/ssr/SSRExecutorManager.ts @@ -134,7 +134,7 @@ export class SSRExecutorManager extends ExecutorManager { const initialVersion = getVersion(); const hasChanges = (): Promise => - Promise.allSettled(Array.from(this._executors.values()).map(executor => executor._taskPromise)).then(() => + Promise.allSettled(Array.from(this).map(executor => executor.pendingPromise)).then(() => Array.from(this).some(executor => executor.isPending) ? hasChanges() : getVersion() !== initialVersion ); diff --git a/src/main/types.ts b/src/main/types.ts index 4ec79d7..c271df5 100644 --- a/src/main/types.ts +++ b/src/main/types.ts @@ -177,6 +177,23 @@ export interface ExecutorState { * @template Value The value stored by the executor. */ export interface Executor extends ExecutorState, Observable> { + /** + * The value of the latest fulfillment. + * + * **Note:** An executor may still have value even if it was {@link isRejected rejected}. Use {@link get}, + * {@link getOrDefault}, or {@link getOrAwait} to retrieve a value of the {@link Executor.isFulfilled fulfilled} + * executor. + */ + readonly value: Value | undefined; + + /** + * The reason of the latest failure. + * + * **Note:** An executor may still have a rejection reason even if it was {@link Executor.isFulfilled fulfilled}. + * Check {@link isRejected} to ensure that an executor is actually rejected. + */ + readonly reason: any; + /** * The integer version of {@link ExecutorState the state of this executor} that is incremented every time the executor * is mutated. @@ -219,12 +236,26 @@ export interface Executor extends ExecutorState, Observable< */ readonly task: ExecutorTask | null; + /** + * The promise of the pending {@link task} execution, or `null` if there's no pending task execution. + * + * **Note:** This promise is aborted if + * [the task is replaced](https://github.com/smikhalevski/react-executor?tab=readme-ov-file#replace-a-task). + * Use {@link getOrAwait} to wait until the executor becomes {@link isSettled settled}. + */ + readonly pendingPromise: AbortablePromise | null; + /** * Returns a {@link value} if the executor is {@link isFulfilled fulfilled}. Throws a {@link reason} if the executor * is {@link isRejected rejected}. Otherwise, throws an {@link !Error}. */ get(): Value; + /** + * Returns a {@link value} if the executor is {@link isFulfilled fulfilled}. Otherwise, returns `undefined`. + */ + getOrDefault(): Value | undefined; + /** * Returns a {@link value} if the executor is {@link isFulfilled fulfilled}. Otherwise, returns the default value. * diff --git a/src/test/ExecutorImpl.test.ts b/src/test/ExecutorImpl.test.ts index db87eba..15c1244 100644 --- a/src/test/ExecutorImpl.test.ts +++ b/src/test/ExecutorImpl.test.ts @@ -24,7 +24,7 @@ describe('ExecutorImpl', () => { expect(executor.isInvalidated).toBe(false); expect(executor.value).toBeUndefined(); expect(executor.reason).toBeUndefined(); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); }); @@ -78,7 +78,7 @@ describe('ExecutorImpl', () => { expect(listenerMock).toHaveBeenCalledTimes(1); expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'pending', target: executor, version: 1 }); - expect(executor._taskPromise).toBe(promise); + expect(executor.pendingPromise).toBe(promise); await expect(promise).resolves.toEqual('aaa'); @@ -88,7 +88,7 @@ describe('ExecutorImpl', () => { expect(executor.isFulfilled).toBe(true); expect(executor.isRejected).toBe(false); expect(executor.value).toBe('aaa'); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); test('aborts the pending task if a new task is submitted', async () => { @@ -111,7 +111,7 @@ describe('ExecutorImpl', () => { expect(executor.isFulfilled).toBe(false); expect(executor.isRejected).toBe(false); expect(executor.value).toBeUndefined(); - expect(executor._taskPromise).toBe(promise2); + expect(executor.pendingPromise).toBe(promise2); await expect(promise2).resolves.toEqual('bbb'); @@ -123,7 +123,7 @@ describe('ExecutorImpl', () => { expect(executor.isFulfilled).toBe(true); expect(executor.isRejected).toBe(false); expect(executor.value).toBe('bbb'); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); test('rejects if a task throws an error', async () => { @@ -135,7 +135,7 @@ describe('ExecutorImpl', () => { expect(listenerMock).toHaveBeenCalledTimes(1); expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'pending', target: executor, version: 1 }); - expect(executor._taskPromise).toBe(promise); + expect(executor.pendingPromise).toBe(promise); await expect(promise).rejects.toBe(expectedReason); @@ -146,7 +146,7 @@ describe('ExecutorImpl', () => { expect(executor.isRejected).toBe(true); expect(executor.value).toBeUndefined(); expect(executor.reason).toBe(expectedReason); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); test('task promise can be aborted', () => { @@ -171,7 +171,7 @@ describe('ExecutorImpl', () => { expect(executor.isRejected).toBe(false); expect(executor.value).toBeUndefined(); expect(executor.reason).toBeUndefined(); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); test('a new task can be executed from abort event handler if previous task is aborted manually', async () => { @@ -190,8 +190,8 @@ describe('ExecutorImpl', () => { promise.abort(); expect(executor.task).toBe(taskMock2); - expect(executor._taskPromise).not.toBeNull(); - expect(executor._taskPromise).not.toBe(promise); + expect(executor.pendingPromise).not.toBeNull(); + expect(executor.pendingPromise).not.toBe(promise); expect(taskMock1).toHaveBeenCalledTimes(1); expect(taskMock1.mock.calls[0][0].aborted).toBe(true); @@ -201,7 +201,7 @@ describe('ExecutorImpl', () => { expect(listenerMock).toHaveBeenNthCalledWith(2, { type: 'aborted', target: executor, version: 2 }); expect(listenerMock).toHaveBeenNthCalledWith(3, { type: 'pending', target: executor, version: 3 }); - await expect(executor._taskPromise).resolves.toBe('bbb'); + await expect(executor.pendingPromise).resolves.toBe('bbb'); expect(executor.isFulfilled).toBe(true); expect(executor.isRejected).toBe(false); @@ -227,9 +227,9 @@ describe('ExecutorImpl', () => { promise2.catch(noop); expect(executor.task).toBe(taskMock3); - expect(executor._taskPromise).not.toBeNull(); - expect(executor._taskPromise).not.toBe(promise1); - expect(executor._taskPromise).not.toBe(promise2); + expect(executor.pendingPromise).not.toBeNull(); + expect(executor.pendingPromise).not.toBe(promise1); + expect(executor.pendingPromise).not.toBe(promise2); expect(taskMock1).toHaveBeenCalledTimes(1); expect(taskMock2).toHaveBeenCalledTimes(1); @@ -242,7 +242,7 @@ describe('ExecutorImpl', () => { expect(listenerMock).toHaveBeenNthCalledWith(3, { type: 'aborted', target: executor, version: 1 }); expect(listenerMock).toHaveBeenNthCalledWith(4, { type: 'pending', target: executor, version: 1 }); - await expect(executor._taskPromise).resolves.toBe('ccc'); + await expect(executor.pendingPromise).resolves.toBe('ccc'); expect(executor.isFulfilled).toBe(true); expect(executor.isRejected).toBe(false); @@ -274,7 +274,7 @@ describe('ExecutorImpl', () => { expect(executor.isRejected).toBe(false); expect(executor.value).toBe('bbb'); expect(executor.reason).toBeUndefined(); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); test('preserves the previous value when a new task is executed', async () => { @@ -285,12 +285,12 @@ describe('ExecutorImpl', () => { expect(executor.isRejected).toBe(false); expect(executor.value).toBe('aaa'); expect(executor.reason).toBeUndefined(); - expect(executor._taskPromise).toBe(promise); + expect(executor.pendingPromise).toBe(promise); await promise; expect(executor.value).toBe('bbb'); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); test('preserves the previous reason when a new task is executed', async () => { @@ -306,12 +306,12 @@ describe('ExecutorImpl', () => { expect(executor.isRejected).toBe(true); expect(executor.value).toBeUndefined(); expect(executor.reason).toBe(expectedReason); - expect(executor._taskPromise).toBe(promise); + expect(executor.pendingPromise).toBe(promise); await promise; expect(executor.value).toBe('bbb'); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); }); @@ -324,7 +324,7 @@ describe('ExecutorImpl', () => { expect(executor.isInvalidated).toBe(false); expect(executor.value).toBe('aaa'); expect(executor.reason).toBeUndefined(); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); expect(listenerMock).toHaveBeenCalledTimes(1); expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'fulfilled', target: executor, version: 1 }); @@ -346,7 +346,7 @@ describe('ExecutorImpl', () => { expect(executor.value).toBe('bbb'); expect(executor.reason).toBeUndefined(); expect(executor.task).toBe(taskMock); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); expect(listenerMock).toHaveBeenCalledTimes(3); expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'pending', target: executor, version: 1 }); @@ -377,9 +377,9 @@ describe('ExecutorImpl', () => { expect(executor.value).toBeUndefined(); expect(executor.reason).toBeUndefined(); expect(executor.task).not.toBeNull(); - expect(executor._taskPromise).not.toBeNull(); + expect(executor.pendingPromise).not.toBeNull(); - await executor._taskPromise; + await executor.pendingPromise; expect(listenerMock).toHaveBeenCalledTimes(2); expect(listenerMock).toHaveBeenNthCalledWith(2, { type: 'fulfilled', target: executor, version: 2 }); @@ -387,7 +387,7 @@ describe('ExecutorImpl', () => { expect(executor.value).toBe('aaa'); expect(executor.reason).toBeUndefined(); expect(executor.task).not.toBeNull(); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); }); @@ -400,7 +400,7 @@ describe('ExecutorImpl', () => { expect(executor.isInvalidated).toBe(false); expect(executor.value).toBeUndefined(); expect(executor.reason).toBe('aaa'); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); expect(listenerMock).toHaveBeenCalledTimes(1); expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'rejected', target: executor, version: 1 }); @@ -422,7 +422,7 @@ describe('ExecutorImpl', () => { expect(executor.value).toBeUndefined(); expect(executor.reason).toBe('bbb'); expect(executor.task).toBe(taskMock); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); expect(listenerMock).toHaveBeenCalledTimes(3); expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'pending', target: executor, version: 1 }); @@ -446,7 +446,7 @@ describe('ExecutorImpl', () => { test('no-op if there is no task', () => { executor.retry(); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); test('no-op if there is a pending task', () => { @@ -456,7 +456,7 @@ describe('ExecutorImpl', () => { executor.retry(); expect(executor.task).toBe(task); - expect(executor._taskPromise).toBe(promise); + expect(executor.pendingPromise).toBe(promise); }); test('executes the latest task', async () => { @@ -489,7 +489,7 @@ describe('ExecutorImpl', () => { expect(executor.isInvalidated).toBe(false); expect(executor.value).toBeUndefined(); expect(executor.reason).toBeUndefined(); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); expect(listenerMock).toHaveBeenCalledTimes(3); expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'fulfilled', target: executor, version: 1 }); @@ -509,7 +509,7 @@ describe('ExecutorImpl', () => { expect(executor.isInvalidated).toBe(false); expect(executor.value).toBeUndefined(); expect(executor.reason).toBeUndefined(); - expect(executor._taskPromise).toBe(promise); + expect(executor.pendingPromise).toBe(promise); }); }); @@ -531,7 +531,7 @@ describe('ExecutorImpl', () => { expect(executor.isInvalidated).toBe(true); expect(executor.value).toBe('aaa'); expect(executor.reason).toBeUndefined(); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); expect(listenerMock).toHaveBeenCalledTimes(2); expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'fulfilled', target: executor, version: 1 }); @@ -561,7 +561,7 @@ describe('ExecutorImpl', () => { expect(listenerMock).toHaveBeenNthCalledWith(1, { type: 'pending', target: executor, version: 1 }); expect(listenerMock).toHaveBeenNthCalledWith(2, { type: 'aborted', target: executor, version: 2 }); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); test('abort preserves the value intact', () => { @@ -573,7 +573,7 @@ describe('ExecutorImpl', () => { expect(executor.isRejected).toBe(false); expect(executor.value).toBe('aaa'); expect(executor.reason).toBeUndefined(); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); test('abort preserves reason intact', () => { @@ -587,7 +587,7 @@ describe('ExecutorImpl', () => { expect(executor.isRejected).toBe(true); expect(executor.value).toBeUndefined(); expect(executor.reason).toBe(expectedReason); - expect(executor._taskPromise).toBeNull(); + expect(executor.pendingPromise).toBeNull(); }); }); diff --git a/src/test/plugin/retryFulfilled.test.ts b/src/test/plugin/retryFulfilled.test.ts index 63cc110..0973a7c 100644 --- a/src/test/plugin/retryFulfilled.test.ts +++ b/src/test/plugin/retryFulfilled.test.ts @@ -1,5 +1,4 @@ import { ExecutorManager } from '../../main'; -import type { ExecutorImpl } from '../../main/ExecutorImpl'; import retryFulfilled from '../../main/plugin/retryFulfilled'; import { noop } from '../../main/utils'; @@ -57,7 +56,7 @@ describe('retryFulfilled', () => { jest.runAllTimers(); expect(executor.isPending).toBe(true); - (executor as ExecutorImpl)._taskPromise!.catch(noop); + executor.pendingPromise!.catch(noop); executor.abort(); // Retry 2 @@ -80,7 +79,7 @@ describe('retryFulfilled', () => { jest.runAllTimers(); expect(executor.isPending).toBe(true); - (executor as ExecutorImpl)._taskPromise!.catch(noop); + executor.pendingPromise!.catch(noop); executor.reject(undefined); // Retry 2 diff --git a/src/test/plugin/retryRejected.test.ts b/src/test/plugin/retryRejected.test.ts index 01827ad..463a758 100644 --- a/src/test/plugin/retryRejected.test.ts +++ b/src/test/plugin/retryRejected.test.ts @@ -1,5 +1,4 @@ import { ExecutorManager } from '../../main'; -import type { ExecutorImpl } from '../../main/ExecutorImpl'; import retryRejected from '../../main/plugin/retryRejected'; import { noop } from '../../main/utils'; @@ -27,21 +26,21 @@ describe('retryRejected', () => { executor.activate(); expect(executor.isPending).toBe(true); - (executor as ExecutorImpl)._taskPromise!.catch(noop); + executor.pendingPromise!.catch(noop); await executor.getOrAwait().then(noop, noop); expect(executor.isPending).toBe(false); // Retry 1 jest.runAllTimers(); expect(executor.isPending).toBe(true); - (executor as ExecutorImpl)._taskPromise!.catch(noop); + executor.pendingPromise!.catch(noop); await executor.getOrAwait().then(noop, noop); expect(executor.isPending).toBe(false); // Retry 2 jest.runAllTimers(); expect(executor.isPending).toBe(true); - (executor as ExecutorImpl)._taskPromise!.catch(noop); + executor.pendingPromise!.catch(noop); await executor.getOrAwait().then(noop, noop); expect(executor.isPending).toBe(false); @@ -61,7 +60,7 @@ describe('retryRejected', () => { executor.activate(); expect(executor.isPending).toBe(true); - (executor as ExecutorImpl)._taskPromise!.catch(noop); + executor.pendingPromise!.catch(noop); await executor.getOrAwait().then(noop, noop); expect(executor.isPending).toBe(false); @@ -69,7 +68,7 @@ describe('retryRejected', () => { jest.runAllTimers(); expect(executor.isPending).toBe(true); - (executor as ExecutorImpl)._taskPromise!.catch(noop); + executor.pendingPromise!.catch(noop); executor.abort(); // Retry 2 @@ -88,7 +87,7 @@ describe('retryRejected', () => { executor.activate(); expect(executor.isPending).toBe(true); - (executor as ExecutorImpl)._taskPromise!.catch(noop); + executor.pendingPromise!.catch(noop); await executor.getOrAwait().then(noop, noop); expect(executor.isPending).toBe(false); @@ -96,7 +95,7 @@ describe('retryRejected', () => { jest.runAllTimers(); expect(executor.isPending).toBe(true); - (executor as ExecutorImpl)._taskPromise!.catch(noop); + executor.pendingPromise!.catch(noop); executor.resolve(undefined); // Retry 2 @@ -115,7 +114,7 @@ describe('retryRejected', () => { const deactivate = executor.activate(); expect(executor.isPending).toBe(true); - (executor as ExecutorImpl)._taskPromise!.catch(noop); + executor.pendingPromise!.catch(noop); await executor.getOrAwait().then(noop, noop); expect(executor.isPending).toBe(false); @@ -125,7 +124,7 @@ describe('retryRejected', () => { deactivate(); - (executor as ExecutorImpl)._taskPromise!.catch(noop); + executor.pendingPromise!.catch(noop); await executor.getOrAwait().then(noop, noop); // Retry 2