From a014699c95e957d6e708df5135de16c800f0f79a Mon Sep 17 00:00:00 2001 From: rahim Date: Mon, 5 Jan 2026 14:51:13 +1100 Subject: [PATCH] docs(plan): store bindings (#283) --- .claude/plans/store-bindings.md | 1429 +++++++++++++++++ commitlint.config.js | 1 + packages/store/src/core/extend-config.ts | 50 + packages/store/src/core/index.ts | 1 + packages/store/src/core/store.ts | 2 + .../src/core/tests/extend-config.test.ts | 230 +++ packages/utils/package.json | 24 +- packages/utils/src/array/index.ts | 1 + .../utils/src/array/tests/uniq-by.test.ts | 59 + packages/utils/src/array/uniq-by.ts | 16 + .../utils/src/function/compose-callbacks.ts | 23 + packages/utils/src/function/index.ts | 1 + .../function/tests/compose-callbacks.test.ts | 79 + packages/utils/tsdown.config.ts | 6 +- 14 files changed, 1912 insertions(+), 10 deletions(-) create mode 100644 .claude/plans/store-bindings.md create mode 100644 packages/store/src/core/extend-config.ts create mode 100644 packages/store/src/core/tests/extend-config.test.ts create mode 100644 packages/utils/src/array/index.ts create mode 100644 packages/utils/src/array/tests/uniq-by.test.ts create mode 100644 packages/utils/src/array/uniq-by.ts create mode 100644 packages/utils/src/function/compose-callbacks.ts create mode 100644 packages/utils/src/function/index.ts create mode 100644 packages/utils/src/function/tests/compose-callbacks.test.ts diff --git a/.claude/plans/store-bindings.md b/.claude/plans/store-bindings.md new file mode 100644 index 00000000..c26e8b79 --- /dev/null +++ b/.claude/plans/store-bindings.md @@ -0,0 +1,1429 @@ +# Store React/DOM Bindings + +## Goal + +Implement React and DOM bindings for Video.js 10's store, enabling: + +- Simple `createStore()` API that returns Provider + hooks/controllers +- Skins define their own store configs and export Provider + Skin + hooks +- Consumers can extend skin configs with additional slices +- Base hooks/controllers for testing and advanced use cases + +## 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`) | + +--- + +## Phase 0: Core Utilities [DONE] + +> Implemented in PR #283. + +- `uniqBy` - `packages/utils/src/array/uniq-by.ts` +- `composeCallbacks` - `packages/utils/src/function/compose-callbacks.ts` +- `extendConfig` - `packages/store/src/core/extend-config.ts` + +--- + +## Phase 0.5: Queue Task Refactor + +Refactor Queue to use a unified `tasks` map with status discriminator. This enables `useMutation` and `useOptimistic` hooks to track request lifecycle. + +**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 +} +``` + +--- + +## Phase 1: React Bindings (`@videojs/store/react`) + +### 1.1 Shared Context (Internal) + +**File:** `packages/store/src/react/context.ts` + +Internal shared context used by all Providers. Not exported publicly. + +```typescript +import type { ReactNode } from 'react'; +import type { AnyStore } from '../core'; + +import { createContext, useContext } from 'react'; + +// Internal shared context - all Providers write to this +const StoreContext = createContext(null); + +/** + * Internal hook for primitive UI components. + * Accesses the nearest store from context without type information. + */ +export function useStoreContext(): AnyStore { + const store = useContext(StoreContext); + if (!store) { + throw new Error('useStoreContext must be used within a Provider'); + } + return store; +} + +/** + * Internal hook to get parent store (may be null). + * Used by Provider to check for existing store in tree. + */ +export function useParentStore(): AnyStore | null { + return useContext(StoreContext); +} + +/** + * Internal provider component. + */ +export function StoreContextProvider({ store, children }: { store: AnyStore; children: ReactNode }) { + return {children}; +} +``` + +### 1.2 `createStore` + +**File:** `packages/store/src/react/create-store.ts` + +```typescript +import type { AnySlice, InferSliceTarget, StoreConfig } from '../core'; +import type { ReactNode } from 'react'; + +import { useEffect, useState } from 'react'; + +import { StoreContextProvider, useParentStore, useStoreContext } from './context'; + +export interface CreateStoreConfig extends StoreConfig< + InferSliceTarget, + Slices +> { + displayName?: string; +} + +export interface ProviderProps { + children: ReactNode; + /** Optional pre-created store. If provided, uses this store. */ + store?: Store, Slices>; + /** If true, inherits store from parent context instead of creating new. Defaults to false (isolated). */ + inherit?: boolean; +} + +export interface CreateStoreResult { + Provider: FC; + useStore: () => Store, Slices>; + useSelector: (selector: (state: UnionSliceState) => T) => T; + useRequest: { + (): UnionSliceRequests; + (selector: (requests: UnionSliceRequests) => T): T; + }; + usePending: () => PendingRecord>; + useMutation: >( + selector: (requests: UnionSliceRequests) => UnionSliceRequests[K] + ) => MutationResult[K]>; + useOptimistic: , T>( + requestSelector: (requests: UnionSliceRequests) => UnionSliceRequests[K], + stateSelector: (state: UnionSliceState) => T + ) => OptimisticResult[K]>; + /** Creates a store instance for imperative access (e.g., attach before render, testing). */ + create: () => Store, Slices>; +} + +export function createStore(config: CreateStoreConfig): CreateStoreResult; +``` + +### 1.3 Types + +**File:** `packages/store/src/react/types.ts` + +```typescript +export type SliceResult = + | { state: InferSliceState; request: InferSliceRequests; isAvailable: true } + | { state: null; request: null; isAvailable: false }; + +export interface MutationResult any> { + /** Trigger the request */ + mutate: Request; + /** Request is currently in flight */ + isPending: boolean; + /** Last request failed */ + isError: boolean; + /** Last request succeeded */ + isSuccess: boolean; + /** No request has been made yet */ + isIdle: boolean; + /** Current status */ + status: 'idle' | 'pending' | 'success' | 'error'; + /** Error from last failed request */ + error: unknown; + /** Clear settled state (error/success) */ + reset: () => void; +} + +export interface OptimisticResult any> extends MutationResult { + /** Current value (optimistic if pending, otherwise confirmed) */ + value: Value; + /** Trigger request with optimistic update */ + setValue: (value: Value) => void; +} +``` + +### 1.4 Base hooks + +**File:** `packages/store/src/react/hooks.ts` + +```typescript +// Base hooks - take store explicitly (for testing/advanced use) +// All hooks take store as first argument for consistency + +export function useSelector(store: S, selector: (state: InferStoreState) => T): T; + +export function useRequest(store: S): InferStoreRequests; +export function useRequest(store: S, selector: (requests: InferStoreRequests) => T): T; + +export function usePending(store: S): PendingRecord>; + +export function useMutation any>( + store: S, + selector: (requests: InferStoreRequests) => R +): MutationResult; + +export function useOptimistic any, T>( + store: S, + requestSelector: (requests: InferStoreRequests) => R, + stateSelector: (state: InferStoreState) => T +): OptimisticResult; +``` + +### 1.5 Implementation Details + +- `create()`: Returns `new Store(config)` - for creating store in `useState` or imperative use +- `Provider`: Resolution order: + 1. If `store` prop provided, uses that + 2. Else if `inherit={true}` and parent store exists in context, uses that + 3. Else, creates new store via `useState(() => new Store(config))` + + **Note:** `inherit` defaults to `false` (isolated). This ensures most players are standalone by default. + Use `inherit` when intentionally sharing state (e.g., thumbnail preview inside main player). + + Uses `StoreContextProvider` internally. Cleanup: `useEffect` calls `store.destroy()` on unmount (only if Provider created the store). + + ```typescript + function Provider({ children, store: providedStore, inherit = false }: ProviderProps) { + const parentStore = useParentStore(); + const shouldInherit = inherit && parentStore != null; + const [store] = useState(() => providedStore ?? (shouldInherit ? parentStore : new Store(config))); + const isOwner = !providedStore && !shouldInherit; // Only destroy if we created it + + useEffect(() => { + return () => { + if (isOwner) store.destroy(); + }; + }, [store, isOwner]); + + return {children}; + } + ``` + +- `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` +- `useSlice(slice)`: Returns `{ state, request, isAvailable }` with null narrowing + +### 1.6 Exports + +**File:** `packages/store/src/react/index.ts` + +```typescript +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'; +// Internal hook for primitive UI components +export { useStoreContext } from './context'; + +export type { CreateStoreConfig, CreateStoreResult, MutationResult, OptimisticResult, SliceResult } from './types'; +``` + +--- + +## Phase 2: Lit Bindings (`@videojs/store/lit`) + +All DOM/Lit bindings live together since they all depend on `@lit/context`. + +### 2.0 @lit/context Research + +Key findings from analyzing `@lit/context@1.1.6`: + +**Dynamic Value Updates:** + +- `ContextProvider.setValue(newValue, force?)` notifies all subscribed consumers +- Uses `Object.is()` for equality - swapping store objects triggers updates automatically +- `force = true` needed only for in-place mutations (same reference) + +**Subscription Model:** + +- Consumers must opt-in: `subscribe: true` in `@consume()` or `ContextConsumer` +- Without subscription, consumers only receive initial value +- Provider stores callbacks in `Map` +- `updateObservers()` iterates all callbacks on value change + +**Store Swapping Pattern (validated):** + +```typescript +class MySkin extends HTMLElement { + #provider = new ContextProvider(this, { context: storeContext }); + + set store(newStore: Store) { + this.#provider.setValue(newStore); // Notifies all subscribers + } +} +``` + +### 2.1 `createStore` + +**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 { createContext } from '@lit/context'; + +export interface CreateStoreConfig extends StoreConfig< + InferSliceTarget, + Slices +> {} + +export interface CreateStoreResult { + /** Combined mixin: provides store via context AND auto-attaches slotted media */ + StoreMixin: >(Base: T) => T; + /** Mixin that provides store via context (no auto-attach) */ + StoreProviderMixin: >(Base: T) => T; + /** Mixin that auto-attaches slotted media elements (requires store from context) */ + StoreAttachMixin: >(Base: T) => T; + /** Context for consuming store in controllers */ + context: Context, Slices>>; + /** Creates a store instance for imperative access */ + create: () => Store, Slices>; +} + +export function createStore(config: CreateStoreConfig): CreateStoreResult; +``` + +**Implementation details:** + +- Uses `@lit/context` for W3C Context Protocol +- Context key auto-generated per `createStore()` call (unique Symbol) +- `StoreMixin`: Combined mixin, equivalent to `StoreAttachMixin(StoreProviderMixin(Base))` +- `StoreProviderMixin`: Mixin that: + - Creates store instance + - Uses `ContextProvider` internally + - Exposes `store` setter that calls `provider.setValue(newStore)` +- `StoreAttachMixin`: Mixin that: + - Consumes store from context + - Observes slotted elements for `