refactor(store): remove queue and task system (#382)

This commit is contained in:
rahim
2026-02-03 15:06:06 +11:00
committed by GitHub
parent 8ed455c20a
commit 9fa172e8a4
35 changed files with 419 additions and 1620 deletions
-15
View File
@@ -1,19 +1,9 @@
import type { TaskKey } from './queue';
import type { RequestMeta } from './request';
import type { Store } from './store';
export interface PendingTask {
key: TaskKey;
meta: RequestMeta | null;
startedAt: number;
}
export interface StoreCallbacks<Target, State> {
onSetup?: (ctx: StoreSetupContext<Target, State>) => void;
onAttach?: (ctx: StoreAttachContext<Target, State>) => void;
onError?: (ctx: StoreErrorContext<Target, State>) => void;
onTaskStart?: (ctx: StoreTaskContext) => void;
onTaskEnd?: (ctx: StoreTaskContext & { error?: unknown }) => void;
}
export interface StoreSetupContext<Target, State> {
@@ -31,8 +21,3 @@ export interface StoreErrorContext<Target, State> {
store: Store<Target, State>;
error: unknown;
}
export interface StoreTaskContext {
key: TaskKey;
meta: RequestMeta | null;
}
+10 -23
View File
@@ -1,29 +1,8 @@
/**
* Error codes for store operations.
*
* @example
* ```ts
* if (isStoreError(error)) {
* switch (error.code) {
* case 'SUPERSEDED':
* // Request was replaced by another - expected behavior
* break;
* case 'ABORTED':
* // Request was aborted
* break;
* }
* }
* ```
*/
export type StoreErrorCode =
/** Request was aborted via AbortSignal. */
| 'ABORTED'
/** Store or queue was destroyed. */
/** Store was destroyed. */
| 'DESTROYED'
/** No target is attached to the store. */
| 'NO_TARGET'
/** Request was replaced by a newer request with the same key. */
| 'SUPERSEDED';
| 'NO_TARGET';
export interface StoreErrorOptions {
cause?: unknown;
@@ -45,3 +24,11 @@ export class StoreError extends Error {
export function isStoreError(error: unknown): error is StoreError {
return error instanceof StoreError;
}
export function throwNoTargetError(): never {
throw new StoreError('NO_TARGET');
}
export function throwDestroyedError(): never {
throw new StoreError('DESTROYED');
}
-3
View File
@@ -1,9 +1,6 @@
export { combine } from './combine';
export * from './config';
export * from './errors';
export type { TaskKey, TaskMode } from './queue';
export { CANCEL_ALL } from './queue';
export * from './request';
export { createSelector } from './selector';
export type { Comparator, Selector } from './shallow-equal';
export { shallowEqual } from './shallow-equal';
-83
View File
@@ -1,83 +0,0 @@
import { abortable } from '@videojs/utils/events';
import { StoreError } from './errors';
/** Cancel all pending tasks (nuclear reset). */
export const CANCEL_ALL = Symbol.for('@videojs/cancel-all');
// ----------------------------------------
// Types
// ----------------------------------------
export type TaskKey = string | symbol;
export type TaskMode = 'exclusive' | 'shared';
export interface QueueTask<Output = unknown> {
key: TaskKey;
mode?: TaskMode;
handler: (ctx: { signal: AbortSignal }) => Promise<Output>;
}
// ----------------------------------------
// Implementation
// ----------------------------------------
export class Queue {
#pending = new Map<TaskKey, AbortController>();
#shared = new Map<TaskKey, Promise<unknown>>();
#destroyed = false;
get destroyed(): boolean {
return this.#destroyed;
}
enqueue<Output>({ key, mode = 'exclusive', handler }: QueueTask<Output>): Promise<Output> {
if (this.#destroyed) {
return Promise.reject(new StoreError('DESTROYED'));
}
// Shared mode: join existing
if (mode === 'shared') {
const existing = this.#shared.get(key);
if (existing) return existing as Promise<Output>;
}
// Supersede pending with same key
this.#pending.get(key)?.abort(new StoreError('SUPERSEDED'));
const abort = new AbortController();
this.#pending.set(key, abort);
// Wrap with abortable so promise rejects on abort even if handler doesn't handle signal
const promise = abortable(handler({ signal: abort.signal }), abort.signal).finally(() => {
this.#pending.delete(key);
this.#shared.delete(key);
});
if (mode === 'shared') {
this.#shared.set(key, promise);
}
return promise;
}
abort(key?: TaskKey): void {
if (key !== undefined) {
this.#pending.get(key)?.abort(new StoreError('ABORTED'));
return;
}
const error = new StoreError('ABORTED');
for (const controller of this.#pending.values()) {
controller.abort(error);
}
}
destroy(): void {
if (this.#destroyed) return;
this.#destroyed = true;
this.abort();
this.#pending.clear();
this.#shared.clear();
}
}
-43
View File
@@ -1,43 +0,0 @@
import type { EventLike } from '@videojs/utils/events';
import { isObject } from '@videojs/utils/predicate';
export const REQUEST_META = Symbol.for('@videojs/request');
// ----------------------------------------
// Request Meta
// ----------------------------------------
export type RequestMetaInit<Context = unknown> = Omit<RequestMeta<Context>, typeof REQUEST_META>;
export interface RequestMeta<Context = unknown> {
[REQUEST_META]: true;
source?: string;
timestamp?: number;
reason?: string;
context?: Context | undefined;
}
export function createRequestMeta<Context = unknown>(init: RequestMetaInit<Context>): RequestMeta<Context> {
return {
[REQUEST_META]: true,
...init,
timestamp: init.timestamp ?? Date.now(),
};
}
export function isRequestMeta(value: unknown): value is RequestMeta {
return isObject(value) && REQUEST_META in value;
}
export function createRequestMetaFromEvent<Context = unknown>(
event: EventLike,
context?: Context
): RequestMeta<Context> {
return {
[REQUEST_META]: true,
source: event.isTrusted ? 'user' : 'system',
timestamp: event.timeStamp,
reason: event.type,
context,
};
}
+4 -7
View File
@@ -1,14 +1,11 @@
import { pick } from '@videojs/utils/object';
import { StoreError } from './errors';
import { throwNoTargetError } from './errors';
import type { AnySlice, InferSliceState, StateContext } from './slice';
const stateContext: StateContext<unknown> = {
task: () => {
throw new StoreError('NO_TARGET');
},
target: () => {
throw new StoreError('NO_TARGET');
},
target: throwNoTargetError,
signal: throwNoTargetError,
abort: throwNoTargetError,
};
/**
+5 -28
View File
@@ -1,33 +1,6 @@
import type { Simplify, UnionToIntersection } from '@videojs/utils/types';
import type { TaskKey, TaskMode } from './queue';
import type { RequestMeta } from './request';
import type { UnknownState } from './state';
// ----------------------------------------
// Task
// ----------------------------------------
export type Task<Target, State> = {
<Output>(handler: TaskHandler<Target, State, Output>): Promise<Awaited<Output>>;
<Output>(options: TaskOptions<Target, State, Output>): Promise<Awaited<Output>>;
};
export interface TaskOptions<Target, State, Output> {
key?: TaskKey;
mode?: TaskMode;
cancels?: TaskKey[];
handler: TaskHandler<Target, State, Output>;
}
export type TaskHandler<Target, State, Output> = (ctx: TaskContext<Target, State>) => Output;
export interface TaskContext<Target, State> {
target: Target;
signal: AbortSignal;
get: () => Readonly<State>;
meta: RequestMeta | null;
}
// ----------------------------------------
// Attach
// ----------------------------------------
@@ -53,8 +26,12 @@ export interface AttachContext<Target, State> {
// ----------------------------------------
export interface StateContext<Target> {
task: Task<Target, UnknownState>;
/** 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;
}
// ----------------------------------------
+23 -117
View File
@@ -1,11 +1,7 @@
import type { EventLike } from '@videojs/utils/events';
import { isFunction, isNull, isObject } from '@videojs/utils/predicate';
import type { PendingTask, StoreCallbacks } from './config';
import { StoreError } from './errors';
import { CANCEL_ALL, Queue } from './queue';
import type { RequestMeta, RequestMetaInit } from './request';
import { createRequestMeta, createRequestMetaFromEvent } from './request';
import type { AttachContext, Slice, StateContext, TaskContext, TaskHandler, TaskOptions } from './slice';
import { isNull, isObject } from '@videojs/utils/predicate';
import type { StoreCallbacks } from './config';
import { throwDestroyedError, throwNoTargetError } from './errors';
import type { AttachContext, Slice, StateContext } from './slice';
import type { StateChange, UnknownState, WritableState } from './state';
import { createState } from './state';
@@ -24,19 +20,30 @@ export function createStore<Target = unknown>(): <State>(
let target: Target | null = null;
let destroyed = false;
let attachAbort: AbortController | null = null;
let stateAbort = new AbortController();
const setupAbort = new AbortController();
const queue = new Queue();
const pending: Record<string, PendingTask> = {};
// Reactive state - initialized after building slice state
let state: WritableState<State>;
function validate() {
if (destroyed) throwDestroyedError();
if (!target) throwNoTargetError();
}
const initialState = slice.state({
task: executeTask,
target: () => {
if (!target) throw new StoreError('NO_TARGET');
return target;
validate();
return target!;
},
signal: () => {
validate();
return AbortSignal.any([attachAbort!.signal, stateAbort.signal]);
},
abort: () => {
stateAbort.abort();
stateAbort = new AbortController();
},
} satisfies StateContext<Target>);
@@ -50,16 +57,12 @@ export function createStore<Target = unknown>(): <State>(
get destroyed() {
return destroyed;
},
get pending() {
return pending;
},
get state() {
return state.current;
},
attach,
destroy,
subscribe,
meta,
} as unknown as TargetStore;
for (const key of Object.keys(initialState as object)) {
@@ -69,24 +72,6 @@ export function createStore<Target = unknown>(): <State>(
});
}
// Proxy returned by meta() - wraps action calls to clear currentMeta after invocation
let currentMeta: RequestMeta | null = null;
const metaProxy = new Proxy(store, {
get(obj, prop) {
const value = Reflect.get(obj, prop);
if (!isFunction(value)) return value;
return (...args: unknown[]) => {
try {
return (value as (...args: unknown[]) => unknown)(...args);
} finally {
currentMeta = null;
}
};
},
});
try {
options.onSetup?.({ store, signal: setupAbort.signal });
} catch (error) {
@@ -96,7 +81,7 @@ export function createStore<Target = unknown>(): <State>(
return store;
function attach(newTarget: Target): () => void {
if (destroyed) throw new StoreError('DESTROYED');
if (destroyed) throwDestroyedError();
attachAbort?.abort();
target = newTarget;
@@ -139,10 +124,11 @@ export function createStore<Target = unknown>(): <State>(
function detach(): void {
if (isNull(target)) return;
stateAbort.abort();
stateAbort = new AbortController();
attachAbort?.abort();
attachAbort = null;
target = null;
queue.abort();
state.patch(initialState);
}
@@ -151,90 +137,12 @@ export function createStore<Target = unknown>(): <State>(
destroyed = true;
detach();
setupAbort.abort();
queue.destroy();
}
function subscribe(callback: StateChange): () => void {
return state.subscribe(callback);
}
function meta(eventOrMeta: EventLike | RequestMetaInit): TargetStore {
currentMeta =
'isTrusted' in eventOrMeta
? createRequestMetaFromEvent(eventOrMeta as EventLike)
: createRequestMeta(eventOrMeta as RequestMetaInit);
return metaProxy as TargetStore;
}
async function executeTask<Output>(handler: TaskHandler<Target, State, Output>): Promise<Awaited<Output>>;
async function executeTask<Output>(options: TaskOptions<Target, State, Output>): Promise<Awaited<Output>>;
async function executeTask<Output>(
handlerOrOptions: TaskHandler<Target, State, Output> | TaskOptions<Target, State, Output>
): Promise<Awaited<Output>> {
if (destroyed) throw new StoreError('DESTROYED');
const taskOptions: TaskOptions<Target, State, Output> = isFunction(handlerOrOptions)
? { handler: handlerOrOptions }
: handlerOrOptions;
const { key, mode = 'exclusive', cancels, handler } = taskOptions;
const taskMeta = currentMeta;
currentMeta = null;
if (cancels) {
for (const cancelKey of cancels) {
if (cancelKey === CANCEL_ALL) {
queue.abort();
} else {
queue.abort(cancelKey);
}
}
}
if (key) {
pending[key as string] = { key, meta: taskMeta, startedAt: Date.now() };
options.onTaskStart?.({ key, meta: taskMeta });
}
const queueHandler = async ({ signal }: { signal: AbortSignal }) => {
if (!target) throw new StoreError('NO_TARGET');
const ctx: TaskContext<Target, State> = {
target,
signal,
get: () => state.current,
meta: taskMeta,
};
return handler(ctx);
};
try {
const result = await queue.enqueue({
key: key ?? Symbol('@videojs/task'),
mode,
handler: queueHandler,
});
if (key) {
delete pending[key as string];
options.onTaskEnd?.({ key, meta: taskMeta });
}
return result as Awaited<Output>;
} catch (error) {
if (key) {
delete pending[key as string];
options.onTaskEnd?.({ key, meta: taskMeta, error });
}
reportError(error);
throw error;
}
}
function reportError(error: unknown): void {
if (options.onError) {
options.onError({ store, error });
@@ -257,12 +165,10 @@ export interface BaseStore<Target = unknown, State = UnknownState> {
[key: string]: unknown;
readonly target: Target | null;
readonly destroyed: boolean;
readonly pending: Readonly<Record<string, PendingTask>>;
readonly state: State;
attach(target: Target): () => void;
destroy(): void;
subscribe(callback: StateChange): () => void;
meta(eventOrMeta: EventLike | RequestMetaInit): Store<Target, State>;
}
export type Store<Target = unknown, State = UnknownState> = BaseStore<Target, State> & State;
+7 -7
View File
@@ -5,9 +5,9 @@ import { isStoreError, StoreError } from '../errors';
describe('errors', () => {
describe('storeError', () => {
it('creates error with code only', () => {
const error = new StoreError('ABORTED');
expect(error.code).toBe('ABORTED');
expect(error.message).toBe('ABORTED');
const error = new StoreError('DESTROYED');
expect(error.code).toBe('DESTROYED');
expect(error.message).toBe('DESTROYED');
expect(error.name).toBe('StoreError');
expect(error).toBeInstanceOf(Error);
});
@@ -20,8 +20,8 @@ describe('errors', () => {
it('supports cause for error chaining', () => {
const cause = new Error('original error');
const error = new StoreError('ABORTED', { cause });
expect(error.code).toBe('ABORTED');
const error = new StoreError('DESTROYED', { cause });
expect(error.code).toBe('DESTROYED');
expect(error.cause).toBe(cause);
});
@@ -36,8 +36,8 @@ describe('errors', () => {
describe('type guard', () => {
it('isStoreError identifies store errors', () => {
expect(isStoreError(new StoreError('ABORTED'))).toBe(true);
expect(isStoreError(new StoreError('SUPERSEDED'))).toBe(true);
expect(isStoreError(new StoreError('DESTROYED'))).toBe(true);
expect(isStoreError(new StoreError('NO_TARGET'))).toBe(true);
expect(isStoreError(new Error('regular'))).toBe(false);
expect(isStoreError(null)).toBe(false);
});
@@ -1,10 +1,9 @@
import { noop } from '@videojs/utils/function';
import { describe, expect, it } from 'vitest';
import { combine, createStore, defineSlice } from '../../index';
describe('store lifecycle integration', () => {
it('full lifecycle: create → attach → use → detach → destroy', async () => {
it('full lifecycle: create → attach → use → detach → destroy', () => {
const events: string[] = [];
class Target extends EventTarget {
@@ -12,14 +11,12 @@ describe('store lifecycle integration', () => {
}
const slice = defineSlice<Target>()({
state: ({ task }) => ({
state: ({ target }) => ({
count: 0,
increment() {
return task(({ target: t }) => {
t.value++;
t.dispatchEvent(new Event('change'));
events.push('increment');
});
target().value++;
target().dispatchEvent(new Event('change'));
events.push('increment');
},
}),
@@ -46,7 +43,7 @@ describe('store lifecycle integration', () => {
expect(events).toEqual(['setup', 'attach-slice', 'attach']);
expect(store.state.count).toBe(5);
await store.increment();
store.increment();
expect(store.state.count).toBe(6);
expect(events).toContain('increment');
@@ -59,235 +56,6 @@ describe('store lifecycle integration', () => {
});
});
describe('task coordination', () => {
it('cancels option aborts related tasks', async () => {
const events: string[] = [];
const slice = defineSlice<unknown>()({
state: ({ task }) => ({
loading: false,
load() {
return task({
key: 'load',
async handler({ signal }) {
events.push('load-start');
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
events.push('load-complete');
resolve('loaded');
}, 100);
signal.addEventListener('abort', () => {
clearTimeout(timeout);
events.push('load-aborted');
reject(new Error('aborted'));
});
});
},
});
},
stop() {
return task({
cancels: ['load'],
handler() {
events.push('stop');
},
});
},
}),
});
const store = createStore<unknown>()(slice, { onError: () => {} });
store.attach({});
const loadPromise = store.load();
await new Promise((r) => setTimeout(r, 10));
await store.stop();
await loadPromise.catch(() => {});
expect(events).toContain('load-start');
expect(events).toContain('load-aborted');
expect(events).toContain('stop');
expect(events).not.toContain('load-complete');
});
it('different keys enable parallel execution', async () => {
const completionOrder: number[] = [];
const slice = defineSlice<unknown>()({
state: ({ task }) => ({
fetching: false,
fetchTrack(id: number) {
return task({
key: `track-${id}`,
async handler() {
await new Promise((r) => setTimeout(r, 10 * id));
completionOrder.push(id);
return id;
},
});
},
}),
});
const store = createStore<unknown>()(slice);
store.attach({});
const [r3, r1, r2] = await Promise.all([store.fetchTrack(3), store.fetchTrack(1), store.fetchTrack(2)]);
expect(r1).toBe(1);
expect(r2).toBe(2);
expect(r3).toBe(3);
expect(completionOrder).toEqual([1, 2, 3]);
});
it('same key tasks supersede each other', async () => {
const executed: string[] = [];
const slice = defineSlice<unknown>()({
state: ({ task }) => ({
running: false,
action(name: string) {
return task({
key: 'shared',
async handler({ signal }) {
executed.push(`${name}-start`);
await new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, 50);
signal.addEventListener('abort', () => {
clearTimeout(timeout);
reject(signal.reason);
});
});
executed.push(`${name}-end`);
return name;
},
});
},
}),
});
const store = createStore<unknown>()(slice, { onError: () => {} });
store.attach({});
const p1 = store.action('first');
const p2 = store.action('second');
const p3 = store.action('third');
await expect(p1).rejects.toThrow();
await expect(p2).rejects.toThrow();
await expect(p3).resolves.toBe('third');
expect(executed).toContain('first-start');
expect(executed).toContain('second-start');
expect(executed).toContain('third-start');
expect(executed).toContain('third-end');
expect(executed).not.toContain('first-end');
expect(executed).not.toContain('second-end');
});
it('mode: shared allows multiple tasks to share fate', async () => {
let handlerCallCount = 0;
const slice = defineSlice<unknown>()({
state: ({ task }) => ({
playing: false,
play() {
return task({
key: 'playback',
mode: 'shared',
async handler() {
handlerCallCount++;
await new Promise((r) => setTimeout(r, 50));
return 'playing';
},
});
},
}),
});
const store = createStore<unknown>()(slice);
store.attach({});
const p1 = store.play();
const p2 = store.play();
const p3 = store.play();
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
expect(r1).toBe('playing');
expect(r2).toBe('playing');
expect(r3).toBe('playing');
expect(handlerCallCount).toBe(1);
});
it('mode: shared rejects all promises together on error', async () => {
const slice = defineSlice<unknown>()({
state: ({ task }) => ({
playing: false,
play() {
return task({
key: 'playback',
mode: 'shared',
async handler() {
await new Promise((r) => setTimeout(r, 20));
throw new Error('playback failed');
},
});
},
}),
});
const store = createStore<unknown>()(slice, { onError: () => {} });
store.attach({});
const p1 = store.play();
const p2 = store.play();
await expect(p1).rejects.toThrow('playback failed');
await expect(p2).rejects.toThrow('playback failed');
});
it('mode: shared allows new task after previous completes', async () => {
let callCount = 0;
const slice = defineSlice<unknown>()({
state: ({ task }) => ({
playing: false,
play() {
return task({
key: 'playback',
mode: 'shared',
async handler() {
callCount++;
await new Promise((r) => setTimeout(r, 10));
return `call-${callCount}`;
},
});
},
}),
});
const store = createStore<unknown>()(slice);
store.attach({});
const p1 = store.play();
const p2 = store.play();
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toBe('call-1');
expect(r2).toBe('call-1');
const p3 = store.play();
const r3 = await p3;
expect(r3).toBe('call-2');
expect(callCount).toBe(2);
});
});
describe('state syncing', () => {
it('multiple slices merge state correctly', () => {
const audioSlice = defineSlice<{ volume: number; rate: number }>()({
@@ -319,7 +87,7 @@ describe('state syncing', () => {
});
describe('immediate execution', () => {
it('task handler side effect triggers event and state sync', async () => {
it('action side effect triggers event and state sync', () => {
class MockMedia extends EventTarget {
paused = true;
play() {
@@ -329,12 +97,10 @@ describe('immediate execution', () => {
}
const playbackSlice = defineSlice<MockMedia>()({
state: ({ task }) => ({
state: ({ target }) => ({
paused: true,
play() {
return task(({ target }) => {
target.play();
});
target().play();
},
}),
@@ -358,109 +124,17 @@ describe('immediate execution', () => {
});
});
describe('meta tracing', () => {
it('store.meta() passes meta to task handlers', async () => {
let receivedMeta: unknown = null;
const slice = defineSlice<unknown>()({
state: ({ task }) => ({
playing: false,
play() {
return task({
key: 'playback',
handler({ meta }) {
receivedMeta = meta;
},
});
},
}),
});
const store = createStore<unknown>()(slice);
store.attach({});
await store.meta({ source: 'user', reason: 'button-click' }).play();
expect(receivedMeta).toMatchObject({
source: 'user',
reason: 'button-click',
});
});
it('onTaskStart and onTaskEnd callbacks fire', async () => {
const events: string[] = [];
const slice = defineSlice<unknown>()({
state: ({ task }) => ({
count: 0,
increment() {
return task({
key: 'increment',
async handler() {
await new Promise((r) => setTimeout(r, 10));
},
});
},
}),
});
const store = createStore<unknown>()(slice, {
onTaskStart: ({ key }) => events.push(`start:${String(key)}`),
onTaskEnd: ({ key, error }) => events.push(`end:${String(key)}${error ? ':error' : ''}`),
});
store.attach({});
await store.increment();
expect(events).toEqual(['start:increment', 'end:increment']);
});
it('pending tracks running tasks', async () => {
const slice = defineSlice<unknown>()({
state: ({ task }) => ({
loading: false,
load() {
return task({
key: 'load',
async handler() {
await new Promise((r) => setTimeout(r, 50));
},
});
},
}),
});
const store = createStore<unknown>()(slice);
store.attach({});
expect(store.pending.load).toBeUndefined();
const promise = store.load();
expect(store.pending.load).toBeDefined();
expect(store.pending.load?.key).toBe('load');
expect(store.pending.load?.startedAt).toBeTypeOf('number');
await promise;
expect(store.pending.load).toBeUndefined();
});
});
describe('sync actions', () => {
it('task handler allows sync mutations', async () => {
it('allows sync mutations on target', () => {
class Target {
volume = 1;
}
const slice = defineSlice<Target>()({
state: ({ task }) => ({
state: ({ target }) => ({
volume: 1,
setVolume(value: number) {
return task(({ target }) => {
target.volume = value;
});
target().volume = value;
},
}),
@@ -473,23 +147,23 @@ describe('sync actions', () => {
const targetInstance = new Target();
store.attach(targetInstance);
await store.setVolume(0.5);
store.setVolume(0.5);
expect(targetInstance.volume).toBe(0.5);
});
it('task throws when not attached', async () => {
it('throws when not attached', () => {
const slice = defineSlice<unknown>()({
state: ({ task }) => ({
state: ({ target }) => ({
value: 0,
doSomething() {
return task(() => {});
target(); // Will throw NO_TARGET
},
}),
});
const store = createStore<unknown>()(slice, { onError: noop });
const store = createStore<unknown>()(slice, { onError: () => {} });
await expect(store.doSomething()).rejects.toThrow('NO_TARGET');
expect(() => store.doSomething()).toThrow('NO_TARGET');
});
});
-376
View File
@@ -1,376 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { Queue } from '../queue';
describe('Queue', () => {
describe('enqueue', () => {
it('executes handler immediately', async () => {
const queue = new Queue();
const handler = vi.fn().mockResolvedValue('result');
const promise = queue.enqueue({
key: 'test',
handler,
});
expect(handler).toHaveBeenCalled();
await expect(promise).resolves.toBe('result');
});
it('passes signal to handler', async () => {
const queue = new Queue();
let receivedSignal: AbortSignal | undefined;
await queue.enqueue({
key: 'test',
handler: async ({ signal }) => {
receivedSignal = signal;
return 'result';
},
});
expect(receivedSignal).toBeInstanceOf(AbortSignal);
});
it('supersedes pending task with same key', async () => {
const queue = new Queue();
let aborted = false;
const first = queue.enqueue({
key: 'shared',
handler: async ({ signal }) => {
await new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, 1000);
signal.addEventListener('abort', () => {
clearTimeout(timeout);
aborted = true;
reject(signal.reason);
});
});
},
});
// Let first task start
await new Promise((r) => setTimeout(r, 10));
const second = queue.enqueue({
key: 'shared',
handler: async () => 'new result',
});
await expect(first).rejects.toMatchObject({ code: 'SUPERSEDED' });
await expect(second).resolves.toBe('new result');
expect(aborted).toBe(true);
});
it('runs tasks with different keys in parallel', async () => {
const queue = new Queue();
const results: string[] = [];
const task1 = queue.enqueue({
key: 'key-a',
handler: async () => {
results.push('a-start');
await new Promise((r) => setTimeout(r, 20));
results.push('a-end');
return 'a';
},
});
const task2 = queue.enqueue({
key: 'key-b',
handler: async () => {
results.push('b-start');
await new Promise((r) => setTimeout(r, 10));
results.push('b-end');
return 'b';
},
});
await Promise.all([task1, task2]);
expect(results).toEqual(['a-start', 'b-start', 'b-end', 'a-end']);
});
});
describe('mode', () => {
it('exclusive mode (default) supersedes same key', async () => {
const queue = new Queue();
const first = queue.enqueue({
key: 'k',
handler: async ({ signal }) => {
await new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason));
setTimeout(() => {}, 1000);
});
},
});
await new Promise((r) => setTimeout(r, 10));
const second = queue.enqueue({
key: 'k',
mode: 'exclusive',
handler: async () => 'second',
});
await expect(first).rejects.toMatchObject({ code: 'SUPERSEDED' });
await expect(second).resolves.toBe('second');
});
it('shared mode joins existing promise with same key', async () => {
const queue = new Queue();
let callCount = 0;
const handler = async () => {
callCount++;
await new Promise((r) => setTimeout(r, 50));
return 'result';
};
const first = queue.enqueue({ key: 'shared', mode: 'shared', handler });
const second = queue.enqueue({ key: 'shared', mode: 'shared', handler });
const [result1, result2] = await Promise.all([first, second]);
expect(callCount).toBe(1);
expect(result1).toBe('result');
expect(result2).toBe('result');
});
it('shared mode creates new task after first completes', async () => {
const queue = new Queue();
let callCount = 0;
const handler = async () => {
callCount++;
return `result-${callCount}`;
};
const first = await queue.enqueue({ key: 'shared', mode: 'shared', handler });
const second = await queue.enqueue({ key: 'shared', mode: 'shared', handler });
expect(callCount).toBe(2);
expect(first).toBe('result-1');
expect(second).toBe('result-2');
});
});
describe('abort', () => {
it('abort(key) aborts pending task with that key', async () => {
const queue = new Queue();
let aborted = false;
const promise = queue.enqueue({
key: 'test',
handler: async ({ signal }) => {
await new Promise((_, reject) => {
signal.addEventListener('abort', () => {
aborted = true;
reject(signal.reason);
});
setTimeout(() => {}, 1000);
});
},
});
await new Promise((r) => setTimeout(r, 10));
queue.abort('test');
await expect(promise).rejects.toMatchObject({ code: 'ABORTED' });
expect(aborted).toBe(true);
});
it('abort() without key aborts all pending tasks', async () => {
const queue = new Queue();
const abortedKeys: string[] = [];
const taskA = queue.enqueue({
key: 'a',
handler: async ({ signal }) => {
await new Promise((_, reject) => {
signal.addEventListener('abort', () => {
abortedKeys.push('a');
reject(signal.reason);
});
setTimeout(() => {}, 1000);
});
},
});
const taskB = queue.enqueue({
key: 'b',
handler: async ({ signal }) => {
await new Promise((_, reject) => {
signal.addEventListener('abort', () => {
abortedKeys.push('b');
reject(signal.reason);
});
setTimeout(() => {}, 1000);
});
},
});
await new Promise((r) => setTimeout(r, 10));
queue.abort();
await expect(taskA).rejects.toMatchObject({ code: 'ABORTED' });
await expect(taskB).rejects.toMatchObject({ code: 'ABORTED' });
expect(abortedKeys).toContain('a');
expect(abortedKeys).toContain('b');
});
it('abort(key) is no-op for non-existent key', () => {
const queue = new Queue();
// Should not throw
queue.abort('nonexistent');
});
});
describe('destroy', () => {
it('rejects enqueue after destroy', async () => {
const queue = new Queue();
queue.destroy();
await expect(queue.enqueue({ key: 'k', handler: vi.fn() })).rejects.toMatchObject({
code: 'DESTROYED',
});
});
it('sets destroyed flag', () => {
const queue = new Queue();
expect(queue.destroyed).toBe(false);
queue.destroy();
expect(queue.destroyed).toBe(true);
});
it('aborts all pending tasks on destroy', async () => {
const queue = new Queue();
const aborted = vi.fn();
const promise = queue.enqueue({
key: 'task',
handler: async ({ signal }) => {
signal.addEventListener('abort', aborted);
await new Promise((r) => setTimeout(r, 100));
},
});
await new Promise((r) => setTimeout(r, 10));
queue.destroy();
await expect(promise).rejects.toMatchObject({ code: 'ABORTED' });
expect(aborted).toHaveBeenCalled();
});
it('destroy is idempotent', () => {
const queue = new Queue();
queue.destroy();
queue.destroy(); // Should not throw
expect(queue.destroyed).toBe(true);
});
});
describe('cleanup', () => {
it('cleans up pending map after task completes', async () => {
const queue = new Queue();
await queue.enqueue({
key: 'test',
handler: async () => 'result',
});
// Enqueue same key should not supersede (no pending task exists)
const handler = vi.fn().mockResolvedValue('new');
await queue.enqueue({ key: 'test', handler });
expect(handler).toHaveBeenCalled();
});
it('cleans up pending map after task fails', async () => {
const queue = new Queue();
await queue
.enqueue({
key: 'test',
handler: async () => {
throw new Error('fail');
},
})
.catch(() => {});
// Enqueue same key should work (no pending task to supersede)
const handler = vi.fn().mockResolvedValue('new');
await queue.enqueue({ key: 'test', handler });
expect(handler).toHaveBeenCalled();
});
it('cleans up shared map after task completes', async () => {
const queue = new Queue();
let callCount = 0;
await queue.enqueue({
key: 'shared',
mode: 'shared',
handler: async () => {
callCount++;
return 'result';
},
});
// Second call should create new task since first completed
await queue.enqueue({
key: 'shared',
mode: 'shared',
handler: async () => {
callCount++;
return 'result2';
},
});
expect(callCount).toBe(2);
});
});
describe('symbol keys', () => {
it('supports symbol as key', async () => {
const queue = new Queue();
const key = Symbol('task');
const result = await queue.enqueue({
key,
handler: async () => 'result',
});
expect(result).toBe('result');
});
it('supersedes by symbol key', async () => {
const queue = new Queue();
const key = Symbol('task');
const first = queue.enqueue({
key,
handler: async ({ signal }) => {
await new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason));
setTimeout(() => {}, 1000);
});
},
});
await new Promise((r) => setTimeout(r, 10));
const second = queue.enqueue({
key,
handler: async () => 'new',
});
await expect(first).rejects.toMatchObject({ code: 'SUPERSEDED' });
await expect(second).resolves.toBe('new');
});
});
});
@@ -1,107 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRequestMeta, createRequestMetaFromEvent, isRequestMeta, REQUEST_META } from '../request';
describe('request', () => {
describe('meta', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
describe('createRequestMeta', () => {
it('creates meta with required fields', () => {
const meta = createRequestMeta({
source: 'user',
context: undefined,
});
expect(meta[REQUEST_META]).toBe(true);
expect(meta.source).toBe('user');
expect(meta.timestamp).toBe(Date.now());
});
it('preserves provided timestamp', () => {
const meta = createRequestMeta({
source: 'system',
timestamp: 12345,
context: undefined,
});
expect(meta.timestamp).toBe(12345);
});
it('includes optional reason', () => {
const meta = createRequestMeta({
source: 'user',
reason: 'button-click',
context: { buttonId: 'play' },
});
expect(meta.reason).toBe('button-click');
expect(meta.context).toEqual({ buttonId: 'play' });
});
});
describe('isRequestMeta', () => {
it('returns true for valid RequestMeta', () => {
const meta = createRequestMeta({ source: 'test', context: undefined });
expect(isRequestMeta(meta)).toBe(true);
});
it('returns false for plain objects', () => {
expect(isRequestMeta({ source: 'test' })).toBe(false);
expect(isRequestMeta({})).toBe(false);
});
it('returns false for non-objects', () => {
expect(isRequestMeta(null)).toBe(false);
expect(isRequestMeta(undefined)).toBe(false);
expect(isRequestMeta('string')).toBe(false);
expect(isRequestMeta(123)).toBe(false);
});
});
describe('createRequestMetaFromEvent', () => {
it('creates meta from trusted event', () => {
const event = {
type: 'click',
timeStamp: 1000,
isTrusted: true,
};
const meta = createRequestMetaFromEvent(event);
expect(meta[REQUEST_META]).toBe(true);
expect(meta.source).toBe('user');
expect(meta.timestamp).toBe(1000);
expect(meta.reason).toBe('click');
});
it('creates meta from synthetic event', () => {
const event = {
type: 'play',
timeStamp: 2000,
isTrusted: false,
};
const meta = createRequestMetaFromEvent(event);
expect(meta.source).toBe('system');
});
it('includes context', () => {
const event = { type: 'test', timeStamp: 0 };
const context = { extra: 'data' };
const meta = createRequestMetaFromEvent(event, context);
expect(meta.context).toEqual({ extra: 'data' });
});
});
});
});
@@ -8,14 +8,12 @@ interface MockMedia {
describe('createSelector', () => {
const volumeSlice = defineSlice<MockMedia>()({
state: ({ task }) => ({
state: ({ target }) => ({
volume: 1,
muted: false,
setVolume(value: number) {
return task(({ target }) => {
target.volume = value;
return value;
});
target().volume = value;
return value;
},
}),
});
@@ -29,7 +27,7 @@ describe('createSelector', () => {
it('selects slice state from store state', () => {
const selectVolume = createSelector(volumeSlice);
const state = { volume: 0.5, muted: true, setVolume: () => Promise.resolve(0.5) };
const state = { volume: 0.5, muted: true, setVolume: () => 0.5 };
const selected = selectVolume(state);
@@ -55,7 +53,7 @@ describe('createSelector', () => {
const state = {
volume: 0.75,
muted: false,
setVolume: () => Promise.resolve(0.75),
setVolume: () => 0.75,
paused: false,
ended: false,
};
@@ -76,7 +74,7 @@ describe('createSelector', () => {
it('returns stable references when state values are the same', () => {
const selectVolume = createSelector(volumeSlice);
const setVolume = () => Promise.resolve(1);
const setVolume = () => 1;
const state1 = { volume: 1, muted: false, setVolume };
const state2 = { volume: 1, muted: false, setVolume };
+9 -43
View File
@@ -9,15 +9,10 @@ describe('defineSlice', () => {
}
const slice = defineSlice<Target>()({
state: ({ task }) => ({
state: ({ target }) => ({
count: 0,
increment(amount: number) {
return task({
key: 'increment',
handler: ({ target }) => {
target.value += amount;
},
});
target().value += amount;
},
}),
@@ -30,7 +25,7 @@ describe('defineSlice', () => {
expect(slice.attach).toBeTypeOf('function');
});
it('factory receives task and target helpers', () => {
it('factory receives target helper', () => {
interface Target {
value: number;
}
@@ -44,21 +39,16 @@ describe('defineSlice', () => {
expect(factorySpy).not.toHaveBeenCalled();
});
it('allows sync actions using task handler', () => {
it('allows sync actions using target()', () => {
interface Target {
volume: number;
}
const slice = defineSlice<Target>()({
state: ({ task }) => ({
state: ({ target }) => ({
volume: 1,
setVolume(value: number) {
return task({
key: 'volume',
handler: ({ target }) => {
target.volume = value;
},
});
target().volume = value;
},
}),
});
@@ -66,40 +56,16 @@ describe('defineSlice', () => {
expect(slice.state).toBeTypeOf('function');
});
it('allows async actions using task()', () => {
it('allows async actions using target()', () => {
interface Target {
play: () => Promise<void>;
}
const slice = defineSlice<Target>()({
state: ({ task }) => ({
state: ({ target }) => ({
playing: false,
play() {
return task({
key: 'playback',
handler: ({ target }) => target.play(),
});
},
}),
});
expect(slice.state).toBeTypeOf('function');
});
it('supports task shorthand (fire-and-forget)', () => {
interface Target {
src: string;
load: () => void;
}
const slice = defineSlice<Target>()({
state: ({ task }) => ({
loading: false,
load(src: string) {
return task(({ target }) => {
target.src = src;
target.load();
});
return target().play();
},
}),
});
+128 -158
View File
@@ -16,20 +16,16 @@ describe('store', () => {
}
const audioSlice = defineSlice<MockMedia>()({
state: ({ task }) => ({
state: ({ target }) => ({
volume: 1,
muted: false,
setVolume(volume: number) {
return task(({ target }) => {
target.volume = volume;
target.dispatchEvent(new Event('volumechange'));
});
target().volume = volume;
target().dispatchEvent(new Event('volumechange'));
},
setMuted(muted: boolean) {
return task(({ target }) => {
target.muted = muted;
target.dispatchEvent(new Event('volumechange'));
});
target().muted = muted;
target().dispatchEvent(new Event('volumechange'));
},
}),
@@ -46,25 +42,15 @@ describe('store', () => {
});
const playbackSlice = defineSlice<MockMedia>()({
state: ({ task }) => ({
state: ({ target }) => ({
paused: true,
play() {
return task({
key: 'playback',
async handler({ target }) {
target.play();
target.paused = false;
},
});
target().play();
target().paused = false;
},
pause() {
return task({
key: 'playback',
async handler({ target }) {
target.pause();
target.paused = true;
},
});
target().pause();
target().paused = true;
},
}),
@@ -167,136 +153,26 @@ describe('store', () => {
});
describe('actions', () => {
it('executes action on target', async () => {
it('executes action on target', () => {
const store = createStore<MockMedia>()(audioSlice);
const media = new MockMedia();
store.attach(media);
await store.setVolume(0.5);
store.setVolume(0.5);
expect(media.volume).toBe(0.5);
});
it('throws StoreError without target', async () => {
it('throws StoreError without target', () => {
const store = createStore<MockMedia>()(audioSlice, { onError: () => {} });
await expect(store.setVolume(0.5)).rejects.toMatchObject({ code: 'NO_TARGET' });
});
it('coordinates actions with same key', async () => {
const store = createStore<MockMedia>()(playbackSlice, { onError: () => {} });
const media = new MockMedia();
store.attach(media);
const playPromise = store.play();
const pausePromise = store.pause();
await expect(playPromise).rejects.toMatchObject({ code: 'SUPERSEDED' });
await pausePromise;
expect(media.paused).toBe(true);
});
it('passes meta to handler', async () => {
let receivedMeta: unknown = null;
const slice = defineSlice<MockMedia>()({
state: ({ task }) => ({
value: 0,
action() {
return task({
key: 'action',
handler({ meta }) {
receivedMeta = meta;
},
});
},
}),
});
const store = createStore<MockMedia>()(slice);
store.attach(new MockMedia());
await store.meta({ source: 'user', reason: 'test' }).action();
expect(receivedMeta).toMatchObject({
source: 'user',
reason: 'test',
});
});
it('clears meta after action without task()', async () => {
let receivedMeta: unknown = 'not-called';
const slice = defineSlice<MockMedia>()({
state: ({ task }) => ({
value: 0,
// Sync action that doesn't use task()
syncAction() {
// Does nothing with meta
},
// Action that uses task() to capture meta
asyncAction() {
return task({
key: 'async',
handler({ meta }) {
receivedMeta = meta;
},
});
},
}),
});
const store = createStore<MockMedia>()(slice);
store.attach(new MockMedia());
// Call sync action with meta - meta should be cleared after
store.meta({ source: 'user', reason: 'sync' }).syncAction();
// Call async action without meta - should NOT receive leaked meta
await store.asyncAction();
expect(receivedMeta).toBeNull();
});
it('isolates meta between chained calls', async () => {
const receivedMetas: unknown[] = [];
const slice = defineSlice<MockMedia>()({
state: ({ task }) => ({
value: 0,
action() {
return task({
key: 'action',
handler({ meta }) {
receivedMetas.push(meta);
},
});
},
}),
});
const store = createStore<MockMedia>()(slice);
store.attach(new MockMedia());
await store.meta({ source: 'first' }).action();
await store.meta({ source: 'second' }).action();
await store.action(); // No meta
expect(receivedMetas).toHaveLength(3);
expect(receivedMetas[0]).toMatchObject({ source: 'first' });
expect(receivedMetas[1]).toMatchObject({ source: 'second' });
expect(receivedMetas[2]).toBeNull();
expect(() => store.setVolume(0.5)).toThrow();
});
});
describe('subscribe', () => {
it('notifies on state change', async () => {
it('notifies on state change', () => {
const store = createStore<MockMedia>()(audioSlice);
const media = new MockMedia();
@@ -305,14 +181,14 @@ describe('store', () => {
const listener = vi.fn();
store.subscribe(listener);
await store.setVolume(0.5);
store.setVolume(0.5);
flush();
expect(listener).toHaveBeenCalled();
expect(store.state.volume).toBe(0.5);
});
it('unsubscribe stops notifications', async () => {
it('unsubscribe stops notifications', () => {
const store = createStore<MockMedia>()(audioSlice);
const media = new MockMedia();
@@ -322,7 +198,7 @@ describe('store', () => {
const unsubscribe = store.subscribe(listener);
unsubscribe();
await store.setVolume(0.5);
store.setVolume(0.5);
flush();
expect(listener).not.toHaveBeenCalled();
@@ -341,43 +217,137 @@ describe('store', () => {
expect(store.target).toBeNull();
});
it('rejects actions after destroy', async () => {
it('throws on attach after destroy', () => {
const store = createStore<MockMedia>()(audioSlice);
store.attach(new MockMedia());
store.destroy();
await expect(store.setVolume(0.5)).rejects.toThrow();
expect(() => store.attach(new MockMedia())).toThrow();
});
});
describe('error handling', () => {
it('calls onError for action errors', async () => {
it('calls onError for action errors', () => {
const onError = vi.fn();
const failingSlice = defineSlice<MockMedia>()({
state: ({ task }) => ({
state: ({ target }) => ({
value: 0,
fail() {
return task(() => {
throw new Error('action failed');
});
target(); // This will throw NO_TARGET
},
}),
});
const store = createStore<MockMedia>()(failingSlice, { onError });
// No target attached, so target() will throw
expect(() => store.fail()).toThrow();
});
});
describe('signal and abort', () => {
it('signal() throws when not attached', () => {
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(),
}),
});
const store = createStore<MockMedia>()(slice);
store.attach(new MockMedia());
await store.fail().catch(() => {});
const sig = store.getSignal();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.any(Error),
store,
})
);
expect(sig).toBeInstanceOf(AbortSignal);
expect(sig.aborted).toBe(false);
});
it('signal aborts on detach', () => {
const slice = defineSlice<MockMedia>()({
state: ({ signal }) => ({
getSignal: () => signal(),
}),
});
const store = createStore<MockMedia>()(slice);
const detach = store.attach(new MockMedia());
const sig = store.getSignal();
expect(sig.aborted).toBe(false);
detach();
expect(sig.aborted).toBe(true);
});
it('abort() aborts current signal', () => {
const slice = defineSlice<MockMedia>()({
state: ({ signal, abort }) => ({
getSignal: () => signal(),
abort: () => abort(),
}),
});
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();
expect(sig.aborted).toBe(false);
store.attach(new MockMedia()); // Reattach
expect(sig.aborted).toBe(true);
});
});
});