fix(utils): polyfill AbortSignal.any for Chromium ≤115 (#1142)

This commit is contained in:
rahim
2026-03-26 13:47:48 -07:00
committed by GitHub
parent 9142841d34
commit c3641c888c
4 changed files with 162 additions and 4 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
import { anyAbortSignal } from '@videojs/utils/events';
import { generateId } from './utils/generate-id';
// =============================================================================
@@ -76,7 +77,7 @@ export class Task<TValue = void, TError = unknown> implements TaskLike<TValue, T
const rawId = config?.id;
this.id = typeof rawId === 'function' ? rawId() : (rawId ?? generateId());
this.#signal = config?.signal
? AbortSignal.any([this.#abortController.signal, config.signal])
? anyAbortSignal([this.#abortController.signal, config.signal])
: this.#abortController.signal;
}
@@ -1,3 +1,5 @@
import { anyAbortSignal } from '@videojs/utils/events';
export type SignalKey = PropertyKey;
export class AbortControllerRegistry {
@@ -29,6 +31,6 @@ export class AbortControllerRegistry {
this.#keys.get(key)?.abort();
const controller = new AbortController();
this.#keys.set(key, controller);
return AbortSignal.any([this.#base.signal, controller.signal]);
return anyAbortSignal([this.#base.signal, controller.signal]);
}
}
+26
View File
@@ -1,3 +1,29 @@
/**
* Compose multiple abort signals into one that aborts when **any** input fires.
* Uses native `AbortSignal.any` when available, otherwise falls back to a
* manual `AbortController` composition for Chromium ≤115 and similar runtimes.
*/
export function anyAbortSignal(signals: AbortSignal[]): AbortSignal {
if ('any' in AbortSignal) {
return AbortSignal.any(signals);
}
const controller = new AbortController();
for (const signal of signals) {
if (signal.aborted) {
controller.abort(signal.reason);
return controller.signal;
}
signal.addEventListener('abort', () => controller.abort(signal.reason), {
signal: controller.signal,
});
}
return controller.signal;
}
/**
* Race a promise against an abort signal. Rejects immediately if the signal
* is already aborted or becomes aborted before the promise settles.
+131 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { abortable } from '../abort';
import { abortable, anyAbortSignal } from '../abort';
describe('abortable', () => {
it('resolves when promise resolves before abort', async () => {
@@ -69,3 +69,132 @@ describe('abortable', () => {
expect(removeEventListenerSpy).toHaveBeenCalled();
});
});
describe('anyAbortSignal', () => {
it('returns an AbortSignal', () => {
const a = new AbortController();
const b = new AbortController();
const signal = anyAbortSignal([a.signal, b.signal]);
expect(signal).toBeInstanceOf(AbortSignal);
});
it('is not aborted initially when no input is aborted', () => {
const a = new AbortController();
const b = new AbortController();
const signal = anyAbortSignal([a.signal, b.signal]);
expect(signal.aborted).toBe(false);
});
it('aborts immediately if the first input signal is already aborted', () => {
const a = new AbortController();
const b = new AbortController();
const reason = new Error('already aborted');
a.abort(reason);
const signal = anyAbortSignal([a.signal, b.signal]);
expect(signal.aborted).toBe(true);
expect(signal.reason).toBe(reason);
});
it('aborts immediately if the second input signal is already aborted', () => {
const a = new AbortController();
const b = new AbortController();
const reason = new Error('already aborted');
b.abort(reason);
const signal = anyAbortSignal([a.signal, b.signal]);
expect(signal.aborted).toBe(true);
expect(signal.reason).toBe(reason);
});
it('aborts when the first input signal fires', () => {
const a = new AbortController();
const b = new AbortController();
const signal = anyAbortSignal([a.signal, b.signal]);
const reason = new Error('a aborted');
a.abort(reason);
expect(signal.aborted).toBe(true);
expect(signal.reason).toBe(reason);
});
it('aborts when the second input signal fires', () => {
const a = new AbortController();
const b = new AbortController();
const signal = anyAbortSignal([a.signal, b.signal]);
const reason = new Error('b aborted');
b.abort(reason);
expect(signal.aborted).toBe(true);
expect(signal.reason).toBe(reason);
});
it('propagates the reason from the triggering signal', () => {
const a = new AbortController();
const b = new AbortController();
const signal = anyAbortSignal([a.signal, b.signal]);
const reason = 'custom reason';
a.abort(reason);
expect(signal.reason).toBe(reason);
});
it('works with more than two signals', () => {
const a = new AbortController();
const b = new AbortController();
const c = new AbortController();
const signal = anyAbortSignal([a.signal, b.signal, c.signal]);
const reason = new Error('c aborted');
c.abort(reason);
expect(signal.aborted).toBe(true);
expect(signal.reason).toBe(reason);
});
describe('fallback path', () => {
const nativeAny = AbortSignal.any;
beforeEach(() => {
// @ts-expect-error -- removing native to test fallback
delete AbortSignal.any;
});
afterEach(() => {
AbortSignal.any = nativeAny;
});
it('works without native AbortSignal.any', () => {
const a = new AbortController();
const b = new AbortController();
const signal = anyAbortSignal([a.signal, b.signal]);
expect(signal.aborted).toBe(false);
a.abort(new Error('fallback'));
expect(signal.aborted).toBe(true);
expect(signal.reason).toEqual(new Error('fallback'));
});
it('aborts immediately if input is already aborted (fallback)', () => {
const a = new AbortController();
a.abort(new Error('pre-aborted'));
const signal = anyAbortSignal([a.signal]);
expect(signal.aborted).toBe(true);
expect(signal.reason).toEqual(new Error('pre-aborted'));
});
});
});