diff --git a/.claude/plans/store/reactive.md b/.claude/plans/store/reactive.md new file mode 100644 index 00000000..19464436 --- /dev/null +++ b/.claude/plans/store/reactive.md @@ -0,0 +1,63 @@ +# Reactive State + +**Status:** COMPLETE +**PR:** [#311](https://github.com/videojs/v10/pull/311) + +Replaced class-based `State` with proxy-based reactive primitives. Inspired by [Valtio](https://github.com/pmndrs/valtio). + +## API + +```ts +import { batch, flush, reactive, snapshot, subscribe, subscribeKeys, track } from '@videojs/store'; +``` + +| Export | Purpose | +| -------------------------------- | ---------------------------------------------- | +| `reactive(initial)` | Create reactive state with parent bubbling | +| `isReactive(value)` | Check if value is reactive | +| `subscribe(state, fn)` | Subscribe to all changes | +| `subscribeKeys(state, keys, fn)` | Subscribe to specific key changes | +| `batch(fn)` | Group mutations, flush after | +| `flush()` | Force pending notifications (for tests) | +| `snapshot(state)` | Return frozen shallow copy | +| `track(state)` | Track property access for fine-grained updates | + +## Key Behaviors + +- **Auto-batching** — Notifications deferred to microtask +- **Nested tracking** — Auto-wraps nested objects, changes bubble to parents +- **Synchronous reads** — Mutations apply immediately, only notifications are deferred + +## Decisions + +| Question | Decision | +| ------------- | -------------------------------------------------------------------- | +| Batching | Auto-batch to microtask. Export `flush()` for tests. | +| React pattern | `useSyncExternalStore` with version counter for change detection | +| Lit pattern | `SnapshotController` with `host.requestUpdate()` + optional callback | + +## Breaking Changes + +**Removed:** + +- React: `useMutation`, `useOptimistic`, `useSelector` +- Lit: `MutationController`, `OptimisticController`, `SelectorController` + +**Migration:** + +| Before | After | +| ----------------------------------- | ------------------------------------- | +| `useSelector(store, s => s.volume)` | `useSnapshot(store.state).volume` | +| `useMutation(store, 'play')` | `useSnapshot(store.queue.tasks).play` | +| `SelectorController` | `SnapshotController` | +| `store.subscribe(fn)` | `subscribe(store.state, fn)` | + +## Testing + +Use `flush()` for synchronous assertions: + +```ts +state.volume = 0.5; +flush(); +expect(listener).toHaveBeenCalledOnce(); +``` diff --git a/.claude/skills/api/references/anti-patterns.md b/.claude/skills/api/references/anti-patterns.md index b166429d..0dfa3bf9 100644 --- a/.claude/skills/api/references/anti-patterns.md +++ b/.claude/skills/api/references/anti-patterns.md @@ -63,7 +63,7 @@ function useVolume() { // Good: Explicit dependency function useVolume(store: MediaStore) { - return useSelector(store, (s) => s.volume); + return useSnapshot(store.state).volume; } ``` diff --git a/.claude/skills/api/review/templates.md b/.claude/skills/api/review/templates.md index 397ab28d..96c2db86 100644 --- a/.claude/skills/api/review/templates.md +++ b/.claude/skills/api/review/templates.md @@ -208,7 +208,7 @@ return state.status === 'playing' ### New APIs -- `useSelector` hook added — [Review new surface] +- `useSnapshot` hook added — [Review new surface] ### Internal Changes diff --git a/.claude/skills/component/references/lit.md b/.claude/skills/component/references/lit.md index 03f1f4d4..11a07741 100644 --- a/.claude/skills/component/references/lit.md +++ b/.claude/skills/component/references/lit.md @@ -48,7 +48,7 @@ All store-related controllers live in `@videojs/store/lit`. See that package for ```ts class MyElement extends ReactiveElement { - #paused = new SelectorController(this, context, (s) => s.paused); + #state = new SnapshotController(this, store.state); #play = new RequestController(this, context, 'play'); } ``` @@ -71,10 +71,10 @@ Internal utility that resolves a store from either a direct instance or context. ```ts // Direct store — value available immediately -const selector = new SelectorController(this, store, (s) => s.paused); +const state = new SnapshotController(this, store.state); -// Context — value available after context resolves -const selector = new SelectorController(this, storeContext, (s) => s.paused); +// Context — value available after context resolves via StateController from createStore +const state = new StateController(this); ``` Controllers handle both cases transparently. The `StoreAccessor`: @@ -90,7 +90,7 @@ Controllers handle both cases transparently. The `StoreAccessor`: Always export an explicit host type for controllers and mixins: ```ts -export type SelectorControllerHost = ReactiveControllerHost & HTMLElement; +export type SnapshotControllerHost = ReactiveControllerHost & HTMLElement; export type ProviderMixinHost = ReactiveElement & EventTarget; ``` diff --git a/.claude/skills/docs/templates/api-reference.md b/.claude/skills/docs/templates/api-reference.md index 3faddcab..64b0d19d 100644 --- a/.claude/skills/docs/templates/api-reference.md +++ b/.claude/skills/docs/templates/api-reference.md @@ -416,16 +416,16 @@ new ControllerName(host, source, ...args) ### Example -import { SelectorController } from '@videojs/store/lit'; +import { SnapshotController } from '@videojs/store/lit'; class PlayButton extends LitElement { -#paused = new SelectorController(this, context, s => s.paused); +#state = new SnapshotController(this, store.state); #play = new RequestController(this, context, 'play'); render() { return html\` \`; } diff --git a/.claude/skills/docs/templates/component-page.md b/.claude/skills/docs/templates/component-page.md index 7ded32f5..947e4f67 100644 --- a/.claude/skills/docs/templates/component-page.md +++ b/.claude/skills/docs/templates/component-page.md @@ -222,8 +222,8 @@ return ( ); } - ```` + ```vue @@ -264,6 +264,7 @@ const volume = ref(1); ``` + ```` --- @@ -354,10 +355,10 @@ ElementName.define('custom-name', StoreMixin); ### Controllers Used -| Controller | Selector/Request | Purpose | -| -------------------- | ---------------- | -------------------- | -| `SelectorController` | `s => s.paused` | Track playback state | -| `RequestController` | `'play'` | Play request | +| Controller | Usage | Purpose | +| --------------------- | --------------- | -------------------- | +| `SnapshotController` | `store.state` | Track playback state | +| `RequestController` | `'play'` | Play request | ### Events @@ -417,3 +418,4 @@ When writing component documentation: - [ ] Accessibility section - [ ] Keyboard interactions table - [ ] See Also section +```` diff --git a/.claude/skills/docs/templates/readme.md b/.claude/skills/docs/templates/readme.md index 592522dc..6a71814d 100644 --- a/.claude/skills/docs/templates/readme.md +++ b/.claude/skills/docs/templates/readme.md @@ -165,7 +165,7 @@ Core concepts: Core concepts: -- Controllers (SelectorController, RequestController, TasksController) +- Controllers (SnapshotController, RequestController, TasksController) - Mixins (StoreMixin, StoreProviderMixin, StoreAttachMixin) - createStore factory pattern - Context API diff --git a/CLAUDE.md b/CLAUDE.md index 15df5e41..b929cf49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,8 +138,8 @@ packages/utils/src/dom/ Use the exact exported name being tested (preserving case): ```ts -// selector-controller.test.ts — class export -describe('SelectorController', () => { ... }); +// snapshot-controller.test.ts — class export +describe('SnapshotController', () => { ... }); // provider-mixin.test.ts — factory function export describe('createStoreProviderMixin', () => { ... }); @@ -380,6 +380,18 @@ type Mixin = ... function createStore(...) { ... } ``` +### React: Lazy Initialization + +Use `useState` with initializer function for objects that should only be created once. Don't use `useRef` with inline object creation — the object is created on every render even though only the first value is kept: + +```ts +// Bad - creates new Set on every render +const trackedRef = useRef(new Set()); + +// Good - initializer only runs once +const [tracked] = useState(() => new Set()); +``` + ### No Obvious Comments Don't write comments that restate what the code does. Comments should explain _why_, not _what_: diff --git a/packages/html/src/skins/frosted/index.ts b/packages/html/src/skins/frosted/index.ts index da2336ef..c15c454e 100644 --- a/packages/html/src/skins/frosted/index.ts +++ b/packages/html/src/skins/frosted/index.ts @@ -4,7 +4,7 @@ export { create as createStore, extendConfig, RequestController, - SelectorController, + StateController, StoreAttachMixin, StoreMixin, StoreProviderMixin, diff --git a/packages/html/src/skins/frosted/store.ts b/packages/html/src/skins/frosted/store.ts index 7735a23c..00a8511c 100644 --- a/packages/html/src/skins/frosted/store.ts +++ b/packages/html/src/skins/frosted/store.ts @@ -34,7 +34,7 @@ export const { StoreMixin, StoreProviderMixin, StoreAttachMixin, - SelectorController, + StateController, RequestController, TasksController, create, diff --git a/packages/react/src/skins/frosted/index.ts b/packages/react/src/skins/frosted/index.ts index fc347c14..481dcb6a 100644 --- a/packages/react/src/skins/frosted/index.ts +++ b/packages/react/src/skins/frosted/index.ts @@ -2,12 +2,4 @@ export { Skin, type SkinProps } from './skin'; -export { - create as createStore, - extendConfig, - Provider, - useRequest, - useSelector, - useStore, - useTasks, -} from './store'; +export { create as createStore, extendConfig, Provider, useRequest, useSnapshot, useStore, useTasks } from './store'; diff --git a/packages/react/src/skins/frosted/store.ts b/packages/react/src/skins/frosted/store.ts index c275573a..50e285a9 100644 --- a/packages/react/src/skins/frosted/store.ts +++ b/packages/react/src/skins/frosted/store.ts @@ -20,7 +20,7 @@ const baseConfig = { * import { extendConfig } from '@videojs/react/skins/frosted'; * import { chaptersSlice } from './slices/chapters'; * - * const { Provider, useSelector } = createStore( + * const { Provider, useSnapshot } = createStore( * extendConfig({ slices: [chaptersSlice] }) * ); * ``` @@ -31,11 +31,4 @@ export function extendConfig[] = []>( return extendBaseConfig(baseConfig, extension); } -export const { - Provider, - create, - useStore, - useSelector, - useRequest, - useTasks, -} = createStore(baseConfig); +export const { Provider, create, useStore, useSnapshot, useRequest, useTasks } = createStore(baseConfig); diff --git a/packages/store/README.md b/packages/store/README.md index e0313f58..d5c4f38a 100644 --- a/packages/store/README.md +++ b/packages/store/README.md @@ -255,38 +255,24 @@ store.destroy(); ### Subscribing to State +State is reactive—subscribe to be notified when any property changes: + ```ts +import { subscribe, subscribeKeys } from '@videojs/store'; + // Subscribe to all state changes -const unsubscribe = store.subscribe((state) => { - console.log('State changed:', state); +const unsubscribe = subscribe(store.state, () => { + console.log('State changed:', store.state.volume); }); -// Single value - only fires when volume changes -store.subscribe( - s => s.volume, - volume => console.log('Volume:', volume) -); - -// Multiple values - auto-optimized with key-based subscription -store.subscribe( - s => ({ volume: s.volume, muted: s.muted }), - ({ volume, muted }) => updateAudioUI(volume, muted) -); - -// Derived value -store.subscribe( - s => Math.round(s.volume * 100), - percent => console.log(`${percent}%`) -); - -// Custom equality function -store.subscribe( - s => s.playlist, - playlist => renderPlaylist(playlist), - { equalityFn: shallowEqual } -); +// Subscribe to specific keys only +subscribeKeys(store.state, ['volume', 'muted'], () => { + console.log('Audio changed:', store.state.volume, store.state.muted); +}); ``` +Mutations are auto-batched—multiple changes in the same tick trigger only one notification. + Slices sync state from the target via `getSnapshot`. The `update` callback triggers a sync, and the store only notifies subscribers for keys that actually changed: ```ts @@ -494,8 +480,8 @@ queue.reset('seek'); // clear specific request queue.reset(); // clear all settled // Subscribe to task changes -queue.subscribe((tasks) => { - const playTask = tasks.play; +subscribe(queue.tasks, () => { + const playTask = queue.tasks.play; if (playTask?.status === 'pending') { console.log('Play in progress...'); } @@ -555,8 +541,10 @@ await queue.enqueue({ Use `subscribe` to react to task changes—useful for loading states and error handling: ```ts -queue.subscribe((tasks) => { - for (const [name, task] of Object.entries(tasks)) { +import { subscribe } from '@videojs/store'; + +subscribe(queue.tasks, () => { + for (const [name, task] of Object.entries(queue.tasks)) { if (task?.status === 'error' && !task.cancelled) { toast.error(`${name} failed: ${task.error}`); } @@ -564,8 +552,8 @@ queue.subscribe((tasks) => { }); // Analytics -queue.subscribe((tasks) => { - for (const task of Object.values(tasks)) { +subscribe(queue.tasks, () => { + for (const task of Object.values(queue.tasks)) { if (task && task.status !== 'pending') { analytics.track('request', { name: task.name, @@ -579,40 +567,35 @@ queue.subscribe((tasks) => { ## Advanced -### Custom State +### Reactive Primitives -The store uses a simple state container by default. Provide a custom factory for -framework-native reactivity: +The store uses reactive state internally. You can also use these primitives directly: ```ts -import { createStore } from '@videojs/store'; +import { flush, isReactive, reactive, snapshot, subscribe, subscribeKeys } from '@videojs/store'; -// Default -const store = createStore({ - slices: [ - /* ... */ - ], -}); +// Create reactive state +const state = reactive({ volume: 1, muted: false }); -// Custom -const store = createStore({ - slices: [ - /* ... */ - ], - state: initial => new VueStateAdapter(initial), -}); -``` +// Mutate directly - changes are auto-batched +state.volume = 0.5; +state.muted = true; +// Only ONE notification fires (after microtask) -Custom state must match the `State` class interface, where `K` is `keyof T`: +// Subscribe to all changes +subscribe(state, () => console.log('Changed:', state.volume)); -```ts -class State { - get value(): T; - set(key: K, value: T[K]): void; - patch(partial: Partial): void; - subscribe(listener: (state: T) => void): () => void; - subscribeKeys(keys: K[], listener: (state: Pick) => void): () => void; -} +// Subscribe to specific keys +subscribeKeys(state, ['volume'], () => console.log('Volume:', state.volume)); + +// Check if value is reactive +isReactive(state); // true + +// Get frozen snapshot +const snap = snapshot(state); + +// Force immediate notification (mainly for tests) +flush(); ``` ### Capability Checking diff --git a/packages/store/src/core/extend-config.ts b/packages/store/src/core/extend-config.ts index f656f0ca..dffe4c46 100644 --- a/packages/store/src/core/extend-config.ts +++ b/packages/store/src/core/extend-config.ts @@ -40,7 +40,6 @@ export function extendConfig[], ExtS // Extension overrides if provided queue: extension.queue ?? base.queue, - state: extension.state ?? base.state, // Compose lifecycle hooks (both called, base first) onSetup: composeCallbacks(base.onSetup, extension.onSetup as typeof base.onSetup), diff --git a/packages/store/src/core/queue.ts b/packages/store/src/core/queue.ts index 5382b7fa..59b2b0ae 100644 --- a/packages/store/src/core/queue.ts +++ b/packages/store/src/core/queue.ts @@ -1,9 +1,11 @@ import type { Request, RequestMeta } from './request'; +import type { Reactive } from './state'; import type { ErrorTask, PendingTask, SuccessTask, Task, TaskContext, TaskKey } from './task'; import { isUndefined } from '@videojs/utils/predicate'; import { StoreError } from './errors'; +import { reactive } from './state'; // ---------------------------------------- // Types @@ -29,20 +31,18 @@ export type TasksRecord = { [K in keyof Tasks]?: Task, Tasks[K]['input'], Tasks[K]['output']>; }; -export type QueueListener = (tasks: TasksRecord) => void; - // ---------------------------------------- // Implementation // ---------------------------------------- export class Queue { - readonly #subscribers = new Set>(); + /** Reactive tasks. Subscribe via `subscribe(queue.tasks, fn)`. */ + readonly tasks: Reactive>; - #tasks: TasksRecord = {}; #destroyed = false; - get tasks(): Readonly> { - return Object.freeze({ ...this.#tasks }); + constructor() { + this.tasks = reactive({} as TasksRecord); } get destroyed(): boolean { @@ -52,45 +52,17 @@ export class Queue { /** Clear settled task(s). If name provided, clears that task. If no name, clears all settled. */ reset(name?: keyof Tasks): void { if (!isUndefined(name)) { - const task = this.#tasks[name]; + const task = this.tasks[name]; if (!task || task.status === 'pending') return; - delete this.#tasks[name]; - this.#notifySubscribers(); - + delete this.tasks[name]; return; } - let cleared = false; - for (const key of Reflect.ownKeys(this.#tasks)) { - const task = this.#tasks[key]; + for (const key of Reflect.ownKeys(this.tasks) as (keyof Tasks)[]) { + const task = this.tasks[key]; if (task && task.status !== 'pending') { - delete this.#tasks[key]; - cleared = true; - } - } - - if (cleared) { - this.#notifySubscribers(); - } - } - - subscribe(listener: QueueListener): () => void { - this.#subscribers.add(listener); - return () => { - this.#subscribers.delete(listener); - }; - } - - #notifySubscribers(): void { - if (this.#subscribers.size === 0) return; - - const snapshot = this.tasks; - for (const listener of this.#subscribers) { - try { - listener(snapshot); - } catch (e) { - console.error('[vjs-queue]', e); + delete this.tasks[key]; } } } @@ -105,8 +77,7 @@ export class Queue { } // Supersede any pending task with the same key (may have different name) - // Don't delete - let error handler update status to 'error' so controllers can see it - for (const task of Object.values(this.#tasks)) { + for (const task of Object.values(this.tasks) as Task[]) { if (task?.key === key && task.status === 'pending') { task.abort.abort(new StoreError('SUPERSEDED')); } @@ -129,7 +100,7 @@ export class Queue { /** Abort task(s). If name provided, aborts that task. If no name, aborts all. */ abort(name?: keyof Tasks): void { if (!isUndefined(name)) { - const task = this.#tasks[name]; + const task = this.tasks[name]; if (task?.status === 'pending') { task.abort.abort(new StoreError('ABORTED')); } @@ -139,7 +110,7 @@ export class Queue { const error = new StoreError('ABORTED'); - for (const task of Object.values(this.#tasks)) { + for (const task of Object.values(this.tasks) as Task[]) { if (task?.status === 'pending') { task.abort.abort(error); } @@ -151,8 +122,11 @@ export class Queue { this.#destroyed = true; this.abort(); - this.#subscribers.clear(); - this.#tasks = {}; + + // Clear all tasks + for (const key of Reflect.ownKeys(this.tasks) as (keyof Tasks)[]) { + delete this.tasks[key]; + } } async #executeNow(params: { @@ -181,9 +155,8 @@ export class Queue { meta, }; - // Store tasks by name for controller access (different names can share same key) - this.#tasks[name as keyof Tasks] = pendingTask; - this.#notifySubscribers(); + // Store tasks by name for controller access + (this.tasks as TasksRecord)[name as keyof Tasks] = pendingTask; try { if (abort.signal.aborted) { @@ -198,33 +171,27 @@ export class Queue { resolve(result); - const successTask: SuccessTask = { - ...pendingTask, - status: 'success', - settledAt: Date.now(), - output: result, - }; - - // Only update if we're still the current task for this name - if (this.#tasks[name as keyof Tasks] === pendingTask) { - this.#tasks[name as keyof Tasks] = successTask; - this.#notifySubscribers(); + // Only update if we're still the current task for this name (compare by ID since reactive wraps tasks) + const currentTask = this.tasks[name as keyof Tasks]; + if (currentTask?.id === id) { + Object.assign(currentTask, { + status: 'success', + settledAt: Date.now(), + output: result, + } satisfies Partial); } } catch (error) { reject(error); - const errorTask: ErrorTask = { - ...pendingTask, - status: 'error', - settledAt: Date.now(), - error, - cancelled: abort.signal.aborted, - }; - - // Only update if we're still the current task for this name - if (this.#tasks[name as keyof Tasks] === pendingTask) { - this.#tasks[name as keyof Tasks] = errorTask; - this.#notifySubscribers(); + // Only update if we're still the current task for this name (compare by ID since reactive wraps tasks) + const currentTask = this.tasks[name as keyof Tasks]; + if (currentTask?.id === id) { + Object.assign(currentTask, { + status: 'error', + settledAt: Date.now(), + error, + cancelled: abort.signal.aborted, + } satisfies Partial); } } } @@ -239,6 +206,7 @@ export class Queue { * * - Tasks execute immediately when enqueued * - Same key = supersede previous (abort pending) + * - Subscribe to task changes via `subscribe(queue.tasks, fn)` * * @example * // Loose typing (default) diff --git a/packages/store/src/core/state.ts b/packages/store/src/core/state.ts index ae2347d0..44c6823c 100644 --- a/packages/store/src/core/state.ts +++ b/packages/store/src/core/state.ts @@ -1,89 +1,226 @@ -/** - * Default state container. - * - * Extend or implement the same shape for custom state handling. - */ -export class State { - #state: T; +import { isObject, isPlainObject } from '@videojs/utils/predicate'; - readonly #listeners = new Set<(state: T) => void>(); - readonly #keyListeners = new Map void>>(); +type Listener = (changedKeys: ReadonlySet) => void; - constructor(initial: T) { - this.#state = { ...initial }; - } +/** Symbol used to brand reactive objects. */ +const REACTIVE_SYMBOL = Symbol('@videojs/reactive'); - get value(): T { - return this.#state; - } +/** A reactive state object created by `reactive()`. */ +export type Reactive = T & { readonly [REACTIVE_SYMBOL]: true }; - set(key: K, value: T[K]): void { - if (this.#state[key] === value) return; - this.#state = { ...this.#state, [key]: value }; - this.#notify([key]); - } +/** Extract the underlying state type from a `Reactive`. */ +export type InferReactiveState = R extends Reactive ? T : never; - patch(partial: Partial): void { - const changedKeys: (keyof T)[] = []; +// Track which objects are reactive (for isReactive check) +const reactiveCache = new WeakSet(); - for (const [key, value] of Object.entries(partial)) { - if (this.#state[key as keyof T] !== value) { - changedKeys.push(key as keyof T); - } - } +// Map from target -> reactive object (to find reactive from within set handler) +const reactiveMap = new WeakMap(); - if (changedKeys.length > 0) { - this.#state = { ...this.#state, ...partial }; - this.#notify(changedKeys); - } - } +// Global listeners per proxy +const listeners = new WeakMap>(); - subscribe(listener: (state: T) => void): () => void { - this.#listeners.add(listener); - return () => this.#listeners.delete(listener); - } +// Key-specific listeners per proxy +const keyListeners = new WeakMap>>(); - subscribeKeys( - keys: K[], - listener: (state: Pick) => void, - ): () => void { - for (const key of keys) { - let set = this.#keyListeners.get(key); +// Parent references for bubbling (proxy -> parent proxy + key) +interface ParentInfo { + parent: object; + key: PropertyKey; +} +const parents = new WeakMap(); - if (!set) { - set = new Set(); - this.#keyListeners.set(key, set); +// Pending changes (proxy -> keys that changed) +const pending = new Map>(); + +// Batching +let batchDepth = 0; +let flushScheduled = false; + +/** Create a reactive state object with optional parent for change bubbling. */ +export function reactive(initial: T, parent?: object, parentKey?: PropertyKey): Reactive { + const proxy = new Proxy(initial, { + set(target, prop, value, receiver) { + const prev = Reflect.get(target, prop, receiver); + if (Object.is(prev, value)) return true; + + // Get the reactive object for this target + const thisReactive = reactiveMap.get(target)!; + + // Auto-wrap nested plain objects with this as parent + if (isPlainObject(value) && !isReactive(value)) { + value = reactive(value, thisReactive, prop); } - set.add(listener); - } + Reflect.set(target, prop, value, receiver); - return () => { - for (const key of keys) { - this.#keyListeners.get(key)?.delete(listener); + // Mark this and all parents as pending + let current: object | undefined = thisReactive; + let changedKey: PropertyKey = prop; + while (current) { + if (!pending.has(current)) pending.set(current, new Set()); + pending.get(current)!.add(changedKey); + const info = parents.get(current); + if (!info) break; + changedKey = info.key; + current = info.parent; } - }; - } - #notify(changedKeys: (keyof T)[]): void { - for (const listener of this.#listeners) { - listener(this.#state); - } + if (batchDepth === 0) scheduleFlush(); + return true; + }, - const notified = new Set<(state: T, changedKeys: (keyof T)[]) => void>(); + deleteProperty(target, prop) { + const hadProp = Reflect.has(target, prop); + const result = Reflect.deleteProperty(target, prop); - for (const key of changedKeys) { - const set = this.#keyListeners.get(key); - if (!set) continue; - - for (const listener of set) { - if (!notified.has(listener)) { - notified.add(listener); - listener(this.#state); + if (hadProp && result) { + const thisReactive = reactiveMap.get(target)!; + let current: object | undefined = thisReactive; + let changedKey: PropertyKey = prop; + while (current) { + if (!pending.has(current)) pending.set(current, new Set()); + pending.get(current)!.add(changedKey); + const info = parents.get(current); + if (!info) break; + changedKey = info.key; + current = info.parent; } + + if (batchDepth === 0) scheduleFlush(); + } + + return result; + }, + }); + + reactiveCache.add(proxy); + reactiveMap.set(initial, proxy); + if (parent && parentKey !== undefined) parents.set(proxy, { parent, key: parentKey }); + + // Auto-wrap nested plain objects after creation (so we can set parent) + for (const key of Object.keys(initial) as (keyof T)[]) { + const value = initial[key]; + if (isPlainObject(value) && !isReactive(value)) { + (initial as Record)[key as string] = reactive(value, proxy, key); + } + } + + // Cast is safe: the proxy is branded at runtime via reactiveCache + return proxy as Reactive; +} + +/** Check if a value is reactive (created by this module). */ +export function isReactive(value: T | unknown): value is Reactive { + return isObject(value) && reactiveCache.has(value); +} + +function scheduleFlush(): void { + if (flushScheduled) return; + flushScheduled = true; + queueMicrotask(flush); +} + +/** Force pending notifications immediately. Mainly for tests. */ +export function flush(): void { + flushScheduled = false; + + for (const [target, keys] of pending) { + // Notify global listeners for this target (with changed keys) + listeners.get(target)?.forEach(fn => fn(keys)); + + // Notify key-specific listeners (no args - already filtered by key) + const targetKeyListeners = keyListeners.get(target); + if (targetKeyListeners) { + for (const key of keys) { + targetKeyListeners.get(key)?.forEach(fn => fn(keys)); } } } + + pending.clear(); +} + +/** Group mutations; notifications fire after fn completes. */ +export function batch(fn: () => R): R { + batchDepth++; + try { + return fn(); + } finally { + batchDepth--; + if (batchDepth === 0) scheduleFlush(); + } } -export type StateFactory = (initial: T) => State; +/** Subscribe to all changes on a reactive state object. */ +export function subscribe(state: Reactive, fn: Listener): () => void { + if (!listeners.has(state)) listeners.set(state, new Set()); + listeners.get(state)!.add(fn); + return () => listeners.get(state)?.delete(fn); +} + +/** Subscribe to changes on specific keys of a reactive state object. */ +export function subscribeKeys(state: Reactive, keys: (keyof T)[], fn: Listener): () => void { + if (!keyListeners.has(state)) keyListeners.set(state, new Map()); + const targetMap = keyListeners.get(state)!; + + for (const key of keys) { + if (!targetMap.has(key)) targetMap.set(key, new Set()); + targetMap.get(key)!.add(fn); + } + + return () => { + for (const key of keys) { + targetMap.get(key)?.delete(fn); + } + }; +} + +/** Return a frozen shallow copy of the current state. */ +export function snapshot(state: Reactive): Readonly { + return Object.freeze({ ...state }); +} + +export interface Tracker { + /** Tracking proxy that records which properties are accessed. */ + tracked: T; + /** Subscribe function compatible with useSyncExternalStore. */ + subscribe: (onStoreChange: () => void) => () => void; + /** Returns version that increments on relevant changes. */ + getSnapshot: () => number; + /** Clear tracked keys for next render cycle. */ + next: () => void; +} + +/** + * Track property access on reactive state. + * + * Returns a tracker that records which properties are accessed and only + * triggers updates when those specific properties change. Designed for + * use with React's `useSyncExternalStore` or Lit's reactive controller pattern. + */ +export function track(state: Reactive): Tracker { + const accessed = new Set(); + + let version = 0; + + const tracked = new Proxy(state, { + get(target, prop, receiver) { + if (typeof prop !== 'symbol') accessed.add(prop); + return Reflect.get(target, prop, receiver); + }, + }); + + return { + tracked, + subscribe: notify => + subscribe(state, (changedKeys) => { + if (accessed.size === 0 || [...changedKeys].some(k => accessed.has(k))) { + version++; + notify(); + } + }), + getSnapshot: () => version, + next: () => accessed.clear(), + }; +} diff --git a/packages/store/src/core/store.ts b/packages/store/src/core/store.ts index ce9ab557..a79b9032 100644 --- a/packages/store/src/core/store.ts +++ b/packages/store/src/core/store.ts @@ -7,26 +7,27 @@ import type { UnionSliceTarget, UnionSliceTasks, } from './slice'; -import type { StateFactory } from './state'; +import type { Reactive } from './state'; import type { PendingTask, Task, TaskContext } from './task'; -import { getSelectorKeys } from '@videojs/utils/object'; import { isNull } from '@videojs/utils/predicate'; import { StoreError } from './errors'; import { Queue } from './queue'; import { createRequestMeta, resolveRequestCancel, resolveRequestKey } from './request'; -import { State } from './state'; +import { reactive } from './state'; export class Store[] = AnySlice[]> { readonly #config: StoreConfig; readonly #slices: Slices; readonly #queue: Queue>; - readonly #state: State>; readonly #request: UnionSliceRequests; readonly #requestConfigs: Map>; readonly #setupAbort = new AbortController(); + /** Reactive state. Subscribe via `subscribe(store.state, fn)`. */ + readonly state: Reactive & object>; + #target: Target | null = null; #attachAbort: AbortController | null = null; #destroyed = false; @@ -36,9 +37,7 @@ export class Store[] = AnySlice[ this.#slices = config.slices; this.#queue = config.queue ?? new Queue>(); - - const factory = config.state ?? (initial => new State(initial)); - this.#state = factory(this.#createInitialState()); + this.state = reactive(this.#createInitialState() as UnionSliceState & object); this.#requestConfigs = this.#buildRequestConfigs(); this.#request = this.#buildRequestProxy(); @@ -61,10 +60,6 @@ export class Store[] = AnySlice[ return this.#target; } - get state(): UnionSliceState { - return this.#state.value; - } - get request(): UnionSliceRequests { return this.#request; } @@ -138,50 +133,6 @@ export class Store[] = AnySlice[ this.#resetState(); } - // ---------------------------------------- - // Subscribe - // ---------------------------------------- - - subscribe(listener: (state: UnionSliceState) => void): () => void; - subscribe( - selector: Selector, Selected>, - listener: (selected: Selected) => void, - options?: SubscribeOptions - ): () => void; - subscribe( - selectorOrListener: ((state: UnionSliceState) => void) | Selector, Selected>, - maybeListener?: (selected: Selected) => void, - options?: SubscribeOptions, - ): () => void { - if (!maybeListener) { - return this.#state.subscribe(selectorOrListener); - } - - const selector = selectorOrListener as Selector, Selected>; - const listener = maybeListener; - const equalityFn = options?.equalityFn ?? Object.is; - - let prev = selector(this.#state.value); - const handler = (state: UnionSliceState) => { - const next = selector(state); - if (!equalityFn(prev, next)) { - prev = next; - listener(next); - } - }; - - const keys = getSelectorKeys(selector, this.#state.value); - - if (keys) { - return this.#state.subscribeKeys( - keys as (keyof UnionSliceState)[], - handler as (state: Pick, keyof UnionSliceState>) => void, - ); - } - - return this.#state.subscribe(handler); - } - // ---------------------------------------- // Destroy // ---------------------------------------- @@ -214,7 +165,7 @@ export class Store[] = AnySlice[ initialState: slice.initialState, }); - this.#state.patch(snapshot); + Object.assign(this.state as object, snapshot); } catch (error) { this.#handleError({ error }); } @@ -231,7 +182,7 @@ export class Store[] = AnySlice[ } #resetState(): void { - this.#state.patch(this.#createInitialState()); + Object.assign(this.state as object, this.#createInitialState()); } // ---------------------------------------- @@ -251,10 +202,10 @@ export class Store[] = AnySlice[ } #buildRequestProxy(): UnionSliceRequests { - const proxy: Record Promise> = {}; + const reqProxy: Record Promise> = {}; for (const [name, config] of this.#requestConfigs) { - proxy[name] = (input?: unknown, meta?: RequestMetaInit) => { + reqProxy[name] = (input?: unknown, meta?: RequestMetaInit) => { if (this.#destroyed) { return Promise.reject(new StoreError('DESTROYED')); } @@ -263,7 +214,7 @@ export class Store[] = AnySlice[ }; } - return proxy as UnionSliceRequests; + return reqProxy as UnionSliceRequests; } async #execute( @@ -354,18 +305,11 @@ export function createStore( export type AnyStore = Store[]>; -export type Selector = (state: State) => Selected; - -export interface SubscribeOptions { - equalityFn?: (a: T, b: T) => boolean; -} - export type AnyStoreConfig = StoreConfig; export interface StoreConfig[]> { slices: Slices; queue?: Queue>; - state?: StateFactory>; onSetup?: (ctx: StoreSetupContext) => void; onAttach?: (ctx: StoreAttachContext) => void; onError?: (ctx: StoreErrorContext) => void; diff --git a/packages/store/src/core/tests/extend-config.test.ts b/packages/store/src/core/tests/extend-config.test.ts index 98ad34db..4bcdb76a 100644 --- a/packages/store/src/core/tests/extend-config.test.ts +++ b/packages/store/src/core/tests/extend-config.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it, vi } from 'vitest'; import { extendConfig } from '../extend-config'; import { Queue } from '../queue'; import { createSlice } from '../slice'; -import { State } from '../state'; // Test target type interface TestTarget { @@ -202,29 +201,5 @@ describe('extendConfig', () => { expect(result.queue).toBe(baseQueue); }); - - it('uses extension state factory when provided', () => { - const baseState = (initial: any) => new State(initial); - const extState = (initial: any) => new State(initial); - - const slice = createTestSlice('a'); - const base = createBaseConfig([slice], { state: baseState }); - const extension = { state: extState }; - - const result = extendConfig(base, extension); - - expect(result.state).toBe(extState); - }); - - it('falls back to base state factory when extension has none', () => { - const baseState = (initial: any) => new State(initial); - - const slice = createTestSlice('a'); - const base = createBaseConfig([slice], { state: baseState }); - - const result = extendConfig(base, {}); - - expect(result.state).toBe(baseState); - }); }); }); diff --git a/packages/store/src/core/tests/integration/store.test.ts b/packages/store/src/core/tests/integration/store.test.ts index dae19b4e..7fa211ec 100644 --- a/packages/store/src/core/tests/integration/store.test.ts +++ b/packages/store/src/core/tests/integration/store.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createSlice, createStore } from '../../index'; +import { flush, subscribeKeys } from '../../state'; describe('store lifecycle integration', () => { it('full lifecycle: create → attach → use → detach → destroy', async () => { @@ -274,23 +275,20 @@ describe('state syncing', () => { store.attach(new Target()); - store.subscribe( - s => s.volume, - (volume) => { - volumeUpdates.push(volume); - }, - ); + subscribeKeys(store.state, ['volume'], () => { + volumeUpdates.push(store.state.volume); + }); - store.subscribe( - s => s.muted, - (muted) => { - mutedUpdates.push(muted); - }, - ); + subscribeKeys(store.state, ['muted'], () => { + mutedUpdates.push(store.state.muted); + }); await store.request.setVolume(0.5); + flush(); await store.request.setMuted(true); + flush(); await store.request.setVolume(0.8); + flush(); expect(volumeUpdates).toEqual([0.5, 0.8]); expect(mutedUpdates).toEqual([true]); diff --git a/packages/store/src/core/tests/queue.test.ts b/packages/store/src/core/tests/queue.test.ts index cebf1e33..030e8efd 100644 --- a/packages/store/src/core/tests/queue.test.ts +++ b/packages/store/src/core/tests/queue.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createQueue } from '../queue'; +import { flush, subscribe } from '../state'; describe('Queue', () => { describe('enqueue', () => { @@ -203,11 +204,11 @@ describe('Queue', () => { }); describe('subscribe', () => { - it('returns an unsubscribe function', () => { + it('subscribe returns an unsubscribe function', () => { const queue = createQueue(); const listener = vi.fn(); - const unsubscribe = queue.subscribe(listener); + const unsubscribe = subscribe(queue.tasks, listener); expect(unsubscribe).toBeTypeOf('function'); }); @@ -216,7 +217,7 @@ describe('Queue', () => { const queue = createQueue(); const listener = vi.fn(); - queue.subscribe(listener); + subscribe(queue.tasks, listener); const promise = queue.enqueue({ name: 'test', @@ -224,54 +225,21 @@ describe('Queue', () => { handler: vi.fn().mockResolvedValue('result'), }); + // Flush to trigger notifications (auto-batched) + flush(); + await promise; + flush(); - // Called when pending (dispatch) and when settled + // Called when pending and when settled expect(listener).toHaveBeenCalledTimes(2); }); - it('notifies with tasks map on dispatch and settlement', async () => { - const queue = createQueue(); - const listener = vi.fn(); - - queue.subscribe(listener); - - let resolveHandler: () => void; - const handlerPromise = new Promise((resolve) => { - resolveHandler = resolve; - }); - - const promise = queue.enqueue({ - name: 'test', - key: 'test-key', - handler: async () => { - await handlerPromise; - return 'result'; - }, - }); - - // Wait for dispatch - await new Promise(r => setTimeout(r, 10)); - - expect(listener).toHaveBeenCalledTimes(1); - const pendingSnapshot = listener.mock.calls[0]![0] as Record; - expect(Reflect.ownKeys(pendingSnapshot).length).toBe(1); - expect(pendingSnapshot.test?.status).toBe('pending'); - - resolveHandler!(); - await promise; - - expect(listener).toHaveBeenCalledTimes(2); - const settledSnapshot = listener.mock.calls[1]![0] as Record; - expect(Reflect.ownKeys(settledSnapshot).length).toBe(1); - expect(settledSnapshot.test?.status).toBe('success'); - }); - it('unsubscribe stops notifications', async () => { const queue = createQueue(); const listener = vi.fn(); - const unsubscribe = queue.subscribe(listener); + const unsubscribe = subscribe(queue.tasks, listener); unsubscribe(); await queue.enqueue({ @@ -279,6 +247,7 @@ describe('Queue', () => { key: 'test-key', handler: vi.fn().mockResolvedValue('result'), }); + flush(); expect(listener).not.toHaveBeenCalled(); }); @@ -288,59 +257,19 @@ describe('Queue', () => { const listener1 = vi.fn(); const listener2 = vi.fn(); - queue.subscribe(listener1); - queue.subscribe(listener2); + subscribe(queue.tasks, listener1); + subscribe(queue.tasks, listener2); await queue.enqueue({ name: 'test', key: 'test-key', handler: vi.fn().mockResolvedValue('result'), }); + flush(); - expect(listener1).toHaveBeenCalledTimes(2); - expect(listener2).toHaveBeenCalledTimes(2); - }); - - it('catches and logs listener errors', async () => { - const queue = createQueue(); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const errorListener = vi.fn(() => { - throw new Error('Listener error'); - }); - const successListener = vi.fn(); - - queue.subscribe(errorListener); - queue.subscribe(successListener); - - await queue.enqueue({ - name: 'test', - key: 'test-key', - handler: vi.fn().mockResolvedValue('result'), - }); - - expect(errorListener).toHaveBeenCalled(); - expect(successListener).toHaveBeenCalled(); - expect(consoleSpy).toHaveBeenCalledWith('[vjs-queue]', expect.any(Error)); - - consoleSpy.mockRestore(); - }); - - it('provides strongly typed tasks object', async () => { - const queue = createQueue(); - - queue.subscribe((tasks) => { - const task = tasks.playback; - if (task) { - expect(task.key).toBe('playback'); - expect(task.name).toBeDefined(); - } - }); - - await queue.enqueue({ - name: 'play', - key: 'playback', - handler: vi.fn().mockResolvedValue(undefined), - }); + // Called once per batch (pending + settled batched together) + expect(listener1).toHaveBeenCalled(); + expect(listener2).toHaveBeenCalled(); }); }); @@ -509,21 +438,22 @@ describe('Queue', () => { handler: async () => 'result', }); - queue.subscribe(listener); + subscribe(queue.tasks, listener); queue.reset('task'); + flush(); expect(listener).toHaveBeenCalledTimes(1); - const snapshot = listener.mock.calls[0]![0] as Record; - expect(snapshot.task).toBeUndefined(); + expect(queue.tasks.task).toBeUndefined(); }); it('does not notify subscribers when task does not exist', () => { const queue = createQueue(); const listener = vi.fn(); - queue.subscribe(listener); + subscribe(queue.tasks, listener); queue.reset('nonexistent'); + flush(); expect(listener).not.toHaveBeenCalled(); }); @@ -570,31 +500,27 @@ describe('Queue', () => { }); }); - describe('tasks getter', () => { - it('returns frozen snapshot', async () => { + describe('tasks property', () => { + it('returns reactive state', async () => { const queue = createQueue(); await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' }); - const tasks = queue.tasks; - - expect(Object.isFrozen(tasks)).toBe(true); + // Tasks is now reactive state, not a frozen object + expect(queue.tasks.task?.status).toBe('success'); }); - it('returns independent snapshots', async () => { + it('reflects changes immediately', async () => { const queue = createQueue(); + const tasks = queue.tasks; + await queue.enqueue({ name: 'first', key: 'k', handler: async () => 'first' }); - const snapshot1 = queue.tasks; + // Same reference reflects updates + expect(tasks.first?.status).toBe('success'); await queue.enqueue({ name: 'second', key: 'k', handler: async () => 'second' }); - const snapshot2 = queue.tasks; - - expect(snapshot1).not.toBe(snapshot2); - if (snapshot1.first?.status === 'success' && snapshot2.second?.status === 'success') { - expect(snapshot1.first.output).toBe('first'); - expect(snapshot2.second.output).toBe('second'); - } + expect(tasks.second?.status).toBe('success'); }); }); diff --git a/packages/store/src/core/tests/queue.types.test.ts b/packages/store/src/core/tests/queue.types.test.ts index faafd438..d97a589d 100644 --- a/packages/store/src/core/tests/queue.types.test.ts +++ b/packages/store/src/core/tests/queue.types.test.ts @@ -29,11 +29,10 @@ describe('queue types', () => { expectTypeOf(queue.abort).returns.toBeVoid(); }); - it('subscribe takes listener and returns unsubscribe', () => { + it('tasks is reactive', () => { const queue = createQueue(); - expectTypeOf(queue.subscribe).toBeFunction(); - expectTypeOf(queue.subscribe).returns.toExtend<() => void>(); + expectTypeOf(queue.tasks).toBeObject(); }); it('destroy returns void', () => { diff --git a/packages/store/src/core/tests/state.test.ts b/packages/store/src/core/tests/state.test.ts index 6180f259..3b42baca 100644 --- a/packages/store/src/core/tests/state.test.ts +++ b/packages/store/src/core/tests/state.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import { State } from '../state'; +import { batch, flush, isReactive, reactive, snapshot, subscribe, subscribeKeys, track } from '../state'; -describe('state', () => { +describe('reactive', () => { interface TestState { volume: number; muted: boolean; @@ -10,136 +10,381 @@ describe('state', () => { } const createState = () => - new State({ + reactive({ volume: 1, muted: false, currentTime: 0, }); - describe('value', () => { - it('returns current state', () => { - const state = createState(); - expect(state.value).toEqual({ - volume: 1, - muted: false, - currentTime: 0, - }); + describe('reactive', () => { + it('creates reactive state', () => { + const s = createState(); + expect(s.volume).toBe(1); + expect(s.muted).toBe(false); }); - it('value is immutable reference', () => { - const state = createState(); - const first = state.value; - state.set('volume', 0.5); - expect(first).not.toBe(state.value); - expect(first.volume).toBe(1); - }); - }); - - describe('set', () => { - it('updates single key', () => { - const state = createState(); - state.set('volume', 0.5); - expect(state.value.volume).toBe(0.5); + it('allows direct mutation', () => { + const s = createState(); + s.volume = 0.5; + expect(s.volume).toBe(0.5); }); - it('does not notify on same value', () => { - const state = createState(); - const listener = vi.fn(); - state.subscribe(listener); - - state.set('volume', 1); // same as initial - expect(listener).not.toHaveBeenCalled(); - }); - }); - - describe('patch', () => { - it('updates multiple keys', () => { - const state = createState(); - state.patch({ volume: 0.5, muted: true }); - - expect(state.value).toMatchObject({ - volume: 0.5, - muted: true, - }); - }); - - it('only notifies for changed keys', () => { - const state = createState(); - const listener = vi.fn(); - state.subscribeKeys(['volume'], listener); - - state.patch({ volume: 1, muted: true }); // volume unchanged - expect(listener).not.toHaveBeenCalled(); - - state.patch({ volume: 0.5 }); - expect(listener).toHaveBeenCalledOnce(); + it('tracks reactive via isReactive', () => { + const s = createState(); + expect(isReactive(s)).toBe(true); + expect(isReactive({})).toBe(false); + expect(isReactive(null)).toBe(false); + expect(isReactive(undefined)).toBe(false); }); }); describe('subscribe', () => { - it('calls listener on any change', () => { - const state = createState(); + it('notifies on change after microtask', async () => { + const p = createState(); const listener = vi.fn(); - state.subscribe(listener); + subscribe(p, listener); - state.set('volume', 0.5); - expect(listener).toHaveBeenCalledWith(state.value); + p.volume = 0.5; - state.set('muted', true); - expect(listener).toHaveBeenCalledTimes(2); + // Not called yet (deferred to microtask) + expect(listener).not.toHaveBeenCalled(); + + // Wait for microtask + await Promise.resolve(); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('can force immediate notification with flush()', () => { + const p = createState(); + const listener = vi.fn(); + subscribe(p, listener); + + p.volume = 0.5; + expect(listener).not.toHaveBeenCalled(); + + flush(); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('does not notify on same value', () => { + const p = createState(); + const listener = vi.fn(); + subscribe(p, listener); + + p.volume = 1; // same as initial + flush(); + expect(listener).not.toHaveBeenCalled(); }); it('returns unsubscribe function', () => { - const state = createState(); + const p = createState(); const listener = vi.fn(); - const unsub = state.subscribe(listener); - state.set('volume', 0.5); + const unsub = subscribe(p, listener); + p.volume = 0.5; + flush(); expect(listener).toHaveBeenCalledOnce(); unsub(); - state.set('volume', 0.3); - expect(listener).toHaveBeenCalledOnce(); + p.volume = 0.3; + flush(); + expect(listener).toHaveBeenCalledOnce(); // still 1 }); }); describe('subscribeKeys', () => { it('only notifies for specified keys', () => { - const state = createState(); + const p = createState(); const volumeListener = vi.fn(); const mutedListener = vi.fn(); - state.subscribeKeys(['volume'], volumeListener); - state.subscribeKeys(['muted'], mutedListener); + subscribeKeys(p, ['volume'], volumeListener); + subscribeKeys(p, ['muted'], mutedListener); + + p.volume = 0.5; + flush(); - state.set('volume', 0.5); expect(volumeListener).toHaveBeenCalledOnce(); expect(mutedListener).not.toHaveBeenCalled(); - state.set('muted', true); + p.muted = true; + flush(); + expect(volumeListener).toHaveBeenCalledOnce(); expect(mutedListener).toHaveBeenCalledOnce(); }); - it('notifies once per change even with multiple keys', () => { - const state = createState(); - const listener = vi.fn(); - state.subscribeKeys(['volume', 'muted'], listener); - - state.patch({ volume: 0.5, muted: true }); - expect(listener).toHaveBeenCalledOnce(); - }); - it('unsubscribes from all keys', () => { - const state = createState(); + const p = createState(); const listener = vi.fn(); - const unsub = state.subscribeKeys(['volume', 'muted'], listener); + const unsub = subscribeKeys(p, ['volume', 'muted'], listener); unsub(); - state.set('volume', 0.5); - state.set('muted', true); + p.volume = 0.5; + p.muted = true; + flush(); + expect(listener).not.toHaveBeenCalled(); }); }); + + describe('batch', () => { + it('batches multiple mutations into one notification', () => { + const p = createState(); + const listener = vi.fn(); + subscribe(p, listener); + + p.volume = 0.5; + p.muted = true; + p.currentTime = 10; + + flush(); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('explicit batch() groups mutations', () => { + const p = createState(); + const listener = vi.fn(); + subscribe(p, listener); + + batch(() => { + p.volume = 0.5; + p.muted = true; + p.currentTime = 10; + }); + + flush(); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('batch() returns the result of the function', () => { + const result = batch(() => 42); + expect(result).toBe(42); + }); + }); + + describe('snapshot', () => { + it('returns a frozen shallow copy', () => { + const p = createState(); + const snap = snapshot(p); + + expect(snap).toEqual({ volume: 1, muted: false, currentTime: 0 }); + expect(Object.isFrozen(snap)).toBe(true); + }); + + it('snapshot is independent of future changes', () => { + const p = createState(); + const snap = snapshot(p); + + p.volume = 0.5; + expect(snap.volume).toBe(1); + }); + }); + + describe('parent bubbling', () => { + it('notifies parent when child changes', () => { + const parent = reactive<{ nested: { value: number } }>({ + nested: { value: 0 }, + }); + const parentListener = vi.fn(); + subscribe(parent, parentListener); + + parent.nested.value = 42; + flush(); + + expect(parentListener).toHaveBeenCalledOnce(); + }); + + it('auto-wraps nested objects', () => { + const s = reactive<{ nested?: { value: number } }>({}); + + s.nested = { value: 0 }; + expect(isReactive(s.nested)).toBe(true); + + const listener = vi.fn(); + subscribe(s, listener); + + s.nested.value = 42; + flush(); + + expect(listener).toHaveBeenCalledOnce(); + }); + + it('subscribeKeys on parent fires when nested child changes', () => { + const s = reactive<{ nested: { value: number }; other: number }>({ + nested: { value: 0 }, + other: 0, + }); + const nestedListener = vi.fn(); + const otherListener = vi.fn(); + + subscribeKeys(s, ['nested'], nestedListener); + subscribeKeys(s, ['other'], otherListener); + + s.nested.value = 42; + flush(); + + expect(nestedListener).toHaveBeenCalledOnce(); + expect(otherListener).not.toHaveBeenCalled(); + }); + + it('bubbles correct key through multiple levels', () => { + const s = reactive<{ a: { b: { c: number } } }>({ + a: { b: { c: 0 } }, + }); + const listener = vi.fn(); + subscribeKeys(s, ['a'], listener); + + s.a.b.c = 42; + flush(); + + expect(listener).toHaveBeenCalledOnce(); + }); + }); + + describe('delete property', () => { + it('notifies on property deletion', () => { + const s = reactive<{ value?: number }>({ value: 1 }); + const listener = vi.fn(); + subscribe(s, listener); + + delete s.value; + flush(); + + expect(listener).toHaveBeenCalledOnce(); + expect(s.value).toBeUndefined(); + }); + }); + + describe('changedKeys', () => { + it('subscribe listener receives changed keys', () => { + const s = createState(); + const listener = vi.fn(); + subscribe(s, listener); + + s.volume = 0.5; + flush(); + + expect(listener).toHaveBeenCalledWith(new Set(['volume'])); + }); + + it('subscribe listener receives multiple changed keys', () => { + const s = createState(); + const listener = vi.fn(); + subscribe(s, listener); + + s.volume = 0.5; + s.muted = true; + flush(); + + expect(listener).toHaveBeenCalledWith(new Set(['volume', 'muted'])); + }); + }); + + describe('track', () => { + it('tracks accessed properties', () => { + const s = createState(); + const { tracked, subscribe: sub, getSnapshot, next } = track(s); + const listener = vi.fn(); + + // Access volume + void tracked.volume; + + sub(listener); + + // Change volume - should notify + s.volume = 0.5; + flush(); + expect(listener).toHaveBeenCalledOnce(); + expect(getSnapshot()).toBe(1); + + // Change muted - should NOT notify (not accessed) + s.muted = true; + flush(); + expect(listener).toHaveBeenCalledOnce(); // still 1 + + // Access muted, call next to clear, then access again + next(); + void tracked.muted; + + // Now muted change should notify + s.muted = false; + flush(); + expect(listener).toHaveBeenCalledTimes(2); + expect(getSnapshot()).toBe(2); + }); + + it('notifies on first render when nothing tracked yet', () => { + const s = createState(); + const { subscribe: sub, getSnapshot } = track(s); + const listener = vi.fn(); + + sub(listener); + + // No properties accessed yet, but should still notify + s.volume = 0.5; + flush(); + + expect(listener).toHaveBeenCalledOnce(); + expect(getSnapshot()).toBe(1); + }); + + it('next() clears tracked keys', () => { + const s = createState(); + const { tracked, subscribe: sub, next } = track(s); + const listener = vi.fn(); + + // Access volume + void tracked.volume; + sub(listener); + + // Clear tracked keys + next(); + + // Now volume change should NOT notify (nothing tracked after next()) + // But actually it SHOULD because accessed.size === 0 triggers notification + s.volume = 0.5; + flush(); + expect(listener).toHaveBeenCalledOnce(); + + // Access muted only + void tracked.muted; + + // Volume change should NOT notify now + s.volume = 0.3; + flush(); + expect(listener).toHaveBeenCalledOnce(); // still 1 + }); + + it('getSnapshot increments only on relevant changes', () => { + const s = createState(); + const { tracked, subscribe: sub, getSnapshot, next } = track(s); + + // Access only volume + void tracked.volume; + sub(() => {}); + + expect(getSnapshot()).toBe(0); + + s.volume = 0.5; + flush(); + expect(getSnapshot()).toBe(1); + + // Access muted now + next(); + void tracked.muted; + + // Change volume - not tracked anymore after next() + // But muted is tracked, and accessed.size > 0, so volume change won't trigger + s.volume = 0.3; + flush(); + expect(getSnapshot()).toBe(1); // unchanged + + // Change muted - should trigger + s.muted = true; + flush(); + expect(getSnapshot()).toBe(2); + }); + }); }); diff --git a/packages/store/src/core/tests/store.test.ts b/packages/store/src/core/tests/store.test.ts index 7d3fb8d8..68c03b65 100644 --- a/packages/store/src/core/tests/store.test.ts +++ b/packages/store/src/core/tests/store.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createQueue } from '../queue'; import { createSlice } from '../slice'; +import { flush, subscribe } from '../state'; import { createStore } from '../store'; describe('store', () => { @@ -265,7 +266,7 @@ describe('store', () => { }); }); - describe('subscribe', () => { + describe('subscribe (via reactive)', () => { it('notifies on state change', async () => { const store = createStore({ slices: [audioSlice], @@ -275,39 +276,16 @@ describe('store', () => { store.attach(media); const listener = vi.fn(); - store.subscribe(listener); + subscribe(store.state, listener); await store.request.setVolume(0.5); + flush(); - const calls = listener.mock.calls; - const lastCall = calls[calls.length - 1]; - expect(lastCall).toBeDefined(); - const lastState = lastCall![0]; - expect(lastState.volume).toBe(0.5); + expect(listener).toHaveBeenCalled(); + expect(store.state.volume).toBe(0.5); }); - it('selector subscription only notifies when selected value changes', async () => { - const store = createStore({ - slices: [audioSlice], - }); - - const media = new MockMedia(); - store.attach(media); - - const volumeListener = vi.fn(); - store.subscribe(s => s.volume, volumeListener); - - // Reset after attach sync - volumeListener.mockClear(); - - await store.request.setMuted(true); - expect(volumeListener).not.toHaveBeenCalled(); - - await store.request.setVolume(0.7); - expect(volumeListener).toHaveBeenCalledWith(0.7); - }); - - it('object selector uses key optimization', async () => { + it('unsubscribe stops notifications', async () => { const store = createStore({ slices: [audioSlice], }); @@ -316,35 +294,13 @@ describe('store', () => { store.attach(media); const listener = vi.fn(); - store.subscribe(s => ({ volume: s.volume, muted: s.muted }), listener); - - // Reset after attach sync - listener.mockClear(); + const unsubscribe = subscribe(store.state, listener); + unsubscribe(); await store.request.setVolume(0.5); - expect(listener).toHaveBeenCalledWith({ volume: 0.5, muted: false }); - }); + flush(); - it('supports custom equality function', async () => { - const store = createStore({ - slices: [audioSlice], - }); - - const media = new MockMedia(); - store.attach(media); - - const listener = vi.fn(); - // Custom equality that ignores small volume changes - store.subscribe(s => s.volume, listener, { equalityFn: (a, b) => Math.abs(a - b) < 0.1 }); - - // Reset after attach sync - listener.mockClear(); - - await store.request.setVolume(0.95); // Within threshold of 1 expect(listener).not.toHaveBeenCalled(); - - await store.request.setVolume(0.5); // Outside threshold - expect(listener).toHaveBeenCalledWith(0.5); }); }); @@ -399,10 +355,12 @@ describe('store', () => { await store.request.fail().catch(() => {}); - expect(onError).toHaveBeenCalledWith({ - error: expect.any(Error), - store, - }); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.any(Error), + store, + }), + ); }); }); }); diff --git a/packages/store/src/core/tests/store.types.test.ts b/packages/store/src/core/tests/store.types.test.ts index 72fc17f4..d1f5d32a 100644 --- a/packages/store/src/core/tests/store.types.test.ts +++ b/packages/store/src/core/tests/store.types.test.ts @@ -1,4 +1,5 @@ import type { Queue, TasksRecord } from '../queue'; +import type { Reactive } from '../state'; import type { InferStoreRequests, InferStoreState, InferStoreTarget, InferStoreTasks } from '../store'; import { describe, expectTypeOf, it } from 'vitest'; @@ -133,23 +134,17 @@ describe('store types', () => { }); describe('subscribe', () => { - it('listener receives full state', () => { + it('state is reactive', () => { const store = createSingleSliceStore(); - store.subscribe((state) => { - expectTypeOf(state).toEqualTypeOf<{ volume: number; muted: boolean }>(); - }); + expectTypeOf(store.state).toEqualTypeOf>(); }); - it('selector listener receives selected value', () => { + it('state properties have correct types', () => { const store = createSingleSliceStore(); - store.subscribe( - s => s.volume, - (volume) => { - expectTypeOf(volume).toEqualTypeOf(); - }, - ); + expectTypeOf(store.state.volume).toEqualTypeOf(); + expectTypeOf(store.state.muted).toEqualTypeOf(); }); }); diff --git a/packages/store/src/lit/controllers/index.ts b/packages/store/src/lit/controllers/index.ts index cfb8b078..d49417b4 100644 --- a/packages/store/src/lit/controllers/index.ts +++ b/packages/store/src/lit/controllers/index.ts @@ -1,19 +1,5 @@ -export type { - AsyncStatus, - MutationError, - MutationIdle, - MutationPending, - MutationResult, - MutationSuccess, - OptimisticError, - OptimisticIdle, - OptimisticPending, - OptimisticResult, - OptimisticSuccess, -} from '../../shared/types'; +export type { AsyncStatus } from '../../shared/types'; -export { MutationController, type MutationControllerHost } from './mutation-controller'; -export { OptimisticController, type OptimisticControllerHost } from './optimistic-controller'; export { RequestController, type RequestControllerHost } from './request-controller'; -export { SelectorController, type SelectorControllerHost } from './selector-controller'; +export { SnapshotController, type SnapshotControllerHost } from './snapshot-controller'; export { TasksController, type TasksControllerHost } from './tasks-controller'; diff --git a/packages/store/src/lit/controllers/mutation-controller.ts b/packages/store/src/lit/controllers/mutation-controller.ts deleted file mode 100644 index 961bd025..00000000 --- a/packages/store/src/lit/controllers/mutation-controller.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; -import type { EnsureFunction } from '@videojs/utils/types'; -import type { AnyStore, InferStoreRequests } from '../../core/store'; -import type { Task } from '../../core/task'; -import type { MutationResult } from '../../shared/types'; -import type { StoreSource } from '../store-accessor'; - -import { noop } from '@videojs/utils/function'; -import { isNull } from '@videojs/utils/predicate'; - -import { StoreAccessor } from '../store-accessor'; - -export type MutationControllerHost = ReactiveControllerHost & HTMLElement; - -/** - * Tracks a mutation's status with discriminated union result. - * Triggers host updates when the task status changes. - * - * Accepts either a direct store instance or a context that provides one. - * - * @example Direct store - * ```ts - * class MyElement extends LitElement { - * #playMutation = new MutationController(this, store, 'play'); - * - * render() { - * const mutation = this.#playMutation.value; - * return html` - * - * `; - * } - * } - * ``` - * - * @example Context source (from createStore) - * ```ts - * const { context } = createStore({ slices: [playbackSlice] }); - * - * class MyElement extends LitElement { - * #playMutation = new MutationController(this, context, 'play'); - * - * render() { - * const mutation = this.#playMutation.value; - * return html` - * - * `; - * } - * } - * ``` - */ -export class MutationController< - Store extends AnyStore, - Name extends keyof InferStoreRequests, - Mutate extends InferStoreRequests[Name] = InferStoreRequests[Name], -> implements ReactiveController { - readonly #host: MutationControllerHost; - readonly #accessor: StoreAccessor; - readonly #name: Name; - - #task: Task | undefined; - #unsubscribe = noop; - - constructor(host: MutationControllerHost, source: StoreSource, name: Name) { - this.#host = host; - this.#name = name; - this.#accessor = new StoreAccessor(host, source, store => this.#connect(store)); - - // Initialize task if store available immediately (direct store case) - const store = this.#accessor.value; - if (store) this.#task = store.queue.tasks[name]; - - host.addController(this); - } - - get value(): MutationResult>>> { - const store = this.#accessor.value; - if (isNull(store)) { - throw new Error('MutationController: Store not available from context'); - } - - const task = this.#task; - - const base = { - mutate: store.request[this.#name] as Mutate, - reset: this.#reset, - }; - - if (task?.status === 'success') { - return { - status: 'success', - ...base, - data: task.output as Awaited>>, - }; - } - - if (task?.status === 'error') { - return { - status: 'error', - ...base, - error: task.error, - }; - } - - return { - status: task?.status ?? 'idle', - ...base, - }; - } - - #reset = (): void => { - const store = this.#accessor.value; - if (store) store.queue.reset(this.#name); - }; - - hostConnected(): void { - this.#accessor.hostConnected(); - } - - hostDisconnected(): void { - this.#unsubscribe(); - this.#unsubscribe = noop; - } - - #connect(store: Store): void { - this.#unsubscribe(); - this.#task = store.queue.tasks[this.#name]; - - this.#unsubscribe = store.queue.subscribe((tasks) => { - const newTask = tasks[this.#name]; - if (newTask !== this.#task) { - this.#task = newTask; - this.#host.requestUpdate(); - } - }); - } -} diff --git a/packages/store/src/lit/controllers/optimistic-controller.ts b/packages/store/src/lit/controllers/optimistic-controller.ts deleted file mode 100644 index dfc6a95b..00000000 --- a/packages/store/src/lit/controllers/optimistic-controller.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; -import type { EnsureFunction } from '@videojs/utils/types'; -import type { AnyStore, InferStoreRequests, InferStoreState } from '../../core/store'; -import type { Task } from '../../core/task'; -import type { OptimisticResult } from '../../shared/types'; -import type { StoreSource } from '../store-accessor'; - -import { Disposer } from '@videojs/utils/events'; -import { isNull } from '@videojs/utils/predicate'; - -import { StoreAccessor } from '../store-accessor'; - -export type OptimisticControllerHost = ReactiveControllerHost & HTMLElement; - -/** - * Shows optimistic value while mutation is pending, actual value otherwise. - * When setValue is called, immediately shows the new value while the request - * is in flight. Reverts to actual value if the request fails. - * - * Accepts either a direct store instance or a context that provides one. - * - * @example Direct store - * ```ts - * class VolumeSlider extends LitElement { - * #volume = new OptimisticController(this, store, 'setVolume', s => s.volume); - * - * render() { - * const { value, setValue, status } = this.#volume.value; - * return html` - * setValue(Number(e.target.value))} - * style="opacity: ${status === 'pending' ? 0.5 : 1}" - * /> - * `; - * } - * } - * ``` - * - * @example Context source (from createStore) - * ```ts - * const { context } = createStore({ slices: [volumeSlice] }); - * - * class VolumeSlider extends LitElement { - * #volume = new OptimisticController(this, context, 'setVolume', s => s.volume); - * - * render() { - * const { value, setValue, status } = this.#volume.value; - * return html` - * setValue(Number(e.target.value))} - * style="opacity: ${status === 'pending' ? 0.5 : 1}" - * /> - * `; - * } - * } - * ``` - */ -export class OptimisticController< - Store extends AnyStore, - Name extends keyof InferStoreRequests, - Value, - Request extends InferStoreRequests[Name] = InferStoreRequests[Name], -> implements ReactiveController { - readonly #host: OptimisticControllerHost; - readonly #accessor: StoreAccessor; - readonly #name: Name; - readonly #selector: (state: InferStoreState) => Value; - readonly #disposer = new Disposer(); - - #optimistic: Value | null = null; - #task: Task | undefined; - - constructor( - host: OptimisticControllerHost, - source: StoreSource, - name: Name, - selector: (state: InferStoreState) => Value, - ) { - this.#host = host; - this.#name = name; - this.#selector = selector; - this.#accessor = new StoreAccessor(host, source, store => this.#connect(store)); - - // Initialize task if store available immediately (direct store case) - const store = this.#accessor.value; - if (store) this.#task = store.queue.tasks[name]; - - host.addController(this); - } - - get value(): OptimisticResult ReturnType>> { - const store = this.#accessor.value; - if (isNull(store)) { - throw new Error('OptimisticController: Store not available from context'); - } - - const task = this.#task; - - // Show optimistic value when set (cleared on task settlement) - const value = this.#optimistic !== null ? this.#optimistic : this.#selector(store.state); - const base = { - value, - setValue: this.#setValue, - reset: this.#reset, - }; - - if (task?.status === 'error') { - return { - status: 'error', - ...base, - error: task.error, - }; - } - - return { - status: task?.status ?? 'idle', - ...base, - }; - } - - #setValue = (newValue: Value): ReturnType> => { - const store = this.#accessor.value; - if (isNull(store)) { - throw new Error('OptimisticController: Store not available from context'); - } - - this.#optimistic = newValue; - this.#host.requestUpdate(); - - const request = store.request[this.#name] as (value: Value) => ReturnType>; - - return request(newValue); - }; - - #reset = (): void => { - const store = this.#accessor.value; - if (isNull(store)) return; - - this.#optimistic = null; - this.#host.requestUpdate(); - - this.#task = store.queue.tasks[this.#name]; - if (this.#task) store.queue.reset(this.#name); - }; - - hostConnected(): void { - this.#accessor.hostConnected(); - } - - hostDisconnected(): void { - this.#disposer.dispose(); - } - - #connect(store: Store): void { - this.#disposer.dispose(); - this.#task = store.queue.tasks[this.#name]; - - this.#disposer.add(store.subscribe(this.#selector, () => this.#host.requestUpdate())); - - this.#disposer.add( - store.queue.subscribe((tasks) => { - const newTask = tasks[this.#name]; - if (newTask !== this.#task) { - this.#task = newTask; - - // Clear optimistic value when task settles - if (this.#optimistic !== null && newTask?.status !== 'pending') { - this.#optimistic = null; - } - - this.#host.requestUpdate(); - } - }), - ); - } -} diff --git a/packages/store/src/lit/controllers/request-controller.ts b/packages/store/src/lit/controllers/request-controller.ts index 9b1440b6..9f6e580a 100644 --- a/packages/store/src/lit/controllers/request-controller.ts +++ b/packages/store/src/lit/controllers/request-controller.ts @@ -24,16 +24,12 @@ export type RequestControllerHost = ReactiveControllerHost & HTMLElement; * } * ``` * - * @example Context source (from createStore) + * @example Context source * ```ts * const { context } = createStore({ slices: [playbackSlice] }); * * class MyElement extends LitElement { * #play = new RequestController(this, context, 'play'); - * - * render() { - * return html``; - * } * } * ``` */ diff --git a/packages/store/src/lit/controllers/selector-controller.ts b/packages/store/src/lit/controllers/selector-controller.ts deleted file mode 100644 index d328b2d6..00000000 --- a/packages/store/src/lit/controllers/selector-controller.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; -import type { AnyStore, InferStoreState } from '../../core/store'; -import type { StoreSource } from '../store-accessor'; - -import { noop } from '@videojs/utils/function'; -import { isNull } from '@videojs/utils/predicate'; - -import { StoreAccessor } from '../store-accessor'; - -export type SelectorControllerHost = ReactiveControllerHost & HTMLElement; - -/** - * Subscribes to a selected portion of store state. - * Triggers host updates when the selected value changes. - * - * Accepts either a direct store instance or a context that provides one. - * - * @example Direct store - * ```ts - * class MyElement extends LitElement { - * #paused = new SelectorController(this, store, s => s.paused); - * - * render() { - * return html``; - * } - * } - * ``` - * - * @example Context source (from createStore) - * ```ts - * const { context } = createStore({ slices: [playbackSlice] }); - * - * class MyElement extends LitElement { - * #paused = new SelectorController(this, context, s => s.paused); - * - * render() { - * return html``; - * } - * } - * ``` - */ -export class SelectorController implements ReactiveController { - readonly #host: SelectorControllerHost; - readonly #accessor: StoreAccessor; - readonly #selector: (state: InferStoreState) => Value; - - #value: Value | undefined; - #unsubscribe = noop; - - constructor(host: SelectorControllerHost, source: StoreSource, selector: (state: InferStoreState) => Value) { - this.#host = host; - this.#selector = selector; - this.#accessor = new StoreAccessor(host, source, store => this.#connect(store)); - - // Initialize value if store available immediately (direct store case) - const store = this.#accessor.value; - if (store) this.#value = selector(store.state); - - host.addController(this); - } - - get value(): Value { - const store = this.#accessor.value; - if (isNull(store)) { - throw new Error('SelectorController: Store not available from context'); - } - return this.#value as Value; - } - - hostConnected(): void { - this.#accessor.hostConnected(); - } - - hostDisconnected(): void { - this.#unsubscribe(); - this.#unsubscribe = noop; - } - - #connect(store: Store): void { - this.#unsubscribe(); - this.#value = this.#selector(store.state); - this.#unsubscribe = store.subscribe(this.#selector, (value) => { - this.#value = value; - this.#host.requestUpdate(); - }); - } -} diff --git a/packages/store/src/lit/controllers/snapshot-controller.ts b/packages/store/src/lit/controllers/snapshot-controller.ts new file mode 100644 index 00000000..55313098 --- /dev/null +++ b/packages/store/src/lit/controllers/snapshot-controller.ts @@ -0,0 +1,89 @@ +import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; +import type { Reactive, Tracker } from '../../core/state'; + +import { noop } from '@videojs/utils/function'; + +import { track } from '../../core/state'; + +export type SnapshotControllerHost = ReactiveControllerHost & HTMLElement; + +export interface SnapshotControllerOptions { + /** Called when tracked state changes (after requestUpdate). */ + onChange?: (snapshot: T) => void; +} + +/** + * Subscribes to reactive state and triggers host updates when tracked properties change. + * + * Automatically tracks which properties are accessed during render and only + * subscribes to changes on those specific keys. + * + * @example Basic usage + * ```ts + * class MyElement extends LitElement { + * #state = new SnapshotController(this, store.state); + * + * render() { + * const { volume, muted } = this.#state.value; + * return html`${muted ? 'Muted' : volume}`; + * } + * } + * ``` + * + * @example With onChange callback + * ```ts + * class MyElement extends LitElement { + * #state = new SnapshotController(this, store.state, { + * onChange: (state) => console.log('State changed:', state.volume), + * }); + * } + * ``` + */ +export class SnapshotController implements ReactiveController { + readonly #host: SnapshotControllerHost; + readonly #state: Reactive; + readonly #onChange: ((snapshot: T) => void) | undefined; + #tracker: Tracker | null = null; + #unsubscribe = noop; + + constructor(host: SnapshotControllerHost, state: Reactive, options?: SnapshotControllerOptions) { + this.#host = host; + this.#state = state; + this.#onChange = options?.onChange; + host.addController(this); + } + + /** Returns the tracking proxy. Access properties to subscribe to their changes. */ + get value(): T { + if (!this.#tracker) { + this.#tracker = track(this.#state); + } + return this.#tracker.tracked; + } + + hostConnected(): void { + if (!this.#tracker) { + this.#tracker = track(this.#state); + } + + this.#unsubscribe = this.#tracker.subscribe(() => { + this.#host.requestUpdate(); + this.#onChange?.(this.#state); + }); + } + + hostUpdated(): void { + this.#tracker?.next(); + } + + hostDisconnected(): void { + this.#unsubscribe(); + this.#unsubscribe = noop; + this.#tracker = null; + } +} + +export namespace SnapshotController { + export type Host = SnapshotControllerHost; + export type Options = SnapshotControllerOptions; +} diff --git a/packages/store/src/lit/controllers/tasks-controller.ts b/packages/store/src/lit/controllers/tasks-controller.ts index 3f6f882f..955b0a5d 100644 --- a/packages/store/src/lit/controllers/tasks-controller.ts +++ b/packages/store/src/lit/controllers/tasks-controller.ts @@ -5,6 +5,7 @@ import type { StoreSource } from '../store-accessor'; import { noop } from '@videojs/utils/function'; import { isNull } from '@videojs/utils/predicate'; +import { subscribe } from '../../core/state'; import { StoreAccessor } from '../store-accessor'; export type TasksControllerHost = ReactiveControllerHost & HTMLElement; @@ -28,18 +29,12 @@ export type TasksControllerHost = ReactiveControllerHost & HTMLElement; * } * ``` * - * @example Context source (from createStore) + * @example Context source * ```ts * const { context } = createStore({ slices: [playbackSlice] }); * * class MyElement extends LitElement { * #tasks = new TasksController(this, context); - * - * render() { - * const playTask = this.#tasks.value.play; - * const isPending = playTask?.status === 'pending'; - * return html``; - * } * } * ``` */ @@ -81,8 +76,8 @@ export class TasksController implements ReactiveControll #connect(store: Store): void { this.#unsubscribe(); this.#value = store.queue.tasks; - this.#unsubscribe = store.queue.subscribe((tasks) => { - this.#value = tasks; + this.#unsubscribe = subscribe(store.queue.tasks, () => { + this.#value = store.queue.tasks; this.#host.requestUpdate(); }); } diff --git a/packages/store/src/lit/controllers/tests/mutation-controller.test.ts b/packages/store/src/lit/controllers/tests/mutation-controller.test.ts deleted file mode 100644 index 8025c6f8..00000000 --- a/packages/store/src/lit/controllers/tests/mutation-controller.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { noop } from '@videojs/utils/function'; - -import { describe, expect, it } from 'vitest'; -import { createSlice } from '../../../core/slice'; -import { createStore as createCoreStore } from '../../../core/store'; -import { createCoreTestStore, createCustomKeyTestStore, createMockHost, MockMedia } from '../../tests/test-utils'; -import { MutationController } from '../mutation-controller'; - -describe('MutationController', () => { - it('returns mutation result with idle status initially', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new MutationController(host, store, 'setVolume'); - - expect(controller.value.status).toBe('idle'); - }); - - it('registers with host', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new MutationController(host, store, 'setVolume'); - - expect(host.controllers.has(controller)).toBe(true); - }); - - it('provides mutate function', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new MutationController(host, store, 'setVolume'); - - expect(typeof controller.value.mutate).toBe('function'); - }); - - it('tracks success state with data', async () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new MutationController(host, store, 'setVolume'); - controller.hostConnected(); - - await controller.value.mutate(0.7); - - expect(controller.value.status).toBe('success'); - if (controller.value.status === 'success') { - expect(controller.value.data).toBe(0.7); - } - expect(host.updateCount).toBeGreaterThan(0); - }); - - it('reset clears settled state', async () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new MutationController(host, store, 'setVolume'); - controller.hostConnected(); - - await controller.value.mutate(0.5); - expect(controller.value.status).toBe('success'); - - controller.value.reset(); - - expect(controller.value.status).toBe('idle'); - }); - - it('unsubscribes on hostDisconnected', async () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new MutationController(host, store, 'setVolume'); - controller.hostConnected(); - controller.hostDisconnected(); - - const updateCountBefore = host.updateCount; - await store.request.setVolume!(0.5); - - expect(host.updateCount).toBe(updateCountBefore); - }); - - it('tracks error with error object', async () => { - const host = createMockHost(); - - // Create a slice with a failing request for testing - const failingSlice = createSlice()({ - initialState: { volume: 1, muted: false }, - getSnapshot: ({ target }) => ({ - volume: target.volume, - muted: target.muted, - }), - subscribe: () => {}, - request: { - failingRequest: async () => { - throw new Error('Test error'); - }, - }, - }); - - const failingStore = createCoreStore({ - slices: [failingSlice], - onError: noop, - }); - - const target = new MockMedia(); - failingStore.attach(target); - - const controller = new MutationController(host, failingStore, 'failingRequest'); - controller.hostConnected(); - - try { - await controller.value.mutate(); - } catch { - // Expected to throw - } - - expect(controller.value.status).toBe('error'); - if (controller.value.status === 'error') { - expect(controller.value.error).toBeInstanceOf(Error); - expect((controller.value.error as Error).message).toBe('Test error'); - } - }); - - it('tracks pending status while mutation is in flight', async () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new MutationController(host, store, 'slowSetVolume'); - controller.hostConnected(); - - const promise = controller.value.mutate(0.5); - - // Wait for task to start - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(controller.value.status).toBe('pending'); - - await promise; - - expect(controller.value.status).toBe('success'); - }); - - it('syncs state on reconnect after disconnect', async () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new MutationController(host, store, 'setVolume'); - controller.hostConnected(); - - await controller.value.mutate(0.5); - expect(controller.value.status).toBe('success'); - - controller.hostDisconnected(); - - // Trigger another mutation while disconnected - await store.request.setVolume!(0.8); - - // Reconnect - should sync to current task state - controller.hostConnected(); - - expect(controller.value.status).toBe('success'); - }); - - describe('custom key (name !== key)', () => { - it('tracks task by name when key differs', async () => { - const { store } = createCustomKeyTestStore(); - const host = createMockHost(); - - // adjustVolume has name='adjustVolume' but key='audio-settings' - const controller = new MutationController(host, store, 'adjustVolume'); - controller.hostConnected(); - - const promise = controller.value.mutate(0.5); - - // Wait for task to start - await new Promise(resolve => setTimeout(resolve, 5)); - - expect(controller.value.status).toBe('pending'); - - await promise; - - expect(controller.value.status).toBe('success'); - if (controller.value.status === 'success') { - expect(controller.value.data).toBe(0.5); - } - }); - - it('tracks correct task when multiple requests share same key', async () => { - const { store } = createCustomKeyTestStore(); - const hostVolume = createMockHost(); - const hostMute = createMockHost(); - - // Both adjustVolume and toggleMute have key='audio-settings' - const volumeController = new MutationController(hostVolume, store, 'adjustVolume'); - const muteController = new MutationController(hostMute, store, 'toggleMute'); - volumeController.hostConnected(); - muteController.hostConnected(); - - // Start volume adjustment - const volumePromise = volumeController.value.mutate(0.5); - - await new Promise(resolve => setTimeout(resolve, 5)); - - // Volume controller should be pending - expect(volumeController.value.status).toBe('pending'); - // Mute controller should be idle (different name, even though same key) - expect(muteController.value.status).toBe('idle'); - - await volumePromise; - - expect(volumeController.value.status).toBe('success'); - expect(muteController.value.status).toBe('idle'); - }); - - it('superseded task shows error status', async () => { - const { store } = createCustomKeyTestStore(); - const hostVolume = createMockHost(); - const hostMute = createMockHost(); - - const volumeController = new MutationController(hostVolume, store, 'adjustVolume'); - const muteController = new MutationController(hostMute, store, 'toggleMute'); - volumeController.hostConnected(); - muteController.hostConnected(); - - // Start volume adjustment - const volumePromise = volumeController.value.mutate(0.5); - - await new Promise(resolve => setTimeout(resolve, 5)); - - // Start mute toggle - this will supersede volume because same key - const mutePromise = muteController.value.mutate(true); - - // Wait for superseding to happen - await new Promise(resolve => setTimeout(resolve, 5)); - - // Volume task was superseded - try { - await volumePromise; - } catch { - // Expected - task was superseded - } - - // Tasks are keyed by name, so superseded task shows error status - expect(volumeController.value.status).toBe('error'); - expect(muteController.value.status).toBe('pending'); - - await mutePromise; - - expect(muteController.value.status).toBe('success'); - }); - }); -}); diff --git a/packages/store/src/lit/controllers/tests/optimistic-controller.test.ts b/packages/store/src/lit/controllers/tests/optimistic-controller.test.ts deleted file mode 100644 index 0dcc5d21..00000000 --- a/packages/store/src/lit/controllers/tests/optimistic-controller.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { noop } from '@videojs/utils/function'; - -import { describe, expect, it } from 'vitest'; -import { createSlice } from '../../../core/slice'; -import { createStore as createCoreStore } from '../../../core/store'; -import { createCoreTestStore, createCustomKeyTestStore, createMockHost, MockMedia } from '../../tests/test-utils'; -import { OptimisticController } from '../optimistic-controller'; - -describe('OptimisticController', () => { - it('returns actual value initially', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - - expect(controller.value.value).toBe(1); - expect(controller.value.status).toBe('idle'); - }); - - it('registers with host', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - - expect(host.controllers.has(controller)).toBe(true); - }); - - it('provides setValue function', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - - expect(typeof controller.value.setValue).toBe('function'); - }); - - it('updates actual value after mutation completes', async () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - controller.hostConnected(); - - await controller.value.setValue(0.3); - - expect(controller.value.value).toBe(0.3); - expect(controller.value.status).toBe('success'); - }); - - it('reset clears error state', async () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - controller.hostConnected(); - - await controller.value.setValue(0.5); - - controller.value.reset(); - - expect(controller.value.status).toBe('idle'); - }); - - it('triggers host update when state changes', async () => { - const { store, target } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - controller.hostConnected(); - - target.volume = 0.8; - target.dispatchEvent(new Event('volumechange')); - - expect(host.updateCount).toBeGreaterThan(0); - expect(controller.value.value).toBe(0.8); - }); - - it('unsubscribes on hostDisconnected', async () => { - const { store, target } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - controller.hostConnected(); - controller.hostDisconnected(); - - const updateCountBefore = host.updateCount; - target.volume = 0.2; - target.dispatchEvent(new Event('volumechange')); - - expect(host.updateCount).toBe(updateCountBefore); - }); - - it('shows optimistic value immediately while pending', async () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'slowSetVolume', s => s.volume); - controller.hostConnected(); - - const promise = controller.value.setValue(0.3); - - // Optimistic value shown immediately - expect(controller.value.value).toBe(0.3); - expect(host.updateCount).toBeGreaterThan(0); - - // Wait for task to start - await new Promise(resolve => setTimeout(resolve, 10)); - expect(controller.value.status).toBe('pending'); - expect(controller.value.value).toBe(0.3); - - await promise; - - // After completion, shows actual value - expect(controller.value.status).toBe('success'); - expect(controller.value.value).toBe(0.3); - }); - - it('reverts to actual value on error', async () => { - const host = createMockHost(); - - const failingSlice = createSlice()({ - initialState: { volume: 1, muted: false }, - getSnapshot: ({ target }) => ({ - volume: target.volume, - muted: target.muted, - }), - subscribe: () => {}, - request: { - failingSetVolume: async () => { - await new Promise(resolve => setTimeout(resolve, 10)); - throw new Error('Test error'); - }, - }, - }); - - const failingStore = createCoreStore({ - slices: [failingSlice], - onError: noop, - }); - - const target = new MockMedia(); - failingStore.attach(target); - - const controller = new OptimisticController(host, failingStore, 'failingSetVolume', s => s.volume); - controller.hostConnected(); - - const promise = controller.value.setValue(0.5); - - // Optimistic value shown immediately - expect(controller.value.value).toBe(0.5); - - try { - await promise; - } catch { - // Expected - } - - // After error, shows actual value (reverted) - expect(controller.value.status).toBe('error'); - expect(controller.value.value).toBe(1); // Original value - if (controller.value.status === 'error') { - expect(controller.value.error).toBeInstanceOf(Error); - } - }); - - it('handles rapid setValue calls (superseding)', async () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'slowSetVolume', s => s.volume); - controller.hostConnected(); - - // Fire multiple rapid calls - const promise1 = controller.value.setValue(0.3); - const promise2 = controller.value.setValue(0.5); - const promise3 = controller.value.setValue(0.7); - - // Should show latest optimistic value - expect(controller.value.value).toBe(0.7); - - // First two get superseded - await expect(promise1).rejects.toMatchObject({ code: 'SUPERSEDED' }); - await expect(promise2).rejects.toMatchObject({ code: 'SUPERSEDED' }); - await promise3; - - expect(controller.value.status).toBe('success'); - expect(controller.value.value).toBe(0.7); - }); - - it('reset when already idle is safe', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - controller.hostConnected(); - - expect(controller.value.status).toBe('idle'); - - // Reset when idle should not throw - controller.value.reset(); - - expect(controller.value.status).toBe('idle'); - expect(controller.value.value).toBe(1); - }); - - describe('custom key (name !== key)', () => { - it('tracks task by name when key differs', async () => { - const { store } = createCustomKeyTestStore(); - const host = createMockHost(); - - // adjustVolume has name='adjustVolume' but key='audio-settings' - const controller = new OptimisticController(host, store, 'adjustVolume', s => s.volume); - controller.hostConnected(); - - const promise = controller.value.setValue(0.5); - - // Optimistic value shown immediately - expect(controller.value.value).toBe(0.5); - - // Wait for task to start - await new Promise(resolve => setTimeout(resolve, 5)); - - expect(controller.value.status).toBe('pending'); - - await promise; - - expect(controller.value.status).toBe('success'); - expect(controller.value.value).toBe(0.5); - }); - - it('tracks correct task when multiple requests share same key', async () => { - const { store } = createCustomKeyTestStore(); - const hostVolume = createMockHost(); - const hostMute = createMockHost(); - - // Both adjustVolume and toggleMute have key='audio-settings' - const volumeController = new OptimisticController(hostVolume, store, 'adjustVolume', s => s.volume); - const muteController = new OptimisticController(hostMute, store, 'toggleMute', s => s.muted); - volumeController.hostConnected(); - muteController.hostConnected(); - - // Start volume adjustment - const volumePromise = volumeController.value.setValue(0.5); - - await new Promise(resolve => setTimeout(resolve, 5)); - - // Volume controller should be pending - expect(volumeController.value.status).toBe('pending'); - expect(volumeController.value.value).toBe(0.5); // Optimistic - - // Mute controller should be idle (different name, even though same key) - expect(muteController.value.status).toBe('idle'); - expect(muteController.value.value).toBe(false); // Actual - - await volumePromise; - - expect(volumeController.value.status).toBe('success'); - expect(muteController.value.status).toBe('idle'); - }); - - it('superseded task shows error status', async () => { - const { store } = createCustomKeyTestStore(); - const hostVolume = createMockHost(); - const hostMute = createMockHost(); - - const volumeController = new OptimisticController(hostVolume, store, 'adjustVolume', s => s.volume); - const muteController = new OptimisticController(hostMute, store, 'toggleMute', s => s.muted); - volumeController.hostConnected(); - muteController.hostConnected(); - - // Start volume adjustment - const volumePromise = volumeController.value.setValue(0.5); - - await new Promise(resolve => setTimeout(resolve, 5)); - - // Start mute toggle - this will supersede volume because same key - const mutePromise = muteController.value.setValue(true); - - // Mute shows optimistic immediately - expect(muteController.value.value).toBe(true); - - // Volume task was superseded - try { - await volumePromise; - } catch { - // Expected - task was superseded - } - - // Wait for subscription callbacks to fire - await new Promise(resolve => setTimeout(resolve, 10)); - - // Tasks are keyed by name, so superseded task shows error status - expect(volumeController.value.status).toBe('error'); - - // Complete the mute operation - await mutePromise; - - expect(muteController.value.status).toBe('success'); - }); - }); -}); diff --git a/packages/store/src/lit/controllers/tests/selector-controller.test.ts b/packages/store/src/lit/controllers/tests/selector-controller.test.ts deleted file mode 100644 index 78cc2018..00000000 --- a/packages/store/src/lit/controllers/tests/selector-controller.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { createCoreTestStore, createMockHost } from '../../tests/test-utils'; -import { SelectorController } from '../selector-controller'; - -describe('SelectorController', () => { - it('returns selected state', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new SelectorController(host, store, s => s.volume); - - expect(controller.value).toBe(1); - }); - - it('registers with host', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new SelectorController(host, store, s => s.volume); - - expect(host.controllers.has(controller)).toBe(true); - }); - - it('subscribes on hostConnected', () => { - const { store, target } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new SelectorController(host, store, s => s.volume); - controller.hostConnected(); - - target.volume = 0.5; - target.dispatchEvent(new Event('volumechange')); - - expect(controller.value).toBe(0.5); - expect(host.updateCount).toBe(1); - }); - - it('unsubscribes on hostDisconnected', () => { - const { store, target } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new SelectorController(host, store, s => s.volume); - controller.hostConnected(); - controller.hostDisconnected(); - - const updateCountBefore = host.updateCount; - target.volume = 0.3; - target.dispatchEvent(new Event('volumechange')); - - expect(host.updateCount).toBe(updateCountBefore); - }); - - it('syncs value on reconnect after state changed while disconnected', () => { - const { store, target } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new SelectorController(host, store, s => s.volume); - controller.hostConnected(); - - expect(controller.value).toBe(1); - - controller.hostDisconnected(); - - target.volume = 0.3; - target.dispatchEvent(new Event('volumechange')); - - // Value should still be stale (not subscribed) - expect(controller.value).toBe(1); - - // Reconnect - should have current value - controller.hostConnected(); - - expect(controller.value).toBe(0.3); - }); - - it('does not trigger update when unrelated state changes', () => { - const { store, target } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new SelectorController(host, store, s => s.volume); - controller.hostConnected(); - - target.muted = true; - target.dispatchEvent(new Event('volumechange')); - - // Volume didn't change, so no update should be triggered - expect(host.updateCount).toBe(0); - }); - - it('handles multiple reconnect cycles', () => { - const { store, target } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new SelectorController(host, store, s => s.volume); - - // First connect/disconnect - controller.hostConnected(); - target.volume = 0.5; - target.dispatchEvent(new Event('volumechange')); - expect(controller.value).toBe(0.5); - controller.hostDisconnected(); - - // Change while disconnected - target.volume = 0.3; - target.dispatchEvent(new Event('volumechange')); - - // Second connect - should sync to current value - controller.hostConnected(); - expect(controller.value).toBe(0.3); - - // Changes should work again - target.volume = 0.8; - target.dispatchEvent(new Event('volumechange')); - expect(controller.value).toBe(0.8); - }); -}); diff --git a/packages/store/src/lit/controllers/tests/types.test.ts b/packages/store/src/lit/controllers/tests/types.test.ts index 5c25c8b2..3087b826 100644 --- a/packages/store/src/lit/controllers/tests/types.test.ts +++ b/packages/store/src/lit/controllers/tests/types.test.ts @@ -1,35 +1,30 @@ -import type { MutationResult, OptimisticResult } from '../index'; - import { describe, expectTypeOf, it } from 'vitest'; import { createCoreTestStore, createMockHost } from '../../tests/test-utils'; -import { MutationController } from '../mutation-controller'; -import { OptimisticController } from '../optimistic-controller'; import { RequestController } from '../request-controller'; -import { SelectorController } from '../selector-controller'; +import { SnapshotController } from '../snapshot-controller'; import { TasksController } from '../tasks-controller'; describe('controller types', () => { - describe('SelectorController', () => { - it('value has selected type', () => { + describe('SnapshotController', () => { + it('value has state type', () => { const { store } = createCoreTestStore(); const host = createMockHost(); - const controller = new SelectorController(host, store, s => s.volume); + const controller = new SnapshotController(host, store.state); - expectTypeOf(controller.value).toEqualTypeOf(); + // controller.value is the unwrapped type (without Reactive brand) + expectTypeOf(controller.value).toEqualTypeOf<{ volume: number; muted: boolean } & object>(); }); - it('value type matches selector return type', () => { + it('value properties have correct types', () => { const { store } = createCoreTestStore(); const host = createMockHost(); - const controller = new SelectorController(host, store, s => ({ - volume: s.volume, - muted: s.muted, - })); + const controller = new SnapshotController(host, store.state); - expectTypeOf(controller.value).toEqualTypeOf<{ volume: number; muted: boolean }>(); + expectTypeOf(controller.value.volume).toEqualTypeOf(); + expectTypeOf(controller.value.muted).toEqualTypeOf(); }); }); @@ -45,91 +40,6 @@ describe('controller types', () => { }); }); - describe('MutationController', () => { - it('value is MutationResult', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new MutationController(host, store, 'setVolume'); - - expectTypeOf(controller.value).toExtend>(); - }); - - it('value has mutate function', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new MutationController(host, store, 'setVolume'); - - expectTypeOf(controller.value.mutate).toBeFunction(); - }); - - it('value has reset function', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new MutationController(host, store, 'setVolume'); - - expectTypeOf(controller.value.reset).toEqualTypeOf<() => void>(); - }); - - it('value has status property', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new MutationController(host, store, 'setVolume'); - - expectTypeOf(controller.value.status).toEqualTypeOf<'idle' | 'pending' | 'success' | 'error'>(); - }); - }); - - describe('OptimisticController', () => { - it('value is OptimisticResult', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - - expectTypeOf(controller.value).toExtend>(); - }); - - it('value has selected type', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - - expectTypeOf(controller.value.value).toEqualTypeOf(); - }); - - it('value has setValue function', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - - expectTypeOf(controller.value.setValue).toBeFunction(); - }); - - it('value has reset function', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - - expectTypeOf(controller.value.reset).toEqualTypeOf<() => void>(); - }); - - it('value has status property', () => { - const { store } = createCoreTestStore(); - const host = createMockHost(); - - const controller = new OptimisticController(host, store, 'setVolume', s => s.volume); - - expectTypeOf(controller.value.status).toEqualTypeOf<'idle' | 'pending' | 'success' | 'error'>(); - }); - }); - describe('TasksController', () => { it('value is tasks record', () => { const { store } = createCoreTestStore(); diff --git a/packages/store/src/lit/create-store.ts b/packages/store/src/lit/create-store.ts index 1eb393ce..9470aad3 100644 --- a/packages/store/src/lit/create-store.ts +++ b/packages/store/src/lit/create-store.ts @@ -5,14 +5,12 @@ import type { TasksRecord } from '../core/queue'; import type { AnySlice, UnionSliceRequests, UnionSliceState, UnionSliceTarget, UnionSliceTasks } from '../core/slice'; import type { StoreConfig, StoreConsumer, StoreProvider } from '../core/store'; -import { createContext } from '@lit/context'; +import { ContextConsumer, createContext } from '@lit/context'; +import { noop } from '@videojs/utils/function'; +import { subscribe } from '../core/state'; import { Store } from '../core/store'; -import { - RequestController as RequestControllerBase, - SelectorController as SelectorControllerBase, - TasksController as TasksControllerBase, -} from './controllers'; +import { RequestController as RequestControllerBase, TasksController as TasksControllerBase } from './controllers'; import { createStoreAttachMixin, createStoreMixin, createStoreProviderMixin } from './mixins'; export const contextKey = Symbol('@videojs/store'); @@ -85,26 +83,27 @@ export interface CreateStoreResult { create: () => Store, Slices>; /** - * Selector controller bound to this store's context. - * Subscribes to a selected portion of store state. + * State controller bound to this store's context. + * Subscribes to store state changes and triggers host updates. * * @example * ```ts - * const { SelectorController } = createStore({ slices: [playbackSlice] }); + * const { StateController } = createStore({ slices: [playbackSlice] }); * * class MyElement extends LitElement { - * #paused = new SelectorController(this, s => s.paused); + * #state = new StateController(this); * * render() { - * return html``; + * return html``; * } * } * ``` */ - SelectorController: new ( - host: CreateStoreHost, - selector: (state: UnionSliceState) => Value - ) => SelectorControllerBase, Slices>, Value>; + StateController: new (host: CreateStoreHost) => { + value: UnionSliceState; + hostConnected: () => void; + hostDisconnected: () => void; + }; /** * Request controller bound to this store's context. @@ -164,7 +163,7 @@ export interface CreateStoreResult { * import { createStore } from '@videojs/store/lit'; * import { playbackSlice } from '@videojs/core/dom'; * - * const { StoreMixin, SelectorController } = createStore({ + * const { StoreMixin, StateController } = createStore({ * slices: [playbackSlice], * }); * @@ -173,10 +172,10 @@ export interface CreateStoreResult { * * // Create a control element that uses the store via context * class MyControl extends LitElement { - * #paused = new SelectorController(this, s => s.paused); + * #state = new StateController(this); * * render() { - * return html`${this.#paused.value ? 'Paused' : 'Playing'}`; + * return html`${this.#state.value.paused ? 'Paused' : 'Playing'}`; * } * } * @@ -208,9 +207,42 @@ export function createStore(config: CreateStoreConfig const StoreAttachMixin = createStoreAttachMixin(context); const StoreMixin = createStoreMixin(context, create); - class SelectorController extends SelectorControllerBase { - constructor(host: CreateStoreHost, selector: (state: State) => Value) { - super(host, context, selector); + class StateController { + readonly #host: CreateStoreHost; + readonly #consumer: ContextConsumer; + #unsubscribe = noop; + + constructor(host: CreateStoreHost) { + this.#host = host; + this.#consumer = new ContextConsumer(host, { + context, + subscribe: true, + callback: store => this.#connect(store), + }); + host.addController(this); + } + + get value(): State { + const store = this.#consumer.value; + if (!store) { + throw new Error('StateController: Store not available from context'); + } + return store.state; + } + + hostConnected(): void { + this.#consumer.hostConnected(); + } + + hostDisconnected(): void { + this.#unsubscribe(); + this.#unsubscribe = noop; + } + + #connect(store: ProvidedStore | undefined): void { + this.#unsubscribe(); + if (!store) return; + this.#unsubscribe = subscribe(store.state, () => this.#host.requestUpdate()); } } @@ -232,7 +264,7 @@ export function createStore(config: CreateStoreConfig StoreAttachMixin, context, create, - SelectorController, + StateController, RequestController, TasksController, }; diff --git a/packages/store/src/lit/index.ts b/packages/store/src/lit/index.ts index e46a733f..90bda696 100644 --- a/packages/store/src/lit/index.ts +++ b/packages/store/src/lit/index.ts @@ -1,11 +1,5 @@ -export type { AsyncStatus, MutationResult, OptimisticResult } from './controllers'; -export { - MutationController, - OptimisticController, - RequestController, - SelectorController, - TasksController, -} from './controllers'; +export type { AsyncStatus } from './controllers'; +export { RequestController, SnapshotController, TasksController } from './controllers'; // createStore factory (returns bound controllers) export { createStore } 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 ae958283..ba56433a 100644 --- a/packages/store/src/lit/tests/create-store.test.ts +++ b/packages/store/src/lit/tests/create-store.test.ts @@ -110,18 +110,18 @@ describe('createStore', () => { expect(result).toHaveProperty('StoreAttachMixin'); expect(result).toHaveProperty('context'); expect(result).toHaveProperty('create'); - expect(result).toHaveProperty('SelectorController'); + expect(result).toHaveProperty('StateController'); expect(result).toHaveProperty('RequestController'); expect(result).toHaveProperty('TasksController'); }); }); describe('bound controllers', () => { - it('SelectorController is a class', () => { - const { SelectorController } = createStore({ slices: [audioSlice] }); + it('StateController is a class', () => { + const { StateController } = createStore({ slices: [audioSlice] }); - expect(typeof SelectorController).toBe('function'); - expect(SelectorController.prototype).toBeDefined(); + expect(typeof StateController).toBe('function'); + expect(StateController.prototype).toBeDefined(); }); it('RequestController is a class', () => { diff --git a/packages/store/src/react/create-store.tsx b/packages/store/src/react/create-store.tsx index 4c5c4ee5..22cc5772 100644 --- a/packages/store/src/react/create-store.tsx +++ b/packages/store/src/react/create-store.tsx @@ -9,11 +9,7 @@ import { useEffect, useState } from 'react'; import { Store } from '../core/store'; import { StoreContextProvider, useParentStore, useStoreContext } from './context'; -import { - useRequest as useRequestBase, - useSelector as useSelectorBase, - useTasks as useTasksBase, -} from './hooks'; +import { useRequest as useRequestBase, useSnapshot as useSnapshotBase, useTasks as useTasksBase } from './hooks'; // ---------------------------------------- // Types @@ -54,10 +50,10 @@ export interface CreateStoreResult { useStore: () => Store, Slices>; /** - * Subscribes to a selected portion of state. - * Re-renders only when the selected value changes. + * Returns a snapshot of the store state. + * Re-renders when state changes. */ - useSelector: (selector: (state: UnionSliceState) => T) => T; + useSnapshot: () => UnionSliceState; /** * Returns the request map or a specific request by name. @@ -92,7 +88,7 @@ export interface CreateStoreResult { * * @example * ```tsx - * const { Provider, useStore, useSelector, useRequest, useTasks, create } = createStore({ + * const { Provider, useStore, useSnapshot, useRequest, useTasks, create } = createStore({ * slices: [playbackSlice, presentationSlice], * }); * ``` @@ -155,9 +151,9 @@ export function createStore(config: CreateStoreConfig return useStoreContext() as StoreType; } - function useSelector(selector: (state: State) => T): T { + function useSnapshot(): State { const store = useStore(); - return useSelectorBase(store, selector); + return useSnapshotBase(store.state) as State; } function useRequest(): Requests; @@ -175,7 +171,7 @@ export function createStore(config: CreateStoreConfig return { Provider, useStore, - useSelector, + useSnapshot, useRequest: useRequest as CreateStoreResult['useRequest'], useTasks, create, diff --git a/packages/store/src/react/hooks/index.ts b/packages/store/src/react/hooks/index.ts index b2cb3708..1754014c 100644 --- a/packages/store/src/react/hooks/index.ts +++ b/packages/store/src/react/hooks/index.ts @@ -1,19 +1,5 @@ -export type { - AsyncStatus, - MutationError, - MutationIdle, - MutationPending, - MutationResult, - MutationSuccess, - OptimisticError, - OptimisticIdle, - OptimisticPending, - OptimisticResult, - OptimisticSuccess, -} from '../../shared/types'; +export type { AsyncStatus } from '../../shared/types'; -export { useMutation } from './use-mutation'; -export { useOptimistic } from './use-optimistic'; export { useRequest } from './use-request'; -export { useSelector } from './use-selector'; +export { useSnapshot } from './use-snapshot'; export { useTasks } from './use-tasks'; diff --git a/packages/store/src/react/hooks/tests/use-mutation.test.tsx b/packages/store/src/react/hooks/tests/use-mutation.test.tsx deleted file mode 100644 index c489721b..00000000 --- a/packages/store/src/react/hooks/tests/use-mutation.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; - -import { describe, expect, it, vi } from 'vitest'; - -import { useMutation } from '../use-mutation'; -import { createAsyncTestStore, createTestStore } from './test-utils'; - -describe('useMutation', () => { - it('returns mutation result with idle status initially', () => { - const { store } = createAsyncTestStore(); - - const { result } = renderHook(() => useMutation(store, 'setVolume')); - - expect(result.current.status).toBe('idle'); - expect(typeof result.current.mutate).toBe('function'); - expect(typeof result.current.reset).toBe('function'); - }); - - it('updates to success status after successful mutation', async () => { - const { store } = createAsyncTestStore(); - - const { result } = renderHook(() => useMutation(store, 'setVolume')); - - await act(async () => { - await result.current.mutate(0.5); - }); - - expect(result.current.status).toBe('success'); - if (result.current.status === 'success') { - expect(result.current.data).toBe(0.5); - } - }); - - it('updates to error status after failed mutation', async () => { - const { store } = createAsyncTestStore(); - - const { result } = renderHook(() => useMutation(store, 'failingRequest')); - - await act(async () => { - try { - await result.current.mutate(); - } catch { - // Expected to throw - } - }); - - expect(result.current.status).toBe('error'); - if (result.current.status === 'error') { - expect(result.current.error).toBeInstanceOf(Error); - expect((result.current.error as Error).message).toBe('Request failed'); - } - }); - - it('reset clears settled state', async () => { - const { store } = createAsyncTestStore(); - - const { result } = renderHook(() => useMutation(store, 'setVolume')); - - await act(async () => { - await result.current.mutate(0.5); - }); - - expect(result.current.status).toBe('success'); - - await act(async () => { - result.current.reset(); - }); - - expect(result.current.status).toBe('idle'); - }); - - it('re-renders only when task status changes', async () => { - const { store } = createAsyncTestStore(); - const renderCount = vi.fn(); - - const { result } = renderHook(() => { - renderCount(); - return useMutation(store, 'setVolume'); - }); - - expect(renderCount).toHaveBeenCalledTimes(1); - - await act(async () => { - await result.current.mutate(0.5); - }); - - // Should have re-rendered for pending and success - expect(renderCount.mock.calls.length).toBeGreaterThan(1); - }); - - it('mutate function is stable across renders', () => { - const { store } = createAsyncTestStore(); - - const { result, rerender } = renderHook(() => useMutation(store, 'setVolume')); - - const firstMutate = result.current.mutate; - rerender(); - - expect(result.current.mutate).toBe(firstMutate); - }); - - it('works with synchronous requests', async () => { - const { store } = createTestStore(); - - const { result } = renderHook(() => useMutation(store, 'setVolume')); - - await act(async () => { - await result.current.mutate(0.5); - }); - - expect(result.current.status).toBe('success'); - if (result.current.status === 'success') { - expect(result.current.data).toBe(0.5); - } - }); -}); diff --git a/packages/store/src/react/hooks/tests/use-optimistic.test.tsx b/packages/store/src/react/hooks/tests/use-optimistic.test.tsx deleted file mode 100644 index 98ca4de3..00000000 --- a/packages/store/src/react/hooks/tests/use-optimistic.test.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; - -import { describe, expect, it, vi } from 'vitest'; - -import { useOptimistic } from '../use-optimistic'; -import { createAsyncTestStore, createCustomKeyTestStore, createTestStore } from './test-utils'; - -describe('useOptimistic', () => { - it('returns actual value initially with idle status', () => { - const { store } = createAsyncTestStore(); - - const { result } = renderHook(() => useOptimistic(store, 'setVolume', s => s.volume)); - - expect(result.current.value).toBe(1); - expect(result.current.status).toBe('idle'); - expect(typeof result.current.setValue).toBe('function'); - expect(typeof result.current.reset).toBe('function'); - }); - - it('shows optimistic value immediately while pending', async () => { - const { store } = createAsyncTestStore(); - - const { result } = renderHook(() => useOptimistic(store, 'slowSetVolume', s => s.volume)); - - // Start the request but don't await - let promise: Promise; - act(() => { - promise = result.current.setValue(0.3); - }); - - // Optimistic value shown immediately - expect(result.current.value).toBe(0.3); - - // Wait for task to start - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 10)); - }); - - expect(result.current.status).toBe('pending'); - expect(result.current.value).toBe(0.3); - - // Wait for completion - await act(async () => { - await promise; - }); - - expect(result.current.status).toBe('success'); - expect(result.current.value).toBe(0.3); - }); - - it('updates to actual value after mutation completes', async () => { - const { store } = createAsyncTestStore(); - - const { result } = renderHook(() => useOptimistic(store, 'setVolume', s => s.volume)); - - await act(async () => { - await result.current.setValue(0.5); - }); - - expect(result.current.status).toBe('success'); - expect(result.current.value).toBe(0.5); - }); - - it('reverts to actual value on error', async () => { - const { store } = createAsyncTestStore(); - - const { result } = renderHook(() => useOptimistic(store, 'failingSetVolume', s => s.volume)); - - // Start the request - let promise: Promise; - act(() => { - promise = result.current.setValue(0.5); - }); - - // Optimistic value shown immediately - expect(result.current.value).toBe(0.5); - - // Wait for error - await act(async () => { - try { - await promise; - } catch { - // Expected - } - }); - - // After error, shows actual value (reverted) and error status - expect(result.current.status).toBe('error'); - expect(result.current.value).toBe(1); // Original value - if (result.current.status === 'error') { - expect(result.current.error).toBeInstanceOf(Error); - } - }); - - it('reset clears optimistic state', async () => { - const { store } = createAsyncTestStore(); - - const { result } = renderHook(() => useOptimistic(store, 'setVolume', s => s.volume)); - - await act(async () => { - await result.current.setValue(0.5); - }); - - expect(result.current.status).toBe('success'); - - await act(async () => { - result.current.reset(); - }); - - expect(result.current.status).toBe('idle'); - expect(result.current.value).toBe(0.5); // Actual value after successful mutation - }); - - it('reset when idle is safe', () => { - const { store } = createAsyncTestStore(); - - const { result } = renderHook(() => useOptimistic(store, 'setVolume', s => s.volume)); - - expect(result.current.status).toBe('idle'); - - // Reset when idle should not throw - act(() => { - result.current.reset(); - }); - - expect(result.current.status).toBe('idle'); - expect(result.current.value).toBe(1); - }); - - it('triggers re-render when state changes externally', async () => { - const { store, target } = createAsyncTestStore(); - const renderCount = vi.fn(); - - const { result } = renderHook(() => { - renderCount(); - return useOptimistic(store, 'setVolume', s => s.volume); - }); - - expect(renderCount).toHaveBeenCalledTimes(1); - expect(result.current.value).toBe(1); - - // Change volume externally - await act(async () => { - target.volume = 0.8; - target.dispatchEvent(new Event('volumechange')); - }); - - expect(renderCount.mock.calls.length).toBeGreaterThan(1); - expect(result.current.value).toBe(0.8); - }); - - it('handles rapid setValue calls (superseding)', async () => { - const { store } = createAsyncTestStore(); - - const { result } = renderHook(() => useOptimistic(store, 'slowSetVolume', s => s.volume)); - - // Fire multiple rapid calls - let promise1: Promise; - let promise2: Promise; - let promise3: Promise; - - act(() => { - promise1 = result.current.setValue(0.3); - promise2 = result.current.setValue(0.5); - promise3 = result.current.setValue(0.7); - }); - - // Should show latest optimistic value - expect(result.current.value).toBe(0.7); - - // First two get superseded - await act(async () => { - await expect(promise1).rejects.toMatchObject({ code: 'SUPERSEDED' }); - await expect(promise2).rejects.toMatchObject({ code: 'SUPERSEDED' }); - await promise3; - }); - - expect(result.current.status).toBe('success'); - expect(result.current.value).toBe(0.7); - }); - - it('setValue function is stable across renders', () => { - const { store } = createAsyncTestStore(); - - const { result, rerender } = renderHook(() => useOptimistic(store, 'setVolume', s => s.volume)); - - const firstSetValue = result.current.setValue; - rerender(); - - expect(result.current.setValue).toBe(firstSetValue); - }); - - it('works with synchronous requests', async () => { - const { store } = createTestStore(); - - const { result } = renderHook(() => useOptimistic(store, 'setVolume', s => s.volume)); - - await act(async () => { - await result.current.setValue(0.5); - }); - - expect(result.current.status).toBe('success'); - expect(result.current.value).toBe(0.5); - }); - - describe('custom key (name !== key)', () => { - it('tracks task by name when key differs', async () => { - const { store } = createCustomKeyTestStore(); - - // adjustVolume has name='adjustVolume' but key='audio-settings' - const { result } = renderHook(() => useOptimistic(store, 'adjustVolume', s => s.volume)); - - let promise: Promise; - act(() => { - promise = result.current.setValue(0.5); - }); - - // Optimistic value shown immediately - expect(result.current.value).toBe(0.5); - - // Wait for task to start - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 5)); - }); - - expect(result.current.status).toBe('pending'); - - await act(async () => { - await promise; - }); - - expect(result.current.status).toBe('success'); - expect(result.current.value).toBe(0.5); - }); - - it('tracks correct task when multiple requests share same key', async () => { - const { store } = createCustomKeyTestStore(); - - // Both adjustVolume and toggleMute have key='audio-settings' - const { result: volumeResult } = renderHook(() => useOptimistic(store, 'adjustVolume', s => s.volume)); - - const { result: muteResult } = renderHook(() => useOptimistic(store, 'toggleMute', s => s.muted)); - - // Start volume adjustment - let volumePromise: Promise; - act(() => { - volumePromise = volumeResult.current.setValue(0.5); - }); - - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 5)); - }); - - // Volume controller should be pending - expect(volumeResult.current.status).toBe('pending'); - expect(volumeResult.current.value).toBe(0.5); // Optimistic - - // Mute controller should be idle (different name, even though same key) - expect(muteResult.current.status).toBe('idle'); - expect(muteResult.current.value).toBe(false); // Actual - - await act(async () => { - await volumePromise; - }); - - expect(volumeResult.current.status).toBe('success'); - expect(muteResult.current.status).toBe('idle'); - }); - - it('superseded task shows error status', async () => { - const { store } = createCustomKeyTestStore(); - - const { result: volumeResult } = renderHook(() => useOptimistic(store, 'adjustVolume', s => s.volume)); - - const { result: muteResult } = renderHook(() => useOptimistic(store, 'toggleMute', s => s.muted)); - - // Start volume adjustment - let volumePromise: Promise; - act(() => { - volumePromise = volumeResult.current.setValue(0.5); - }); - - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 5)); - }); - - // Start mute toggle - this will supersede volume because same key - let mutePromise: Promise; - act(() => { - mutePromise = muteResult.current.setValue(true); - }); - - // Mute shows optimistic immediately - expect(muteResult.current.value).toBe(true); - - // Volume task was superseded - await act(async () => { - try { - await volumePromise; - } catch { - // Expected - task was superseded - } - }); - - // Wait for subscription callbacks to fire - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 10)); - }); - - // Tasks are keyed by name, so superseded task shows error status - expect(volumeResult.current.status).toBe('error'); - - // Complete the mute operation - await act(async () => { - await mutePromise; - }); - - expect(muteResult.current.status).toBe('success'); - }); - }); -}); diff --git a/packages/store/src/react/hooks/tests/use-selector.test.tsx b/packages/store/src/react/hooks/tests/use-selector.test.tsx deleted file mode 100644 index 219bdc1a..00000000 --- a/packages/store/src/react/hooks/tests/use-selector.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; - -import { describe, expect, it, vi } from 'vitest'; - -import { useSelector } from '../use-selector'; -import { createTestStore } from './test-utils'; - -describe('useSelector', () => { - it('returns selected state', () => { - const { store } = createTestStore(); - - const { result } = renderHook(() => useSelector(store, s => s.volume)); - - expect(result.current).toBe(1); - }); - - it('re-renders when selected state changes', async () => { - const { store, target } = createTestStore(); - - const { result } = renderHook(() => useSelector(store, s => s.volume)); - - expect(result.current).toBe(1); - - await act(async () => { - target.volume = 0.5; - target.dispatchEvent(new Event('volumechange')); - }); - - expect(result.current).toBe(0.5); - }); - - it('does not re-render when unrelated state changes', async () => { - const { store, target } = createTestStore(); - const renderCount = vi.fn(); - - renderHook(() => { - renderCount(); - return useSelector(store, s => s.volume); - }); - - expect(renderCount).toHaveBeenCalledTimes(1); - - await act(async () => { - target.muted = true; - target.dispatchEvent(new Event('volumechange')); - }); - - // Should not re-render because volume didn't change - expect(renderCount).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/store/src/react/hooks/use-mutation.ts b/packages/store/src/react/hooks/use-mutation.ts deleted file mode 100644 index e6cd0497..00000000 --- a/packages/store/src/react/hooks/use-mutation.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { EnsureFunction } from '@videojs/utils/types'; -import type { AnyStore, InferStoreRequests } from '../../core/store'; -import type { Task } from '../../core/task'; -import type { MutationResult } from '../../shared/types'; - -import { useCallback, useRef, useSyncExternalStore } from 'react'; - -/** - * Track a store request as a mutation with status, data, and error. - * - * Subscribes to the task queue and re-renders when the mutation's status changes. - * - * Returns a discriminated union — use `status` to narrow the type and access - * `data` (on success) or `error` (on failure). - * - * @param store - The store instance containing the request - * @param name - The request name to track (type-safe with autocomplete) - * @returns A discriminated union with the mutation's current state - * - * @example - * ```tsx - * function SourceSelector() { - * const source = useMutation(store, 'setSource'); - * - * return ( - * <> - * - * {source.status === 'error' && ( - *

