fix(store): guard abort on request supersession (#313)

This commit is contained in:
rahim
2026-01-20 01:58:52 +11:00
committed by GitHub
parent f25ad6c924
commit e643ee1f08
5 changed files with 97 additions and 14 deletions
+2 -9
View File
@@ -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<Tasks extends TaskRecord = DefaultTaskRecord> {
(this.tasks as TasksRecord<Tasks>)[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);
+2 -5
View File
@@ -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<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
}
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');
+21
View File
@@ -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<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
return Promise.reject(signal.reason);
}
let onAbort: () => void;
return Promise.race([
promise,
new Promise<never>((_, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener('abort', onAbort, { once: true });
}),
]).finally(() => {
signal.removeEventListener('abort', onAbort);
});
}
+1
View File
@@ -1,2 +1,3 @@
export * from './abort';
export * from './disposer';
export * from './event-like';
@@ -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();
});
});