diff --git a/packages/store/src/core/queue.ts b/packages/store/src/core/queue.ts index 71af8b05..3785106c 100644 --- a/packages/store/src/core/queue.ts +++ b/packages/store/src/core/queue.ts @@ -2,6 +2,7 @@ import type { Request, RequestMeta, RequestMode } from './request'; import type { Reactive } from './state'; import type { ErrorTask, PendingTask, SuccessTask, Task, TaskContext, TaskKey } from './task'; +import { abortable } from '@videojs/utils/events'; import { isUndefined } from '@videojs/utils/predicate'; import { StoreError } from './errors'; @@ -184,15 +185,7 @@ export class Queue { (this.tasks as TasksRecord)[name as keyof Tasks] = pendingTask; try { - if (abort.signal.aborted) { - throw abort.signal.reason || new StoreError('ABORTED'); - } - - const result = await handler({ input, signal: abort.signal }); - - if (abort.signal.aborted) { - throw abort.signal.reason || new StoreError('ABORTED'); - } + const result = await abortable(handler({ input, signal: abort.signal }), abort.signal); resolve(result); diff --git a/packages/store/src/core/store.ts b/packages/store/src/core/store.ts index 43fb1463..50d48708 100644 --- a/packages/store/src/core/store.ts +++ b/packages/store/src/core/store.ts @@ -10,6 +10,7 @@ import type { import type { Reactive } from './state'; import type { PendingTask, Task, TaskContext } from './task'; +import { abortable } from '@videojs/utils/events'; import { isNull } from '@videojs/utils/predicate'; import { StoreError } from './errors'; @@ -242,11 +243,7 @@ export class Store[] = AnySlice[ } for (const guard of config.guard) { - if (signal.aborted) { - throw new StoreError('ABORTED'); - } - - const result = await guard({ target, signal }); + const result = await abortable(Promise.resolve(guard({ target, signal })), signal); if (!result) { throw new StoreError('REJECTED'); diff --git a/packages/utils/src/events/abort.ts b/packages/utils/src/events/abort.ts new file mode 100644 index 00000000..fee91275 --- /dev/null +++ b/packages/utils/src/events/abort.ts @@ -0,0 +1,21 @@ +/** + * Race a promise against an abort signal. Rejects immediately if the signal + * is already aborted or becomes aborted before the promise settles. + */ +export function abortable(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(signal.reason); + } + + let onAbort: () => void; + + return Promise.race([ + promise, + new Promise((_, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + }), + ]).finally(() => { + signal.removeEventListener('abort', onAbort); + }); +} diff --git a/packages/utils/src/events/index.ts b/packages/utils/src/events/index.ts index 5d763bd4..af7ebb6d 100644 --- a/packages/utils/src/events/index.ts +++ b/packages/utils/src/events/index.ts @@ -1,2 +1,3 @@ +export * from './abort'; export * from './disposer'; export * from './event-like'; diff --git a/packages/utils/src/events/tests/abort.test.ts b/packages/utils/src/events/tests/abort.test.ts new file mode 100644 index 00000000..d1a8e557 --- /dev/null +++ b/packages/utils/src/events/tests/abort.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { abortable } from '../abort'; + +describe('abortable', () => { + it('resolves when promise resolves before abort', async () => { + const controller = new AbortController(); + const result = await abortable(Promise.resolve('value'), controller.signal); + + expect(result).toBe('value'); + }); + + it('rejects when promise rejects before abort', async () => { + const controller = new AbortController(); + + await expect(abortable(Promise.reject(new Error('fail')), controller.signal)).rejects.toThrow('fail'); + }); + + it('rejects immediately if signal already aborted', async () => { + const controller = new AbortController(); + const reason = new Error('aborted'); + + controller.abort(reason); + + await expect(abortable(Promise.resolve('value'), controller.signal)).rejects.toBe(reason); + }); + + it('rejects when signal aborts before promise settles', async () => { + const controller = new AbortController(); + const reason = new Error('aborted'); + const neverResolves = new Promise(() => {}); + + const promise = abortable(neverResolves, controller.signal); + + controller.abort(reason); + + await expect(promise).rejects.toBe(reason); + }); + + it('cleans up abort listener after promise resolves', async () => { + const controller = new AbortController(); + const removeEventListenerSpy = vi.spyOn(controller.signal, 'removeEventListener'); + + await abortable(Promise.resolve('value'), controller.signal); + + expect(removeEventListenerSpy).toHaveBeenCalled(); + }); + + it('cleans up abort listener after promise rejects', async () => { + const controller = new AbortController(); + const removeEventListenerSpy = vi.spyOn(controller.signal, 'removeEventListener'); + + await abortable(Promise.reject(new Error('fail')), controller.signal).catch(() => {}); + + expect(removeEventListenerSpy).toHaveBeenCalled(); + }); + + it('cleans up abort listener after abort', async () => { + const controller = new AbortController(); + const removeEventListenerSpy = vi.spyOn(controller.signal, 'removeEventListener'); + const neverResolves = new Promise(() => {}); + + const promise = abortable(neverResolves, controller.signal); + + controller.abort(new Error('aborted')); + + await promise.catch(() => {}); + + expect(removeEventListenerSpy).toHaveBeenCalled(); + }); +});