Failed to load: {String(source.error)}

- * )} - * - * ); - * } - * ``` - */ -export function useMutation< - Store extends AnyStore, - Name extends keyof InferStoreRequests, - Mutate extends InferStoreRequests[Name] = InferStoreRequests[Name], ->(store: Store, name: Name): MutationResult>>> { - type Data = Awaited>>; - - const taskRef = useRef(store.queue.tasks[name]); - - const subscribe = useCallback( - (onStoreChange: () => void) => - store.queue.subscribe((tasks) => { - const newTask = tasks[name]; - if (newTask !== taskRef.current) { - taskRef.current = newTask; - onStoreChange(); - } - }), - [store, name], - ); - - const getSnapshot = useCallback(() => taskRef.current, []); - - const task = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); - - const resetRef = useRef(() => store.queue.reset(name)); - - const base = { - mutate: store.request[name] as Mutate, - reset: resetRef.current, - }; - - if (task?.status === 'success') { - return { - status: 'success', - ...base, - data: task.output as Data, - }; - } - - if (task?.status === 'error') { - return { - status: 'error', - ...base, - error: task.error, - }; - } - - return { - status: task?.status ?? 'idle', - ...base, - } as MutationResult; -} - -export namespace useMutation { - export type Result = MutationResult; -} diff --git a/packages/store/src/react/hooks/use-optimistic.ts b/packages/store/src/react/hooks/use-optimistic.ts deleted file mode 100644 index 61df3ce5..00000000 --- a/packages/store/src/react/hooks/use-optimistic.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { EnsureFunction } from '@videojs/utils/types'; -import type { AnyStore, InferStoreRequests, InferStoreState } from '../../core/store'; -import type { Task } from '../../core/task'; -import type { OptimisticResult } from '../../shared/types'; - -import { useCallback, useReducer, useRef, useSyncExternalStore } from 'react'; - -/** - * Track a store request with optimistic updates. - * - * Shows the optimistic value immediately while the request is pending, - * then updates to the actual value on success or reverts on error. - * - * Returns a discriminated union — use `status` to narrow the type. - * - * @param store - The store instance containing the request - * @param name - The request name to track (type-safe with autocomplete) - * @param selector - Function to select the value from store state - * @returns A discriminated union with the current optimistic state - * - * @example - * ```tsx - * function VolumeSlider() { - * const result = useOptimistic(store, 'setVolume', s => s.volume); - * - * return ( - * result.setValue(Number(e.target.value))} - * style={{ opacity: result.status === 'pending' ? 0.5 : 1 }} - * /> - * ); - * } - * ``` - */ -export function useOptimistic< - Store extends AnyStore, - Name extends keyof InferStoreRequests, - Value, - Request extends InferStoreRequests[Name] = InferStoreRequests[Name], ->( - store: Store, - name: Name, - selector: (state: InferStoreState) => Value, -): OptimisticResult ReturnType>> { - // Force update mechanism for optimistic value changes - const [, forceUpdate] = useReducer((x: number) => x + 1, 0); - - // Track optimistic value (null = use actual state) - const optimisticRef = useRef(null); - const taskRef = useRef(store.queue.tasks[name]); - - // Subscribe to store state for actual value - const subscribeToState = useCallback( - (onStoreChange: () => void) => store.subscribe(selector, onStoreChange), - [store, selector], - ); - - const getStateSnapshot = useCallback(() => selector(store.state), [store, selector]); - - const actualValue = useSyncExternalStore(subscribeToState, getStateSnapshot, getStateSnapshot); - - // Subscribe to task queue for status - const subscribeToQueue = useCallback( - (onStoreChange: () => void) => - store.queue.subscribe((tasks) => { - const newTask = tasks[name]; - if (newTask !== taskRef.current) { - taskRef.current = newTask; - - // Clear optimistic value when task settles - if (optimisticRef.current !== null && newTask?.status !== 'pending') { - optimisticRef.current = null; - } - - onStoreChange(); - } - }), - [store, name], - ); - - const getQueueSnapshot = useCallback(() => taskRef.current, []); - - const task = useSyncExternalStore(subscribeToQueue, getQueueSnapshot, getQueueSnapshot); - - // setValue: set optimistic value and call request - const setValueRef = useRef((newValue: Value): ReturnType> => { - optimisticRef.current = newValue; - forceUpdate(); - - const request = store.request[name] as (value: Value) => ReturnType>; - return request(newValue); - }); - - // reset: clear optimistic value and reset task - const resetRef = useRef(() => { - optimisticRef.current = null; - forceUpdate(); - - taskRef.current = store.queue.tasks[name]; - if (taskRef.current) store.queue.reset(name); - }); - - // Build result with discriminated union - const value = optimisticRef.current !== null ? optimisticRef.current : actualValue; - const base = { - value, - setValue: setValueRef.current, - reset: resetRef.current, - }; - - if (task?.status === 'error') { - return { - status: 'error', - ...base, - error: task.error, - }; - } - - return { - status: task?.status ?? 'idle', - ...base, - }; -} - -export namespace useOptimistic { - export type Result = OptimisticResult; -} diff --git a/packages/store/src/react/hooks/use-selector.ts b/packages/store/src/react/hooks/use-selector.ts deleted file mode 100644 index 2f95230b..00000000 --- a/packages/store/src/react/hooks/use-selector.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { AnyStore, InferStoreState } from '../../core/store'; - -import { useCallback, useSyncExternalStore } from 'react'; - -/** - * Subscribe to a slice of store state. - * - * Only re-renders when the selected value changes (shallow comparison). - * - * The selector function should return a stable reference for objects - * to avoid unnecessary re-renders. - * - * @param store - The store instance to subscribe to - * @param selector - Function that extracts the desired value from state - * @returns The selected value, updated when it changes - * - * @example - * ```tsx - * function VolumeDisplay() { - * const volume = useSelector(store, (s) => s.volume); - * return {Math.round(volume * 100)}%; - * } - * ``` - */ -export function useSelector(store: S, selector: (state: InferStoreState) => T): T { - const subscribe = useCallback( - (onStoreChange: () => void) => store.subscribe(selector, onStoreChange), - [store, selector], - ); - - const getSnapshot = useCallback(() => selector(store.state), [store, selector]); - - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -} diff --git a/packages/store/src/react/hooks/use-snapshot.ts b/packages/store/src/react/hooks/use-snapshot.ts new file mode 100644 index 00000000..1362b292 --- /dev/null +++ b/packages/store/src/react/hooks/use-snapshot.ts @@ -0,0 +1,34 @@ +import type { Reactive } from '../../core/state'; + +import { useState, useSyncExternalStore } from 'react'; +import { track } from '../../core/state'; + +/** + * Subscribe to reactive state and re-render when accessed properties change. + * + * Automatically tracks which properties are accessed during render and only + * re-renders when those specific properties change. + * + * @param state - Reactive state created by `reactive()` + * @returns The state, which triggers re-renders when accessed properties change + * + * @example + * ```tsx + * function VolumeDisplay() { + * const state = useSnapshot(store.state); + * return {Math.round(state.volume * 100)}%; + * } + * ``` + */ +export function useSnapshot(state: Reactive): T { + const [{ tracked, subscribe, getSnapshot, next }] = useState(() => track(state)); + + useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + next(); + + return tracked; +} + +export namespace useSnapshot { + export type Result = T; +} diff --git a/packages/store/src/react/hooks/use-tasks.ts b/packages/store/src/react/hooks/use-tasks.ts index 9cab4ddc..edef7eef 100644 --- a/packages/store/src/react/hooks/use-tasks.ts +++ b/packages/store/src/react/hooks/use-tasks.ts @@ -3,15 +3,14 @@ import type { AnyStore, InferStoreTasks } from '../../core/store'; import { useCallback, useRef, useSyncExternalStore } from 'react'; +import { subscribe } from '../../core/state'; + /** * Subscribe to task queue state. * * Returns a record of all tasks keyed by request name. * Re-renders when any task is added, updated, or removed. * - * For tracking a single mutation, prefer `useMutation` which provides a more ergonomic API with - * status helpers. - * * @param store - The store instance to subscribe to * @returns Record of tasks keyed by request name * @@ -33,18 +32,21 @@ import { useCallback, useRef, useSyncExternalStore } from 'react'; * ``` */ export function useTasks(store: S): TasksRecord> { - const tasksRef = useRef(store.queue.tasks); + const versionRef = useRef(0); - const subscribe = useCallback( + const subscribeToQueue = useCallback( (onStoreChange: () => void) => - store.queue.subscribe((tasks) => { - tasksRef.current = tasks; + subscribe(store.queue.tasks, () => { + versionRef.current++; onStoreChange(); }), [store], ); - const getSnapshot = useCallback(() => tasksRef.current as TasksRecord>, []); + const getSnapshot = useCallback(() => versionRef.current, []); - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + useSyncExternalStore(subscribeToQueue, getSnapshot, getSnapshot); + + // Return the tasks proxy directly + return store.queue.tasks as TasksRecord>; } diff --git a/packages/store/src/react/index.ts b/packages/store/src/react/index.ts index 23076328..7f908660 100644 --- a/packages/store/src/react/index.ts +++ b/packages/store/src/react/index.ts @@ -3,4 +3,4 @@ export { useStoreContext } from './context'; export { createStore } from './create-store'; export type { CreateStoreConfig, CreateStoreResult, ProviderProps } from './create-store'; -export { useMutation, useOptimistic, useRequest, useSelector, useTasks } from './hooks'; +export { useRequest, useSnapshot, useTasks } from './hooks'; diff --git a/packages/store/src/react/tests/create-store.test.tsx b/packages/store/src/react/tests/create-store.test.tsx index 736e33e2..e717e6ec 100644 --- a/packages/store/src/react/tests/create-store.test.tsx +++ b/packages/store/src/react/tests/create-store.test.tsx @@ -143,38 +143,38 @@ describe('createStore', () => { }); }); - describe('useSelector', () => { - it('selects state from context store', () => { - const { Provider, useSelector, create } = createStore({ slices: [audioSlice] }); + describe('useSnapshot', () => { + it('returns state from context store', () => { + const { Provider, useSnapshot, create } = createStore({ slices: [audioSlice] }); const store = create(); const target = new MockMedia(); store.attach(target); - const { result } = renderHook(() => useSelector(s => s.volume), { + const { result } = renderHook(() => useSnapshot(), { wrapper: ({ children }: { children: ReactNode }) => {children}, }); - expect(result.current).toBe(1); + expect(result.current.volume).toBe(1); }); it('updates when state changes', async () => { - const { Provider, useSelector, create } = createStore({ slices: [audioSlice] }); + const { Provider, useSnapshot, create } = createStore({ slices: [audioSlice] }); const store = create(); const target = new MockMedia(); store.attach(target); - const { result } = renderHook(() => useSelector(s => s.volume), { + const { result } = renderHook(() => useSnapshot(), { wrapper: ({ children }: { children: ReactNode }) => {children}, }); - expect(result.current).toBe(1); + expect(result.current.volume).toBe(1); await act(async () => { target.volume = 0.5; target.dispatchEvent(new Event('volumechange')); }); - expect(result.current).toBe(0.5); + expect(result.current.volume).toBe(0.5); }); }); diff --git a/packages/store/src/shared/types.ts b/packages/store/src/shared/types.ts index ebda14aa..5a99df64 100644 --- a/packages/store/src/shared/types.ts +++ b/packages/store/src/shared/types.ts @@ -1,7 +1,3 @@ -// ---------------------------------------- -// Async Status -// ---------------------------------------- - /** * Lifecycle status for async operations. * @@ -11,134 +7,3 @@ * - `'error'` — Operation failed with an error */ export type AsyncStatus = 'idle' | 'pending' | 'success' | 'error'; - -// ---------------------------------------- -// Mutation Types -// ---------------------------------------- - -/** - * Common properties shared by all mutation states. - */ -interface MutationBase { - status: AsyncStatus; - mutate: Mutate; - reset: () => void; -} - -/** - * Mutation hasn't been triggered yet. - * This is the initial state before calling `mutate()`. - */ -export interface MutationIdle extends MutationBase { - status: 'idle'; -} - -/** - * Mutation is in flight, waiting for the request to complete. - * The UI should typically show a loading indicator. - */ -export interface MutationPending extends MutationBase { - status: 'pending'; -} - -/** - * Mutation completed successfully. - * The `data` property contains the request's return value. - */ -export interface MutationSuccess extends MutationBase { - status: 'success'; - data: Data; -} - -/** - * Mutation failed with an error. - * The `error` property contains the thrown exception. - */ -export interface MutationError extends MutationBase { - status: 'error'; - error: unknown; -} - -/** - * Discriminated union representing all possible mutation states. - * - * Use `status` to narrow the type and access state-specific properties: - * - * ```ts - * if (mutation.status === 'success') { - * console.log(mutation.data); // Data is available - * } - * if (mutation.status === 'error') { - * console.log(mutation.error); // Error is available - * } - * ``` - */ -export type MutationResult - = | MutationIdle - | MutationPending - | MutationSuccess - | MutationError; - -// ---------------------------------------- -// Optimistic Types -// ---------------------------------------- - -/** - * Common properties shared by all optimistic states. - */ -interface OptimisticBase { - value: Value; - setValue: SetValue; - reset: () => void; -} - -/** - * No optimistic update is active. - * The `value` reflects the actual store state. - */ -export interface OptimisticIdle extends OptimisticBase { - status: 'idle'; -} - -/** - * An optimistic update is in flight. - * The `value` shows the optimistic (predicted) value while waiting. - */ -export interface OptimisticPending extends OptimisticBase { - status: 'pending'; -} - -/** - * The optimistic update completed successfully. - * The `value` now reflects the confirmed store state. - */ -export interface OptimisticSuccess extends OptimisticBase { - status: 'success'; -} - -/** - * The optimistic update failed. - * The `value` has reverted to the actual store state. - * The `error` property contains the thrown exception. - */ -export interface OptimisticError extends OptimisticBase { - status: 'error'; - error: unknown; -} - -/** - * Discriminated union representing all possible optimistic update states. - * - * Use `status` to narrow the type and access state-specific properties: - * - * ```ts - * if (optimistic.status === 'error') { - * console.log(optimistic.error); // Error is available - * } - * ``` - */ -export type OptimisticResult - = | OptimisticIdle - | OptimisticPending - | OptimisticSuccess - | OptimisticError; diff --git a/packages/utils/src/object/index.ts b/packages/utils/src/object/index.ts index 8f3adbf1..81945939 100644 --- a/packages/utils/src/object/index.ts +++ b/packages/utils/src/object/index.ts @@ -1,2 +1 @@ export { pick } from './pick'; -export { getSelectorKeys, type Selector } from './selector'; diff --git a/packages/utils/src/object/selector.ts b/packages/utils/src/object/selector.ts deleted file mode 100644 index 67bd8067..00000000 --- a/packages/utils/src/object/selector.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { isObject } from '../predicate/predicate'; - -/** - * A function that selects a subset of state. - */ -export type Selector = (state: State) => Selected; - -/** - * Extract state keys a selector depends on. Returns null for primitives/arrays. - * - * @example - * getSelectorKeys((s) => ({ volume: s.volume }), state); // ['volume'] - */ -export function getSelectorKeys( - selector: Selector, - state: State, -): (keyof State)[] | null { - const result = selector(state); - - if (!isObject(result) || Array.isArray(result)) { - return null; - } - - return Object.keys(result) as (keyof State)[]; -} diff --git a/packages/utils/src/object/tests/selector.test.ts b/packages/utils/src/object/tests/selector.test.ts deleted file mode 100644 index baaeaef4..00000000 --- a/packages/utils/src/object/tests/selector.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { getSelectorKeys } from '../selector'; - -describe('getSelectorKeys', () => { - interface State { - volume: number; - muted: boolean; - currentTime: number; - } - - const state: State = { volume: 1, muted: false, currentTime: 0 }; - - it('extracts keys from object selector result', () => { - const selector = (s: State) => ({ volume: s.volume, muted: s.muted }); - expect(getSelectorKeys(selector, state)).toEqual(['volume', 'muted']); - }); - - it('extracts single key', () => { - const selector = (s: State) => ({ volume: s.volume }); - expect(getSelectorKeys(selector, state)).toEqual(['volume']); - }); - - it('returns null for primitive selector result (number)', () => { - const selector = (s: State) => s.volume; - expect(getSelectorKeys(selector, state)).toBeNull(); - }); - - it('returns null for primitive selector result (boolean)', () => { - const selector = (s: State) => s.muted; - expect(getSelectorKeys(selector, state)).toBeNull(); - }); - - it('returns null for primitive selector result (string)', () => { - const selector = () => 'hello'; - expect(getSelectorKeys(selector, state)).toBeNull(); - }); - - it('returns null for array selector result', () => { - const selector = (s: State) => [s.volume, s.muted]; - expect(getSelectorKeys(selector, state)).toBeNull(); - }); - - it('returns null for null selector result', () => { - const selector = () => null; - expect(getSelectorKeys(selector, state)).toBeNull(); - }); - - it('returns null for undefined selector result', () => { - const selector = () => undefined; - expect(getSelectorKeys(selector, state)).toBeNull(); - }); - - it('returns empty array for empty object selector', () => { - const selector = () => ({}); - expect(getSelectorKeys(selector, state)).toEqual([]); - }); - - it('handles derived/computed properties in selector', () => { - const selector = (s: State) => ({ - volumePercent: Math.round(s.volume * 100), - isMuted: s.muted, - }); - - // Returns the result object's keys, not the state keys accessed - expect(getSelectorKeys(selector, state)).toEqual(['volumePercent', 'isMuted']); - }); - - it('handles selector that accesses nested state', () => { - interface NestedState { - audio: { volume: number; muted: boolean }; - video: { quality: string }; - } - - const nestedState: NestedState = { - audio: { volume: 1, muted: false }, - video: { quality: 'hd' }, - }; - - const selector = (s: NestedState) => ({ audio: s.audio }); - expect(getSelectorKeys(selector, nestedState)).toEqual(['audio']); - }); -}); diff --git a/packages/utils/src/predicate/predicate.ts b/packages/utils/src/predicate/predicate.ts index 622c2bdc..989bb276 100644 --- a/packages/utils/src/predicate/predicate.ts +++ b/packages/utils/src/predicate/predicate.ts @@ -37,12 +37,18 @@ export function isObject(value: unknown): value is object { return value !== null && typeof value === 'object'; } +/** + * Check if a value is a plain object (not a class instance like Date, Map, etc). + */ +export function isPlainObject(value: unknown): value is Record { + if (!isObject(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === null || proto === Object.prototype; +} + /** * Check if a value is an AbortError. */ export function isAbortError(value: unknown): value is Error { - return ( - value instanceof Error - && value.name === 'AbortError' - ); + return value instanceof Error && value.name === 'AbortError'; } diff --git a/packages/utils/src/predicate/tests/predicate.test.ts b/packages/utils/src/predicate/tests/predicate.test.ts index f4ecafad..a96ca5c0 100644 --- a/packages/utils/src/predicate/tests/predicate.test.ts +++ b/packages/utils/src/predicate/tests/predicate.test.ts @@ -8,6 +8,7 @@ import { isNull, isNumber, isObject, + isPlainObject, isPromise, isString, isUndefined, @@ -175,6 +176,44 @@ describe('predicate', () => { }); }); + describe('isPlainObject', () => { + it('returns true for plain objects', () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ a: 1 })).toBe(true); + expect(isPlainObject(Object.create(null))).toBe(true); + expect(isPlainObject(new Object())).toBe(true); + }); + + it('returns false for arrays', () => { + expect(isPlainObject([])).toBe(false); + expect(isPlainObject([1, 2, 3])).toBe(false); + }); + + it('returns false for class instances', () => { + class Foo {} + expect(isPlainObject(new Foo())).toBe(false); + expect(isPlainObject(new Date())).toBe(false); + expect(isPlainObject(new Map())).toBe(false); + expect(isPlainObject(new Set())).toBe(false); + expect(isPlainObject(/regex/)).toBe(false); + }); + + it('returns false for primitives', () => { + expect(isPlainObject(null)).toBe(false); + expect(isPlainObject(undefined)).toBe(false); + expect(isPlainObject('string')).toBe(false); + expect(isPlainObject(123)).toBe(false); + expect(isPlainObject(true)).toBe(false); + // eslint-disable-next-line symbol-description + expect(isPlainObject(Symbol())).toBe(false); + }); + + it('returns false for functions', () => { + expect(isPlainObject(() => {})).toBe(false); + expect(isPlainObject(() => {})).toBe(false); + }); + }); + describe('isAbortError', () => { it('returns true for AbortError', () => { const error = new DOMException('Aborted', 'AbortError');