diff --git a/.claude/plans/store-bindings.md b/.claude/plans/store-bindings.md index e4ffeaa7..9d6e6038 100644 --- a/.claude/plans/store-bindings.md +++ b/.claude/plans/store-bindings.md @@ -13,33 +13,35 @@ Implement React and DOM bindings for Video.js 10's store, enabling: ## Key Decisions -| Decision | Resolution | -| ------------------- | ----------------------------------------------------------------------------------- | -| Store creation | `createStore({ slices, displayName? })` - types inferred from slices | -| Hook naming | `useStore`, `useSelector`, `useRequest`, `useTasks`, `useMutation`, `useOptimistic` | -| Controller naming | `SelectorController`, `RequestController`, `TasksController`, etc | -| Selector hook | `useSelector(selector)` - requires selector (Redux-style) | -| Store hook | `useStore()` - returns store instance | -| Request hook | `useRequest()` or `useRequest(r => r.foo)` - full map or single request | -| Tasks hook | `useTasks()` - returns `store.queue.tasks` (reactive, full lifecycle) | -| Mutation hook | `useMutation(r => r.foo)` - status tracking (isPending, isError, error) | -| Optimistic hook | `useOptimistic(r => r.foo, s => s.bar)` - optimistic value + status | -| Settled state | Core Queue tracks last result/error per key, cleared on next request | -| Base hooks | All take store as first arg: `useSelector(store, sel)`, etc | -| createStore hooks | Returns all hooks including `useMutation` and `useOptimistic` | -| Slice hook return | `{ state, request, isAvailable }` - state/request null when unavailable | -| Skin exports | `Provider`, `Skin`, `extendConfig` | -| Slice namespace | `export * as media` → `media.playback` | -| Video component | Generic, exported from `@videojs/react` (not from skins) | -| Lit mixins | `StoreMixin` (combined), `StoreProviderMixin`, `StoreAttachMixin` | -| Primitives context | `useStoreContext()` internal hook for primitive UI components | -| displayName | For React DevTools component naming | -| Component types | Namespace pattern: `Skin.Props` via `namespace Skin { export type Props }` | -| Element define | `FrostedSkinElement.define(tagName, { mixins })` for declarative setup | -| Config extension | `extendConfig()` uses `uniqBy` + `composeCallbacks` from utils | -| Provider resolution | Isolated by default; `inherit` prop to use parent store from context | -| Store instance | `create()` method for imperative store creation | -| Package structure | `store/react` and `store/lit` (no `store/dom`) | +| Decision | Resolution | +| ------------------- | ------------------------------------------------------------------------------------- | +| Store creation | `createStore({ slices, displayName? })` - types inferred from slices | +| Hook naming | `useStore`, `useSelector`, `useRequest`, `useTasks`, `useMutation`, `useOptimistic` | +| Controller naming | `SelectorController`, `RequestController`, `TasksController`, etc | +| Selector hook | `useSelector(selector)` - requires selector (Redux-style) | +| Store hook | `useStore()` - returns store instance | +| Request hook | `useRequest()` or `useRequest('name')` - full map or single request by name | +| Tasks hook | `useTasks()` - returns `store.queue.tasks` (reactive, full lifecycle) | +| Mutation hook | `useMutation(store, 'name')` - base hook only, direct name param | +| Optimistic hook | `useOptimistic(store, 'name', s => s.bar)` - base hook only, direct name param | +| Mutation/Optimistic | Discriminated union types with `status` field for type narrowing | +| Settled state | Core Queue tracks last result/error per key, cleared on next request | +| Base hooks | All take store as first arg: `useSelector(store, sel)`, etc | +| createStore hooks | Returns `useStore`, `useSelector`, `useRequest`, `useTasks` (NOT mutation/optimistic) | +| Slice hook return | `{ state, request, isAvailable }` - state/request null when unavailable | +| Skin exports | `Provider`, `Skin`, `extendConfig` | +| Slice namespace | `export * as media` → `media.playback` | +| Video component | Generic, exported from `@videojs/react` (not from skins) | +| Lit mixins | `StoreMixin` (combined), `StoreProviderMixin`, `StoreAttachMixin` | +| Primitives context | `useStoreContext()` internal hook for primitive UI components | +| displayName | For React DevTools component naming | +| Component types | Namespace pattern: `Skin.Props` via `namespace Skin { export type Props }` | +| Element define | `FrostedSkinElement.define(tagName, { mixins })` for declarative setup | +| Config extension | `extendConfig()` uses `uniqBy` + `composeCallbacks` from utils | +| Provider resolution | Isolated by default; `inherit` prop to use parent store from context | +| Store instance | `create()` method for imperative store creation | +| Package structure | `store/react` and `store/lit` (no `store/dom`) | +| Shared types | `AsyncStatus`, `MutationResult`, `OptimisticResult` in `src/shared/types.ts` | --- @@ -341,10 +343,14 @@ export { context, extendConfig, StoreAttachMixin, StoreMixin, StoreProviderMixin ```tsx import { createStore, media, Video } from '@videojs/react'; +// With mutation status tracking (base hook - import directly) + +// With optimistic updates (base hook - import directly) +import { useMutation, useOptimistic } from '@videojs/store/react'; // Note: media is re-exported from @videojs/react (not @videojs/core/dom) -const { Provider, useSelector, useRequest, useMutation, useOptimistic } = createStore({ +const { Provider, useStore, useSelector, useRequest } = createStore({ slices: [media.playback], }); @@ -359,39 +365,39 @@ function App() { function MyCustomControls() { const currentTime = useSelector((s) => s.currentTime); - const seek = useRequest((r) => r.seek); + const seek = useRequest('seek'); return ; } -// With mutation status tracking function PlayButton() { + const store = useStore(); const paused = useSelector((s) => s.paused); - const { mutate: play, isPending } = useMutation((r) => r.play); - const { mutate: pause } = useMutation((r) => r.pause); + const playResult = useMutation(store, 'play'); + const pauseResult = useMutation(store, 'pause'); return ( - ); } -// With optimistic updates function VolumeSlider() { - const { value, setValue, isPending, isError } = useOptimistic( - (r) => r.changeVolume, - (s) => s.volume - ); + const store = useStore(); + const result = useOptimistic(store, 'setVolume', (s) => s.volume); return ( <> setValue(Number(e.target.value))} - style={{ opacity: isPending ? 0.5 : 1 }} + value={result.value} + onChange={(e) => result.setValue(Number(e.target.value))} + style={{ opacity: result.status === 'pending' ? 0.5 : 1 }} /> - {isError && Failed to change volume} + {result.status === 'error' && Failed to change volume} ); } @@ -562,16 +568,33 @@ packages/store/src/ │ ├── tests/ │ │ └── extend-config.test.ts # DONE │ └── index.ts # DONE +├── shared/ +│ └── types.ts # DONE (AsyncStatus, MutationResult, OptimisticResult) ├── react/ -│ ├── context.ts # NEW (internal shared context) -│ ├── create-store.ts # NEW -│ ├── hooks.ts # NEW (base hooks) -│ ├── types.ts # NEW +│ ├── context.ts # DONE (internal shared context) +│ ├── create-store.tsx # DONE (Provider, useStore, useSelector, useRequest, useTasks) +│ ├── hooks/ # DONE (base hooks split into separate files) +│ │ ├── index.ts +│ │ ├── use-selector.ts +│ │ ├── use-request.ts +│ │ ├── use-tasks.ts +│ │ ├── use-mutation.ts +│ │ └── tests/ +│ │ ├── test-utils.ts +│ │ ├── use-selector.test.tsx +│ │ ├── use-request.test.tsx +│ │ ├── use-tasks.test.tsx +│ │ └── use-mutation.test.tsx │ └── index.ts └── lit/ - ├── create-store.ts # NEW (StoreMixin, StoreProviderMixin, StoreAttachMixin, context) - ├── controllers.ts # NEW (SelectorController, RequestController, etc.) - ├── types.ts # NEW + ├── create-store.ts # DONE (StoreMixin, StoreProviderMixin, StoreAttachMixin, context) + ├── controllers/ # DONE (split into separate files) + │ ├── index.ts + │ ├── selector-controller.ts + │ ├── request-controller.ts + │ ├── tasks-controller.ts + │ ├── mutation-controller.ts + │ └── optimistic-controller.ts └── index.ts packages/core/src/dom/ @@ -645,13 +668,17 @@ packages/html/src/ - `media` namespace export ✓ - Type guards and utilities ✓ -6. **Phase 4**: Mutation Hooks/Controllers - - React: `useMutation(store, selector)` - - Lit: `MutationController(host, store, selector)` +6. **Phase 4**: Mutation Hooks/Controllers **[DONE - PR #290]** + - React: `useMutation(store, name)` - base hook with direct name param ✓ + - React: `useRequest(store, name)` - updated to use direct name param ✓ + - Lit: `MutationController(host, store, name)` ✓ + - Shared types: `MutationResult` discriminated union in `src/shared/types.ts` ✓ + - Hooks split into `src/react/hooks/` directory ✓ 7. **Phase 5**: Optimistic Hooks/Controllers - - React: `useOptimistic(store, reqSel, stateSel)` - - Lit: `OptimisticController(host, store, reqSel, stateSel)` + - React: `useOptimistic(store, name, stateSelector)` - base hook + - Lit: `OptimisticController(host, store, name, stateSelector)` + - Shared types: `OptimisticResult` discriminated union 8. **Phase 6**: Skins - React skin (Provider, Skin, extendConfig) @@ -790,7 +817,7 @@ Use `types` + `default` format to match existing packages. | #285 | Queue Task Refactor | Unified tasks map, status discriminator | Closed | | #228 | Optimistic Updates | useMutation, useOptimistic | Open | | #229 | React Bindings | createStore, hooks, context | Closed | -| #230 | Lit Bindings | Controllers, mixins, context | Open | +| #230 | Lit Bindings | Controllers, mixins, context | Closed | | #239 | DOM Media Slices | media slices | Closed | | #231 | Skin Stores | Skin store configuration | Open | @@ -829,17 +856,19 @@ PR #292: DOM Media Slices [DONE] ├── Type guards and utilities ✓ ├── References #218 └── Closes #239 -├── References #218 -└── Closes #230 -PR D: Mutation Hooks/Controllers -├── React: useMutation -├── Lit: MutationController +PR #290: Mutation Hooks/Controllers [DONE] +├── React: useMutation(store, name) - base hook with direct name param ✓ +├── React: useRequest(store, name) - updated to use direct name param ✓ +├── Lit: MutationController(host, store, name) ✓ +├── Shared types: MutationResult discriminated union ✓ +├── Hooks split into src/react/hooks/ directory ✓ └── References #228 -PR E: Optimistic Hooks/Controllers -├── React: useOptimistic -├── Lit: OptimisticController +PR #291: Optimistic Hooks/Controllers +├── React: useOptimistic(store, name, stateSelector) +├── Lit: OptimisticController(host, store, name, stateSelector) +├── Shared types: OptimisticResult discriminated union └── Closes #228 PR F: Skins @@ -852,9 +881,9 @@ PR F: Skins ### Dependency Graph ``` -PR #283 ───> PR #287 ───> PR #288 ───> PR D ───> PR E ───> PR F - └──> PR #289 (done) ──────────────────┘ - └──> PR #292 (done) ──────────────────┘ +PR #283 ───> PR #287 ───> PR #288 ───> PR #290 ───> PR #291 ───> PR F (Skins) + └──> PR #289 (done) ────────────────────────┘ + └──> PR #292 (done) ────────────────────────┘ ``` PRs are sequential. PR #288, #289, #292 can technically parallel after PR #287, but we'll do them sequentially for easier review. diff --git a/packages/store/src/core/queue.ts b/packages/store/src/core/queue.ts index 98d7d47b..62dbc625 100644 --- a/packages/store/src/core/queue.ts +++ b/packages/store/src/core/queue.ts @@ -13,13 +13,6 @@ export type TaskKey = T & (string | symbol); export type EnsureTaskKey = T extends string | symbol ? T : never; -/** - * Status for async operations (mutations, optimistic updates). - * - * Used by framework bindings (React, Lit) for tracking request lifecycle. - */ -export type AsyncStatus = 'idle' | 'pending' | 'success' | 'error'; - /** * A task scheduler controls when a task flushes. * diff --git a/packages/store/src/core/tests/queue.types.test.ts b/packages/store/src/core/tests/queue.types.test.ts index 98345a22..32ceef0b 100644 --- a/packages/store/src/core/tests/queue.types.test.ts +++ b/packages/store/src/core/tests/queue.types.test.ts @@ -1,24 +1,9 @@ -import type { AsyncStatus, ErrorTask, PendingTask, SuccessTask, Task, TasksRecord } from '../queue'; +import type { ErrorTask, PendingTask, SuccessTask, Task, TasksRecord } from '../queue'; import { describe, expectTypeOf, it } from 'vitest'; - import { createQueue } from '../queue'; describe('queue types', () => { - describe('AsyncStatus', () => { - it('includes all status values', () => { - const idle: AsyncStatus = 'idle'; - const pending: AsyncStatus = 'pending'; - const success: AsyncStatus = 'success'; - const error: AsyncStatus = 'error'; - - expectTypeOf(idle).toExtend(); - expectTypeOf(pending).toExtend(); - expectTypeOf(success).toExtend(); - expectTypeOf(error).toExtend(); - }); - }); - describe('Task', () => { it('is discriminated union of task states', () => { const task: Task = {} as Task; diff --git a/packages/store/src/lit/controllers/index.ts b/packages/store/src/lit/controllers/index.ts index 15a88be8..814dd35c 100644 --- a/packages/store/src/lit/controllers/index.ts +++ b/packages/store/src/lit/controllers/index.ts @@ -1,23 +1,19 @@ -export type { AsyncStatus } from '../../core/queue'; - -export { MutationController } from './mutation-controller'; export type { + AsyncStatus, MutationError, MutationIdle, MutationPending, MutationResult, MutationSuccess, -} from './mutation-controller'; - -export { OptimisticController } from './optimistic-controller'; -export type { OptimisticError, OptimisticIdle, OptimisticPending, OptimisticResult, OptimisticSuccess, -} from './optimistic-controller'; +} from '../../shared/types'; +export { MutationController } from './mutation-controller'; +export { OptimisticController } from './optimistic-controller'; export { RequestController } from './request-controller'; export { SelectorController } from './selector-controller'; export { TasksController } from './tasks-controller'; diff --git a/packages/store/src/lit/controllers/mutation-controller.ts b/packages/store/src/lit/controllers/mutation-controller.ts index 91170b3f..9d28e12e 100644 --- a/packages/store/src/lit/controllers/mutation-controller.ts +++ b/packages/store/src/lit/controllers/mutation-controller.ts @@ -1,48 +1,11 @@ import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; import type { EnsureFunction } from '@videojs/utils/types'; -import type { AsyncStatus, Task } from '../../core/queue'; +import type { Task } from '../../core/queue'; import type { AnyStore, InferStoreRequests } from '../../core/store'; +import type { MutationResult } from '../../shared/types'; import { noop } from '@videojs/utils/function'; -// ---------------------------------------- -// Mutation Types -// ---------------------------------------- - -interface MutationBase { - status: AsyncStatus; - mutate: Mutate; - reset: () => void; -} - -export interface MutationIdle extends MutationBase { - status: 'idle'; -} - -export interface MutationPending extends MutationBase { - status: 'pending'; -} - -export interface MutationSuccess extends MutationBase { - status: 'success'; - data: Data; -} - -export interface MutationError extends MutationBase { - status: 'error'; - error: unknown; -} - -export type MutationResult - = | MutationIdle - | MutationPending - | MutationSuccess - | MutationError; - -// ---------------------------------------- -// Controller -// ---------------------------------------- - /** * Tracks a mutation's status with discriminated union result. * Triggers host updates when the task status changes. diff --git a/packages/store/src/lit/controllers/optimistic-controller.ts b/packages/store/src/lit/controllers/optimistic-controller.ts index 4602fd1c..bada6e02 100644 --- a/packages/store/src/lit/controllers/optimistic-controller.ts +++ b/packages/store/src/lit/controllers/optimistic-controller.ts @@ -2,46 +2,10 @@ import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-e import type { EnsureFunction } from '@videojs/utils/types'; import type { Task } from '../../core/queue'; import type { AnyStore, InferStoreRequests, InferStoreState } from '../../core/store'; +import type { OptimisticResult } from '../../shared/types'; import { Disposer } from '@videojs/utils/events'; -// ---------------------------------------- -// Optimistic Types -// ---------------------------------------- - -interface OptimisticBase { - value: Value; - setValue: SetValue; - reset: () => void; -} - -export interface OptimisticIdle extends OptimisticBase { - status: 'idle'; -} - -export interface OptimisticPending extends OptimisticBase { - status: 'pending'; -} - -export interface OptimisticSuccess extends OptimisticBase { - status: 'success'; -} - -export interface OptimisticError extends OptimisticBase { - status: 'error'; - error: unknown; -} - -export type OptimisticResult - = | OptimisticIdle - | OptimisticPending - | OptimisticSuccess - | OptimisticError; - -// ---------------------------------------- -// Controller -// ---------------------------------------- - /** * Shows optimistic value while mutation is pending, actual value otherwise. * When setValue is called, immediately shows the new value while the request diff --git a/packages/store/src/react/create-store.tsx b/packages/store/src/react/create-store.tsx index 3a24ea53..4c5c4ee5 100644 --- a/packages/store/src/react/create-store.tsx +++ b/packages/store/src/react/create-store.tsx @@ -9,7 +9,11 @@ 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, + useSelector as useSelectorBase, + useTasks as useTasksBase, +} from './hooks'; // ---------------------------------------- // Types @@ -56,11 +60,11 @@ export interface CreateStoreResult { useSelector: (selector: (state: UnionSliceState) => T) => T; /** - * Returns the request map or a selected request. + * Returns the request map or a specific request by name. */ useRequest: { (): UnionSliceRequests; - (selector: (requests: UnionSliceRequests) => T): T; + >(name: Name): UnionSliceRequests[Name]; }; /** @@ -157,16 +161,10 @@ export function createStore(config: CreateStoreConfig } function useRequest(): Requests; - function useRequest(selector: (requests: Requests) => T): T; - function useRequest(selector?: (requests: Requests) => T): Requests | T { + function useRequest(name: Name): Requests[Name]; + function useRequest(name?: Name): Requests | Requests[Name] { const store = useStore(); - const requests = useRequestBase(store); - - if (isUndefined(selector)) { - return requests; - } - - return selector(requests); + return useRequestBase(store, name as Name); } function useTasks(): TasksRecord { diff --git a/packages/store/src/react/hooks.ts b/packages/store/src/react/hooks.ts deleted file mode 100644 index eee35847..00000000 --- a/packages/store/src/react/hooks.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { TasksRecord } from '../core/queue'; -import type { AnyStore, InferStoreRequests, InferStoreState, InferStoreTasks } from '../core/store'; - -import { isUndefined } from '@videojs/utils/predicate'; - -import { useCallback, useRef, useSyncExternalStore } from 'react'; - -/** - * Subscribe to selected state. Re-renders only when selected value changes. - */ -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); -} - -/** - * Get request map or select a specific request. - */ -export function useRequest(store: S): InferStoreRequests; -export function useRequest(store: S, selector: (requests: InferStoreRequests) => T): T; -// eslint-disable-next-line react/no-unnecessary-use-prefix -export function useRequest( - store: S, - selector?: (requests: InferStoreRequests) => T, -): InferStoreRequests | T { - const request = store.request as InferStoreRequests; - - if (isUndefined(selector)) { - return request; - } - - return selector(request); -} - -/** - * Subscribe to task state changes. - */ -export function useTasks(store: S): TasksRecord> { - const tasksRef = useRef(store.queue.tasks); - - const subscribe = useCallback( - (onStoreChange: () => void) => - store.queue.subscribe((tasks) => { - tasksRef.current = tasks; - onStoreChange(); - }), - [store], - ); - - const getSnapshot = useCallback(() => tasksRef.current as TasksRecord>, []); - - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -} diff --git a/packages/store/src/react/hooks/index.ts b/packages/store/src/react/hooks/index.ts new file mode 100644 index 00000000..746ff42e --- /dev/null +++ b/packages/store/src/react/hooks/index.ts @@ -0,0 +1,13 @@ +export type { + AsyncStatus, + MutationError, + MutationIdle, + MutationPending, + MutationResult, + MutationSuccess, +} from '../../shared/types'; + +export { useMutation } from './use-mutation'; +export { useRequest } from './use-request'; +export { useSelector } from './use-selector'; +export { useTasks } from './use-tasks'; diff --git a/packages/store/src/react/hooks/tests/test-utils.ts b/packages/store/src/react/hooks/tests/test-utils.ts new file mode 100644 index 00000000..9560a061 --- /dev/null +++ b/packages/store/src/react/hooks/tests/test-utils.ts @@ -0,0 +1,92 @@ +import { noop } from '@videojs/utils/function'; +import { createSlice } from '../../../core/slice'; +import { createStore as createCoreStore } from '../../../core/store'; + +// Shared mock target for synchronous tests +export class MockMedia extends EventTarget { + volume = 1; + muted = false; +} + +// Shared slice for synchronous tests +export const audioSlice = createSlice()({ + initialState: { volume: 1, muted: false }, + getSnapshot: ({ target }) => ({ + volume: target.volume, + muted: target.muted, + }), + subscribe: ({ target, update, signal }) => { + target.addEventListener('volumechange', update); + signal.addEventListener('abort', () => { + target.removeEventListener('volumechange', update); + }); + }, + request: { + setVolume: (volume: number, { target }) => { + target.volume = volume; + target.dispatchEvent(new Event('volumechange')); + return volume; + }, + setMuted: (muted: boolean, { target }) => { + target.muted = muted; + target.dispatchEvent(new Event('volumechange')); + return muted; + }, + }, +}); + +export function createTestStore() { + const store = createCoreStore({ slices: [audioSlice] }); + const target = new MockMedia(); + store.attach(target); + return { store, target }; +} + +// Async mock for testing pending states +export class AsyncMockMedia extends EventTarget { + volume = 1; + muted = false; +} + +export const asyncAudioSlice = createSlice()({ + initialState: { volume: 1, muted: false }, + getSnapshot: ({ target }) => ({ + volume: target.volume, + muted: target.muted, + }), + subscribe: ({ target, update, signal }) => { + const handler = () => update(); + target.addEventListener('volumechange', handler); + signal.addEventListener('abort', () => { + target.removeEventListener('volumechange', handler); + }); + }, + request: { + setVolume: { + handler: async (volume: number, { target }) => { + await Promise.resolve(); + target.volume = volume; + target.dispatchEvent(new Event('volumechange')); + return volume; + }, + }, + failingRequest: { + handler: async () => { + await Promise.resolve(); + throw new Error('Request failed'); + }, + }, + }, +}); + +export function createAsyncTestStore() { + const store = createCoreStore({ + slices: [asyncAudioSlice], + onError: noop, + }); + + const target = new AsyncMockMedia(); + store.attach(target); + + return { store, target }; +} diff --git a/packages/store/src/react/hooks/tests/use-mutation.test.tsx b/packages/store/src/react/hooks/tests/use-mutation.test.tsx new file mode 100644 index 00000000..c489721b --- /dev/null +++ b/packages/store/src/react/hooks/tests/use-mutation.test.tsx @@ -0,0 +1,116 @@ +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-request.test.tsx b/packages/store/src/react/hooks/tests/use-request.test.tsx new file mode 100644 index 00000000..1e075497 --- /dev/null +++ b/packages/store/src/react/hooks/tests/use-request.test.tsx @@ -0,0 +1,37 @@ +import { renderHook } from '@testing-library/react'; + +import { describe, expect, it } from 'vitest'; + +import { useRequest } from '../use-request'; +import { createTestStore } from './test-utils'; + +describe('useRequest', () => { + it('returns request map', () => { + const { store } = createTestStore(); + + const { result } = renderHook(() => useRequest(store)); + + expect(result.current).toHaveProperty('setVolume'); + expect(result.current).toHaveProperty('setMuted'); + expect(typeof result.current.setVolume).toBe('function'); + }); + + it('returns request by name', () => { + const { store } = createTestStore(); + + const { result } = renderHook(() => useRequest(store, 'setVolume')); + + expect(typeof result.current).toBe('function'); + }); + + it('returns stable reference', () => { + const { store } = createTestStore(); + + const { result, rerender } = renderHook(() => useRequest(store)); + + const firstRequest = result.current; + rerender(); + + expect(result.current).toBe(firstRequest); + }); +}); diff --git a/packages/store/src/react/hooks/tests/use-selector.test.tsx b/packages/store/src/react/hooks/tests/use-selector.test.tsx new file mode 100644 index 00000000..219bdc1a --- /dev/null +++ b/packages/store/src/react/hooks/tests/use-selector.test.tsx @@ -0,0 +1,51 @@ +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/tests/use-tasks.test.tsx b/packages/store/src/react/hooks/tests/use-tasks.test.tsx new file mode 100644 index 00000000..288b0cee --- /dev/null +++ b/packages/store/src/react/hooks/tests/use-tasks.test.tsx @@ -0,0 +1,31 @@ +import { act, renderHook } from '@testing-library/react'; + +import { describe, expect, it } from 'vitest'; + +import { useTasks } from '../use-tasks'; +import { createTestStore } from './test-utils'; + +describe('useTasks', () => { + it('returns tasks record', () => { + const { store } = createTestStore(); + + const { result } = renderHook(() => useTasks(store)); + + expect(result.current).toEqual({}); + }); + + it('updates when task completes', async () => { + const { store } = createTestStore(); + + const { result } = renderHook(() => useTasks(store)); + + expect(result.current.setVolume).toBeUndefined(); + + await act(async () => { + await store.request.setVolume(0.5); + }); + + expect(result.current.setVolume).toBeDefined(); + expect(result.current.setVolume?.status).toBe('success'); + }); +}); diff --git a/packages/store/src/react/hooks/use-mutation.ts b/packages/store/src/react/hooks/use-mutation.ts new file mode 100644 index 00000000..bc47f4c5 --- /dev/null +++ b/packages/store/src/react/hooks/use-mutation.ts @@ -0,0 +1,98 @@ +import type { EnsureFunction } from '@videojs/utils/types'; +import type { Task } from '../../core/queue'; +import type { AnyStore, InferStoreRequests } from '../../core/store'; +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-request.ts b/packages/store/src/react/hooks/use-request.ts new file mode 100644 index 00000000..0041e074 --- /dev/null +++ b/packages/store/src/react/hooks/use-request.ts @@ -0,0 +1,44 @@ +import type { AnyStore, InferStoreRequests } from '../../core/store'; + +import { isUndefined } from '@videojs/utils/predicate'; + +/** + * Access the store's request methods. + * + * Returns either the full request map or a specific request function by name. + * + * The request map is stable across renders (same reference). + * + * @example + * ```tsx + * // Get all requests + * function Controls() { + * const request = useRequest(store); + * return ; + * } + * + * // Get a specific request by name + * function PlayButton() { + * const play = useRequest(store, 'play'); + * return ; + * } + * ``` + */ +export function useRequest(store: S): InferStoreRequests; +export function useRequest>( + store: S, + name: Name, +): InferStoreRequests[Name]; +// eslint-disable-next-line react/no-unnecessary-use-prefix +export function useRequest>( + store: S, + name?: Name, +): InferStoreRequests | InferStoreRequests[Name] { + const request = store.request as InferStoreRequests; + + if (isUndefined(name)) { + return request; + } + + return request[name]; +} diff --git a/packages/store/src/react/hooks/use-selector.ts b/packages/store/src/react/hooks/use-selector.ts new file mode 100644 index 00000000..2f95230b --- /dev/null +++ b/packages/store/src/react/hooks/use-selector.ts @@ -0,0 +1,34 @@ +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-tasks.ts b/packages/store/src/react/hooks/use-tasks.ts new file mode 100644 index 00000000..9cab4ddc --- /dev/null +++ b/packages/store/src/react/hooks/use-tasks.ts @@ -0,0 +1,50 @@ +import type { TasksRecord } from '../../core/queue'; +import type { AnyStore, InferStoreTasks } from '../../core/store'; + +import { useCallback, useRef, useSyncExternalStore } from 'react'; + +/** + * 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 + * + * @example + * ```tsx + * function TaskList() { + * const tasks = useTasks(store); + * + * return ( + *
    + * {Object.entries(tasks).map(([name, task]) => ( + *
  • + * {name}: {task.status} + *
  • + * ))} + *
