mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(store): remove queue and task system (#382)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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();
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user