diff --git a/internal/design/slice-store-design.md b/internal/design/slice-store-design.md new file mode 100644 index 00000000..7fbd8587 --- /dev/null +++ b/internal/design/slice-store-design.md @@ -0,0 +1,157 @@ +--- +status: implemented +--- + +# Slice-Based Store + +Refactor `@videojs/store` to use "slice" terminology and add explicit `combine` primitive for composition. + +## Problem + +1. **"Feature" terminology in store package** - `@videojs/store` exports `defineFeature` which suggests media player concepts. Store primitives should be generic. + +2. **Composition is hidden** - Features are passed as an array to `createStore`, which handles merging internally. No first-class composition primitive. + +## Solution + +Rename "feature" to "slice" in store package, add explicit `combine` function. "Feature" moves to `@videojs/core` as domain-specific alias. + +**Layering:** +- `@videojs/store` - generic primitives: `defineSlice`, `combine`, `createStore` +- `@videojs/core` - `definePlayerFeature` (= `defineSlice()`), pre-built features +- `createPlayer` accepts `features: []` array, uses `combine` internally + +## Quick Start + +```ts +// @videojs/store - Generic slice (config object pattern) +const volumeSlice = defineSlice()({ + state: ({ task, target }) => ({ + volume: 1, + setVolume(v: number) { + return task(({ target }) => { target.volume = v; return v; }); + }, + }), + attach: ({ target, set, signal }) => { + const sync = () => set({ volume: target.volume }); + target.addEventListener('volumechange', sync); + signal.addEventListener('abort', () => { + target.removeEventListener('volumechange', sync); + }); + }, +}); + +// Compose slices +const store = createStore()(combine(volumeSlice, playbackSlice)); + +// @videojs/core - definePlayerFeature = defineSlice() +const volumeFeature = definePlayerFeature({ + state: ({ task }) => ({ volume: 1, setVolume: ... }), + attach: ({ set }) => { ... }, +}); + +// @videojs/html - createPlayer unchanged +createPlayer({ features: [volumeFeature, playbackFeature] }); +``` + +## API + +### defineSlice + +Config object pattern - same structure as current `defineFeature`: + +```ts +function defineSlice(): ( + config: SliceConfig +) => Slice; + +interface SliceConfig { + state: (ctx: StateContext) => State; + attach?: (ctx: AttachContext) => void; +} + +interface StateContext { + task: Task; + target: () => Target; +} +``` + +### combine + +Merge multiple slices into one: + +```ts +function combine[]>( + ...slices: Slices +): Slice>; +``` + +- State factories called, results merged (last wins on conflict) +- Attach handlers all run, errors caught and reported via `reportError` + +### createStore + +```ts +function createStore(): ( + slice: Slice, + options?: StoreOptions +) => Store; + +// StoreOptions extends StoreCallbacks +interface StoreOptions extends StoreCallbacks {} +``` + +## Error Handling + +`AttachContext` includes `reportError` for error reporting (named after the web standard `reportError()` API): + +```ts +interface AttachContext { + target: Target; + signal: AbortSignal; + store: AttachStore; + get: () => Readonly; + set: (partial: Partial) => void; + reportError: (error: Error) => void; +} +``` + +`combine` catches errors in attach handlers and reports via `reportError`, allowing other handlers to continue. + +## Package Layering + +| Package | Exports | Description | +|---------|---------|-------------| +| `@videojs/store` | `defineSlice`, `combine`, `createStore`, `createSelector`, `Slice`, `AnySlice` | Generic primitives | +| `@videojs/core` | `definePlayerFeature`, pre-built features | `definePlayerFeature` = `defineSlice()` | +| `@videojs/html` | `createPlayer` | Uses `combine` internally | + +## Migration + +Minimal changes - same config object pattern, just renamed: + +```ts +// Before (store package) +import { defineFeature, createFeatureSelector } from '@videojs/store'; +const feature = defineFeature()({ state: ..., attach: ... }); +const store = createStore()({ features: [f1, f2] }); +const select = createFeatureSelector(feature); + +// After (store package) +import { defineSlice, combine, createSelector } from '@videojs/store'; +const slice = defineSlice()({ state: ..., attach: ... }); +const store = createStore()(combine(s1, s2)); +const select = createSelector(slice); + +// Core package - definePlayerFeature for player features +import { definePlayerFeature } from '@videojs/core'; +const feature = definePlayerFeature({ state: ..., attach: ... }); // No () needed +``` + +## Trade-offs + +| Gain | Cost | +|------|------| +| Cleaner package separation | Migration effort | +| Explicit composition via `combine` | One more concept | +| Generic store primitives | None | diff --git a/packages/core/src/dom/feature.ts b/packages/core/src/dom/feature.ts new file mode 100644 index 00000000..b976b59f --- /dev/null +++ b/packages/core/src/dom/feature.ts @@ -0,0 +1,5 @@ +import { defineSlice } from '@videojs/store'; + +import type { PlayerTarget } from './types'; + +export const definePlayerFeature = defineSlice(); diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index 1591c736..32e0316a 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -1,3 +1,4 @@ +export * from './feature'; export * from './store/features'; export * from './store/selectors'; export * from './types'; diff --git a/packages/core/src/dom/store/features/buffer.ts b/packages/core/src/dom/store/features/buffer.ts index 851fe966..2c765132 100644 --- a/packages/core/src/dom/store/features/buffer.ts +++ b/packages/core/src/dom/store/features/buffer.ts @@ -1,11 +1,9 @@ -import type { InferFeatureState } from '@videojs/store'; - -import { defineFeature } from '@videojs/store'; +import type { InferSliceState } from '@videojs/store'; import { listen, serializeTimeRanges } from '@videojs/utils/dom'; -import type { PlayerTarget } from '../../types'; +import { definePlayerFeature } from '../../feature'; -export const bufferFeature = defineFeature()({ +export const bufferFeature = definePlayerFeature({ state: () => ({ /** Buffered time ranges as [start, end] tuples. */ buffered: [] as [number, number][], @@ -29,4 +27,4 @@ export const bufferFeature = defineFeature()({ }, }); -export type BufferState = InferFeatureState; +export type BufferState = InferSliceState; diff --git a/packages/core/src/dom/store/features/playback.ts b/packages/core/src/dom/store/features/playback.ts index b315b8f1..76e295e5 100644 --- a/packages/core/src/dom/store/features/playback.ts +++ b/packages/core/src/dom/store/features/playback.ts @@ -1,11 +1,9 @@ -import type { InferFeatureState } from '@videojs/store'; - -import { defineFeature } from '@videojs/store'; +import type { InferSliceState } from '@videojs/store'; import { listen } from '@videojs/utils/dom'; -import type { PlayerTarget } from '../../types'; +import { definePlayerFeature } from '../../feature'; -export const playbackFeature = defineFeature()({ +export const playbackFeature = definePlayerFeature({ state: ({ task }) => ({ /** Whether playback is paused. */ paused: true, @@ -59,4 +57,4 @@ export const playbackFeature = defineFeature()({ }, }); -export type PlaybackState = InferFeatureState; +export type PlaybackState = InferSliceState; diff --git a/packages/core/src/dom/store/features/source.ts b/packages/core/src/dom/store/features/source.ts index 62e95ad0..0e6b515d 100644 --- a/packages/core/src/dom/store/features/source.ts +++ b/packages/core/src/dom/store/features/source.ts @@ -1,11 +1,10 @@ -import type { InferFeatureState } from '@videojs/store'; - -import { CANCEL_ALL, defineFeature } from '@videojs/store'; +import type { InferSliceState } from '@videojs/store'; +import { CANCEL_ALL } from '@videojs/store'; import { listen } from '@videojs/utils/dom'; -import type { PlayerTarget } from '../../types'; +import { definePlayerFeature } from '../../feature'; -export const sourceFeature = defineFeature()({ +export const sourceFeature = definePlayerFeature({ state: ({ task }) => ({ /** Current media source URL (null if none). */ source: null as string | null, @@ -44,4 +43,4 @@ export const sourceFeature = defineFeature()({ }, }); -export type SourceState = InferFeatureState; +export type SourceState = InferSliceState; diff --git a/packages/core/src/dom/store/features/tests/buffer.test.ts b/packages/core/src/dom/store/features/tests/buffer.test.ts index 45f55230..c1f4d270 100644 --- a/packages/core/src/dom/store/features/tests/buffer.test.ts +++ b/packages/core/src/dom/store/features/tests/buffer.test.ts @@ -1,6 +1,7 @@ import { createStore } from '@videojs/store'; import { describe, expect, it } from 'vitest'; +import type { PlayerTarget } from '../../../types'; import { bufferFeature } from '../buffer'; describe('bufferFeature', () => { @@ -11,7 +12,7 @@ describe('bufferFeature', () => { seekable: createTimeRanges([[0, 120]]), }); - const store = createStore({ features: [bufferFeature] }); + const store = createStore()(bufferFeature); store.attach({ media: video, container: null }); expect(store.state.buffered).toEqual([[0, 60]]); @@ -27,7 +28,7 @@ describe('bufferFeature', () => { seekable: createTimeRanges([[0, 120]]), }); - const store = createStore({ features: [bufferFeature] }); + const store = createStore()(bufferFeature); store.attach({ media: video, container: null }); expect(store.state.buffered).toEqual([ @@ -42,7 +43,7 @@ describe('bufferFeature', () => { seekable: createTimeRanges([[0, 100]]), }); - const store = createStore({ features: [bufferFeature] }); + const store = createStore()(bufferFeature); store.attach({ media: video, container: null }); // Update the mock video's buffered range @@ -63,7 +64,7 @@ describe('bufferFeature', () => { seekable: createTimeRanges([[0, 100]]), }); - const store = createStore({ features: [bufferFeature] }); + const store = createStore()(bufferFeature); store.attach({ media: video, container: null }); // Update the mock video to have no buffered content diff --git a/packages/core/src/dom/store/features/tests/playback.test.ts b/packages/core/src/dom/store/features/tests/playback.test.ts index 54ed98c9..406311f9 100644 --- a/packages/core/src/dom/store/features/tests/playback.test.ts +++ b/packages/core/src/dom/store/features/tests/playback.test.ts @@ -1,6 +1,7 @@ import { createStore } from '@videojs/store'; import { describe, expect, it, vi } from 'vitest'; +import type { PlayerTarget } from '../../../types'; import { playbackFeature } from '../playback'; describe('playbackFeature', () => { @@ -13,7 +14,7 @@ describe('playbackFeature', () => { readyState: HTMLMediaElement.HAVE_ENOUGH_DATA, }); - const store = createStore({ features: [playbackFeature] }); + const store = createStore()(playbackFeature); store.attach({ media: video, container: null }); expect(store.state.paused).toBe(false); @@ -28,7 +29,7 @@ describe('playbackFeature', () => { readyState: HTMLMediaElement.HAVE_CURRENT_DATA, }); - const store = createStore({ features: [playbackFeature] }); + const store = createStore()(playbackFeature); store.attach({ media: video, container: null }); expect(store.state.waiting).toBe(true); @@ -40,7 +41,7 @@ describe('playbackFeature', () => { currentTime: 5, }); - const store = createStore({ features: [playbackFeature] }); + const store = createStore()(playbackFeature); store.attach({ media: video, container: null }); expect(store.state.started).toBe(true); @@ -52,7 +53,7 @@ describe('playbackFeature', () => { currentTime: 0, }); - const store = createStore({ features: [playbackFeature] }); + const store = createStore()(playbackFeature); store.attach({ media: video, container: null }); expect(store.state.started).toBe(true); @@ -61,7 +62,7 @@ describe('playbackFeature', () => { it('updates on play event', () => { const video = createMockVideo({ paused: true }); - const store = createStore({ features: [playbackFeature] }); + const store = createStore()(playbackFeature); store.attach({ media: video, container: null }); expect(store.state.paused).toBe(true); @@ -76,7 +77,7 @@ describe('playbackFeature', () => { it('updates on pause event', () => { const video = createMockVideo({ paused: false }); - const store = createStore({ features: [playbackFeature] }); + const store = createStore()(playbackFeature); store.attach({ media: video, container: null }); expect(store.state.paused).toBe(false); @@ -91,7 +92,7 @@ describe('playbackFeature', () => { it('updates on ended event', () => { const video = createMockVideo({ ended: false }); - const store = createStore({ features: [playbackFeature] }); + const store = createStore()(playbackFeature); store.attach({ media: video, container: null }); expect(store.state.ended).toBe(false); @@ -106,7 +107,7 @@ describe('playbackFeature', () => { it('stops listening when store is destroyed', () => { const video = createMockVideo({}); - const store = createStore({ features: [playbackFeature] }); + const store = createStore()(playbackFeature); store.attach({ media: video, container: null }); store.destroy(); @@ -125,7 +126,7 @@ describe('playbackFeature', () => { const video = createMockVideo({}); video.play = vi.fn().mockResolvedValue(undefined); - const store = createStore({ features: [playbackFeature] }); + const store = createStore()(playbackFeature); store.attach({ media: video, container: null }); await store.play(); @@ -137,7 +138,7 @@ describe('playbackFeature', () => { const video = createMockVideo({}); video.pause = vi.fn(); - const store = createStore({ features: [playbackFeature] }); + const store = createStore()(playbackFeature); store.attach({ media: video, container: null }); store.pause(); 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 0c55090b..2f6a1695 100644 --- a/packages/core/src/dom/store/features/tests/source.test.ts +++ b/packages/core/src/dom/store/features/tests/source.test.ts @@ -1,6 +1,7 @@ import { createStore } from '@videojs/store'; import { describe, expect, it, vi } from 'vitest'; +import type { PlayerTarget } from '../../../types'; import { sourceFeature } from '../source'; describe('sourceFeature', () => { @@ -12,7 +13,7 @@ describe('sourceFeature', () => { readyState: HTMLMediaElement.HAVE_ENOUGH_DATA, }); - const store = createStore({ features: [sourceFeature] }); + const store = createStore()(sourceFeature); store.attach({ media: video, container: null }); expect(store.state.source).toBe('https://example.com/video.mp4'); @@ -25,7 +26,7 @@ describe('sourceFeature', () => { Object.defineProperty(video, 'currentSrc', { value: '', writable: false }); Object.defineProperty(video, 'readyState', { value: HTMLMediaElement.HAVE_NOTHING, writable: false }); - const store = createStore({ features: [sourceFeature] }); + const store = createStore()(sourceFeature); store.attach({ media: video, container: null }); expect(store.state.source).toBe(null); @@ -38,7 +39,7 @@ describe('sourceFeature', () => { readyState: HTMLMediaElement.HAVE_NOTHING, }); - const store = createStore({ features: [sourceFeature] }); + const store = createStore()(sourceFeature); store.attach({ media: video, container: null }); expect(store.state.canPlay).toBe(false); @@ -59,7 +60,7 @@ describe('sourceFeature', () => { currentSrc: 'https://example.com/video.mp4', }); - const store = createStore({ features: [sourceFeature] }); + const store = createStore()(sourceFeature); store.attach({ media: video, container: null }); expect(store.state.source).toBe('https://example.com/video.mp4'); @@ -81,7 +82,7 @@ describe('sourceFeature', () => { readyState: HTMLMediaElement.HAVE_ENOUGH_DATA, }); - const store = createStore({ features: [sourceFeature] }); + const store = createStore()(sourceFeature); store.attach({ media: video, container: null }); expect(store.state.canPlay).toBe(true); @@ -106,7 +107,7 @@ describe('sourceFeature', () => { const video = createMockVideo({}); video.load = vi.fn(); - const store = createStore({ features: [sourceFeature] }); + const store = createStore()(sourceFeature); store.attach({ media: video, container: null }); const result = await store.loadSource('https://example.com/new.mp4'); 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 3831fc02..ed20e681 100644 --- a/packages/core/src/dom/store/features/tests/time.test.ts +++ b/packages/core/src/dom/store/features/tests/time.test.ts @@ -1,6 +1,7 @@ import { createStore } from '@videojs/store'; import { describe, expect, it } from 'vitest'; +import type { PlayerTarget } from '../../../types'; import { timeFeature } from '../time'; describe('timeFeature', () => { @@ -11,7 +12,7 @@ describe('timeFeature', () => { duration: 120, }); - const store = createStore({ features: [timeFeature] }); + const store = createStore()(timeFeature); store.attach({ media: video, container: null }); expect(store.state.currentTime).toBe(30); @@ -24,7 +25,7 @@ describe('timeFeature', () => { duration: Number.NaN, }); - const store = createStore({ features: [timeFeature] }); + const store = createStore()(timeFeature); store.attach({ media: video, container: null }); expect(store.state.duration).toBe(0); @@ -33,7 +34,7 @@ describe('timeFeature', () => { it('updates on timeupdate event', () => { const video = createMockVideo({ currentTime: 0 }); - const store = createStore({ features: [timeFeature] }); + const store = createStore()(timeFeature); store.attach({ media: video, container: null }); expect(store.state.currentTime).toBe(0); @@ -48,7 +49,7 @@ describe('timeFeature', () => { it('updates on durationchange event', () => { const video = createMockVideo({ duration: 0 }); - const store = createStore({ features: [timeFeature] }); + const store = createStore()(timeFeature); store.attach({ media: video, container: null }); expect(store.state.duration).toBe(0); @@ -63,7 +64,7 @@ describe('timeFeature', () => { it('updates on seeked event', () => { const video = createMockVideo({ currentTime: 0 }); - const store = createStore({ features: [timeFeature] }); + const store = createStore()(timeFeature); store.attach({ media: video, container: null }); // Update mock currentTime @@ -79,7 +80,7 @@ describe('timeFeature', () => { duration: 120, }); - const store = createStore({ features: [timeFeature] }); + const store = createStore()(timeFeature); store.attach({ media: video, container: null }); // Update mock to empty state @@ -96,7 +97,7 @@ describe('timeFeature', () => { describe('seek', () => { it('sets currentTime on target and waits for seeked event', async () => { const video = createMockVideo({}); - const store = createStore({ features: [timeFeature] }); + const store = createStore()(timeFeature); store.attach({ media: video, container: null }); const resultPromise = store.seek(45); diff --git a/packages/core/src/dom/store/features/tests/volume.test.ts b/packages/core/src/dom/store/features/tests/volume.test.ts index d3a27c16..5d53254f 100644 --- a/packages/core/src/dom/store/features/tests/volume.test.ts +++ b/packages/core/src/dom/store/features/tests/volume.test.ts @@ -1,6 +1,7 @@ import { createStore } from '@videojs/store'; import { describe, expect, it } from 'vitest'; +import type { PlayerTarget } from '../../../types'; import { volumeFeature } from '../volume'; describe('volumeFeature', () => { @@ -11,7 +12,7 @@ describe('volumeFeature', () => { muted: false, }); - const store = createStore({ features: [volumeFeature] }); + const store = createStore()(volumeFeature); store.attach({ media: video, container: null }); expect(store.state.volume).toBe(0.8); @@ -20,7 +21,7 @@ describe('volumeFeature', () => { it('sets volumeAvailability on attach', () => { const video = createMockVideo({}); - const store = createStore({ features: [volumeFeature] }); + const store = createStore()(volumeFeature); store.attach({ media: video, container: null }); // Should be 'available' or 'unsupported' based on browser capability @@ -30,7 +31,7 @@ describe('volumeFeature', () => { it('updates on volumechange event', () => { const video = createMockVideo({ volume: 1, muted: false }); - const store = createStore({ features: [volumeFeature] }); + const store = createStore()(volumeFeature); store.attach({ media: video, container: null }); expect(store.state.volume).toBe(1); @@ -49,7 +50,7 @@ describe('volumeFeature', () => { describe('changeVolume', () => { it('sets volume on target', async () => { const video = createMockVideo({}); - const store = createStore({ features: [volumeFeature] }); + const store = createStore()(volumeFeature); store.attach({ media: video, container: null }); const result = await store.changeVolume(0.7); @@ -60,7 +61,7 @@ describe('volumeFeature', () => { it('clamps volume to min 0', async () => { const video = createMockVideo({}); - const store = createStore({ features: [volumeFeature] }); + const store = createStore()(volumeFeature); store.attach({ media: video, container: null }); await store.changeVolume(-0.5); @@ -70,7 +71,7 @@ describe('volumeFeature', () => { it('clamps volume to max 1', async () => { const video = createMockVideo({}); - const store = createStore({ features: [volumeFeature] }); + const store = createStore()(volumeFeature); store.attach({ media: video, container: null }); await store.changeVolume(1.5); @@ -82,7 +83,7 @@ describe('volumeFeature', () => { describe('toggleMute', () => { it('toggles mute from false to true', async () => { const video = createMockVideo({ muted: false }); - const store = createStore({ features: [volumeFeature] }); + const store = createStore()(volumeFeature); store.attach({ media: video, container: null }); const result = await store.toggleMute(); @@ -93,7 +94,7 @@ describe('volumeFeature', () => { it('toggles mute from true to false', async () => { const video = createMockVideo({ muted: true }); - const store = createStore({ features: [volumeFeature] }); + const store = createStore()(volumeFeature); store.attach({ media: video, container: null }); const result = await store.toggleMute(); diff --git a/packages/core/src/dom/store/features/time.ts b/packages/core/src/dom/store/features/time.ts index 2932db0d..3f3182de 100644 --- a/packages/core/src/dom/store/features/time.ts +++ b/packages/core/src/dom/store/features/time.ts @@ -1,11 +1,9 @@ -import type { InferFeatureState } from '@videojs/store'; - -import { defineFeature } from '@videojs/store'; +import type { InferSliceState } from '@videojs/store'; import { listen, onEvent } from '@videojs/utils/dom'; -import type { PlayerTarget } from '../../types'; +import { definePlayerFeature } from '../../feature'; -export const timeFeature = defineFeature()({ +export const timeFeature = definePlayerFeature({ state: ({ task }) => ({ /** Current playback position in seconds. */ currentTime: 0, @@ -44,4 +42,4 @@ export const timeFeature = defineFeature()({ }, }); -export type TimeState = InferFeatureState; +export type TimeState = InferSliceState; diff --git a/packages/core/src/dom/store/features/volume.ts b/packages/core/src/dom/store/features/volume.ts index 6e786f5b..b560f9f7 100644 --- a/packages/core/src/dom/store/features/volume.ts +++ b/packages/core/src/dom/store/features/volume.ts @@ -1,11 +1,10 @@ -import type { InferFeatureState } from '@videojs/store'; - -import { defineFeature } from '@videojs/store'; +import type { InferSliceState } from '@videojs/store'; import { listen } from '@videojs/utils/dom'; -import type { FeatureAvailability, PlayerTarget } from '../../types'; +import { definePlayerFeature } from '../../feature'; +import type { FeatureAvailability } from '../../types'; -export const volumeFeature = defineFeature()({ +export const volumeFeature = definePlayerFeature({ state: ({ task }) => ({ /** Volume level from 0 (silent) to 1 (max). */ volume: 1, @@ -50,7 +49,7 @@ export const volumeFeature = defineFeature()({ }, }); -export type VolumeState = InferFeatureState; +export type VolumeState = InferSliceState; /** Check if volume can be programmatically set (fails on iOS Safari). */ function canSetVolume(): FeatureAvailability { diff --git a/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index 864753f2..d9ac1168 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -1,4 +1,4 @@ -import { createFeatureSelector } from '@videojs/store'; +import { createSelector } from '@videojs/store'; import { bufferFeature } from './features/buffer'; import { playbackFeature } from './features/playback'; @@ -6,8 +6,8 @@ import { sourceFeature } from './features/source'; import { timeFeature } from './features/time'; import { volumeFeature } from './features/volume'; -export const selectBuffer = createFeatureSelector(bufferFeature); -export const selectPlayback = createFeatureSelector(playbackFeature); -export const selectSource = createFeatureSelector(sourceFeature); -export const selectTime = createFeatureSelector(timeFeature); -export const selectVolume = createFeatureSelector(volumeFeature); +export const selectBuffer = createSelector(bufferFeature); +export const selectPlayback = createSelector(playbackFeature); +export const selectSource = createSelector(sourceFeature); +export const selectTime = createSelector(timeFeature); +export const selectVolume = createSelector(volumeFeature); diff --git a/packages/core/src/dom/types.ts b/packages/core/src/dom/types.ts index 5cafb4d9..610a732c 100644 --- a/packages/core/src/dom/types.ts +++ b/packages/core/src/dom/types.ts @@ -1,3 +1,5 @@ +import type { AnySlice, Slice, Store, UnionSliceState } from '@videojs/store'; + export interface Media extends HTMLMediaElement {} export interface MediaContainer extends HTMLElement {} @@ -8,3 +10,9 @@ export interface PlayerTarget { } export type FeatureAvailability = 'available' | 'unavailable' | 'unsupported'; + +export type PlayerFeature = Slice; + +export type AnyPlayerFeature = AnySlice; + +export type PlayerStore = Store>; diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index e69de29b..ae126067 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -0,0 +1,26 @@ +// Re-exports from core/dom +export { + type AnyPlayerFeature, + type BufferState, + definePlayerFeature, + type FeatureAvailability, + features, + type Media, + type MediaContainer, + type PlaybackState, + type PlayerFeature, + type PlayerStore, + type PlayerTarget, + type SourceState, + selectBuffer, + selectPlayback, + selectSource, + selectTime, + selectVolume, + type TimeState, + type VolumeState, +} from '@videojs/core/dom'; + +// Re-exports from store (utilities) +export type { Comparator, Selector } from '@videojs/store'; +export { createSelector, shallowEqual } from '@videojs/store'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 72f6f4d2..b919e0e3 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -2,12 +2,16 @@ // Re-exports from core/dom export { + type AnyPlayerFeature, type BufferState, + definePlayerFeature, type FeatureAvailability, features, type Media, type MediaContainer, type PlaybackState, + type PlayerFeature, + type PlayerStore, type PlayerTarget, type SourceState, selectBuffer, @@ -19,13 +23,9 @@ export { type VolumeState, } from '@videojs/core/dom'; -export type { AnyFeature, Feature, InferFeatureState } from '@videojs/store'; - -// Re-exports (for custom features) -export { createFeatureSelector, defineFeature } from '@videojs/store'; -export type { Comparator, Selector } from '@videojs/store/react'; - -// Re-exports (for advanced store access) +// Re-exports from store (utilities) +export type { Comparator, Selector } from '@videojs/store'; +export { createSelector, shallowEqual } from '@videojs/store'; export { useSelector, useStore } from '@videojs/store/react'; // Media primitives diff --git a/packages/react/src/player/create-player.tsx b/packages/react/src/player/create-player.tsx index 4b7042ed..6137390e 100644 --- a/packages/react/src/player/create-player.tsx +++ b/packages/react/src/player/create-player.tsx @@ -1,15 +1,15 @@ 'use client'; -import type { Media, PlayerTarget } from '@videojs/core/dom'; -import type { AnyFeature, FeatureStore, UnionFeatureState } from '@videojs/store'; -import { createStore } from '@videojs/store'; +import type { AnyPlayerFeature, Media, PlayerStore, PlayerTarget } from '@videojs/core/dom'; +import type { UnionSliceState } from '@videojs/store'; +import { combine, createStore } from '@videojs/store'; import { useStore } from '@videojs/store/react'; -import type { ReactNode } from 'react'; +import type { FC, ReactNode } from 'react'; import { useEffect, useState } from 'react'; import { Container, PlayerContextProvider, useMedia, usePlayerContext } from './context'; -export interface CreatePlayerConfig { +export interface CreatePlayerConfig { features: Features; displayName?: string; } @@ -18,26 +18,26 @@ export interface ProviderProps { children: ReactNode; } -export interface CreatePlayerResult { - Provider: (props: ProviderProps) => ReactNode; +export interface CreatePlayerResult { + Provider: FC; Container: typeof Container; usePlayer: UsePlayerHook; useMedia: () => Media | null; } -type UsePlayerHook = { - (): FeatureStore; - (selector: (state: UnionFeatureState) => R): R; +type UsePlayerHook = { + (): PlayerStore; + (selector: (state: UnionSliceState) => R): R; }; -export function createPlayer[]>( +export function createPlayer( config: CreatePlayerConfig ): CreatePlayerResult { - type Store = FeatureStore; - type State = UnionFeatureState; + type Store = PlayerStore; + type State = UnionSliceState; function Provider({ children }: ProviderProps): ReactNode { - const [store] = useState(() => createStore({ features: config.features })); + const [store] = useState(() => createStore()(combine(...config.features))); const [media, setMedia] = useState(null); useEffect(() => () => store.destroy(), [store]); diff --git a/packages/react/src/player/tests/create-player.test.tsx b/packages/react/src/player/tests/create-player.test.tsx index c8dedacf..cf03f1e3 100644 --- a/packages/react/src/player/tests/create-player.test.tsx +++ b/packages/react/src/player/tests/create-player.test.tsx @@ -1,13 +1,13 @@ import { render, renderHook } from '@testing-library/react'; -import { defineFeature } from '@videojs/store'; +import type { PlayerStore } from '@videojs/core/dom'; +import { defineSlice } from '@videojs/store'; import type { ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; - import { createPlayer } from '../create-player'; describe('createPlayer', () => { - // Create a mock feature that works with any target - const mockFeature = defineFeature()({ + // Create a mock slice that works with any target + const mockSlice = defineSlice()({ state: () => ({ volume: 1, muted: false, @@ -17,9 +17,9 @@ describe('createPlayer', () => { describe('Provider', () => { it('creates store on mount', () => { - const { Provider, usePlayer } = createPlayer({ features: [mockFeature] as any }); + const { Provider, usePlayer } = createPlayer({ features: [mockSlice] }); - let store: unknown; + let store!: PlayerStore; function TestComponent() { store = usePlayer(); @@ -33,15 +33,15 @@ describe('createPlayer', () => { ); expect(store).toBeDefined(); - expect(typeof (store as any).subscribe).toBe('function'); - expect(typeof (store as any).attach).toBe('function'); - expect(typeof (store as any).destroy).toBe('function'); + expect(typeof store.subscribe).toBe('function'); + expect(typeof store.attach).toBe('function'); + expect(typeof store.destroy).toBe('function'); }); it('destroys store on unmount', () => { - const { Provider, usePlayer } = createPlayer({ features: [mockFeature] as any }); + const { Provider, usePlayer } = createPlayer({ features: [mockSlice] }); - let store: any; + let store!: PlayerStore; function TestComponent() { store = usePlayer(); @@ -62,15 +62,15 @@ describe('createPlayer', () => { it('uses displayName when provided', () => { const { Provider } = createPlayer({ - features: [mockFeature] as any, + features: [mockSlice], displayName: 'VideoPlayer', }); - expect((Provider as any).displayName).toBe('VideoPlayer.Provider'); + expect(Provider.displayName).toBe('VideoPlayer.Provider'); }); it('renders children', () => { - const { Provider } = createPlayer({ features: [mockFeature] as any }); + const { Provider } = createPlayer({ features: [mockSlice] }); const { container } = render( @@ -84,7 +84,7 @@ describe('createPlayer', () => { describe('usePlayer', () => { it('returns store without selector', () => { - const { Provider, usePlayer } = createPlayer({ features: [mockFeature] as any }); + const { Provider, usePlayer } = createPlayer({ features: [mockSlice] }); const wrapper = ({ children }: { children: ReactNode }) => {children}; @@ -96,7 +96,7 @@ describe('createPlayer', () => { }); it('returns selected state with selector', () => { - const { Provider, usePlayer } = createPlayer({ features: [mockFeature] as any }); + const { Provider, usePlayer } = createPlayer({ features: [mockSlice] }); const wrapper = ({ children }: { children: ReactNode }) => {children}; @@ -106,7 +106,7 @@ describe('createPlayer', () => { }); it('throws outside Provider', () => { - const { usePlayer } = createPlayer({ features: [mockFeature] as any }); + const { usePlayer } = createPlayer({ features: [mockSlice] }); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -120,16 +120,16 @@ describe('createPlayer', () => { describe('Container', () => { it('is exported from createPlayer result', () => { - const { Container } = createPlayer({ features: [mockFeature] as any }); + const { Container } = createPlayer({ features: [mockSlice] }); expect(Container).toBeDefined(); }); }); describe('full integration', () => { it('Provider → Container → media attach flow', () => { - const { Provider, Container, usePlayer } = createPlayer({ features: [mockFeature] as any }); + const { Provider, Container, usePlayer } = createPlayer({ features: [mockSlice] }); - let store: any; + let store!: PlayerStore; function TestComponent() { store = usePlayer(); diff --git a/packages/store/README.md b/packages/store/README.md index aa5fa1e5..46ae6172 100644 --- a/packages/store/README.md +++ b/packages/store/README.md @@ -20,42 +20,43 @@ npm install @videojs/store - **Write Path**: Send requests, coordinate execution, handle failures ```ts -import { createStore } from '@videojs/store'; +import { createStore, defineSlice } from '@videojs/store'; -const store = createStore({ - features: [playbackFeature, volumeFeature], +const volumeSlice = defineSlice()({ + state: () => ({ volume: 1 }), + attach: ({ target, set, signal }) => { + const sync = () => set({ volume: target.volume }); + target.addEventListener('volumechange', sync, { signal }); + }, }); +const store = createStore()(volumeSlice); store.attach(videoElement); // State is flat on the store -const { paused, volume } = store; - -// Actions are flat on the store -await store.play(); -store.setVolume(0.5); +const { volume } = store; ``` ## Core Concepts ### Target -The target is a reference to the external system. Features read from and write to it. +The target is a reference to the external system. Slices read from and write to it. ```ts const videoElement = document.querySelector('video'); store.attach(videoElement); ``` -### Features +### Slices -A feature defines state, how to sync it from the target, and actions to modify the target. +A slice defines state, how to sync it from the target, and actions to modify the target. ```ts -import { defineFeature } from '@videojs/store'; +import { defineSlice } from '@videojs/store'; import { listen } from '@videojs/utils/dom'; -const volumeFeature = defineFeature()({ +const volumeSlice = defineSlice()({ state: ({ task, target }) => ({ volume: 1, muted: false, @@ -87,31 +88,48 @@ const volumeFeature = defineFeature()({ }); ``` -### Feature Type Inference +### Slice Type Inference -State types are fully inferred from the feature config: +State types are fully inferred from the slice config: ```ts -import type { InferFeatureState } from '@videojs/store'; +import type { InferSliceState } from '@videojs/store'; -const volumeFeature = defineFeature()({ +const volumeSlice = defineSlice()({ state: () => ({ volume: 1, muted: false, /* actions */ }), // ... }); -// Infer types from the feature -type VolumeState = InferFeatureState; +// Infer types from the slice +type VolumeState = InferSliceState; // { volume: number; muted: boolean; changeVolume: ...; toggleMute: ... } ``` -For stores with multiple features: +### Combining Slices + +Use `combine` to merge multiple slices into one: ```ts -import type { UnionFeatureState } from '@videojs/store'; +import { combine, createStore, defineSlice } from '@videojs/store'; -const features = [volumeFeature, playbackFeature] as const; +const volumeSlice = defineSlice()({ /* ... */ }); +const playbackSlice = defineSlice()({ /* ... */ }); -type MediaState = UnionFeatureState; +// Combine into a single slice +const mediaSlice = combine(volumeSlice, playbackSlice); +const store = createStore()(mediaSlice); +``` + +Behavior: +- State factories are called in order, results merged (last wins on conflict) +- All attach handlers run; errors are caught and reported via `reportError` +- Use `UnionSliceState` for combined state type inference + +```ts +import type { UnionSliceState } from '@videojs/store'; + +const slices = [volumeSlice, playbackSlice] as const; +type MediaState = UnionSliceState; ``` ### Actions @@ -192,32 +210,37 @@ changeVolume(volume: number) { ## Store -The store composes features and manages the target connection. +The store connects a slice to a target. ```ts -const store = createStore({ - features: [playbackFeature, volumeFeature], +// Simple +const store = createStore()(volumeSlice); - onSetup: ({ store, signal }) => { - // Called when store is created - }, +// With combined slices and options +const store = createStore()( + combine(volumeSlice, playbackSlice), + { + onSetup: ({ store, signal }) => { + // Called when store is created + }, - onAttach: ({ store, target, signal }) => { - // Called when target is attached - }, + onAttach: ({ store, target, signal }) => { + // Called when target is attached + }, - onError: ({ error, store }) => { - // Global error handler - }, + onError: ({ error, store }) => { + // Global error handler + }, - onTaskStart: ({ key, meta }) => { - // Called when a tracked task starts - }, + onTaskStart: ({ key, meta }) => { + // Called when a tracked task starts + }, - onTaskEnd: ({ key, meta, error }) => { - // Called when a tracked task completes - }, -}); + onTaskEnd: ({ key, meta, error }) => { + // Called when a tracked task completes + }, + } +); ``` ### Type Inference @@ -225,7 +248,7 @@ const store = createStore({ ```ts import type { InferStoreState, InferStoreTarget } from '@videojs/store'; -const store = createStore({ features: [volumeFeature, playbackFeature] }); +const store = createStore()(volumeSlice); type State = InferStoreState; type Target = InferStoreTarget; @@ -417,8 +440,7 @@ Handle errors locally via the promise, or globally via `onError`: import { isStoreError } from '@videojs/store'; // Global error handling -const store = createStore({ - features: [playbackFeature], +const store = createStore()(volumeSlice, { onError: ({ error, store }) => { console.error('Store error:', error); }, diff --git a/packages/store/src/core/combine.ts b/packages/store/src/core/combine.ts new file mode 100644 index 00000000..32e19efd --- /dev/null +++ b/packages/store/src/core/combine.ts @@ -0,0 +1,28 @@ +import type { AttachContext, InferSliceState, Slice, StateContext, UnionSliceState } from './slice'; + +/** + * Combines multiple slices into a single slice. + * + * @param slices - The slices to combine. + * @returns A new slice that represents the combination of the input slices. + */ +export function combine[]>( + ...slices: Slices +): Slice> { + return { + state: (ctx: StateContext) => { + const states = slices.map((slice) => slice.state(ctx)); + return Object.assign({}, ...states) as UnionSliceState; + }, + + attach: (ctx: AttachContext>) => { + for (const slice of slices) { + try { + slice.attach?.(ctx as AttachContext>); + } catch (err) { + ctx.reportError(err); + } + } + }, + }; +} diff --git a/packages/store/src/core/config.ts b/packages/store/src/core/config.ts index 24dc8798..c5464c40 100644 --- a/packages/store/src/core/config.ts +++ b/packages/store/src/core/config.ts @@ -1,4 +1,3 @@ -import type { AnyFeature, UnionFeatureState, UnionFeatureTarget } from './feature'; import type { TaskKey } from './queue'; import type { RequestMeta } from './request'; import type { Store } from './store'; @@ -9,11 +8,6 @@ export interface PendingTask { startedAt: number; } -export interface StoreConfig - extends StoreCallbacks, UnionFeatureState> { - features: Features; -} - export interface StoreCallbacks { onSetup?: (ctx: StoreSetupContext) => void; onAttach?: (ctx: StoreAttachContext) => void; diff --git a/packages/store/src/core/feature-selector.ts b/packages/store/src/core/feature-selector.ts deleted file mode 100644 index 30462662..00000000 --- a/packages/store/src/core/feature-selector.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { pick } from '@videojs/utils/object'; -import { StoreError } from './errors'; -import type { AnyFeature, InferFeatureState, StateFactoryContext } from './feature'; - -const stateContext: StateFactoryContext = { - task: () => { - throw new StoreError('NO_TARGET'); - }, - target: () => { - throw new StoreError('NO_TARGET'); - }, -}; - -/** - * Create a type-safe selector for a feature's state. - * - * The selector returns the feature's state slice, or `undefined` if the feature - * is not configured in the store. - * - * @example - * ```ts - * const selectPlayback = createFeatureSelector(playbackFeature); - * - * function PlayButton() { - * const playback = usePlayer(selectPlayback); - * if (!playback) return null; // Feature not configured - * - * return ; - * } - * ``` - */ -export function createFeatureSelector( - feature: F -): (state: Record) => InferFeatureState | undefined { - const initialState = feature.state(stateContext); - const keys = Object.keys(initialState); - - const firstKey = keys[0]; - if (!firstKey) return () => undefined; - - return (state) => { - // WARN: Could be the source of a bug if two features have overlapping state keys - if (!(firstKey in state)) return undefined; - return pick(state, keys) as InferFeatureState; - }; -} diff --git a/packages/store/src/core/index.ts b/packages/store/src/core/index.ts index abdbb108..01abb6d0 100644 --- a/packages/store/src/core/index.ts +++ b/packages/store/src/core/index.ts @@ -1,10 +1,12 @@ +export { combine } from './combine'; export * from './config'; export * from './errors'; -export * from './feature'; -export { createFeatureSelector } from './feature-selector'; 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'; +export * from './slice'; export * from './state'; export * from './store'; diff --git a/packages/store/src/core/selector.ts b/packages/store/src/core/selector.ts new file mode 100644 index 00000000..cd4ae99e --- /dev/null +++ b/packages/store/src/core/selector.ts @@ -0,0 +1,40 @@ +import { pick } from '@videojs/utils/object'; +import { StoreError } from './errors'; +import type { AnySlice, InferSliceState, StateContext } from './slice'; + +const stateContext: StateContext = { + task: () => { + throw new StoreError('NO_TARGET'); + }, + target: () => { + throw new StoreError('NO_TARGET'); + }, +}; + +/** + * Create a type-safe selector for a slice's state. + * + * The selector returns the slice's state, or `undefined` if the slice + * is not configured in the store. + * + * @example + * ```ts + * const selectPlayback = createSelector(playbackSlice); + * selectPlayback(store.state); // { paused, play, pause, ... } | undefined + * ``` + */ +export function createSelector( + slice: S +): (state: Record) => InferSliceState | undefined { + const initialState = slice.state(stateContext); + const keys = Object.keys(initialState as object); + + const firstKey = keys[0]; + if (!firstKey) return () => undefined; + + return (state) => { + // WARN: Could be the source of a bug if two slices have overlapping state keys + if (!(firstKey in state)) return undefined; + return pick(state, keys) as InferSliceState; + }; +} diff --git a/packages/store/src/core/shallow-equal.ts b/packages/store/src/core/shallow-equal.ts index f248d2bf..c4a2b626 100644 --- a/packages/store/src/core/shallow-equal.ts +++ b/packages/store/src/core/shallow-equal.ts @@ -1,3 +1,7 @@ +export type Selector = (state: State) => Result; + +export type Comparator = (a: T, b: T) => boolean; + const hasOwn = Object.prototype.hasOwnProperty; export function shallowEqual(a: T, b: T): boolean { diff --git a/packages/store/src/core/feature.ts b/packages/store/src/core/slice.ts similarity index 50% rename from packages/store/src/core/feature.ts rename to packages/store/src/core/slice.ts index 0b750e33..26ae0caf 100644 --- a/packages/store/src/core/feature.ts +++ b/packages/store/src/core/slice.ts @@ -1,9 +1,7 @@ -import { isObject } from '@videojs/utils/predicate'; import type { Simplify, UnionToIntersection } from '@videojs/utils/types'; import type { TaskKey, TaskMode } from './queue'; import type { RequestMeta } from './request'; - -const FEATURE_SYMBOL = Symbol('@videojs/feature'); +import type { UnknownState } from './state'; // ---------------------------------------- // Task @@ -36,67 +34,58 @@ export interface TaskContext { export type Attach = (ctx: AttachContext) => void; +export interface AttachStore { + readonly state: UnknownState; + subscribe: (callback: () => void) => () => void; +} + export interface AttachContext { target: Target; signal: AbortSignal; + store: AttachStore; get: () => Readonly; set: (partial: Partial) => void; - /** Store instance for cross-feature access via selectors. */ - store: { readonly state: Readonly; subscribe: (callback: () => void) => () => void }; + reportError: (error: unknown) => void; } // ---------------------------------------- -// Feature Context +// State Context // ---------------------------------------- -/** Context passed to state factory - uses loose types to enable State inference. */ -export interface StateFactoryContext { - task: Task; +export interface StateContext { + task: Task; target: () => Target; } // ---------------------------------------- -// Feature +// Slice // ---------------------------------------- -export type StateFactory = (ctx: StateFactoryContext) => State; - -export interface FeatureConfig { - state: StateFactory; - attach?: Attach; +export interface SliceConfig { + state: (ctx: StateContext) => State; + attach?: (ctx: AttachContext) => void; } -export interface Feature extends FeatureConfig { - [FEATURE_SYMBOL]: true; -} +export type Slice = SliceConfig; -export type AnyFeature = Feature; +export type AnySlice = Slice; // ---------------------------------------- // Factory // ---------------------------------------- -export function defineFeature(): (config: FeatureConfig) => Feature { - return (config: FeatureConfig): Feature => ({ - [FEATURE_SYMBOL]: true, - ...config, - }); -} +export type SliceFactory = (config: SliceConfig) => Slice; -export function isFeature(value: unknown): value is AnyFeature { - return isObject(value) && FEATURE_SYMBOL in value; +export function defineSlice(): SliceFactory { + return (config) => config; } // ---------------------------------------- // Inference // ---------------------------------------- -export type InferFeatureTarget = F extends Feature ? Target : never; +export type InferSliceTarget = S extends Slice ? Target : never; -export type InferFeatureState = F extends Feature ? State : never; +export type InferSliceState = S extends Slice ? State : never; -export type UnionFeatureTarget = InferFeatureTarget; - -export type UnionFeatureState = Simplify< - UnionToIntersection> ->; +export type UnionSliceState = Simplify>>; diff --git a/packages/store/src/core/store.ts b/packages/store/src/core/store.ts index ad900285..f3b6b1ef 100644 --- a/packages/store/src/core/store.ts +++ b/packages/store/src/core/store.ts @@ -1,266 +1,248 @@ import type { EventLike } from '@videojs/utils/events'; import { isFunction, isNull, isObject } from '@videojs/utils/predicate'; -import type { PendingTask, StoreConfig } from './config'; +import type { PendingTask, StoreCallbacks } from './config'; import { StoreError } from './errors'; -import type { - AnyFeature, - AttachContext, - StateFactoryContext, - TaskContext, - TaskHandler, - TaskOptions, - UnionFeatureState, - UnionFeatureTarget, -} from './feature'; 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 type { StateChange, UnknownState, WritableState } from './state'; import { createState } from './state'; const STORE_SYMBOL = Symbol('@videojs/store'); -export function createStore(config: StoreConfig): FeatureStore { - type Store = FeatureStore; - type Target = InferStoreTarget; - type State = UnknownState; +export interface StoreOptions extends StoreCallbacks {} - const { features } = config; +export function createStore(): ( + slice: Slice, + options?: StoreOptions +) => Store { + return (slice: Slice, options: StoreOptions = {}): Store => { + type TargetStore = Store; - // Closure state - let target: Target | null = null; - let destroyed = false; - let attachAbort: AbortController | null = null; + // Closure state + let target: Target | null = null; + let destroyed = false; + let attachAbort: AbortController | null = null; - const setupAbort = new AbortController(); - const queue = new Queue(); - const pending: Record = {}; + const setupAbort = new AbortController(); + const queue = new Queue(); + const pending: Record = {}; - // Reactive state - initialized after building features - let state: WritableState; + // Reactive state - initialized after building slice state + let state: WritableState; - const initialState = createInitialState({ - task: executeTask, - target: () => { - if (!target) throw new StoreError('NO_TARGET'); - return target; - }, - }); - - state = createState(initialState); - - const store = { - [STORE_SYMBOL]: true, - get target() { - return target; - }, - get destroyed() { - return destroyed; - }, - get pending() { - return pending; - }, - get state() { - return state.current; - }, - attach, - destroy, - subscribe, - meta, - } as unknown as Store; - - for (const key of Object.keys(initialState)) { - Object.defineProperty(store, key, { - get: () => state.current[key], - enumerable: true, - }); - } - - // 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 { - config.onSetup?.({ store, signal: setupAbort.signal }); - } catch (error) { - handleError(error); - } - - return store; - - function attach(newTarget: Target): () => void { - if (destroyed) throw new StoreError('DESTROYED'); - - attachAbort?.abort(); - target = newTarget; - attachAbort = new AbortController(); - const signal = attachAbort.signal; - - // Create attach context once, share across all features - const attachContext: AttachContext = { - target: newTarget, - signal, - get: () => state.current, - set: (partial) => state.patch(partial), - store: { - get state() { - return state.current; - }, - subscribe, + const initialState = slice.state({ + task: executeTask, + target: () => { + if (!target) throw new StoreError('NO_TARGET'); + return target; }, - }; + } satisfies StateContext); - for (const feature of features) { - try { - feature.attach?.(attachContext); - } catch (error) { - handleError(error); - } + state = createState(initialState); + + const store = { + [STORE_SYMBOL]: true, + get target() { + return target; + }, + 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)) { + Object.defineProperty(store, key, { + get: () => state.current[key as keyof State], + enumerable: true, + }); } + // 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 { - config.onAttach?.({ - store, - target: newTarget as any, - signal, - }); + options.onSetup?.({ store, signal: setupAbort.signal }); } catch (error) { - handleError(error); + reportError(error); } - return detach; - } + return store; - function detach(): void { - if (isNull(target)) return; - attachAbort?.abort(); - attachAbort = null; - target = null; - queue.abort(); - state.patch(initialState); - } + function attach(newTarget: Target): () => void { + if (destroyed) throw new StoreError('DESTROYED'); - function destroy(): void { - if (destroyed) return; - destroyed = true; - detach(); - setupAbort.abort(); - queue.destroy(); - } + attachAbort?.abort(); + target = newTarget; + attachAbort = new AbortController(); + const signal = attachAbort.signal; - function subscribe(callback: StateChange): () => void { - return state.subscribe(callback); - } - - function meta(eventOrMeta: EventLike | RequestMetaInit): Store { - currentMeta = - 'isTrusted' in eventOrMeta - ? createRequestMetaFromEvent(eventOrMeta as EventLike) - : createRequestMeta(eventOrMeta as RequestMetaInit); - - return metaProxy as Store; - } - - function createInitialState(ctx: StateFactoryContext): State { - const result: Record = {}; - - for (const feature of features) { - const featureResult = feature.state(ctx); - Object.assign(result, featureResult); - } - - return result as State; - } - - 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 options: TaskOptions = isFunction(handlerOrOptions) - ? { handler: handlerOrOptions } - : handlerOrOptions; - - const { key, mode = 'exclusive', cancels, handler } = options; - - const meta = 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, startedAt: Date.now() }; - config.onTaskStart?.({ key, meta }); - } - - const queueHandler = async ({ signal }: { signal: AbortSignal }) => { - if (!target) throw new StoreError('NO_TARGET'); - - const ctx: TaskContext = { - target, + // Create attach context + const attachContext: AttachContext = { + target: newTarget, signal, get: () => state.current, - meta, + set: (partial) => state.patch(partial), + reportError, + store: { + get state() { + return state.current; + }, + subscribe, + }, }; - return handler(ctx); - }; - - try { - const result = await queue.enqueue({ - key: key ?? Symbol('@videojs/task'), - mode, - handler: queueHandler, - }); - - if (key) { - delete pending[key as string]; - config.onTaskEnd?.({ key, meta }); + try { + slice.attach?.(attachContext); + } catch (error) { + reportError(error); } - return result as Awaited; - } catch (error) { - if (key) { - delete pending[key as string]; - config.onTaskEnd?.({ key, meta, error }); + try { + options.onAttach?.({ + store, + target: newTarget, + signal, + }); + } catch (error) { + reportError(error); } - handleError(error); - throw error; + return detach; } - } - function handleError(error: unknown): void { - if (config.onError) { - config.onError({ store, error }); - } else { - console.error('[vjs-store]', error); + function detach(): void { + if (isNull(target)) return; + attachAbort?.abort(); + attachAbort = null; + target = null; + queue.abort(); + state.patch(initialState); } - } + + function destroy(): void { + if (destroyed) return; + 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 }); + } else { + console.error('[vjs-store]', error); + } + } + }; } export function isStore(value: unknown): value is AnyStore { @@ -285,11 +267,6 @@ export interface BaseStore { export type Store = BaseStore & State; -export type FeatureStore = Store< - UnionFeatureTarget, - UnionFeatureState ->; - export type AnyStore = BaseStore; export type UnknownStore = Store; diff --git a/packages/store/src/core/tests/integration/store.test.ts b/packages/store/src/core/tests/integration/store.test.ts index 4a357f03..9c9fd0d2 100644 --- a/packages/store/src/core/tests/integration/store.test.ts +++ b/packages/store/src/core/tests/integration/store.test.ts @@ -1,7 +1,7 @@ import { noop } from '@videojs/utils/function'; import { describe, expect, it } from 'vitest'; -import { createStore, defineFeature } from '../../index'; +import { combine, createStore, defineSlice } from '../../index'; describe('store lifecycle integration', () => { it('full lifecycle: create → attach → use → detach → destroy', async () => { @@ -11,7 +11,7 @@ describe('store lifecycle integration', () => { value = 0; } - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ count: 0, increment() { @@ -24,7 +24,7 @@ describe('store lifecycle integration', () => { }), attach({ target: t, signal, set }) { - events.push('attach-feature'); + events.push('attach-slice'); set({ count: t.value }); t.addEventListener('change', () => set({ count: t.value }), { signal }); @@ -32,8 +32,7 @@ describe('store lifecycle integration', () => { }, }); - const store = createStore({ - features: [feature], + const store = createStore()(slice, { onSetup: () => events.push('setup'), onAttach: () => events.push('attach'), }); @@ -44,7 +43,7 @@ describe('store lifecycle integration', () => { targetInstance.value = 5; const detach = store.attach(targetInstance); - expect(events).toEqual(['setup', 'attach-feature', 'attach']); + expect(events).toEqual(['setup', 'attach-slice', 'attach']); expect(store.state.count).toBe(5); await store.increment(); @@ -64,7 +63,7 @@ describe('task coordination', () => { it('cancels option aborts related tasks', async () => { const events: string[] = []; - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ loading: false, load() { @@ -97,10 +96,7 @@ describe('task coordination', () => { }), }); - const store = createStore({ - features: [feature], - onError: () => {}, - }); + const store = createStore()(slice, { onError: () => {} }); store.attach({}); @@ -120,7 +116,7 @@ describe('task coordination', () => { it('different keys enable parallel execution', async () => { const completionOrder: number[] = []; - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ fetching: false, fetchTrack(id: number) { @@ -136,7 +132,7 @@ describe('task coordination', () => { }), }); - const store = createStore({ features: [feature] }); + const store = createStore()(slice); store.attach({}); const [r3, r1, r2] = await Promise.all([store.fetchTrack(3), store.fetchTrack(1), store.fetchTrack(2)]); @@ -150,7 +146,7 @@ describe('task coordination', () => { it('same key tasks supersede each other', async () => { const executed: string[] = []; - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ running: false, action(name: string) { @@ -173,10 +169,7 @@ describe('task coordination', () => { }), }); - const store = createStore({ - features: [feature], - onError: () => {}, - }); + const store = createStore()(slice, { onError: () => {} }); store.attach({}); @@ -199,7 +192,7 @@ describe('task coordination', () => { it('mode: shared allows multiple tasks to share fate', async () => { let handlerCallCount = 0; - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ playing: false, play() { @@ -216,7 +209,7 @@ describe('task coordination', () => { }), }); - const store = createStore({ features: [feature] }); + const store = createStore()(slice); store.attach({}); const p1 = store.play(); @@ -231,7 +224,7 @@ describe('task coordination', () => { }); it('mode: shared rejects all promises together on error', async () => { - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ playing: false, play() { @@ -247,10 +240,7 @@ describe('task coordination', () => { }), }); - const store = createStore({ - features: [feature], - onError: () => {}, - }); + const store = createStore()(slice, { onError: () => {} }); store.attach({}); @@ -264,7 +254,7 @@ describe('task coordination', () => { it('mode: shared allows new task after previous completes', async () => { let callCount = 0; - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ playing: false, play() { @@ -281,7 +271,7 @@ describe('task coordination', () => { }), }); - const store = createStore({ features: [feature] }); + const store = createStore()(slice); store.attach({}); const p1 = store.play(); @@ -299,8 +289,8 @@ describe('task coordination', () => { }); describe('state syncing', () => { - it('multiple features merge state correctly', () => { - const audioFeature = defineFeature<{ volume: number; rate: number }>()({ + it('multiple slices merge state correctly', () => { + const audioSlice = defineSlice<{ volume: number; rate: number }>()({ state: () => ({ volume: 1 }), attach({ target, set }) { @@ -308,7 +298,7 @@ describe('state syncing', () => { }, }); - const playbackFeature = defineFeature<{ volume: number; rate: number }>()({ + const playbackSlice = defineSlice<{ volume: number; rate: number }>()({ state: () => ({ rate: 1 }), attach({ target, set }) { @@ -316,9 +306,7 @@ describe('state syncing', () => { }, }); - const store = createStore({ - features: [audioFeature, playbackFeature], - }); + const store = createStore<{ volume: number; rate: number }>()(combine(audioSlice, playbackSlice)); const target = { volume: 0.5, rate: 1.5 }; store.attach(target); @@ -340,7 +328,7 @@ describe('immediate execution', () => { } } - const playbackFeature = defineFeature()({ + const playbackSlice = defineSlice()({ state: ({ task }) => ({ paused: true, play() { @@ -357,7 +345,7 @@ describe('immediate execution', () => { }, }); - const store = createStore({ features: [playbackFeature] }); + const store = createStore()(playbackSlice); const target = new MockMedia(); store.attach(target); @@ -374,7 +362,7 @@ describe('meta tracing', () => { it('store.meta() passes meta to task handlers', async () => { let receivedMeta: unknown = null; - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ playing: false, play() { @@ -388,7 +376,7 @@ describe('meta tracing', () => { }), }); - const store = createStore({ features: [feature] }); + const store = createStore()(slice); store.attach({}); await store.meta({ source: 'user', reason: 'button-click' }).play(); @@ -402,7 +390,7 @@ describe('meta tracing', () => { it('onTaskStart and onTaskEnd callbacks fire', async () => { const events: string[] = []; - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ count: 0, increment() { @@ -416,8 +404,7 @@ describe('meta tracing', () => { }), }); - const store = createStore({ - features: [feature], + const store = createStore()(slice, { onTaskStart: ({ key }) => events.push(`start:${String(key)}`), onTaskEnd: ({ key, error }) => events.push(`end:${String(key)}${error ? ':error' : ''}`), }); @@ -430,7 +417,7 @@ describe('meta tracing', () => { }); it('pending tracks running tasks', async () => { - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ loading: false, load() { @@ -444,7 +431,7 @@ describe('meta tracing', () => { }), }); - const store = createStore({ features: [feature] }); + const store = createStore()(slice); store.attach({}); expect(store.pending.load).toBeUndefined(); @@ -467,7 +454,7 @@ describe('sync actions', () => { volume = 1; } - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ volume: 1, setVolume(value: number) { @@ -482,7 +469,7 @@ describe('sync actions', () => { }, }); - const store = createStore({ features: [feature] }); + const store = createStore()(slice); const targetInstance = new Target(); store.attach(targetInstance); @@ -492,7 +479,7 @@ describe('sync actions', () => { }); it('task throws when not attached', async () => { - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ value: 0, doSomething() { @@ -501,7 +488,7 @@ describe('sync actions', () => { }), }); - const store = createStore({ features: [feature], onError: noop }); + const store = createStore()(slice, { onError: noop }); await expect(store.doSomething()).rejects.toThrow('NO_TARGET'); }); diff --git a/packages/store/src/core/tests/feature-selector.test.ts b/packages/store/src/core/tests/selector.test.ts similarity index 70% rename from packages/store/src/core/tests/feature-selector.test.ts rename to packages/store/src/core/tests/selector.test.ts index a708902a..50f0433d 100644 --- a/packages/store/src/core/tests/feature-selector.test.ts +++ b/packages/store/src/core/tests/selector.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { defineFeature } from '../feature'; -import { createFeatureSelector } from '../feature-selector'; +import { createSelector } from '../selector'; +import { defineSlice } from '../slice'; interface MockMedia { volume: number; } -describe('createFeatureSelector', () => { - const volumeFeature = defineFeature()({ +describe('createSelector', () => { + const volumeSlice = defineSlice()({ state: ({ task }) => ({ volume: 1, muted: false, @@ -20,15 +20,15 @@ describe('createFeatureSelector', () => { }), }); - const playbackFeature = defineFeature()({ + const playbackSlice = defineSlice()({ state: () => ({ paused: true, ended: false, }), }); - it('selects feature state from store state', () => { - const selectVolume = createFeatureSelector(volumeFeature); + it('selects slice state from store state', () => { + const selectVolume = createSelector(volumeSlice); const state = { volume: 0.5, muted: true, setVolume: () => Promise.resolve(0.5) }; const selected = selectVolume(state); @@ -40,8 +40,8 @@ describe('createFeatureSelector', () => { }); }); - it('returns undefined when feature is not configured', () => { - const selectVolume = createFeatureSelector(volumeFeature); + it('returns undefined when slice is not configured', () => { + const selectVolume = createSelector(volumeSlice); const state = { paused: true, ended: false }; // No volume keys const selected = selectVolume(state); @@ -49,9 +49,9 @@ describe('createFeatureSelector', () => { expect(selected).toBeUndefined(); }); - it('creates separate selectors for different features', () => { - const selectVolume = createFeatureSelector(volumeFeature); - const selectPlayback = createFeatureSelector(playbackFeature); + it('creates separate selectors for different slices', () => { + const selectVolume = createSelector(volumeSlice); + const selectPlayback = createSelector(playbackSlice); const state = { volume: 0.75, muted: false, @@ -75,7 +75,7 @@ describe('createFeatureSelector', () => { }); it('returns stable references when state values are the same', () => { - const selectVolume = createFeatureSelector(volumeFeature); + const selectVolume = createSelector(volumeSlice); const setVolume = () => Promise.resolve(1); const state1 = { volume: 1, muted: false, setVolume }; const state2 = { volume: 1, muted: false, setVolume }; diff --git a/packages/store/src/core/tests/feature.test.ts b/packages/store/src/core/tests/slice.test.ts similarity index 57% rename from packages/store/src/core/tests/feature.test.ts rename to packages/store/src/core/tests/slice.test.ts index 7e2cd433..547c3c7a 100644 --- a/packages/store/src/core/tests/feature.test.ts +++ b/packages/store/src/core/tests/slice.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; -import { defineFeature, isFeature } from '../feature'; +import { defineSlice } from '../slice'; -describe('defineFeature', () => { - it('creates feature with state factory and optional attach', () => { +describe('defineSlice', () => { + it('creates slice with state factory and optional attach', () => { interface Target { value: number; } - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ count: 0, increment(amount: number) { @@ -26,8 +26,8 @@ describe('defineFeature', () => { }, }); - expect(feature.state).toBeTypeOf('function'); - expect(feature.attach).toBeTypeOf('function'); + expect(slice.state).toBeTypeOf('function'); + expect(slice.attach).toBeTypeOf('function'); }); it('factory receives task and target helpers', () => { @@ -37,12 +37,10 @@ describe('defineFeature', () => { const factorySpy = vi.fn().mockReturnValue({ count: 0 }); - defineFeature()({ + defineSlice()({ state: factorySpy, }); - // Can't call the factory directly, but we can verify the shape - // The factory will be called by the store when building features expect(factorySpy).not.toHaveBeenCalled(); }); @@ -51,7 +49,7 @@ describe('defineFeature', () => { volume: number; } - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ volume: 1, setVolume(value: number) { @@ -65,7 +63,7 @@ describe('defineFeature', () => { }), }); - expect(feature.state).toBeTypeOf('function'); + expect(slice.state).toBeTypeOf('function'); }); it('allows async actions using task()', () => { @@ -73,7 +71,7 @@ describe('defineFeature', () => { play: () => Promise; } - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ playing: false, play() { @@ -85,7 +83,7 @@ describe('defineFeature', () => { }), }); - expect(feature.state).toBeTypeOf('function'); + expect(slice.state).toBeTypeOf('function'); }); it('supports task shorthand (fire-and-forget)', () => { @@ -94,7 +92,7 @@ describe('defineFeature', () => { load: () => void; } - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ loading: false, load(src: string) { @@ -106,32 +104,15 @@ describe('defineFeature', () => { }), }); - expect(feature.state).toBeTypeOf('function'); + expect(slice.state).toBeTypeOf('function'); }); it('attach is optional', () => { - const feature = defineFeature()({ + const slice = defineSlice()({ state: () => ({ playing: false }), }); - expect(feature.state).toBeTypeOf('function'); - expect(feature.attach).toBeUndefined(); - }); -}); - -describe('isFeature', () => { - it('returns true for features created with defineFeature', () => { - const feature = defineFeature()({ - state: () => ({ playing: false }), - }); - - expect(isFeature(feature)).toBe(true); - }); - - it('returns false for non-feature objects', () => { - expect(isFeature({})).toBe(false); - expect(isFeature(null)).toBe(false); - expect(isFeature(undefined)).toBe(false); - expect(isFeature({ create: () => {} })).toBe(false); + expect(slice.state).toBeTypeOf('function'); + expect(slice.attach).toBeUndefined(); }); }); diff --git a/packages/store/src/core/tests/store.test.ts b/packages/store/src/core/tests/store.test.ts index 5401051e..c7e7a7ae 100644 --- a/packages/store/src/core/tests/store.test.ts +++ b/packages/store/src/core/tests/store.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { defineFeature } from '../feature'; +import { combine } from '../combine'; +import { defineSlice } from '../slice'; import { flush } from '../state'; import { createStore } from '../store'; @@ -14,7 +15,7 @@ describe('store', () => { pause = vi.fn(); } - const audioFeature = defineFeature()({ + const audioSlice = defineSlice()({ state: ({ task }) => ({ volume: 1, muted: false, @@ -44,7 +45,7 @@ describe('store', () => { }, }); - const playbackFeature = defineFeature()({ + const playbackSlice = defineSlice()({ state: ({ task }) => ({ paused: true, play() { @@ -74,9 +75,7 @@ describe('store', () => { describe('creation', () => { it('creates store with merged initial state', () => { - const store = createStore({ - features: [audioFeature, playbackFeature], - }); + const store = createStore()(combine(audioSlice, playbackSlice)); expect(store.state).toMatchObject({ volume: 1, @@ -87,10 +86,7 @@ describe('store', () => { it('calls onSetup', () => { const onSetup = vi.fn(); - const store = createStore({ - features: [audioFeature], - onSetup, - }); + const store = createStore()(audioSlice, { onSetup }); expect(onSetup).toHaveBeenCalledWith({ store, @@ -101,9 +97,7 @@ describe('store', () => { describe('attach', () => { it('syncs state from target', () => { - const store = createStore({ - features: [audioFeature], - }); + const store = createStore()(audioSlice); const media = new MockMedia(); media.volume = 0.5; @@ -117,10 +111,7 @@ describe('store', () => { it('calls onAttach', () => { const onAttach = vi.fn(); - const store = createStore({ - features: [audioFeature], - onAttach, - }); + const store = createStore()(audioSlice, { onAttach }); const media = new MockMedia(); store.attach(media); @@ -133,9 +124,7 @@ describe('store', () => { }); it('sets up subscriptions', () => { - const store = createStore({ - features: [audioFeature], - }); + const store = createStore()(audioSlice); const media = new MockMedia(); const addListenerSpy = vi.spyOn(media, 'addEventListener'); @@ -146,9 +135,7 @@ describe('store', () => { }); it('detach cleans up', () => { - const store = createStore({ - features: [audioFeature], - }); + const store = createStore()(audioSlice); const media = new MockMedia(); const removeListenerSpy = vi.spyOn(media, 'removeEventListener'); @@ -161,9 +148,7 @@ describe('store', () => { }); it('reattach cleans up previous', () => { - const store = createStore({ - features: [audioFeature], - }); + const store = createStore()(audioSlice); const media1 = new MockMedia(); const m1RemoveListenerSpy = vi.spyOn(media1, 'removeEventListener'); @@ -183,9 +168,7 @@ describe('store', () => { describe('actions', () => { it('executes action on target', async () => { - const store = createStore({ - features: [audioFeature], - }); + const store = createStore()(audioSlice); const media = new MockMedia(); store.attach(media); @@ -196,19 +179,13 @@ describe('store', () => { }); it('throws StoreError without target', async () => { - const store = createStore({ - features: [audioFeature], - onError: () => {}, - }); + 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({ - features: [playbackFeature], - onError: () => {}, - }); + const store = createStore()(playbackSlice, { onError: () => {} }); const media = new MockMedia(); store.attach(media); @@ -225,7 +202,7 @@ describe('store', () => { it('passes meta to handler', async () => { let receivedMeta: unknown = null; - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ value: 0, action() { @@ -239,9 +216,7 @@ describe('store', () => { }), }); - const store = createStore({ - features: [feature], - }); + const store = createStore()(slice); store.attach(new MockMedia()); @@ -256,7 +231,7 @@ describe('store', () => { it('clears meta after action without task()', async () => { let receivedMeta: unknown = 'not-called'; - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ value: 0, // Sync action that doesn't use task() @@ -275,9 +250,7 @@ describe('store', () => { }), }); - const store = createStore({ - features: [feature], - }); + const store = createStore()(slice); store.attach(new MockMedia()); @@ -293,7 +266,7 @@ describe('store', () => { it('isolates meta between chained calls', async () => { const receivedMetas: unknown[] = []; - const feature = defineFeature()({ + const slice = defineSlice()({ state: ({ task }) => ({ value: 0, action() { @@ -307,9 +280,7 @@ describe('store', () => { }), }); - const store = createStore({ - features: [feature], - }); + const store = createStore()(slice); store.attach(new MockMedia()); @@ -326,9 +297,7 @@ describe('store', () => { describe('subscribe', () => { it('notifies on state change', async () => { - const store = createStore({ - features: [audioFeature], - }); + const store = createStore()(audioSlice); const media = new MockMedia(); store.attach(media); @@ -344,9 +313,7 @@ describe('store', () => { }); it('unsubscribe stops notifications', async () => { - const store = createStore({ - features: [audioFeature], - }); + const store = createStore()(audioSlice); const media = new MockMedia(); store.attach(media); @@ -364,9 +331,7 @@ describe('store', () => { describe('destroy', () => { it('cleans up everything', () => { - const store = createStore({ - features: [audioFeature], - }); + const store = createStore()(audioSlice); const media = new MockMedia(); store.attach(media); @@ -377,9 +342,7 @@ describe('store', () => { }); it('rejects actions after destroy', async () => { - const store = createStore({ - features: [audioFeature], - }); + const store = createStore()(audioSlice); store.attach(new MockMedia()); store.destroy(); @@ -392,7 +355,7 @@ describe('store', () => { it('calls onError for action errors', async () => { const onError = vi.fn(); - const failingFeature = defineFeature()({ + const failingSlice = defineSlice()({ state: ({ task }) => ({ value: 0, fail() { @@ -403,10 +366,7 @@ describe('store', () => { }), }); - const store = createStore({ - features: [failingFeature], - onError, - }); + const store = createStore()(failingSlice, { onError }); store.attach(new MockMedia()); diff --git a/packages/store/src/lit/controllers/tests/store-controller.test.ts b/packages/store/src/lit/controllers/tests/store-controller.test.ts index 71931ab3..546457c4 100644 --- a/packages/store/src/lit/controllers/tests/store-controller.test.ts +++ b/packages/store/src/lit/controllers/tests/store-controller.test.ts @@ -3,14 +3,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createCoreTestStore, createTestHost } from '../../tests/test-utils'; import { StoreController } from '../store-controller'; -interface AudioState { - volume: number; - muted: boolean; - setVolume: (volume: number) => Promise; - setMuted: (muted: boolean) => Promise; - slowSetVolume: (volume: number) => Promise; -} - describe('StoreController', () => { afterEach(() => { document.body.innerHTML = ''; @@ -21,7 +13,7 @@ describe('StoreController', () => { const host = createTestHost(); const controller = new StoreController(host, store); - const value = controller.value as AudioState; + const value = controller.value; expect(value.volume).toBe(1); expect(value.muted).toBe(false); @@ -35,11 +27,11 @@ describe('StoreController', () => { const controller = new StoreController(host, store); document.body.appendChild(host); - expect((controller.value as AudioState).volume).toBe(1); + expect(controller.value.volume).toBe(1); await store.setVolume(0.5); - expect((controller.value as AudioState).volume).toBe(0.5); + expect(controller.value.volume).toBe(0.5); expect(host.updateCount).toBeGreaterThan(0); }); @@ -65,7 +57,7 @@ describe('StoreController', () => { document.body.appendChild(host); await store.setVolume(0.5); - expect((controller.value as AudioState).volume).toBe(0.5); + expect(controller.value.volume).toBe(0.5); host.remove(); @@ -74,6 +66,6 @@ describe('StoreController', () => { // Reconnect document.body.appendChild(host); - expect((controller.value as AudioState).volume).toBe(0.8); + expect(controller.value.volume).toBe(0.8); }); }); diff --git a/packages/store/src/lit/create-store.ts b/packages/store/src/lit/create-store.ts index 92775e6f..bd32a831 100644 --- a/packages/store/src/lit/create-store.ts +++ b/packages/store/src/lit/create-store.ts @@ -3,22 +3,22 @@ import { ContextConsumer, createContext } from '@lit/context'; import type { ReactiveControllerHost, ReactiveElement } from '@lit/reactive-element'; import { noop } from '@videojs/utils/function'; import type { Constructor } from '@videojs/utils/types'; -import type { StoreConfig } from '../core/config'; -import type { AnyFeature } from '../core/feature'; -import type { AnyStore, FeatureStore, InferStoreState } from '../core/store'; +import type { StoreCallbacks } from '../core/config'; +import type { InferSliceState, InferSliceTarget, Slice } from '../core/slice'; +import type { AnyStore, InferStoreState, Store } from '../core/store'; import { createStore as createCoreStore } from '../core/store'; import { createContainerMixin, createProviderMixin, createStoreMixin } from './mixins'; import type { StoreConsumer, StoreProvider } from './types'; export const contextKey = Symbol('@videojs/store'); -export interface CreateStoreConfig extends StoreConfig {} +export interface CreateStoreOptions extends StoreCallbacks {} export type CreateStoreHost = ReactiveControllerHost & HTMLElement; -export type StoreControllerValue = InferStoreState; +export type StoreControllerValue = InferStoreState; -export interface CreateStoreResult { +export interface CreateStoreResult { /** * Combined mixin: provides store via context AND auto-attaches slotted media. * @@ -27,7 +27,7 @@ export interface CreateStoreResult { * class MyPlayer extends StoreMixin(LitElement) {} * ``` */ - StoreMixin: >(Base: T) => T & Constructor>; + StoreMixin: >(Base: T) => T & Constructor>; /** * Mixin that provides store via context (no auto-attach). @@ -39,7 +39,7 @@ export interface CreateStoreResult { * class MyProvider extends ProviderMixin(LitElement) {} * ``` */ - ProviderMixin: >(Base: T) => T & Constructor>; + ProviderMixin: >(Base: T) => T & Constructor>; /** * Mixin that auto-attaches slotted media elements (requires store from context). @@ -51,7 +51,7 @@ export interface CreateStoreResult { * class MyControls extends ContainerMixin(LitElement) {} * ``` */ - ContainerMixin: >(Base: T) => T & Constructor>; + ContainerMixin: >(Base: T) => T & Constructor>; /** * Context for consuming store in controllers. @@ -66,7 +66,7 @@ export interface CreateStoreResult { * } * ``` */ - context: Context; + context: Context; /** * Creates a store instance for imperative access. @@ -79,7 +79,7 @@ export interface CreateStoreResult { * store.attach(videoElement); * ``` */ - create: () => Store; + create: () => S; /** * Store controller bound to this store's context. @@ -87,7 +87,7 @@ export interface CreateStoreResult { * * @example * ```ts - * const { StoreController } = createStore({ features: [playbackFeature] }); + * const { StoreController } = createStore(playbackSlice); * * class MyElement extends LitElement { * #store = new StoreController(this); @@ -105,7 +105,7 @@ export interface CreateStoreResult { StoreController: new ( host: CreateStoreHost ) => { - value: StoreControllerValue; + value: StoreControllerValue; hostConnected: () => void; hostDisconnected: () => void; }; @@ -114,18 +114,25 @@ export interface CreateStoreResult { /** * Creates a store factory that returns mixins, context, bound controllers, and a create function. * - * @param config - Store configuration including features and optional lifecycle hooks + * @param slice - Slice defining state and attach behavior + * @param options - Optional store lifecycle hooks * @returns An object containing mixins, context, bound controllers, and create function * * @example * ```ts * import { createStore } from '@videojs/store/lit'; - * import { playbackFeature } from '@videojs/core/dom'; + * import { combine, defineSlice } from '@videojs/store'; * - * const { StoreMixin, StoreController } = createStore({ - * features: [playbackFeature], + * const playbackSlice = defineSlice()({ + * state: () => ({ paused: true }), + * attach: ({ target, set, signal }) => { + * target.addEventListener('play', () => set({ paused: false }), { signal }); + * target.addEventListener('pause', () => set({ paused: true }), { signal }); + * }, * }); * + * const { StoreMixin, StoreController } = createStore(playbackSlice); + * * // Create a player element with store * class MyPlayer extends StoreMixin(LitElement) {} * @@ -143,15 +150,17 @@ export interface CreateStoreResult { * customElements.define('my-control', MyControl); * ``` */ -export function createStore( - config: CreateStoreConfig -): CreateStoreResult> { - type ProvidedStore = FeatureStore; +export function createStore>( + slice: S, + options?: CreateStoreOptions, InferSliceState> +): CreateStoreResult, InferSliceState>> { + type Target = InferSliceTarget; + type ProvidedStore = Store>; const context = createContext(contextKey); function create(): ProvidedStore { - return createCoreStore(config); + return createCoreStore()(slice, options); } const ProviderMixin = createProviderMixin(context, create); @@ -182,7 +191,7 @@ export function createStore( throw new Error('Store not available'); } - // In v2, state and actions are directly on the store object + // State and actions are directly on the store object return store as unknown as StoreControllerValue; } diff --git a/packages/store/src/lit/index.ts b/packages/store/src/lit/index.ts index 7871809b..6d3d460c 100644 --- a/packages/store/src/lit/index.ts +++ b/packages/store/src/lit/index.ts @@ -1,7 +1,7 @@ export { StoreController, SubscriptionController } from './controllers'; export type { - CreateStoreConfig, CreateStoreHost, + CreateStoreOptions, CreateStoreResult, contextKey, } from './create-store'; diff --git a/packages/store/src/lit/tests/create-store.test.ts b/packages/store/src/lit/tests/create-store.test.ts index dc8d176b..52bf9726 100644 --- a/packages/store/src/lit/tests/create-store.test.ts +++ b/packages/store/src/lit/tests/create-store.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { defineFeature } from '../../core/feature'; +import { defineSlice } from '../../core/slice'; import { createStore } from '../create-store'; import { TestBaseElement } from './test-utils'; @@ -11,7 +11,7 @@ describe('createStore', () => { muted = false; } - const audioFeature = defineFeature()({ + const audioSlice = defineSlice()({ state: ({ task }) => ({ volume: 1, muted: false, @@ -38,7 +38,7 @@ describe('createStore', () => { describe('create', () => { it('creates a store instance', () => { - const { create } = createStore({ features: [audioFeature] }); + const { create } = createStore(audioSlice); const store = create(); @@ -47,7 +47,7 @@ describe('createStore', () => { }); it('creates independent store instances', () => { - const { create } = createStore({ features: [audioFeature] }); + const { create } = createStore(audioSlice); const store1 = create(); const store2 = create(); @@ -58,15 +58,15 @@ describe('createStore', () => { describe('context', () => { it('contexts share the same key for interoperability', () => { - const result1 = createStore({ features: [audioFeature] }); - const result2 = createStore({ features: [audioFeature] }); + const result1 = createStore(audioSlice); + const result2 = createStore(audioSlice); // Contexts use a shared key so different store configurations can interoperate expect(result1.context).toBe(result2.context); }); it('context is defined', () => { - const { context } = createStore({ features: [audioFeature] }); + const { context } = createStore(audioSlice); expect(context).toBeDefined(); }); @@ -74,27 +74,25 @@ describe('createStore', () => { describe('mixins', () => { it('returns StoreMixin', () => { - const { StoreMixin } = createStore({ features: [audioFeature] }); + const { StoreMixin } = createStore(audioSlice); expect(typeof StoreMixin).toBe('function'); }); it('returns ProviderMixin', () => { - const { ProviderMixin } = createStore({ features: [audioFeature] }); + const { ProviderMixin } = createStore(audioSlice); expect(typeof ProviderMixin).toBe('function'); }); it('returns ContainerMixin', () => { - const { ContainerMixin } = createStore({ features: [audioFeature] }); + const { ContainerMixin } = createStore(audioSlice); expect(typeof ContainerMixin).toBe('function'); }); it('mixins can be applied to TestBaseElement', () => { - const { StoreMixin, ProviderMixin, ContainerMixin } = createStore({ - features: [audioFeature], - }); + const { StoreMixin, ProviderMixin, ContainerMixin } = createStore(audioSlice); const Mixed1 = StoreMixin(TestBaseElement); const Mixed2 = ProviderMixin(TestBaseElement); @@ -108,7 +106,7 @@ describe('createStore', () => { describe('result object', () => { it('returns all expected properties', () => { - const result = createStore({ features: [audioFeature] }); + const result = createStore(audioSlice); expect(result).toHaveProperty('StoreMixin'); expect(result).toHaveProperty('ProviderMixin'); @@ -121,7 +119,7 @@ describe('createStore', () => { describe('bound controllers', () => { it('StoreController is a class', () => { - const { StoreController } = createStore({ features: [audioFeature] }); + const { StoreController } = createStore(audioSlice); expect(typeof StoreController).toBe('function'); expect(StoreController.prototype).toBeDefined(); diff --git a/packages/store/src/lit/tests/test-utils.ts b/packages/store/src/lit/tests/test-utils.ts index 0d1bed74..cc823dde 100644 --- a/packages/store/src/lit/tests/test-utils.ts +++ b/packages/store/src/lit/tests/test-utils.ts @@ -1,8 +1,8 @@ import { ReactiveElement } from '@lit/reactive-element'; import { noop } from '@videojs/utils/function'; import { afterEach } from 'vitest'; -import { defineFeature } from '../../core/feature'; -import type { FeatureStore } from '../../core/store'; +import { defineSlice } from '../../core/slice'; +import type { Store } from '../../core/store'; import { createStore as createCoreStore } from '../../core/store'; import { createStore as createLitStore } from '../create-store'; @@ -27,7 +27,7 @@ export class MockMedia extends EventTarget { muted = false; } -export const audioFeature = defineFeature()({ +export const audioSlice = defineSlice()({ state: ({ task }) => ({ volume: 1, muted: false, @@ -67,8 +67,8 @@ export const audioFeature = defineFeature()({ }, }); -/** Feature with custom keys (name !== key) for testing superseding behavior. */ -export const customKeyFeature = defineFeature()({ +/** Slice with custom keys (name !== key) for testing superseding behavior. */ +export const customKeySlice = defineSlice()({ state: ({ task }) => ({ volume: 1, muted: false, @@ -110,42 +110,47 @@ export const customKeyFeature = defineFeature()({ }, }); -type TestFeature = typeof audioFeature; -type CustomKeyFeature = typeof customKeyFeature; +export type AudioSliceState = { + volume: number; + muted: boolean; + setVolume: (volume: number) => Promise; + setMuted: (muted: boolean) => Promise; + slowSetVolume: (volume: number) => Promise; +}; -// FeatureStore already includes state intersection, no need to repeat -type TestStore = FeatureStore<[TestFeature]>; -type CustomKeyStore = FeatureStore<[CustomKeyFeature]>; +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 } { - const store = createCoreStore({ - features: [audioFeature], - onError: noop, - }); + const store = createCoreStore()(audioSlice, { onError: noop }); const target = new MockMedia(); store.attach(target); - return { store: store as TestStore, target }; + return { store, target }; } -/** Creates store with custom key feature (name !== key) for testing superseding. */ +/** Creates store with custom key slice (name !== key) for testing superseding. */ export function createCustomKeyTestStore(): { store: CustomKeyStore; target: MockMedia } { - const store = createCoreStore({ - features: [customKeyFeature], - onError: noop, - }); + const store = createCoreStore()(customKeySlice, { onError: noop }); const target = new MockMedia(); store.attach(target); - return { store: store as CustomKeyStore, target }; + return { store, target }; } // For mixin tests - creates lit store factory export function createLitTestStore() { - return createLitStore({ features: [audioFeature] }); + return createLitStore(audioSlice); } /** Type alias for test host. */ diff --git a/packages/store/src/react/hooks/tests/test-utils.ts b/packages/store/src/react/hooks/tests/test-utils.ts index 473a0de9..c75f1b11 100644 --- a/packages/store/src/react/hooks/tests/test-utils.ts +++ b/packages/store/src/react/hooks/tests/test-utils.ts @@ -1,6 +1,6 @@ import { noop } from '@videojs/utils/function'; -import { defineFeature } from '../../../core/feature'; +import { defineSlice } from '../../../core/slice'; import { createStore as createCoreStore } from '../../../core/store'; // Shared mock target for synchronous tests @@ -9,8 +9,8 @@ export class MockMedia extends EventTarget { muted = false; } -// Shared feature for synchronous tests -export const audioFeature = defineFeature()({ +// Shared slice for synchronous tests +export const audioSlice = defineSlice()({ state: ({ task }) => ({ volume: 1, muted: false, @@ -43,7 +43,7 @@ export const audioFeature = defineFeature()({ }); export function createTestStore() { - const store = createCoreStore({ features: [audioFeature] }); + const store = createCoreStore()(audioSlice); const target = new MockMedia(); store.attach(target); return { store, target }; @@ -55,7 +55,7 @@ export class AsyncMockMedia extends EventTarget { muted = false; } -export const asyncAudioFeature = defineFeature()({ +export const asyncAudioSlice = defineSlice()({ state: ({ task }) => ({ volume: 1, muted: false, @@ -102,10 +102,7 @@ export const asyncAudioFeature = defineFeature()({ }); export function createAsyncTestStore() { - const store = createCoreStore({ - features: [asyncAudioFeature], - onError: noop, - }); + const store = createCoreStore()(asyncAudioSlice, { onError: noop }); const target = new AsyncMockMedia(); store.attach(target); @@ -113,8 +110,8 @@ export function createAsyncTestStore() { return { store, target }; } -/** Feature with custom keys (name !== key) for testing superseding behavior. */ -export const customKeyFeature = defineFeature()({ +/** Slice with custom keys (name !== key) for testing superseding behavior. */ +export const customKeySlice = defineSlice()({ state: ({ task }) => ({ volume: 1, muted: false, @@ -157,10 +154,7 @@ export const customKeyFeature = defineFeature()({ }); export function createCustomKeyTestStore() { - const store = createCoreStore({ - features: [customKeyFeature], - onError: noop, - }); + const store = createCoreStore()(customKeySlice, { onError: noop }); const target = new MockMedia(); store.attach(target); diff --git a/packages/store/src/react/hooks/tests/use-store.test.tsx b/packages/store/src/react/hooks/tests/use-store.test.tsx index a94c5a6f..1bf0c49a 100644 --- a/packages/store/src/react/hooks/tests/use-store.test.tsx +++ b/packages/store/src/react/hooks/tests/use-store.test.tsx @@ -4,19 +4,12 @@ import { describe, expect, it } from 'vitest'; import { useStore } from '../use-store'; import { createTestStore } from './test-utils'; -interface AudioState { - volume: number; - muted: boolean; - setVolume: (volume: number) => Promise; - setMuted: (muted: boolean) => Promise; -} - describe('useStore', () => { describe('without selector', () => { it('returns state and action functions', () => { const { store } = createTestStore(); - const { result } = renderHook(() => useStore(store) as AudioState); + const { result } = renderHook(() => useStore(store)); expect(result.current.volume).toBe(1); expect(result.current.muted).toBe(false); @@ -29,7 +22,7 @@ describe('useStore', () => { const { result } = renderHook(() => { renderCount++; - return useStore(store) as AudioState; + return useStore(store); }); expect(renderCount).toBe(1); @@ -53,14 +46,14 @@ describe('useStore', () => { const { result } = renderHook(() => { renderCount++; - return useStore(store, (s: AudioState) => s.volume); + return useStore(store, (s) => s.volume); }); expect(renderCount).toBe(1); expect(result.current).toBe(1); await act(async () => { - await (store as unknown as AudioState).setVolume(0.5); + await store.setVolume(0.5); }); // Should have re-rendered @@ -74,13 +67,13 @@ describe('useStore', () => { const { result } = renderHook(() => { renderCount++; - return useStore(store, (s: AudioState) => s.volume); + return useStore(store, (s) => s.volume); }); expect(renderCount).toBe(1); await act(async () => { - await (store as unknown as AudioState).setMuted(true); + await store.setMuted(true); }); // Should NOT re-render since volume didn't change diff --git a/packages/store/src/react/hooks/use-selector.ts b/packages/store/src/react/hooks/use-selector.ts index 9a082a8d..a228a7d3 100644 --- a/packages/store/src/react/hooks/use-selector.ts +++ b/packages/store/src/react/hooks/use-selector.ts @@ -1,9 +1,7 @@ import { useRef, useSyncExternalStore } from 'react'; -import { shallowEqual } from '../../core/shallow-equal'; +import { type Comparator, type Selector, shallowEqual } from '../../core/shallow-equal'; -export type Selector = (state: S) => R; - -export type Comparator = (a: T, b: T) => boolean; +export type { Comparator, Selector }; /** Subscribe to derived state with customizable equality check. */ export function useSelector(