refactor(store): replace signal/abort with signals namespace (#453)

This commit is contained in:
rahim
2026-02-04 20:27:44 +11:00
committed by GitHub
parent 01c3f891cf
commit d8f15195e3
11 changed files with 383 additions and 130 deletions
@@ -4,11 +4,11 @@ import type { SourceState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
export const sourceFeature = definePlayerFeature({
state: ({ target, abort }): SourceState => ({
state: ({ target, signals }): SourceState => ({
source: null,
canPlay: false,
loadSource(src: string) {
abort(); // Cancel pending operations (e.g., seek)
signals.clear(); // Cancel pending operations (e.g., seek)
const { media } = target();
media.src = src;
+20 -29
View File
@@ -3,40 +3,31 @@ import { noop } from '@videojs/utils/function';
import type { TimeState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
import { hasMetadata } from '../../media/predicate';
import { signalKeys } from '../signal-keys';
export const timeFeature = definePlayerFeature({
state: ({ target, signal }): TimeState => {
let abort: AbortController | null = null;
state: ({ target, signals }): TimeState => ({
currentTime: 0,
duration: 0,
seeking: false,
async seek(time: number) {
const { media } = target(),
signal = signals.supersede(signalKeys.seek);
const supersede = () => {
abort?.abort();
abort = new AbortController();
return AbortSignal.any([signal(), abort.signal]);
};
// If metadata isn't loaded, wait for it before seeking to avoid errors.
if (!hasMetadata(media)) {
const loaded = await onEvent(media, 'loadedmetadata', { signal }).catch(() => false);
if (!loaded) return media.currentTime;
}
return {
currentTime: 0,
duration: 0,
seeking: false,
async seek(time: number) {
const { media } = target(),
signal = supersede();
// Perform the seek and wait for it to complete.
const clampedTime = Math.max(0, Math.min(time, media.duration || Infinity));
media.currentTime = clampedTime;
await onEvent(media, 'seeked', { signal }).catch(noop);
// If metadata isn't loaded, wait for it before seeking to avoid errors.
if (!hasMetadata(media)) {
const loaded = await onEvent(media, 'loadedmetadata', { signal }).catch(() => false);
if (!loaded) return media.currentTime;
}
// Perform the seek and wait for it to complete.
const clampedTime = Math.max(0, Math.min(time, media.duration || Infinity));
media.currentTime = clampedTime;
await onEvent(media, 'seeked', { signal }).catch(noop);
return media.currentTime;
},
};
},
return media.currentTime;
},
}),
attach({ target, signal, set }) {
const { media } = target;
@@ -0,0 +1,3 @@
export const signalKeys = {
seek: Symbol.for('@videojs/seek'),
} as const;
+43 -9
View File
@@ -227,15 +227,44 @@ const unsubscribe = store.subscribe(() => {
Mutations are auto-batched—multiple changes in the same tick trigger only one notification.
## Cancellation Signals
Use `signals` to manage cancellation for async operations. The store provides a `Signals` instance that tracks the attach lifecycle and supports keyed cancellation for superseding work.
```ts
state: ({ target, signals }) => ({
// Supersede pattern: new seek cancels previous seek
async seek(time: number) {
const signal = signals.supersede(signalKeys.seek);
// ...
},
// Cancel all pending operations (e.g., when loading new source)
loadSource(src: string) {
signals.clear();
// ...
},
}),
```
**API:**
| Method | Description |
|--------|-------------|
| `signals.base` | Attach-scoped signal. Aborts on detach or reattach. |
| `signals.supersede(key)` | Returns signal that aborts when same key is superseded or base aborts. |
| `signals.clear()` | Aborts all keyed signals, leaving base intact. |
Define shared keys for cross-slice coordination:
```ts
export const signalKeys = {
seek: Symbol.for('@videojs/seek'),
} as const;
```
## Error Handling
All store errors include a `code` for programmatic handling:
| Code | Description |
| ------------ | ---------------------------- |
| `DESTROYED` | Store destroyed |
| `NO_TARGET` | No target attached |
Handle errors locally via `try/catch`, or globally via `onError`:
```ts
@@ -264,9 +293,14 @@ try {
}
```
## Advanced
All store errors include a `code` for programmatic handling:
### State Primitives
| Code | Description |
| ------------ | ---------------------------- |
| `DESTROYED` | Store destroyed |
| `NO_TARGET` | No target attached |
## State Primitives
The store uses explicit state containers internally. You can use these primitives directly:
+1
View File
@@ -4,6 +4,7 @@ export * from './errors';
export { createSelector } from './selector';
export type { Comparator, Selector } from './shallow-equal';
export { shallowEqual } from './shallow-equal';
export * from './signals';
export * from './slice';
export * from './state';
export * from './store';
+2 -2
View File
@@ -1,11 +1,11 @@
import { pick } from '@videojs/utils/object';
import { throwNoTargetError } from './errors';
import { Signals } from './signals';
import type { AnySlice, InferSliceState, StateContext } from './slice';
const stateContext: StateContext<unknown> = {
target: throwNoTargetError,
signal: throwNoTargetError,
abort: throwNoTargetError,
signals: new Signals(),
};
/**
+34
View File
@@ -0,0 +1,34 @@
export type SignalKey = PropertyKey;
export class Signals {
#base = new AbortController();
#keys = new Map<SignalKey, AbortController>();
/** The attach-scoped signal. Aborts on detach or reattach. */
get base(): AbortSignal {
return this.#base.signal;
}
/** Clears all keyed signals, leaving base intact. */
clear(): void {
for (const controller of this.#keys.values()) {
controller.abort();
}
this.#keys.clear();
}
/** Resets base and clears all keyed signals. */
reset(): void {
this.clear();
this.#base.abort();
this.#base = new AbortController();
}
/** Creates a new signal for the key, superseding any previous signal. */
supersede(key: SignalKey): AbortSignal {
this.#keys.get(key)?.abort();
const controller = new AbortController();
this.#keys.set(key, controller);
return AbortSignal.any([this.#base.signal, controller.signal]);
}
}
+12 -4
View File
@@ -1,4 +1,5 @@
import type { Simplify, UnionToIntersection } from '@videojs/utils/types';
import type { Signals } from './signals';
import type { UnknownState } from './state';
// ----------------------------------------
@@ -28,10 +29,17 @@ export interface AttachContext<Target, State> {
export interface StateContext<Target> {
/** Returns the current target. Throws if not attached. */
target: () => Target;
/** Returns a signal that aborts on detach or when `abort()` is called. Throws if not attached. */
signal: () => AbortSignal;
/** Aborts the current signal and creates a new one. Use to cancel pending operations. */
abort: () => void;
/**
* Cancellation signals for async operations.
*
* - `signals.base` Aborts on detach or reattach. Use for cleanup.
* - `signals.supersede(key)` Returns a signal that aborts when the same key
* is superseded or when base aborts. Use for operations that should cancel
* previous in-flight work (e.g., seek superseding seek).
* - `signals.clear()` Aborts all keyed signals. Use when starting fresh
* (e.g., loading a new source cancels pending seeks).
*/
signals: Signals;
}
// ----------------------------------------
+8 -19
View File
@@ -1,6 +1,7 @@
import { isNull, isObject } from '@videojs/utils/predicate';
import type { StoreCallbacks } from './config';
import { throwDestroyedError, throwNoTargetError } from './errors';
import { Signals } from './signals';
import type { AttachContext, Slice, StateContext } from './slice';
import type { StateChange, SubscribeOptions, UnknownState, WritableState } from './state';
import { createState } from './state';
@@ -19,10 +20,9 @@ export function createStore<Target = unknown>(): <State>(
// Closure state
let target: Target | null = null;
let destroyed = false;
let attachAbort: AbortController | null = null;
let stateAbort = new AbortController();
const setupAbort = new AbortController();
const signals = new Signals();
// Reactive state - initialized after building slice state
let state: WritableState<State>;
@@ -37,14 +37,7 @@ export function createStore<Target = unknown>(): <State>(
validate();
return target!;
},
signal: () => {
validate();
return AbortSignal.any([attachAbort!.signal, stateAbort.signal]);
},
abort: () => {
stateAbort.abort();
stateAbort = new AbortController();
},
signals,
} satisfies StateContext<Target>);
state = createState(initialState);
@@ -83,15 +76,14 @@ export function createStore<Target = unknown>(): <State>(
function attach(newTarget: Target): () => void {
if (destroyed) throwDestroyedError();
attachAbort?.abort();
// Reset signals for new attachment (also cleans up previous if reattaching)
signals.reset();
target = newTarget;
attachAbort = new AbortController();
const signal = attachAbort.signal;
// Create attach context
const attachContext: AttachContext<Target, State> = {
target: newTarget,
signal,
signal: signals.base,
get: () => state.current,
set: (partial) => state.patch(partial),
reportError,
@@ -113,7 +105,7 @@ export function createStore<Target = unknown>(): <State>(
options.onAttach?.({
store,
target: newTarget,
signal,
signal: signals.base,
});
} catch (error) {
reportError(error);
@@ -124,10 +116,7 @@ export function createStore<Target = unknown>(): <State>(
function detach(): void {
if (isNull(target)) return;
stateAbort.abort();
stateAbort = new AbortController();
attachAbort?.abort();
attachAbort = null;
signals.reset();
target = null;
state.patch(initialState);
}
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import { Signals } from '../signals';
describe('Signals', () => {
describe('base', () => {
it('returns an AbortSignal', () => {
const signals = new Signals();
expect(signals.base).toBeInstanceOf(AbortSignal);
});
it('is not aborted initially', () => {
const signals = new Signals();
expect(signals.base.aborted).toBe(false);
});
it('is aborted after reset()', () => {
const signals = new Signals();
const base = signals.base;
signals.reset();
expect(base.aborted).toBe(true);
});
it('returns new signal after reset()', () => {
const signals = new Signals();
const base1 = signals.base;
signals.reset();
const base2 = signals.base;
expect(base1).not.toBe(base2);
expect(base2.aborted).toBe(false);
});
it('is not aborted after clear()', () => {
const signals = new Signals();
const base = signals.base;
signals.clear();
expect(base.aborted).toBe(false);
});
});
describe('clear', () => {
it('aborts keyed signals', () => {
const signals = new Signals();
const signal = signals.supersede('test');
signals.clear();
expect(signal.aborted).toBe(true);
});
it('does not abort base', () => {
const signals = new Signals();
const base = signals.base;
signals.supersede('test');
signals.clear();
expect(base.aborted).toBe(false);
});
it('clears all keyed signals', () => {
const signals = new Signals();
const signal1 = signals.supersede('key1');
const signal2 = signals.supersede('key2');
signals.clear();
expect(signal1.aborted).toBe(true);
expect(signal2.aborted).toBe(true);
});
});
describe('reset', () => {
it('aborts base signal', () => {
const signals = new Signals();
const base = signals.base;
signals.reset();
expect(base.aborted).toBe(true);
});
it('aborts keyed signals', () => {
const signals = new Signals();
const signal = signals.supersede('test');
signals.reset();
expect(signal.aborted).toBe(true);
});
it('creates new base signal', () => {
const signals = new Signals();
const base1 = signals.base;
signals.reset();
const base2 = signals.base;
expect(base1).not.toBe(base2);
expect(base2.aborted).toBe(false);
});
});
describe('supersede', () => {
it('returns an AbortSignal', () => {
const signals = new Signals();
const signal = signals.supersede('test');
expect(signal).toBeInstanceOf(AbortSignal);
});
it('is not aborted initially', () => {
const signals = new Signals();
const signal = signals.supersede('test');
expect(signal.aborted).toBe(false);
});
it('aborts when base is reset', () => {
const signals = new Signals();
const signal = signals.supersede('test');
signals.reset();
expect(signal.aborted).toBe(true);
});
it('aborts previous signal for same key', () => {
const signals = new Signals();
const signal1 = signals.supersede('seek');
const signal2 = signals.supersede('seek');
expect(signal1.aborted).toBe(true);
expect(signal2.aborted).toBe(false);
});
it('does not abort signals with different keys', () => {
const signals = new Signals();
const signal1 = signals.supersede('key1');
const signal2 = signals.supersede('key2');
expect(signal1.aborted).toBe(false);
expect(signal2.aborted).toBe(false);
});
it('supports symbol keys', () => {
const signals = new Signals();
const key = Symbol('test');
const signal1 = signals.supersede(key);
const signal2 = signals.supersede(key);
expect(signal1.aborted).toBe(true);
expect(signal2.aborted).toBe(false);
});
it('allows reusing key after clear()', () => {
const signals = new Signals();
const signal1 = signals.supersede('test');
signals.clear();
const signal2 = signals.supersede('test');
expect(signal1.aborted).toBe(true);
expect(signal2.aborted).toBe(false);
});
});
});
+85 -65
View File
@@ -262,46 +262,34 @@ describe('store', () => {
});
});
describe('signal and abort', () => {
it('signal() throws when not attached', () => {
describe('signals', () => {
it('signals.base returns AbortSignal', () => {
const slice = defineSlice<MockMedia>()({
state: ({ signal }) => ({
getSignal: () => signal(),
}),
});
const store = createStore<MockMedia>()(slice);
expect(() => store.getSignal()).toThrow();
});
it('signal() returns AbortSignal when attached', () => {
const slice = defineSlice<MockMedia>()({
state: ({ signal }) => ({
getSignal: () => signal(),
state: ({ signals }) => ({
getBase: () => signals.base,
}),
});
const store = createStore<MockMedia>()(slice);
store.attach(new MockMedia());
const sig = store.getSignal();
const sig = store.getBase();
expect(sig).toBeInstanceOf(AbortSignal);
expect(sig.aborted).toBe(false);
});
it('signal aborts on detach', () => {
it('signals.base aborts on detach', () => {
const slice = defineSlice<MockMedia>()({
state: ({ signal }) => ({
getSignal: () => signal(),
state: ({ signals }) => ({
getBase: () => signals.base,
}),
});
const store = createStore<MockMedia>()(slice);
const detach = store.attach(new MockMedia());
const sig = store.getSignal();
const sig = store.getBase();
expect(sig.aborted).toBe(false);
detach();
@@ -309,62 +297,94 @@ describe('store', () => {
expect(sig.aborted).toBe(true);
});
it('abort() aborts current signal', () => {
it('signals.base aborts on reattach', () => {
const slice = defineSlice<MockMedia>()({
state: ({ signal, abort }) => ({
getSignal: () => signal(),
abort: () => abort(),
state: ({ signals }) => ({
getBase: () => signals.base,
}),
});
const store = createStore<MockMedia>()(slice);
store.attach(new MockMedia());
const sig1 = store.getSignal();
expect(sig1.aborted).toBe(false);
store.abort();
expect(sig1.aborted).toBe(true);
});
it('abort() creates new signal for subsequent operations', () => {
const slice = defineSlice<MockMedia>()({
state: ({ signal, abort }) => ({
getSignal: () => signal(),
abort: () => abort(),
}),
});
const store = createStore<MockMedia>()(slice);
store.attach(new MockMedia());
const sig1 = store.getSignal();
store.abort();
const sig2 = store.getSignal();
expect(sig1.aborted).toBe(true);
expect(sig2.aborted).toBe(false);
expect(sig1).not.toBe(sig2);
});
it('signal aborts on reattach', () => {
const slice = defineSlice<MockMedia>()({
state: ({ signal }) => ({
getSignal: () => signal(),
}),
});
const store = createStore<MockMedia>()(slice);
store.attach(new MockMedia());
const sig = store.getSignal();
const sig = store.getBase();
expect(sig.aborted).toBe(false);
store.attach(new MockMedia()); // Reattach
expect(sig.aborted).toBe(true);
});
it('signals.supersede() returns AbortSignal combined with base', () => {
const slice = defineSlice<MockMedia>()({
state: ({ signals }) => ({
supersede: (key: string) => signals.supersede(key),
}),
});
const store = createStore<MockMedia>()(slice);
store.attach(new MockMedia());
const sig = store.supersede('test');
expect(sig).toBeInstanceOf(AbortSignal);
expect(sig.aborted).toBe(false);
});
it('signals.supersede() aborts previous signal for same key', () => {
const slice = defineSlice<MockMedia>()({
state: ({ signals }) => ({
supersede: (key: string) => signals.supersede(key),
}),
});
const store = createStore<MockMedia>()(slice);
store.attach(new MockMedia());
const sig1 = store.supersede('seek');
const sig2 = store.supersede('seek');
expect(sig1.aborted).toBe(true);
expect(sig2.aborted).toBe(false);
});
it('signals.supersede() aborts on detach', () => {
const slice = defineSlice<MockMedia>()({
state: ({ signals }) => ({
supersede: (key: string) => signals.supersede(key),
}),
});
const store = createStore<MockMedia>()(slice);
const detach = store.attach(new MockMedia());
const sig = store.supersede('test');
expect(sig.aborted).toBe(false);
detach();
expect(sig.aborted).toBe(true);
});
it('signals.clear() aborts keyed signals but not base', () => {
const slice = defineSlice<MockMedia>()({
state: ({ signals }) => ({
getBase: () => signals.base,
supersede: (key: string) => signals.supersede(key),
clear: () => signals.clear(),
}),
});
const store = createStore<MockMedia>()(slice);
store.attach(new MockMedia());
const base = store.getBase();
const keyed = store.supersede('test');
store.clear();
expect(base.aborted).toBe(false);
expect(keyed.aborted).toBe(true);
});
});
});