diff --git a/.claude/plans/store-bindings.md b/.claude/plans/store-bindings.md index 9104085b..f931614d 100644 --- a/.claude/plans/store-bindings.md +++ b/.claude/plans/store-bindings.md @@ -1,5 +1,7 @@ # Store React/DOM Bindings +> **For AI agents:** When marking a phase as complete, remove detailed API specs and replace with a PR reference (e.g., "Refer to PR #XXX for implementation details"). The PR is the source of truth for completed work. + ## Goal Implement React and DOM bindings for Video.js 10's store, enabling: @@ -11,133 +13,53 @@ 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`, `usePending`, `useMutation`, `useOptimistic` | -| Controller naming | `SelectorController`, `RequestController`, `PendingController`, 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 | -| Pending hook | `usePending()` - returns `store.queue.pending` (reactive) | -| 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(r => r.foo)` - full map or single request | +<<<<<<< Updated upstream +| Tasks hook | `useTasks()` - returns `store.queue.tasks` (reactive) | +======= +| Tasks hook | `useTasks()` - returns `store.queue.tasks` (reactive, full lifecycle) | +>>>>>>> Stashed changes +| 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`) | --- ## Phase 0: Core Utilities [DONE] -> Implemented in PR #283. +> Refer to [PR #283](https://github.com/videojs/v10/pull/283) for implementation details. -- `uniqBy` - `packages/utils/src/array/uniq-by.ts` -- `composeCallbacks` - `packages/utils/src/function/compose-callbacks.ts` -- `extendConfig` - `packages/store/src/core/extend-config.ts` +Added `uniqBy`, `composeCallbacks` utilities and `extendConfig` for store. --- -## Phase 0.5: Queue Task Refactor +## Phase 0.5: Queue Task Refactor [DONE] -Refactor Queue to use a unified `tasks` map with status discriminator. This enables `useMutation` and `useOptimistic` hooks to track request lifecycle. +> Refer to [PR #287](https://github.com/videojs/v10/pull/287) for implementation details. -**File:** `packages/store/src/core/queue.ts` - -### Task Types (Discriminated Union) - -```typescript -// Base fields shared by all task states -interface TaskBase { - id: symbol; - name: string; - key: Key; - input: Input; - startedAt: number; - meta: RequestMeta | null; -} - -// Pending - request in flight -interface PendingTask extends TaskBase { - status: 'pending'; - abort: AbortController; -} - -// Success - completed successfully -interface SuccessTask extends TaskBase { - status: 'success'; - settledAt: number; - duration: number; - output: Output; -} - -// Error - failed or cancelled -interface ErrorTask extends TaskBase { - status: 'error'; - settledAt: number; - duration: number; - error: unknown; - cancelled: boolean; // true if aborted, false if actual error -} - -// Union types -type Task = PendingTask | SuccessTask | ErrorTask; - -type SettledTask = SuccessTask | ErrorTask; -``` - -### Queue API - -```typescript -export class Queue { - // Single source of truth - one task per key (pending OR settled) - get tasks(): Readonly>; - - // Clear settled task for a key (no-op if pending) - reset(key: keyof Tasks): void; - - // Subscribe to task changes - subscribe(listener: (tasks: TasksRecord) => void): () => void; -} -``` - -### Lifecycle - -1. `enqueue()` → task added with `status: 'pending'` -2. Task completes → same entry updated to `status: 'success'` or `status: 'error'` -3. New request for same key → replaces previous (pending aborted, settled cleared) -4. `reset(key)` → removes settled task - -### Usage - -```typescript -const task = queue.tasks.changeVolume; - -// TypeScript narrows based on status -if (task?.status === 'pending') { - task.abort; // available -} -if (task?.status === 'success') { - task.output; // available -} -if (task?.status === 'error') { - task.error; // available - task.cancelled; // true if aborted -} -``` +Refactored Queue to use unified `tasks` map with status discriminator (`PendingTask | SuccessTask | ErrorTask`). Added `tryCatch` utility to `@videojs/utils/function`. --- @@ -191,8 +113,8 @@ export function StoreContextProvider({ store, children }: { store: AnyStore; chi **File:** `packages/store/src/react/create-store.ts` ```typescript -import type { ReactNode } from 'react'; import type { AnySlice, InferSliceTarget, StoreConfig } from '../core'; +import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; @@ -221,7 +143,7 @@ export interface CreateStoreResult { (): UnionSliceRequests; (selector: (requests: UnionSliceRequests) => T): T; }; - usePending: () => PendingRecord>; + useTasks: () => TasksRecord>; useMutation: >( selector: (requests: UnionSliceRequests) => UnionSliceRequests[K] ) => MutationResult[K]>; @@ -241,9 +163,9 @@ export function createStore(config: CreateStoreConfig **File:** `packages/store/src/react/types.ts` ```typescript -export type SliceResult - = | { state: InferSliceState; request: InferSliceRequests; isAvailable: true } - | { state: null; request: null; isAvailable: false }; +export type SliceResult = + | { state: InferSliceState; request: InferSliceRequests; isAvailable: true } + | { state: null; request: null; isAvailable: false }; export interface MutationResult any> { /** Trigger the request */ @@ -285,7 +207,7 @@ export function useSelector(store: S, selector: (state: I export function useRequest(store: S): InferStoreRequests; export function useRequest(store: S, selector: (requests: InferStoreRequests) => T): T; -export function usePending(store: S): PendingRecord>; +export function useTasks(store: S): TasksRecord>; export function useMutation any>( store: S, @@ -332,7 +254,7 @@ export function useOptimistic - `useStore()`: Returns typed store from `useStoreContext()` - `useSelector(selector)`: Uses `useSyncExternalStore` with selector - `useRequest()`: Returns stable `store.request` from context -- `usePending()`: Subscribes to `store.queue`, returns `queue.pending` +- `useTasks()`: Subscribes to `store.queue`, returns `queue.tasks` - `useSlice(slice)`: Returns `{ state, request, isAvailable }` with null narrowing ### 1.6 Exports @@ -344,7 +266,7 @@ export function useOptimistic export { useStoreContext } from './context'; export { createStore } from './create-store'; // Base hooks for testing/advanced use (all take store as first arg) -export { useMutation, useOptimistic, usePending, useRequest, useSelector } from './hooks'; +export { useMutation, useOptimistic, useTasks, useRequest, useSelector } from './hooks'; export type { CreateStoreConfig, CreateStoreResult, MutationResult, OptimisticResult, SliceResult } from './types'; ``` @@ -389,9 +311,9 @@ class MySkin extends HTMLElement { **File:** `packages/store/src/lit/create-store.ts` ```typescript +import type { AnySlice, InferSliceTarget, StoreConfig } from '../core'; import type { Context } from '@lit/context'; import type { ReactiveControllerHost } from '@lit/reactive-element'; -import type { AnySlice, InferSliceTarget, StoreConfig } from '../core'; import { createContext } from '@lit/context'; @@ -458,10 +380,10 @@ export class RequestController implements ReactiveControl get value(): T; } -// PendingController - like usePending(store) -export class PendingController implements ReactiveController { +// TasksController - like useTasks(store) +export class TasksController implements ReactiveController { constructor(host: ReactiveControllerHost, store: S); - get value(): PendingRecord>; + get value(): TasksRecord>; } // MutationController - like useMutation(store, selector) @@ -500,7 +422,7 @@ export class OptimisticController< export { MutationController, OptimisticController, - PendingController, + TasksController, RequestController, SelectorController, } from './controllers'; @@ -928,16 +850,16 @@ function App() { } function MyCustomControls() { - const currentTime = useSelector(s => s.currentTime); - const seek = useRequest(r => r.seek); + const currentTime = useSelector((s) => s.currentTime); + const seek = useRequest((r) => r.seek); return ; } // With mutation status tracking function PlayButton() { - const paused = useSelector(s => s.paused); - const { mutate: play, isPending } = useMutation(r => r.play); - const { mutate: pause } = useMutation(r => r.pause); + const paused = useSelector((s) => s.paused); + const { mutate: play, isPending } = useMutation((r) => r.play); + const { mutate: pause } = useMutation((r) => r.pause); return (