+ * ); + * } + * ``` + */ +export function useTasks(store: S): TasksRecord> { + const tasksRef = useRef(store.queue.tasks); + + const subscribe = useCallback( + (onStoreChange: () => void) => + store.queue.subscribe((tasks) => { + tasksRef.current = tasks; + onStoreChange(); + }), + [store], + ); + + const getSnapshot = useCallback(() => tasksRef.current as TasksRecord>, []); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/packages/store/src/react/index.ts b/packages/store/src/react/index.ts index a2bc28e5..368adfa5 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 { useRequest, useSelector, useTasks } from './hooks'; +export { useMutation, useRequest, useSelector, 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 2a7fa18b..736e33e2 100644 --- a/packages/store/src/react/tests/create-store.test.tsx +++ b/packages/store/src/react/tests/create-store.test.tsx @@ -190,11 +190,11 @@ describe('createStore', () => { expect(result.current).toHaveProperty('setVolume'); }); - it('returns selected request', () => { + it('returns request by name', () => { const { Provider, useRequest, create } = createStore({ slices: [audioSlice] }); const store = create(); - const { result } = renderHook(() => useRequest(r => r.setVolume), { + const { result } = renderHook(() => useRequest('setVolume'), { wrapper: ({ children }: { children: ReactNode }) => {children}, }); diff --git a/packages/store/src/react/tests/hooks.test.tsx b/packages/store/src/react/tests/hooks.test.tsx deleted file mode 100644 index fe5964d9..00000000 --- a/packages/store/src/react/tests/hooks.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; - -import { describe, expect, it, vi } from 'vitest'; - -import { createSlice } from '../../core/slice'; -import { createStore as createCoreStore } from '../../core/store'; -import { useRequest, useSelector, useTasks } from '../hooks'; - -describe('react hooks', () => { - // Mock target - class MockMedia extends EventTarget { - volume = 1; - muted = false; - } - - const audioSlice = createSlice()({ - initialState: { volume: 1, muted: false }, - getSnapshot: ({ target }) => ({ - volume: target.volume, - muted: target.muted, - }), - subscribe: ({ target, update, signal }) => { - target.addEventListener('volumechange', update); - signal.addEventListener('abort', () => { - target.removeEventListener('volumechange', update); - }); - }, - request: { - setVolume: (volume: number, { target }) => { - target.volume = volume; - target.dispatchEvent(new Event('volumechange')); - return volume; - }, - setMuted: (muted: boolean, { target }) => { - target.muted = muted; - target.dispatchEvent(new Event('volumechange')); - return muted; - }, - }, - }); - - function createTestStore() { - const store = createCoreStore({ slices: [audioSlice] }); - const target = new MockMedia(); - store.attach(target); - return { store, target }; - } - - 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); - }); - }); - - describe('useRequest', () => { - it('returns request map', () => { - const { store } = createTestStore(); - - const { result } = renderHook(() => useRequest(store)); - - expect(result.current).toHaveProperty('setVolume'); - expect(result.current).toHaveProperty('setMuted'); - expect(typeof result.current.setVolume).toBe('function'); - }); - - it('returns selected request', () => { - const { store } = createTestStore(); - - const { result } = renderHook(() => useRequest(store, r => r.setVolume)); - - expect(typeof result.current).toBe('function'); - }); - - it('returns stable reference', () => { - const { store } = createTestStore(); - - const { result, rerender } = renderHook(() => useRequest(store)); - - const firstRequest = result.current; - rerender(); - - expect(result.current).toBe(firstRequest); - }); - }); - - describe('useTasks', () => { - it('returns tasks record', () => { - const { store } = createTestStore(); - - const { result } = renderHook(() => useTasks(store)); - - expect(result.current).toEqual({}); - }); - - it('updates when task completes', async () => { - const { store } = createTestStore(); - - const { result } = renderHook(() => useTasks(store)); - - expect(result.current.setVolume).toBeUndefined(); - - await act(async () => { - await store.request.setVolume(0.5); - }); - - expect(result.current.setVolume).toBeDefined(); - expect(result.current.setVolume?.status).toBe('success'); - }); - }); -}); diff --git a/packages/store/src/shared/types.ts b/packages/store/src/shared/types.ts new file mode 100644 index 00000000..ebda14aa --- /dev/null +++ b/packages/store/src/shared/types.ts @@ -0,0 +1,144 @@ +// ---------------------------------------- +// Async Status +// ---------------------------------------- + +/** + * Lifecycle status for async operations. + * + * - `'idle'` — Operation hasn't started yet + * - `'pending'` — Operation is in flight + * - `'success'` — Operation completed successfully + * - `'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/store/tsconfig.json b/packages/store/tsconfig.json index 35146f7c..af8bdd14 100644 --- a/packages/store/tsconfig.json +++ b/packages/store/tsconfig.json @@ -5,5 +5,5 @@ "declarationDir": "types" }, "references": [{ "path": "../utils" }], - "include": ["src/core/**/*.ts"] + "include": ["src/core", "src/shared"] }