diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 6abb170cf7..4c24fc4459 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -145,7 +145,7 @@ describe('TimelineManager', () => { it('cancels month loading', async () => { const month = getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })!; void timelineManager.loadTimelineMonth({ year: 2024, month: 1 }); - const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort'); + const abortSpy = vi.spyOn(month!.loader!.abortController!, 'abort'); month?.cancel(); expect(abortSpy).toBeCalledTimes(1); await timelineManager.loadTimelineMonth({ year: 2024, month: 1 }); @@ -638,12 +638,8 @@ describe('TimelineManager', () => { const previousMonth = getTimelineMonthByDate(timelineManager, { year: 2024, month: 3 }); const a = month!.getFirstAsset(); const b = previousMonth!.getFirstAsset(); - const loadTimelineMonthSpy = vi.spyOn(month!.loader!, 'execute'); - const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute'); const previous = await timelineManager.getLaterAsset(a); expect(previous).toEqual(b); - expect(loadTimelineMonthSpy).toBeCalledTimes(0); - expect(previousMonthSpy).toBeCalledTimes(0); }); it('skips removed assets', async () => { diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 215360b8f9..667b17d507 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -307,8 +307,8 @@ export class TimelineManager extends VirtualScrollManager { return; } - if (!this.initTask.executed) { - await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options)); + if (!this.initTask.succeeded) { + await (this.initTask.running ? this.initTask.waitUntilCompletion() : this.#init(this.#options)); } const changedWidth = viewport.width !== this.viewportWidth; @@ -351,14 +351,10 @@ export class TimelineManager extends VirtualScrollManager { return; } - if (timelineMonth.loader?.executed) { - return; - } - const executionStatus = await timelineMonth.loader?.execute(async (signal: AbortSignal) => { await loadFromTimeBuckets(this, timelineMonth, this.#options, signal); }, cancelable); - if (executionStatus === 'LOADED') { + if (executionStatus === 'SUCCESS') { updateGeometry(this, timelineMonth, { invalidateHeight: false }); this.updateViewportProximities(); } @@ -372,7 +368,7 @@ export class TimelineManager extends VirtualScrollManager { async findTimelineMonthForAsset(asset: AssetDescriptor | AssetResponseDto) { if (!this.isInitialized) { - await this.initTask.waitUntilExecution(); + await this.initTask.waitUntilSucceeded(); } const { id } = asset; diff --git a/web/src/lib/utils/cancellable-task.spec.ts b/web/src/lib/utils/cancellable-task.spec.ts index 27e4678d4f..f34114815b 100644 --- a/web/src/lib/utils/cancellable-task.spec.ts +++ b/web/src/lib/utils/cancellable-task.spec.ts @@ -2,39 +2,39 @@ import { CancellableTask } from '$lib/utils/cancellable-task'; describe('CancellableTask', () => { describe('execute', () => { - it('should execute task successfully and return LOADED', async () => { + it('should execute task successfully and return SUCCESS', async () => { const task = new CancellableTask(); - const taskFn = vi.fn(async (_: AbortSignal) => { + const taskFunction = vi.fn(async (_: AbortSignal) => { await new Promise((resolve) => setTimeout(resolve, 10)); }); - const result = await task.execute(taskFn, true); + const result = await task.execute(taskFunction, true); - expect(result).toBe('LOADED'); - expect(task.executed).toBe(true); - expect(task.loading).toBe(false); - expect(taskFn).toHaveBeenCalledTimes(1); + expect(result).toBe('SUCCESS'); + expect(task.succeeded).toBe(true); + expect(task.running).toBe(false); + expect(taskFunction).toHaveBeenCalledTimes(1); }); - it('should call loadedCallback when task completes successfully', async () => { - const loadedCallback = vi.fn(); - const task = new CancellableTask(loadedCallback); - const taskFn = vi.fn(async () => {}); + it('should call succeededCallback when task completes successfully', async () => { + const succeededCallback = vi.fn(); + const task = new CancellableTask(succeededCallback); + const taskFunction = vi.fn(async () => {}); - await task.execute(taskFn, true); + await task.execute(taskFunction, true); - expect(loadedCallback).toHaveBeenCalledTimes(1); + expect(succeededCallback).toHaveBeenCalledTimes(1); }); it('should return DONE if task is already executed', async () => { const task = new CancellableTask(); - const taskFn = vi.fn(async () => {}); + const taskFunction = vi.fn(async () => {}); - await task.execute(taskFn, true); - const result = await task.execute(taskFn, true); + await task.execute(taskFunction, true); + const result = await task.execute(taskFunction, true); expect(result).toBe('DONE'); - expect(taskFn).toHaveBeenCalledTimes(1); + expect(taskFunction).toHaveBeenCalledTimes(1); }); it('should wait if task is already running', async () => { @@ -43,42 +43,42 @@ describe('CancellableTask', () => { const taskPromise = new Promise((resolve) => { resolveTask = resolve; }); - const taskFn = vi.fn(async () => { + const taskFunction = vi.fn(async () => { await taskPromise; }); - const promise1 = task.execute(taskFn, true); - const promise2 = task.execute(taskFn, true); + const promise1 = task.execute(taskFunction, true); + const promise2 = task.execute(taskFunction, true); - expect(task.loading).toBe(true); + expect(task.running).toBe(true); resolveTask!(); const [result1, result2] = await Promise.all([promise1, promise2]); - expect(result1).toBe('LOADED'); + expect(result1).toBe('SUCCESS'); expect(result2).toBe('WAITED'); - expect(taskFn).toHaveBeenCalledTimes(1); + expect(taskFunction).toHaveBeenCalledTimes(1); }); it('should pass AbortSignal to task function', async () => { const task = new CancellableTask(); let capturedSignal: AbortSignal | null = null; - const taskFn = async (signal: AbortSignal) => { + const taskFunction = async (signal: AbortSignal) => { await Promise.resolve(); capturedSignal = signal; }; - await task.execute(taskFn, true); + await task.execute(taskFunction, true); expect(capturedSignal).toBeInstanceOf(AbortSignal); }); it('should set cancellable flag correctly', async () => { const task = new CancellableTask(); - const taskFn = vi.fn(async () => {}); + const taskFunction = vi.fn(async () => {}); expect(task.cancellable).toBe(true); - const promise = task.execute(taskFn, false); + const promise = task.execute(taskFunction, false); expect(task.cancellable).toBe(false); await promise; }); @@ -89,14 +89,14 @@ describe('CancellableTask', () => { const taskPromise = new Promise((resolve) => { resolveTask = resolve; }); - const taskFn = vi.fn(async () => { + const taskFunction = vi.fn(async () => { await taskPromise; }); - const promise1 = task.execute(taskFn, false); + const promise1 = task.execute(taskFunction, false); expect(task.cancellable).toBe(false); - const promise2 = task.execute(taskFn, true); + const promise2 = task.execute(taskFunction, true); expect(task.cancellable).toBe(false); resolveTask!(); @@ -108,7 +108,7 @@ describe('CancellableTask', () => { it('should cancel a running task', async () => { const task = new CancellableTask(); let taskStarted = false; - const taskFn = async (signal: AbortSignal) => { + const taskFunction = async (signal: AbortSignal) => { taskStarted = true; await new Promise((resolve) => setTimeout(resolve, 100)); if (signal.aborted) { @@ -116,9 +116,7 @@ describe('CancellableTask', () => { } }; - const promise = task.execute(taskFn, true); - - // Wait a bit to ensure task has started + const promise = task.execute(taskFunction, true); await new Promise((resolve) => setTimeout(resolve, 10)); expect(taskStarted).toBe(true); @@ -126,20 +124,20 @@ describe('CancellableTask', () => { const result = await promise; expect(result).toBe('CANCELED'); - expect(task.executed).toBe(false); + expect(task.succeeded).toBe(false); }); it('should call canceledCallback when task is canceled', async () => { const canceledCallback = vi.fn(); const task = new CancellableTask(undefined, canceledCallback); - const taskFn = async (signal: AbortSignal) => { + const taskFunction = async (signal: AbortSignal) => { await new Promise((resolve) => setTimeout(resolve, 100)); if (signal.aborted) { throw new DOMException('Aborted', 'AbortError'); } }; - const promise = task.execute(taskFn, true); + const promise = task.execute(taskFunction, true); await new Promise((resolve) => setTimeout(resolve, 10)); task.cancel(); await promise; @@ -149,16 +147,16 @@ describe('CancellableTask', () => { it('should not cancel if task is not cancellable', async () => { const task = new CancellableTask(); - const taskFn = vi.fn(async () => { + const taskFunction = vi.fn(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); }); - const promise = task.execute(taskFn, false); + const promise = task.execute(taskFunction, false); task.cancel(); const result = await promise; - expect(result).toBe('LOADED'); - expect(task.executed).toBe(true); + expect(result).toBe('SUCCESS'); + expect(task.succeeded).toBe(true); }); it('should return CANCELED when concurrent caller is waiting and task is canceled', async () => { @@ -167,15 +165,15 @@ describe('CancellableTask', () => { const taskPromise = new Promise((resolve) => { resolveTask = resolve; }); - const taskFn = async (signal: AbortSignal) => { + const taskFunction = async (signal: AbortSignal) => { await taskPromise; if (signal.aborted) { throw new DOMException('Aborted', 'AbortError'); } }; - const promise1 = task.execute(taskFn, true); - const promise2 = task.execute(taskFn, true); + const promise1 = task.execute(taskFunction, true); + const promise2 = task.execute(taskFunction, true); task.cancel(); resolveTask!(); @@ -187,41 +185,41 @@ describe('CancellableTask', () => { it('should not cancel if task is already executed', async () => { const task = new CancellableTask(); - const taskFn = vi.fn(async () => {}); + const taskFunction = vi.fn(async () => {}); - await task.execute(taskFn, true); - expect(task.executed).toBe(true); + await task.execute(taskFunction, true); + expect(task.succeeded).toBe(true); task.cancel(); - expect(task.executed).toBe(true); + expect(task.succeeded).toBe(true); }); }); describe('reset', () => { it('should reset task to initial state', async () => { const task = new CancellableTask(); - const taskFn = vi.fn(async () => {}); + const taskFunction = vi.fn(async () => {}); - await task.execute(taskFn, true); - expect(task.executed).toBe(true); + await task.execute(taskFunction, true); + expect(task.succeeded).toBe(true); await task.reset(); - expect(task.executed).toBe(false); - expect(task.cancelToken).toBe(null); - expect(task.loading).toBe(false); + expect(task.succeeded).toBe(false); + expect(task.abortController).toBe(null); + expect(task.running).toBe(false); }); it('should cancel running task before resetting', async () => { const task = new CancellableTask(); - const taskFn = async (signal: AbortSignal) => { + const taskFunction = async (signal: AbortSignal) => { await new Promise((resolve) => setTimeout(resolve, 100)); if (signal.aborted) { throw new DOMException('Aborted', 'AbortError'); } }; - const promise = task.execute(taskFn, true); + const promise = task.execute(taskFunction, true); await new Promise((resolve) => setTimeout(resolve, 10)); const resetPromise = task.reset(); @@ -229,30 +227,30 @@ describe('CancellableTask', () => { await promise; await resetPromise; - expect(task.executed).toBe(false); - expect(task.loading).toBe(false); + expect(task.succeeded).toBe(false); + expect(task.running).toBe(false); }); it('should allow re-execution after reset', async () => { const task = new CancellableTask(); - const taskFn = vi.fn(async () => {}); + const taskFunction = vi.fn(async () => {}); - await task.execute(taskFn, true); + await task.execute(taskFunction, true); await task.reset(); - const result = await task.execute(taskFn, true); + const result = await task.execute(taskFunction, true); - expect(result).toBe('LOADED'); - expect(task.executed).toBe(true); - expect(taskFn).toHaveBeenCalledTimes(2); + expect(result).toBe('SUCCESS'); + expect(task.succeeded).toBe(true); + expect(taskFunction).toHaveBeenCalledTimes(2); }); }); describe('waitUntilCompletion', () => { it('should return DONE if task is already executed', async () => { const task = new CancellableTask(); - const taskFn = vi.fn(async () => {}); + const taskFunction = vi.fn(async () => {}); - await task.execute(taskFn, true); + await task.execute(taskFunction, true); const result = await task.waitUntilCompletion(); expect(result).toBe('DONE'); @@ -264,11 +262,11 @@ describe('CancellableTask', () => { const taskPromise = new Promise((resolve) => { resolveTask = resolve; }); - const taskFn = async () => { + const taskFunction = async () => { await taskPromise; }; - const executePromise = task.execute(taskFn, true); + const executePromise = task.execute(taskFunction, true); const waitPromise = task.waitUntilCompletion(); resolveTask!(); @@ -280,14 +278,14 @@ describe('CancellableTask', () => { it('should return CANCELED if task is canceled', async () => { const task = new CancellableTask(); - const taskFn = async (signal: AbortSignal) => { + const taskFunction = async (signal: AbortSignal) => { await new Promise((resolve) => setTimeout(resolve, 100)); if (signal.aborted) { throw new DOMException('Aborted', 'AbortError'); } }; - const executePromise = task.execute(taskFn, true); + const executePromise = task.execute(taskFunction, true); const waitPromise = task.waitUntilCompletion(); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -299,13 +297,13 @@ describe('CancellableTask', () => { }); }); - describe('waitUntilExecution', () => { + describe('waitUntilSucceeded', () => { it('should return DONE if task is already executed', async () => { const task = new CancellableTask(); - const taskFn = vi.fn(async () => {}); + const taskFunction = vi.fn(async () => {}); - await task.execute(taskFn, true); - const result = await task.waitUntilExecution(); + await task.execute(taskFunction, true); + const result = await task.waitUntilSucceeded(); expect(result).toBe('DONE'); }); @@ -316,12 +314,12 @@ describe('CancellableTask', () => { const taskPromise = new Promise((resolve) => { resolveTask = resolve; }); - const taskFn = async () => { + const taskFunction = async () => { await taskPromise; }; - const executePromise = task.execute(taskFn, true); - const waitPromise = task.waitUntilExecution(); + const executePromise = task.execute(taskFunction, true); + const waitPromise = task.waitUntilSucceeded(); resolveTask!(); @@ -335,7 +333,7 @@ describe('CancellableTask', () => { const task = new CancellableTask(); let attempt = 0; - const taskFn = async (signal: AbortSignal) => { + const taskFunction = async (signal: AbortSignal) => { attempt++; await new Promise((resolve) => setTimeout(resolve, 100)); if (signal.aborted && attempt === 1) { @@ -344,8 +342,8 @@ describe('CancellableTask', () => { }; // Start first execution - const executePromise1 = task.execute(taskFn, true); - const waitPromise = task.waitUntilExecution(); + const executePromise1 = task.execute(taskFunction, true); + const waitPromise = task.waitUntilSucceeded(); // Cancel the first execution vi.advanceTimersByTime(10); @@ -354,12 +352,12 @@ describe('CancellableTask', () => { await executePromise1; // Start second execution - const executePromise2 = task.execute(taskFn, true); + const executePromise2 = task.execute(taskFunction, true); vi.advanceTimersByTime(100); const [executeResult, waitResult] = await Promise.all([executePromise2, waitPromise]); - expect(executeResult).toBe('LOADED'); + expect(executeResult).toBe('SUCCESS'); expect(waitResult).toBe('WAITED'); expect(attempt).toBe(2); @@ -371,98 +369,98 @@ describe('CancellableTask', () => { it('should return ERRORED when task throws non-abort error', async () => { const task = new CancellableTask(); const error = new Error('Task failed'); - const taskFn = async () => { + const taskFunction = async () => { await Promise.resolve(); throw error; }; - const result = await task.execute(taskFn, true); + const result = await task.execute(taskFunction, true); expect(result).toBe('ERRORED'); - expect(task.executed).toBe(false); + expect(task.succeeded).toBe(false); }); it('should call errorCallback when task throws non-abort error', async () => { const errorCallback = vi.fn(); const task = new CancellableTask(undefined, undefined, errorCallback); const error = new Error('Task failed'); - const taskFn = async () => { + const taskFunction = async () => { await Promise.resolve(); throw error; }; - await task.execute(taskFn, true); + await task.execute(taskFunction, true); expect(errorCallback).toHaveBeenCalledTimes(1); expect(errorCallback).toHaveBeenCalledWith(error); }); - it('should return CANCELED when task throws AbortError', async () => { + it('should return ERRORED when task throws AbortError without signal being aborted', async () => { const task = new CancellableTask(); - const taskFn = async () => { + const taskFunction = async () => { await Promise.resolve(); throw new DOMException('Aborted', 'AbortError'); }; - const result = await task.execute(taskFn, true); + const result = await task.execute(taskFunction, true); - expect(result).toBe('CANCELED'); - expect(task.executed).toBe(false); + expect(result).toBe('ERRORED'); + expect(task.succeeded).toBe(false); }); it('should allow re-execution after error', async () => { const task = new CancellableTask(); - const taskFn1 = async () => { + const taskFunction1 = async () => { await Promise.resolve(); throw new Error('Failed'); }; - const taskFn2 = vi.fn(async () => {}); + const taskFunction2 = vi.fn(async () => {}); - const result1 = await task.execute(taskFn1, true); + const result1 = await task.execute(taskFunction1, true); expect(result1).toBe('ERRORED'); - const result2 = await task.execute(taskFn2, true); - expect(result2).toBe('LOADED'); - expect(task.executed).toBe(true); + const result2 = await task.execute(taskFunction2, true); + expect(result2).toBe('SUCCESS'); + expect(task.succeeded).toBe(true); }); }); - describe('loading property', () => { + describe('running property', () => { it('should return true when task is running', async () => { const task = new CancellableTask(); let resolveTask: () => void; const taskPromise = new Promise((resolve) => { resolveTask = resolve; }); - const taskFn = async () => { + const taskFunction = async () => { await taskPromise; }; - expect(task.loading).toBe(false); + expect(task.running).toBe(false); - const promise = task.execute(taskFn, true); - expect(task.loading).toBe(true); + const promise = task.execute(taskFunction, true); + expect(task.running).toBe(true); resolveTask!(); await promise; - expect(task.loading).toBe(false); + expect(task.running).toBe(false); }); }); describe('complete promise', () => { it('should resolve when task completes successfully', async () => { const task = new CancellableTask(); - const taskFn = vi.fn(async () => {}); + const taskFunction = vi.fn(async () => {}); const completePromise = task.complete; - await task.execute(taskFn, true); + await task.execute(taskFunction, true); await expect(completePromise).resolves.toBeUndefined(); }); it('should reject when task is canceled', async () => { const task = new CancellableTask(); - const taskFn = async (signal: AbortSignal) => { + const taskFunction = async (signal: AbortSignal) => { await new Promise((resolve) => setTimeout(resolve, 100)); if (signal.aborted) { throw new DOMException('Aborted', 'AbortError'); @@ -470,7 +468,7 @@ describe('CancellableTask', () => { }; const completePromise = task.complete; - const promise = task.execute(taskFn, true); + const promise = task.execute(taskFunction, true); await new Promise((resolve) => setTimeout(resolve, 10)); task.cancel(); await promise; @@ -480,13 +478,13 @@ describe('CancellableTask', () => { it('should reject when task errors', async () => { const task = new CancellableTask(); - const taskFn = async () => { + const taskFunction = async () => { await Promise.resolve(); throw new Error('Failed'); }; const completePromise = task.complete; - await task.execute(taskFn, true); + await task.execute(taskFunction, true); await expect(completePromise).rejects.toBeUndefined(); }); @@ -496,27 +494,22 @@ describe('CancellableTask', () => { it('should automatically call abort() on signal when task is canceled', async () => { const task = new CancellableTask(); let capturedSignal: AbortSignal | null = null; - const taskFn = async (signal: AbortSignal) => { + const taskFunction = async (signal: AbortSignal) => { capturedSignal = signal; - // Simulate a long-running task await new Promise((resolve) => setTimeout(resolve, 100)); if (signal.aborted) { throw new DOMException('Aborted', 'AbortError'); } }; - const promise = task.execute(taskFn, true); - - // Wait a bit to ensure task has started + const promise = task.execute(taskFunction, true); await new Promise((resolve) => setTimeout(resolve, 10)); expect(capturedSignal).not.toBeNull(); expect(capturedSignal!.aborted).toBe(false); - // Cancel the task task.cancel(); - // Verify the signal was aborted expect(capturedSignal!.aborted).toBe(true); const result = await promise; @@ -526,25 +519,22 @@ describe('CancellableTask', () => { it('should detect if signal was aborted after task completes', async () => { const task = new CancellableTask(); let controller: AbortController | null = null; - const taskFn = async (_: AbortSignal) => { - // Capture the controller to abort it externally - controller = task.cancelToken; - // Simulate some work + const taskFunction = async (_: AbortSignal) => { + // Capture the controller to abort it externally before the function returns + controller = task.abortController; await new Promise((resolve) => setTimeout(resolve, 10)); - // Now abort before the function returns controller?.abort(); }; - const result = await task.execute(taskFn, true); + const result = await task.execute(taskFunction, true); expect(result).toBe('CANCELED'); - expect(task.executed).toBe(false); + expect(task.succeeded).toBe(false); }); it('should handle abort signal in async operations', async () => { const task = new CancellableTask(); - const taskFn = async (signal: AbortSignal) => { - // Simulate listening to abort signal during async operation + const taskFunction = async (signal: AbortSignal) => { return new Promise((resolve, reject) => { signal.addEventListener('abort', () => { reject(new DOMException('Aborted', 'AbortError')); @@ -553,7 +543,7 @@ describe('CancellableTask', () => { }); }; - const promise = task.execute(taskFn, true); + const promise = task.execute(taskFunction, true); await new Promise((resolve) => setTimeout(resolve, 10)); task.cancel(); diff --git a/web/src/lib/utils/cancellable-task.ts b/web/src/lib/utils/cancellable-task.ts index c8cd9db4c0..a3b032c489 100644 --- a/web/src/lib/utils/cancellable-task.ts +++ b/web/src/lib/utils/cancellable-task.ts @@ -1,47 +1,60 @@ +/** + * A one-shot async task with cancellation support via AbortController/AbortSignal. + * + * State machine: + * + * IDLE ──execute()──▶ RUNNING ──task succeeds──▶ SUCCEEDED (terminal) + * │ + * ├──cancel()/abort──▶ CANCELED ──▶ IDLE + * └──task throws─────▶ ERRORED ──▶ IDLE + * + * SUCCEEDED is terminal — further execute() calls return 'DONE'. + * Call reset() to move from SUCCEEDED back to IDLE for re-execution. + * + * execute() return values: 'SUCCESS' | 'DONE' | 'WAITED' | 'CANCELED' | 'ERRORED' + */ export class CancellableTask { - cancelToken: AbortController | null = null; + abortController: AbortController | null = null; cancellable: boolean = true; /** - * A promise that resolves once the bucket is loaded, and rejects if bucket is canceled. + * A promise that resolves once the task completes, and rejects if the task is canceled or errored. */ complete!: Promise; - executed: boolean = false; + succeeded: boolean = false; - private loadedSignal: (() => void) | undefined; - private canceledSignal: (() => void) | undefined; + private completeResolve: (() => void) | undefined; + private completeReject: (() => void) | undefined; constructor( - private loadedCallback?: () => void, + private succeededCallback?: () => void, private canceledCallback?: () => void, private errorCallback?: (error: unknown) => void, ) { this.init(); } - get loading() { - return !!this.cancelToken; + get running() { + return !!this.abortController; } async waitUntilCompletion() { - if (this.executed) { + if (this.succeeded) { return 'DONE'; } - // The `complete` promise resolves when executed, rejects when canceled/errored. try { - const complete = this.complete; - await complete; + await this.complete; return 'WAITED'; } catch { - // ignore + // expected when canceled } return 'CANCELED'; } - async waitUntilExecution() { + async waitUntilSucceeded() { // Keep retrying until the task completes successfully (not canceled) for (;;) { try { - if (this.executed) { + if (this.succeeded) { return 'DONE'; } await this.complete; @@ -52,17 +65,15 @@ export class CancellableTask { } } - async execute Promise>(f: F, cancellable: boolean) { - if (this.executed) { + async execute(task: (abortSignal: AbortSignal) => Promise, cancellable: boolean) { + if (this.succeeded) { return 'DONE'; } // if promise is pending, wait on previous request instead. - if (this.cancelToken) { - // if promise is pending, and preventCancel is requested, - // do not allow transition from prevent cancel to allow cancel. - if (this.cancellable && !cancellable) { - this.cancellable = cancellable; + if (this.abortController) { + if (!cancellable) { + this.cancellable = false; } try { await this.complete; @@ -72,45 +83,42 @@ export class CancellableTask { } } this.cancellable = cancellable; - const cancelToken = (this.cancelToken = new AbortController()); + const abortController = (this.abortController = new AbortController()); try { - await f(cancelToken.signal); - if (cancelToken.signal.aborted) { + await task(abortController.signal); + if (abortController.signal.aborted) { return 'CANCELED'; } - this.#transitionToExecuted(); - return 'LOADED'; + this.#transitionToSucceeded(); + return 'SUCCESS'; } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((error as any).name === 'AbortError') { - // abort error is not treated as an error, but as a cancellation. + if (abortController.signal.aborted) { return 'CANCELED'; } this.#transitionToErrored(error); return 'ERRORED'; } finally { - if (this.cancelToken === cancelToken) { - this.cancelToken = null; + if (this.abortController === abortController) { + this.abortController = null; } } } private init() { + this.abortController = null; + this.succeeded = false; this.complete = new Promise((resolve, reject) => { - this.cancelToken = null; - this.executed = false; - this.loadedSignal = resolve; - this.canceledSignal = reject; + this.completeResolve = resolve; + this.completeReject = reject; }); // Suppress unhandled rejection warning this.complete.catch(() => {}); } - // will reset this job back to the initial state (isLoaded=false, no errors, etc) async reset() { this.#transitionToCancelled(); - if (this.cancelToken) { + if (this.abortController) { await this.waitUntilCompletion(); } this.init(); @@ -121,27 +129,26 @@ export class CancellableTask { } #transitionToCancelled() { - if (this.executed) { + if (this.succeeded) { return; } if (!this.cancellable) { return; } - this.cancelToken?.abort(); - this.canceledSignal?.(); + this.abortController?.abort(); + this.completeReject?.(); this.init(); this.canceledCallback?.(); } - #transitionToExecuted() { - this.executed = true; - this.loadedSignal?.(); - this.loadedCallback?.(); + #transitionToSucceeded() { + this.succeeded = true; + this.completeResolve?.(); + this.succeededCallback?.(); } #transitionToErrored(error: unknown) { - this.cancelToken = null; - this.canceledSignal?.(); + this.completeReject?.(); this.init(); this.errorCallback?.(error); }