diff --git a/.claude/plans/store/bindings.md b/.claude/plans/store/bindings.md index d11ab290..c0f279e6 100644 --- a/.claude/plans/store/bindings.md +++ b/.claude/plans/store/bindings.md @@ -11,7 +11,7 @@ Implemented React and Lit bindings for `@videojs/store`: `createStore()` factory | Phase | PR | Description | | ----- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | | 0 | [#283](https://github.com/videojs/v10/pull/283) | `uniqBy`, `composeCallbacks`, `extendConfig` utilities | -| 0.5 | [#287](https://github.com/videojs/v10/pull/287) | Queue task refactor | +| 0.5 | [#287](https://github.com/videojs/v10/pull/287) | Queue task refactor (later removed) | | 1 | [#288](https://github.com/videojs/v10/pull/288) | React bindings | | 2 | [#289](https://github.com/videojs/v10/pull/289) | Lit bindings | | 3 | [#292](https://github.com/videojs/v10/pull/292) | DOM media slices | diff --git a/.claude/plans/store/store-v2.md b/.claude/plans/store/store-v2.md index 31a5db78..856ee3cf 100644 --- a/.claude/plans/store/store-v2.md +++ b/.claude/plans/store/store-v2.md @@ -2,11 +2,22 @@ Simplify store package by removing queue public API, simplifying internals, and cleaning up bindings. -**Note:** RequestMeta stays in implementation, removed from types only where task.ts removal requires it. - --- -## Phase 1: Queue Simplification +## Phase 1: Queue Removal ✅ + +> **COMPLETED:** Queue, task, and meta were completely removed. Actions now use `target()` directly. + +**What was done:** +- Deleted `queue.ts`, `request.ts` and their tests +- Removed `Task`, `TaskOptions`, `TaskHandler`, `TaskContext` types from `slice.ts` +- Simplified `StateContext` to just `{ target: () => Target }` +- Removed `pending`, `meta()`, `onTaskStart`, `onTaskEnd` from store +- Removed `ABORTED`, `SUPERSEDED` error codes +- Actions now call `target()` directly instead of using `task()` wrapper + +
+Original plan (obsolete) Remove public API, keep internal for abort/supersession/modes. @@ -324,6 +335,8 @@ pnpm lint pnpm build:packages ``` +
+ --- ## Phase 2: Bindings Cleanup (Separate PR) diff --git a/.claude/skills/docs/SKILL.md b/.claude/skills/docs/SKILL.md index 92a88e8f..fd59e714 100644 --- a/.claude/skills/docs/SKILL.md +++ b/.claude/skills/docs/SKILL.md @@ -121,8 +121,8 @@ Show why something is better: // ❌ Don't — mutate directly video.volume = 0.5; // No coordination, no error handling -// ✅ Do — use requests -await player.request.setVolume(0.5); // Queued, cancellable, tracked +// ✅ Do — use store actions +player.setVolume(0.5); // Managed, observable, integrated with store ``` ## Familiar Terms diff --git a/.claude/skills/git/references/branch.md b/.claude/skills/git/references/branch.md index f76a4575..1ca741e7 100644 --- a/.claude/skills/git/references/branch.md +++ b/.claude/skills/git/references/branch.md @@ -18,13 +18,13 @@ type/short-description | `feat/volume-slider` | New volume slider component | | `feat/media-queries` | Add media query support | | `fix/slider-drag-edge` | Fix edge case in slider dragging | -| `fix/race-condition-queue` | Fix race condition in queue | +| `fix/state-sync-race` | Fix race condition in state sync | | `refactor/store-cleanup` | Clean up store internals | | `chore/bump-deps` | Dependency updates | | `docs/readme-examples` | Update README examples | | `test/slider-keyboard` | Add keyboard tests for slider | | `rfc/request-api` | RFC for new request API design | -| `design/queue-design` | Design doc for queue architecture| +| `design/slice-store` | Design doc for slice/store arch | | `plan/store-simplification` | Planning store architecture | ## Guidelines diff --git a/.claude/skills/git/references/commit.md b/.claude/skills/git/references/commit.md index 270d3a73..2f115e57 100644 --- a/.claude/skills/git/references/commit.md +++ b/.claude/skills/git/references/commit.md @@ -42,7 +42,7 @@ The `!` signals breaking changes in the changelog. ``` feat(html): add volume slider component -fix(store): prevent race condition in queue +fix(store): prevent race condition in state sync chore(root): bump vitest to v3 docs(core): document request lifecycle refactor(utils): simplify event listener cleanup diff --git a/.claude/skills/git/references/pr.md b/.claude/skills/git/references/pr.md index f8165b22..9daf6eb5 100644 --- a/.claude/skills/git/references/pr.md +++ b/.claude/skills/git/references/pr.md @@ -104,8 +104,8 @@ Fix race condition where rapid play/pause could leave player in inconsistent sta ## Changes -- Queue play/pause requests to prevent overlapping operations -- Add guard against redundant state transitions +- Add guard to debounce rapid play/pause calls +- Prevent redundant state transitions ## Testing diff --git a/.claude/skills/git/references/scope.md b/.claude/skills/git/references/scope.md index c9abc26d..b47e632a 100644 --- a/.claude/skills/git/references/scope.md +++ b/.claude/skills/git/references/scope.md @@ -50,7 +50,7 @@ packages/html/src/slider.ts packages/html/src/button.ts → html # Cross-package refactor -packages/store/src/queue.ts +packages/store/src/slice.ts packages/core/src/media.ts → packages (or primary one) # Root configs diff --git a/CLAUDE.md b/CLAUDE.md index f8cc437c..c513d032 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -266,22 +266,22 @@ function isStoreError(value: unknown): value is StoreError { Use symbols to identify objects when `instanceof` isn't reliable (e.g., cross-realm, serialization boundaries): ```ts -const QUEUE_SYMBOL = Symbol('@videojs/queue'); +const STORE_SYMBOL = Symbol('@videojs/store'); -interface Queue { - [QUEUE_SYMBOL]: true; +interface Store { + [STORE_SYMBOL]: true; // ... } -function createQueue(): Queue { +function createStore(): Store { return { - [QUEUE_SYMBOL]: true, + [STORE_SYMBOL]: true, // ... }; } -function isQueue(value: unknown): value is Queue { - return isObject(value) && QUEUE_SYMBOL in value; +function isStore(value: unknown): value is Store { + return isObject(value) && STORE_SYMBOL in value; } ``` @@ -346,7 +346,7 @@ import { Disposer } from '@videojs/utils/events'; connect(): void { this.#disposer.add(store.subscribe(...)); - this.#disposer.add(queue.subscribe(...)); + this.#disposer.add(listen(element, 'click', handler)); } disconnect(): void { diff --git a/internal/design/queue-design.md b/internal/design/archive/queue-design.md similarity index 100% rename from internal/design/queue-design.md rename to internal/design/archive/queue-design.md diff --git a/packages/core/src/dom/media/predicate.ts b/packages/core/src/dom/media/predicate.ts new file mode 100644 index 00000000..5cb0b0bf --- /dev/null +++ b/packages/core/src/dom/media/predicate.ts @@ -0,0 +1,3 @@ +export function hasMetadata(media: HTMLMediaElement): boolean { + return media.readyState >= HTMLMediaElement.HAVE_METADATA; +} diff --git a/packages/core/src/dom/store/features/playback.ts b/packages/core/src/dom/store/features/playback.ts index 76e295e5..5ac1b161 100644 --- a/packages/core/src/dom/store/features/playback.ts +++ b/packages/core/src/dom/store/features/playback.ts @@ -4,7 +4,7 @@ import { listen } from '@videojs/utils/dom'; import { definePlayerFeature } from '../../feature'; export const playbackFeature = definePlayerFeature({ - state: ({ task }) => ({ + state: ({ target }) => ({ /** Whether playback is paused. */ paused: true, /** Whether playback has reached the end. */ @@ -13,26 +13,13 @@ export const playbackFeature = definePlayerFeature({ started: false, /** Whether playback is stalled waiting for data. */ waiting: false, - - /** Start playback. Returns when playback begins. */ + /** Start playback. */ play() { - return task({ - key: 'playback', - mode: 'shared', - async handler({ target }) { - await target.media.play(); - }, - }); + return target().media.play(); }, - /** Pause playback immediately. */ pause() { - return task({ - key: 'playback', - handler({ target }) { - target.media.pause(); - }, - }); + target().media.pause(); }, }), diff --git a/packages/core/src/dom/store/features/source.ts b/packages/core/src/dom/store/features/source.ts index 0e6b515d..ff81757f 100644 --- a/packages/core/src/dom/store/features/source.ts +++ b/packages/core/src/dom/store/features/source.ts @@ -1,27 +1,23 @@ import type { InferSliceState } from '@videojs/store'; -import { CANCEL_ALL } from '@videojs/store'; import { listen } from '@videojs/utils/dom'; import { definePlayerFeature } from '../../feature'; export const sourceFeature = definePlayerFeature({ - state: ({ task }) => ({ + state: ({ target, abort }) => ({ /** Current media source URL (null if none). */ source: null as string | null, /** Whether enough data is loaded to begin playback. */ canPlay: false, - - /** Load a new media source. Cancels all pending operations. Returns the new source URL. */ + /** Load a new media source. Returns the new source URL. */ loadSource(src: string) { - return task({ - key: 'source', - cancels: [CANCEL_ALL], - handler({ target }) { - target.media.src = src; - target.media.load(); - return src; - }, - }); + abort(); // Cancel pending operations (e.g., seek) + + const { media } = target(); + media.src = src; + media.load(); + + return src; }, }), diff --git a/packages/core/src/dom/store/features/tests/source.test.ts b/packages/core/src/dom/store/features/tests/source.test.ts index 2f6a1695..0fd6c9bb 100644 --- a/packages/core/src/dom/store/features/tests/source.test.ts +++ b/packages/core/src/dom/store/features/tests/source.test.ts @@ -1,8 +1,9 @@ -import { createStore } from '@videojs/store'; +import { combine, createStore } from '@videojs/store'; import { describe, expect, it, vi } from 'vitest'; import type { PlayerTarget } from '../../../types'; import { sourceFeature } from '../source'; +import { timeFeature } from '../time'; describe('sourceFeature', () => { describe('attach', () => { @@ -116,6 +117,28 @@ describe('sourceFeature', () => { expect(video.load).toHaveBeenCalled(); expect(result).toBe('https://example.com/new.mp4'); }); + + it('aborts pending operations when loading new source', async () => { + const video = createMockVideo({ + readyState: HTMLMediaElement.HAVE_METADATA, + }); + video.load = vi.fn(); + + const store = createStore()(combine(sourceFeature, timeFeature)); + store.attach({ media: video, container: null }); + + // Start a seek that will wait for seeked event + const seekPromise = store.seek(30); + + // Load new source before seek completes - should abort the seek + store.loadSource('https://example.com/new.mp4'); + + // Seek should resolve immediately (aborted) + const result = await seekPromise; + expect(result).toBe(30); // Returns current position + + expect(video.load).toHaveBeenCalled(); + }); }); }); }); diff --git a/packages/core/src/dom/store/features/tests/time.test.ts b/packages/core/src/dom/store/features/tests/time.test.ts index ed20e681..da9fd419 100644 --- a/packages/core/src/dom/store/features/tests/time.test.ts +++ b/packages/core/src/dom/store/features/tests/time.test.ts @@ -110,6 +110,47 @@ describe('timeFeature', () => { const result = await resultPromise; expect(result).toBe(45); }); + + it('aborts pending seek on detach', async () => { + const video = createMockVideo({}); + const store = createStore()(timeFeature); + const detach = store.attach({ media: video, container: null }); + + const resultPromise = store.seek(45); + + expect(video.currentTime).toBe(45); + + // Detach before seeked event fires + detach(); + + // Should resolve with current time (seek was aborted) + const result = await resultPromise; + expect(result).toBe(45); + }); + + it('supersedes previous seek when new seek starts', async () => { + const video = createMockVideo({}); + const store = createStore()(timeFeature); + store.attach({ media: video, container: null }); + + // Start first seek + const seek1Promise = store.seek(10); + + // Start second seek before first completes (supersedes) + const seek2Promise = store.seek(20); + + expect(video.currentTime).toBe(20); + + // First seek should resolve immediately (aborted) + const result1 = await seek1Promise; + expect(result1).toBe(20); // Returns current position + + // Fire seeked for second seek + video.dispatchEvent(new Event('seeked')); + + const result2 = await seek2Promise; + expect(result2).toBe(20); + }); }); }); }); @@ -118,7 +159,8 @@ function createMockVideo( overrides: Partial<{ currentTime: number; duration: number; - }> + readyState: number; + }> = {} ): HTMLVideoElement { const video = document.createElement('video'); @@ -128,6 +170,12 @@ function createMockVideo( if (overrides.duration !== undefined) { Object.defineProperty(video, 'duration', { value: overrides.duration, writable: false, configurable: true }); } + // Default to HAVE_METADATA so seek tests work without waiting for loadedmetadata + Object.defineProperty(video, 'readyState', { + value: overrides.readyState ?? HTMLMediaElement.HAVE_METADATA, + writable: false, + configurable: true, + }); return video; } diff --git a/packages/core/src/dom/store/features/time.ts b/packages/core/src/dom/store/features/time.ts index 3f3182de..47cc99dd 100644 --- a/packages/core/src/dom/store/features/time.ts +++ b/packages/core/src/dom/store/features/time.ts @@ -1,27 +1,46 @@ import type { InferSliceState } from '@videojs/store'; import { listen, onEvent } from '@videojs/utils/dom'; - +import { noop } from '@videojs/utils/function'; import { definePlayerFeature } from '../../feature'; +import { hasMetadata } from '../../media/predicate'; export const timeFeature = definePlayerFeature({ - state: ({ task }) => ({ - /** Current playback position in seconds. */ - currentTime: 0, - /** Total duration in seconds (0 if unknown). */ - duration: 0, + state: ({ target, signal }) => { + let abort: AbortController | null = null; - /** Seek to a time in seconds. Returns the requested time. */ - seek(time: number) { - return task({ - key: 'seek', - async handler({ target, signal }) { - target.media.currentTime = time; - await onEvent(target.media, 'seeked', { signal }); - return target.media.currentTime; // actual position after seek - }, - }); - }, - }), + const supersede = () => { + abort?.abort(); + abort = new AbortController(); + return AbortSignal.any([signal(), abort.signal]); + }; + + return { + /** Current playback position in seconds. */ + currentTime: 0, + /** Total duration in seconds (0 if unknown). */ + duration: 0, + /** Whether a seek operation is in progress. */ + seeking: false, + /** Seek to a time in seconds. Returns the actual position after seek. */ + async seek(time: number) { + const { media } = target(), + signal = supersede(); + + // If metadata isn't loaded, wait for it before seeking to avoid errors. + if (!hasMetadata(media)) { + const loaded = await onEvent(media, 'loadedmetadata', { signal }).catch(() => false); + if (!loaded) return media.currentTime; + } + + // Perform the seek and wait for it to complete. + const clampedTime = Math.max(0, Math.min(time, media.duration || Infinity)); + media.currentTime = clampedTime; + await onEvent(media, 'seeked', { signal }).catch(noop); + + return media.currentTime; + }, + }; + }, attach({ target, signal, set }) { const { media } = target; @@ -29,13 +48,15 @@ export const timeFeature = definePlayerFeature({ const sync = () => set({ currentTime: media.currentTime, - duration: media.duration || 0, + duration: Number.isFinite(media.duration) ? media.duration : 0, + seeking: media.seeking, }); sync(); listen(media, 'timeupdate', sync, { signal }); listen(media, 'durationchange', sync, { signal }); + listen(media, 'seeking', sync, { signal }); listen(media, 'seeked', sync, { signal }); listen(media, 'loadedmetadata', sync, { signal }); listen(media, 'emptied', sync, { signal }); diff --git a/packages/core/src/dom/store/features/volume.ts b/packages/core/src/dom/store/features/volume.ts index b560f9f7..3ac43dec 100644 --- a/packages/core/src/dom/store/features/volume.ts +++ b/packages/core/src/dom/store/features/volume.ts @@ -5,7 +5,7 @@ import { definePlayerFeature } from '../../feature'; import type { FeatureAvailability } from '../../types'; export const volumeFeature = definePlayerFeature({ - state: ({ task }) => ({ + state: ({ target }) => ({ /** Volume level from 0 (silent) to 1 (max). */ volume: 1, /** Whether audio is muted. */ @@ -15,24 +15,16 @@ export const volumeFeature = definePlayerFeature({ /** Set volume (clamped 0-1). Returns the clamped value. */ changeVolume(volume: number) { - return task({ - key: 'volume', - handler({ target }) { - target.media.volume = Math.max(0, Math.min(1, volume)); - return target.media.volume; - }, - }); + const { media } = target(); + media.volume = Math.max(0, Math.min(1, volume)); + return media.volume; }, /** Toggle mute state. Returns new muted value. */ toggleMute() { - return task({ - key: 'mute', - handler({ target }) { - target.media.muted = !target.media.muted; - return target.media.muted; - }, - }); + const { media } = target(); + media.muted = !media.muted; + return media.muted; }, }), @@ -42,7 +34,6 @@ export const volumeFeature = definePlayerFeature({ set({ volumeAvailability: canSetVolume() }); const sync = () => set({ volume: media.volume, muted: media.muted }); - sync(); listen(media, 'volumechange', sync, { signal }); diff --git a/packages/store/README.md b/packages/store/README.md index 46ae6172..c4d7a44a 100644 --- a/packages/store/README.md +++ b/packages/store/README.md @@ -62,7 +62,7 @@ const volumeSlice = defineSlice()({ muted: false, // Sync - use target() directly - setVolume(value: number) { + setVolume() { target().volume = Math.max(0, Math.min(1, value)); }, diff --git a/packages/store/src/core/config.ts b/packages/store/src/core/config.ts index c5464c40..99ff7250 100644 --- a/packages/store/src/core/config.ts +++ b/packages/store/src/core/config.ts @@ -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 { onSetup?: (ctx: StoreSetupContext) => void; onAttach?: (ctx: StoreAttachContext) => void; onError?: (ctx: StoreErrorContext) => void; - onTaskStart?: (ctx: StoreTaskContext) => void; - onTaskEnd?: (ctx: StoreTaskContext & { error?: unknown }) => void; } export interface StoreSetupContext { @@ -31,8 +21,3 @@ export interface StoreErrorContext { store: Store; error: unknown; } - -export interface StoreTaskContext { - key: TaskKey; - meta: RequestMeta | null; -} diff --git a/packages/store/src/core/errors.ts b/packages/store/src/core/errors.ts index ed9b14db..d6fa58f3 100644 --- a/packages/store/src/core/errors.ts +++ b/packages/store/src/core/errors.ts @@ -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'); +} diff --git a/packages/store/src/core/index.ts b/packages/store/src/core/index.ts index 01abb6d0..f506ec56 100644 --- a/packages/store/src/core/index.ts +++ b/packages/store/src/core/index.ts @@ -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'; diff --git a/packages/store/src/core/queue.ts b/packages/store/src/core/queue.ts deleted file mode 100644 index 4c320e2e..00000000 --- a/packages/store/src/core/queue.ts +++ /dev/null @@ -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 { - key: TaskKey; - mode?: TaskMode; - handler: (ctx: { signal: AbortSignal }) => Promise; -} - -// ---------------------------------------- -// Implementation -// ---------------------------------------- - -export class Queue { - #pending = new Map(); - #shared = new Map>(); - #destroyed = false; - - get destroyed(): boolean { - return this.#destroyed; - } - - enqueue({ key, mode = 'exclusive', handler }: QueueTask): Promise { - 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; - } - - // 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(); - } -} diff --git a/packages/store/src/core/request.ts b/packages/store/src/core/request.ts deleted file mode 100644 index b3888fe9..00000000 --- a/packages/store/src/core/request.ts +++ /dev/null @@ -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 = Omit, typeof REQUEST_META>; - -export interface RequestMeta { - [REQUEST_META]: true; - source?: string; - timestamp?: number; - reason?: string; - context?: Context | undefined; -} - -export function createRequestMeta(init: RequestMetaInit): RequestMeta { - 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( - event: EventLike, - context?: Context -): RequestMeta { - return { - [REQUEST_META]: true, - source: event.isTrusted ? 'user' : 'system', - timestamp: event.timeStamp, - reason: event.type, - context, - }; -} diff --git a/packages/store/src/core/selector.ts b/packages/store/src/core/selector.ts index cd4ae99e..161ed621 100644 --- a/packages/store/src/core/selector.ts +++ b/packages/store/src/core/selector.ts @@ -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 = { - task: () => { - throw new StoreError('NO_TARGET'); - }, - target: () => { - throw new StoreError('NO_TARGET'); - }, + target: throwNoTargetError, + signal: throwNoTargetError, + abort: throwNoTargetError, }; /** diff --git a/packages/store/src/core/slice.ts b/packages/store/src/core/slice.ts index 26ae0caf..807d8fb5 100644 --- a/packages/store/src/core/slice.ts +++ b/packages/store/src/core/slice.ts @@ -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 = { - (handler: TaskHandler): Promise>; - (options: TaskOptions): Promise>; -}; - -export interface TaskOptions { - key?: TaskKey; - mode?: TaskMode; - cancels?: TaskKey[]; - handler: TaskHandler; -} - -export type TaskHandler = (ctx: TaskContext) => Output; - -export interface TaskContext { - target: Target; - signal: AbortSignal; - get: () => Readonly; - meta: RequestMeta | null; -} - // ---------------------------------------- // Attach // ---------------------------------------- @@ -53,8 +26,12 @@ export interface AttachContext { // ---------------------------------------- export interface StateContext { - task: Task; + /** 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; } // ---------------------------------------- diff --git a/packages/store/src/core/store.ts b/packages/store/src/core/store.ts index f3b6b1ef..c2bd6a01 100644 --- a/packages/store/src/core/store.ts +++ b/packages/store/src/core/store.ts @@ -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(): ( 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 = {}; // Reactive state - initialized after building slice state let state: WritableState; + 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); @@ -50,16 +57,12 @@ export function createStore(): ( 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(): ( }); } - // 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(): ( 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(): ( 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(): ( 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(handler: TaskHandler): Promise>; - async function executeTask(options: TaskOptions): Promise>; - async function executeTask( - handlerOrOptions: TaskHandler | TaskOptions - ): Promise> { - if (destroyed) throw new StoreError('DESTROYED'); - - const taskOptions: TaskOptions = 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, - 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; - } 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 { [key: string]: unknown; readonly target: Target | null; readonly destroyed: boolean; - readonly pending: Readonly>; readonly state: State; attach(target: Target): () => void; destroy(): void; subscribe(callback: StateChange): () => void; - meta(eventOrMeta: EventLike | RequestMetaInit): Store; } export type Store = BaseStore & State; diff --git a/packages/store/src/core/tests/errors.test.ts b/packages/store/src/core/tests/errors.test.ts index f117c0df..35c59444 100644 --- a/packages/store/src/core/tests/errors.test.ts +++ b/packages/store/src/core/tests/errors.test.ts @@ -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); }); diff --git a/packages/store/src/core/tests/integration/store.test.ts b/packages/store/src/core/tests/integration/store.test.ts index 9c9fd0d2..ccdb365c 100644 --- a/packages/store/src/core/tests/integration/store.test.ts +++ b/packages/store/src/core/tests/integration/store.test.ts @@ -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()({ - 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()({ - 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()(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()({ - 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()(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()({ - 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()(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()({ - 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()(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()({ - 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()(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()({ - 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()(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()({ - 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()({ - state: ({ task }) => ({ - playing: false, - play() { - return task({ - key: 'playback', - handler({ meta }) { - receivedMeta = meta; - }, - }); - }, - }), - }); - - const store = createStore()(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()({ - state: ({ task }) => ({ - count: 0, - increment() { - return task({ - key: 'increment', - async handler() { - await new Promise((r) => setTimeout(r, 10)); - }, - }); - }, - }), - }); - - const store = createStore()(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()({ - state: ({ task }) => ({ - loading: false, - load() { - return task({ - key: 'load', - async handler() { - await new Promise((r) => setTimeout(r, 50)); - }, - }); - }, - }), - }); - - const store = createStore()(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()({ - 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()({ - state: ({ task }) => ({ + state: ({ target }) => ({ value: 0, doSomething() { - return task(() => {}); + target(); // Will throw NO_TARGET }, }), }); - const store = createStore()(slice, { onError: noop }); + const store = createStore()(slice, { onError: () => {} }); - await expect(store.doSomething()).rejects.toThrow('NO_TARGET'); + expect(() => store.doSomething()).toThrow('NO_TARGET'); }); }); diff --git a/packages/store/src/core/tests/queue.test.ts b/packages/store/src/core/tests/queue.test.ts deleted file mode 100644 index e275b015..00000000 --- a/packages/store/src/core/tests/queue.test.ts +++ /dev/null @@ -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'); - }); - }); -}); diff --git a/packages/store/src/core/tests/request.test.ts b/packages/store/src/core/tests/request.test.ts deleted file mode 100644 index 2949b5a0..00000000 --- a/packages/store/src/core/tests/request.test.ts +++ /dev/null @@ -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' }); - }); - }); - }); -}); diff --git a/packages/store/src/core/tests/selector.test.ts b/packages/store/src/core/tests/selector.test.ts index 50f0433d..0c1c08d0 100644 --- a/packages/store/src/core/tests/selector.test.ts +++ b/packages/store/src/core/tests/selector.test.ts @@ -8,14 +8,12 @@ interface MockMedia { describe('createSelector', () => { const volumeSlice = defineSlice()({ - 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 }; diff --git a/packages/store/src/core/tests/slice.test.ts b/packages/store/src/core/tests/slice.test.ts index 547c3c7a..fe92f209 100644 --- a/packages/store/src/core/tests/slice.test.ts +++ b/packages/store/src/core/tests/slice.test.ts @@ -9,15 +9,10 @@ describe('defineSlice', () => { } const slice = defineSlice()({ - 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()({ - 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; } const slice = defineSlice()({ - 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()({ - state: ({ task }) => ({ - loading: false, - load(src: string) { - return task(({ target }) => { - target.src = src; - target.load(); - }); + return target().play(); }, }), }); diff --git a/packages/store/src/core/tests/store.test.ts b/packages/store/src/core/tests/store.test.ts index c7e7a7ae..daf8b092 100644 --- a/packages/store/src/core/tests/store.test.ts +++ b/packages/store/src/core/tests/store.test.ts @@ -16,20 +16,16 @@ describe('store', () => { } const audioSlice = defineSlice()({ - 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()({ - 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()(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()(audioSlice, { onError: () => {} }); - await expect(store.setVolume(0.5)).rejects.toMatchObject({ code: 'NO_TARGET' }); - }); - - it('coordinates actions with same key', async () => { - const store = createStore()(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()({ - state: ({ task }) => ({ - value: 0, - action() { - return task({ - key: 'action', - handler({ meta }) { - receivedMeta = meta; - }, - }); - }, - }), - }); - - const store = createStore()(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()({ - 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()(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()({ - state: ({ task }) => ({ - value: 0, - action() { - return task({ - key: 'action', - handler({ meta }) { - receivedMetas.push(meta); - }, - }); - }, - }), - }); - - const store = createStore()(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()(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()(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()(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()({ - state: ({ task }) => ({ + state: ({ target }) => ({ value: 0, fail() { - return task(() => { - throw new Error('action failed'); - }); + target(); // This will throw NO_TARGET }, }), }); const store = createStore()(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()({ + state: ({ signal }) => ({ + getSignal: () => signal(), + }), + }); + + const store = createStore()(slice); + + expect(() => store.getSignal()).toThrow(); + }); + + it('signal() returns AbortSignal when attached', () => { + const slice = defineSlice()({ + state: ({ signal }) => ({ + getSignal: () => signal(), + }), + }); + + const store = createStore()(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()({ + state: ({ signal }) => ({ + getSignal: () => signal(), + }), + }); + + const store = createStore()(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()({ + state: ({ signal, abort }) => ({ + getSignal: () => signal(), + abort: () => abort(), + }), + }); + + const store = createStore()(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()({ + state: ({ signal, abort }) => ({ + getSignal: () => signal(), + abort: () => abort(), + }), + }); + + const store = createStore()(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()({ + state: ({ signal }) => ({ + getSignal: () => signal(), + }), + }); + + const store = createStore()(slice); + store.attach(new MockMedia()); + + const sig = store.getSignal(); + expect(sig.aborted).toBe(false); + + store.attach(new MockMedia()); // Reattach + + expect(sig.aborted).toBe(true); }); }); }); diff --git a/packages/store/src/lit/tests/test-utils.ts b/packages/store/src/lit/tests/test-utils.ts index d5cf03f7..eb5c5cb0 100644 --- a/packages/store/src/lit/tests/test-utils.ts +++ b/packages/store/src/lit/tests/test-utils.ts @@ -27,73 +27,24 @@ export class MockMedia extends EventTarget { } export const audioSlice = defineSlice()({ - state: ({ task }) => ({ + state: ({ target }) => ({ volume: 1, muted: false, setVolume(volume: number) { - return task(({ target }) => { - target.volume = volume; - target.dispatchEvent(new Event('volumechange')); - return volume; - }); + target().volume = volume; + target().dispatchEvent(new Event('volumechange')); + return volume; }, setMuted(muted: boolean) { - return task(({ target }) => { - target.muted = muted; - target.dispatchEvent(new Event('volumechange')); - return muted; - }); + target().muted = muted; + target().dispatchEvent(new Event('volumechange')); + return muted; }, - slowSetVolume(volume: number) { - return task(async ({ target }) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - target.volume = volume; - target.dispatchEvent(new Event('volumechange')); - return volume; - }); - }, - }), - - attach({ target, signal, set }) { - const sync = () => set({ volume: target.volume, muted: target.muted }); - - sync(); - - target.addEventListener('volumechange', sync); - signal.addEventListener('abort', () => { - target.removeEventListener('volumechange', sync); - }); - }, -}); - -/** Slice with custom keys (name !== key) for testing superseding behavior. */ -export const customKeySlice = defineSlice()({ - state: ({ task }) => ({ - volume: 1, - muted: false, - // name='adjustVolume', key='audio-settings' - adjustVolume(volume: number) { - return task({ - key: 'audio-settings', - async handler({ target }) { - await new Promise((resolve) => setTimeout(resolve, 20)); - target.volume = volume; - target.dispatchEvent(new Event('volumechange')); - return volume; - }, - }); - }, - // name='toggleMute', key='audio-settings' (same key - will supersede adjustVolume) - toggleMute(muted: boolean) { - return task({ - key: 'audio-settings', - async handler({ target }) { - await new Promise((resolve) => setTimeout(resolve, 20)); - target.muted = muted; - target.dispatchEvent(new Event('volumechange')); - return muted; - }, - }); + async slowSetVolume(volume: number) { + await new Promise((resolve) => setTimeout(resolve, 50)); + target().volume = volume; + target().dispatchEvent(new Event('volumechange')); + return volume; }, }), @@ -112,20 +63,12 @@ export const customKeySlice = defineSlice()({ export type AudioSliceState = { volume: number; muted: boolean; - setVolume: (volume: number) => Promise; - setMuted: (muted: boolean) => Promise; + setVolume: (volume: number) => number; + setMuted: (muted: boolean) => boolean; slowSetVolume: (volume: number) => Promise; }; -type CustomKeySliceState = { - volume: number; - muted: boolean; - adjustVolume: (volume: number) => Promise; - toggleMute: (muted: boolean) => Promise; -}; - type TestStore = Store; -type CustomKeyStore = Store; // For controller tests - creates core store with attached target export function createCoreTestStore(): { store: TestStore; target: MockMedia } { @@ -137,16 +80,6 @@ export function createCoreTestStore(): { store: TestStore; target: MockMedia } { return { store, target }; } -/** Creates store with custom key slice (name !== key) for testing superseding. */ -export function createCustomKeyTestStore(): { store: CustomKeyStore; target: MockMedia } { - const store = createCoreStore()(customKeySlice, { onError: noop }); - - const target = new MockMedia(); - store.attach(target); - - return { store, target }; -} - /** Type alias for test host. */ export type TestHost = TestHostElement; diff --git a/packages/store/src/react/hooks/tests/test-utils.ts b/packages/store/src/react/hooks/tests/test-utils.ts index c75f1b11..6a304f7e 100644 --- a/packages/store/src/react/hooks/tests/test-utils.ts +++ b/packages/store/src/react/hooks/tests/test-utils.ts @@ -11,22 +11,18 @@ export class MockMedia extends EventTarget { // Shared slice for synchronous tests export const audioSlice = defineSlice()({ - state: ({ task }) => ({ + state: ({ target }) => ({ volume: 1, muted: false, setVolume(volume: number) { - return task(({ target }) => { - target.volume = volume; - target.dispatchEvent(new Event('volumechange')); - return volume; - }); + target().volume = volume; + target().dispatchEvent(new Event('volumechange')); + return volume; }, setMuted(muted: boolean) { - return task(({ target }) => { - target.muted = muted; - target.dispatchEvent(new Event('volumechange')); - return muted; - }); + target().muted = muted; + target().dispatchEvent(new Event('volumechange')); + return muted; }, }), @@ -56,36 +52,28 @@ export class AsyncMockMedia extends EventTarget { } export const asyncAudioSlice = defineSlice()({ - state: ({ task }) => ({ + state: ({ target }) => ({ volume: 1, muted: false, - setVolume(volume: number) { - return task(async ({ target }) => { - await Promise.resolve(); - target.volume = volume; - target.dispatchEvent(new Event('volumechange')); - return volume; - }); + async setVolume(volume: number) { + await Promise.resolve(); + target().volume = volume; + target().dispatchEvent(new Event('volumechange')); + return volume; }, - slowSetVolume(volume: number) { - return task(async ({ target }) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - target.volume = volume; - target.dispatchEvent(new Event('volumechange')); - return volume; - }); + async slowSetVolume(volume: number) { + await new Promise((resolve) => setTimeout(resolve, 50)); + target().volume = volume; + target().dispatchEvent(new Event('volumechange')); + return volume; }, - failingRequest() { - return task(async () => { - await Promise.resolve(); - throw new Error('Request failed'); - }); + async failingRequest() { + await Promise.resolve(); + throw new Error('Request failed'); }, - failingSetVolume() { - return task(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - throw new Error('Test error'); - }); + async failingSetVolume() { + await new Promise((resolve) => setTimeout(resolve, 10)); + throw new Error('Test error'); }, }), @@ -109,55 +97,3 @@ export function createAsyncTestStore() { return { store, target }; } - -/** Slice with custom keys (name !== key) for testing superseding behavior. */ -export const customKeySlice = defineSlice()({ - state: ({ task }) => ({ - volume: 1, - muted: false, - // name='adjustVolume', key='audio-settings' - adjustVolume(volume: number) { - return task({ - key: 'audio-settings', - async handler({ target }) { - await new Promise((resolve) => setTimeout(resolve, 20)); - target.volume = volume; - target.dispatchEvent(new Event('volumechange')); - return volume; - }, - }); - }, - // name='toggleMute', key='audio-settings' (same key - will supersede adjustVolume) - toggleMute(muted: boolean) { - return task({ - key: 'audio-settings', - async handler({ target }) { - await new Promise((resolve) => setTimeout(resolve, 20)); - target.muted = muted; - target.dispatchEvent(new Event('volumechange')); - return muted; - }, - }); - }, - }), - - attach({ target, signal, set }) { - const sync = () => set({ volume: target.volume, muted: target.muted }); - - sync(); - - target.addEventListener('volumechange', sync); - signal.addEventListener('abort', () => { - target.removeEventListener('volumechange', sync); - }); - }, -}); - -export function createCustomKeyTestStore() { - const store = createCoreStore()(customKeySlice, { onError: noop }); - - const target = new MockMedia(); - store.attach(target); - - return { store, target }; -} diff --git a/packages/utils/src/dom/event.ts b/packages/utils/src/dom/event.ts index 2ec57303..f419898e 100644 --- a/packages/utils/src/dom/event.ts +++ b/packages/utils/src/dom/event.ts @@ -44,7 +44,7 @@ export function onEvent(target: EventTarget, type: string, options?: OnEventOpti export function onEvent(target: EventTarget, type: string, options?: OnEventOptions): Promise { return new Promise((resolve, reject) => { const handleAbort = () => { - reject(options?.signal?.reason ?? new DOMException('Aborted', 'AbortError')); + reject(options?.signal?.reason ?? 'Aborted'); }; // If already aborted, reject immediately