From eea4c512ddf561bd9c7ab34ef07a23bd3795fc3e Mon Sep 17 00:00:00 2001 From: Rahim Date: Mon, 19 Jan 2026 21:12:42 +1100 Subject: [PATCH] docs(claude): compact old store plans --- .claude/plans/store-bindings.md | 814 -------------------- .claude/plans/store-queue-simplification.md | 627 --------------- .claude/plans/store/bindings.md | 38 + .claude/plans/store/queue-simplification.md | 60 ++ 4 files changed, 98 insertions(+), 1441 deletions(-) delete mode 100644 .claude/plans/store-bindings.md delete mode 100644 .claude/plans/store-queue-simplification.md create mode 100644 .claude/plans/store/bindings.md create mode 100644 .claude/plans/store/queue-simplification.md diff --git a/.claude/plans/store-bindings.md b/.claude/plans/store-bindings.md deleted file mode 100644 index a8469ad2..00000000 --- a/.claude/plans/store-bindings.md +++ /dev/null @@ -1,814 +0,0 @@ -# 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: - -- 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`, `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?, mixin?)` - pass mixin directly | -| Slot handling | Default slot (``) - no `slot="media"` attribute needed | -| 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` | - ---- - -## Phase 0: Core Utilities [DONE] - -> Refer to [PR #283](https://github.com/videojs/v10/pull/283) for implementation details. - -Added `uniqBy`, `composeCallbacks` utilities and `extendConfig` for store. - ---- - -## Phase 0.5: Queue Task Refactor [DONE] - -> Refer to [PR #287](https://github.com/videojs/v10/pull/287) for implementation details. - -Refactored Queue to use unified `tasks` map with status discriminator (`PendingTask | SuccessTask | ErrorTask`). Added `tryCatch` utility to `@videojs/utils/function`. - ---- - -## Phase 1: React Bindings (`@videojs/store/react`) [DONE] - -> Refer to [PR #288](https://github.com/videojs/v10/pull/288) for implementation details. - -Basic React bindings for the store: - -- Shared context (`useStoreContext`, `useParentStore`, `StoreContextProvider`) -- `createStore()` factory returning Provider + typed hooks -- Base hooks: `useSelector`, `useRequest`, `useTasks` -- Provider with `store` prop (pre-created) and `inherit` prop (parent context) -- `create()` method for imperative store creation - -Types live next to implementations (no separate `types.ts`). - ---- - -## Phase 2: Lit Bindings (`@videojs/store/lit`) [DONE] - -> Refer to [PR #289](https://github.com/videojs/v10/pull/289) for implementation details. - -Basic Lit bindings for the store: - -- Controllers: `SelectorController`, `RequestController`, `TasksController` for reactive state -- Mixins: `StoreProviderMixin`, `StoreAttachMixin`, `StoreMixin` (combined) for custom elements -- `createStore()` factory returning typed mixins, context, and `create()` function -- Auto-attach media elements via slot change observation -- Proper cleanup on disconnect (slot listeners, subscriptions) -- Sync controller values on reconnect to avoid stale state - -**Note:** `MutationController` and `OptimisticController` are NOT in this phase - they are Phase 4 and 5. - ---- - -## Phase 3: DOM Media Slices (`@videojs/core/dom`) [DONE] - -> Refer to [PR #292](https://github.com/videojs/v10/pull/292) for implementation details. - -Modular media slices for `HTMLMediaElement`: `playbackSlice`, `timeSlice`, `bufferSlice`, `volumeSlice`, `sourceSlice`. Includes `media` namespace export, type guards, and `serializeTimeRanges` utility. - ---- - -## Phase 4: React Package Setup (`@videojs/react`) - -### 4.1 Video component - -**File:** `packages/react/src/media/video.tsx` - -```typescript -import type { VideoHTMLAttributes, RefCallback } from 'react'; -import { useCallback } from 'react'; -import { useStore } from '../store'; -import { useComposedRefs } from '../utils/use-composed-refs'; - -export interface VideoProps extends VideoHTMLAttributes { - ref?: RefCallback | React.RefObject; -} - -/** - * Video element that automatically attaches to the store. - * Uses React 19 ref cleanup pattern. - */ -export function Video({ children, ref, ...props }: VideoProps): JSX.Element { - const store = useStore(); - - const attachRef: RefCallback = useCallback((el) => { - if (el) { - const detach = store.attach(el); - // React 19: return cleanup function - return detach; - } - }, [store]); - - const composedRef = useComposedRefs(ref, attachRef); - - return ( - - ); -} -``` - -### 4.2 Package exports - -**File:** `packages/react/src/index.ts` - -```typescript -// Media elements -export { Video } from './media/video'; -export type { VideoProps } from './media/video'; - -// Re-export slices for convenience (users import from @videojs/react, not @videojs/core/dom) -export { media } from '@videojs/core/dom'; -// Re-export for extension -export { createStore } from '@videojs/store/react'; -``` - ---- - -## Phase 5: Frosted Skin (React) - -### 5.1 Store config - -**File:** `packages/react/src/skins/frosted/store.ts` - -```typescript -import type { AnySlice, StoreConfig } from '@videojs/store'; - -import { extendConfig as extendBaseConfig } from '@videojs/store'; -import { createStore } from '@videojs/store/react'; - -import { media } from './slices'; // internal - re-exported from @videojs/react - -/** Base config for frosted skin. */ -const baseConfig = { - slices: [media.playback] as const, - displayName: 'FrostedSkin', -}; - -/** - * Extends frosted skin config with additional slices/hooks. - * Composes lifecycle hooks (both called, base first). - */ -export function extendConfig(extension?: Partial>) { - return extendBaseConfig(baseConfig, extension); -} - -export const { Provider, create } = createStore(extendConfig()); -``` - -### 5.2 Skin component - -**File:** `packages/react/src/skins/frosted/skin.tsx` - -```typescript -import type { PropsWithChildren } from 'react'; - -export type SkinProps = PropsWithChildren<{ - className?: string; -}>; - -export function Skin({ children, className }: SkinProps): JSX.Element { - return ( -
- {children} - {/* Controls rendered here */} -
- ); -} - -/** - * Namespace pattern for component types. - * Allows: `Skin.Props` instead of importing `SkinProps` separately. - */ -export namespace Skin { - export type Props = SkinProps; -} -``` - -### 5.3 Exports - -**File:** `packages/react/src/skins/frosted/index.ts` - -```typescript -export { Skin } from './skin'; -export type { SkinProps } from './skin'; -export { extendConfig, Provider } from './store'; -``` - ---- - -## Phase 6: Frosted Skin [DONE] - -> Refer to [PR #298](https://github.com/videojs/v10/pull/298) for implementation details. - -React and HTML frosted skin store setup: - -- React: `Provider`, `Skin`, `extendConfig`, `createStore` in `@videojs/react/skins/frosted` -- HTML: `FrostedSkinElement`, `StoreMixin`, `extendConfig` in `@videojs/html/skins/frosted` -- Auto-define entry: `@videojs/html/define/vjs-frosted-skin` for CDN usage -- `StoreAttachMixin` uses default slot (no `slot="media"` attribute needed) - -**Note:** Don't export `context` from skins (causes unique symbol type issues). Users can import from `@videojs/store/lit` if needed. - ---- - -## Usage Examples - -### React: Custom UI - -```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, useStore, useSelector, useRequest } = createStore({ - slices: [media.playback], -}); - -function App() { - return ( - - - ); -} - -function MyCustomControls() { - const currentTime = useSelector((s) => s.currentTime); - const seek = useRequest('seek'); - return ; -} - -function PlayButton() { - const store = useStore(); - const paused = useSelector((s) => s.paused); - const playResult = useMutation(store, 'play'); - const pauseResult = useMutation(store, 'pause'); - - return ( - - ); -} - -function VolumeSlider() { - const store = useStore(); - const result = useOptimistic(store, 'setVolume', (s) => s.volume); - - return ( - <> - result.setValue(Number(e.target.value))} - style={{ opacity: result.status === 'pending' ? 0.5 : 1 }} - /> - {result.status === 'error' && Failed to change volume} - - ); -} -``` - -### React: Pre-created store instance (imperative access) - -```tsx -import { useState } from 'react'; - -import { createStore, media, Video } from '@videojs/react'; - -const { Provider, create, useSelector } = createStore({ - slices: [media.playback], -}); - -function App() { - // Create store instance in useState for stable reference - const [store] = useState(() => create()); - - return ( - - - ); -} -``` - -### React: Frosted skin - -```tsx -import { Video } from '@videojs/react'; -import { Provider, Skin } from '@videojs/react/skins/frosted'; - -function App() { - return ( - - - - - ); -} -``` - -### React: Extending frosted with custom slices - -```tsx -import { createStore, Video } from '@videojs/react'; -import { extendConfig, Skin } from '@videojs/react/skins/frosted'; - -import { chaptersSlice } from './slices/chapters'; - -// Extend frosted config with custom slice (merges with base slices) -const { Provider, useSlice } = createStore( - extendConfig({ slices: [chaptersSlice] }) -); - -function App() { - return ( - - - - - - ); -} - -function ChaptersPanel() { - const chapters = useSlice(chaptersSlice); - if (!chapters.isAvailable) return null; - return
{chapters.state.markers.map(...)}
; -} -``` - -### HTML: Frosted skin (CDN) - -```html - - - - - -``` - -### HTML: Custom provider element - -```html - - - - - - -``` - -Where `my-player.js` contains: - -```typescript -import { ReactiveElement } from '@lit/reactive-element'; -import { media } from '@videojs/core/dom'; -import { createStore } from '@videojs/store/lit'; - -const { StoreMixin } = createStore({ - slices: [...media.all], -}); - -// Create custom element with store provider and auto-attach -class MyPlayer extends StoreMixin(ReactiveElement) { - constructor() { - super(); - const shadow = this.attachShadow({ mode: 'open' }); - shadow.innerHTML = ''; - } -} - -customElements.define('my-player', MyPlayer); -``` - -### HTML: Extending frosted with custom slices - -```html - - - - - -``` - -Where `my-extended-skin.js` contains: - -```typescript -import { extendConfig, FrostedSkinElement } from '@videojs/html/skins/frosted'; -import { createStore } from '@videojs/store/lit'; - -import { chaptersSlice } from './slices/chapters.js'; - -// Extend frosted config with custom slice (merges with base slices) -const { StoreMixin } = createStore(extendConfig({ slices: [chaptersSlice] })); - -FrostedSkinElement.define('my-extended-skin', StoreMixin); -``` - ---- - -## File Structure - -``` -packages/utils/src/ -├── array/ -│ ├── uniq-by.ts # DONE -│ ├── tests/ -│ │ └── uniq-by.test.ts # DONE -│ └── index.ts # DONE -└── function/ - ├── compose-callbacks.ts # DONE - ├── tests/ - │ └── compose-callbacks.test.ts # DONE - └── index.ts # DONE - -packages/store/src/ -├── core/ -│ ├── store.ts # existing -│ ├── slice.ts # existing -│ ├── queue.ts # existing -│ ├── extend-config.ts # DONE -│ ├── tests/ -│ │ └── extend-config.test.ts # DONE -│ └── index.ts # DONE -├── shared/ -│ └── types.ts # DONE (AsyncStatus, MutationResult, OptimisticResult) -├── react/ -│ ├── 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 -│ │ ├── use-optimistic.ts # DONE (PR #291) -│ │ └── tests/ -│ │ ├── test-utils.ts -│ │ ├── use-selector.test.tsx -│ │ ├── use-request.test.tsx -│ │ ├── use-tasks.test.tsx -│ │ ├── use-mutation.test.tsx -│ │ └── use-optimistic.test.tsx # DONE (PR #291) -│ └── index.ts -└── lit/ - ├── 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/ -├── store/ -│ └── slices/ -│ ├── playback.ts # DONE -│ ├── time.ts # DONE -│ ├── buffer.ts # DONE -│ ├── volume.ts # DONE -│ ├── source.ts # DONE -│ ├── index.parts.ts # DONE (media namespace parts) -│ ├── index.ts # DONE (media namespace + exports) -│ └── tests/ # DONE -├── predicate.ts # DONE (type guards) -└── index.ts # DONE - -packages/react/src/ -├── media/ -│ └── video.tsx # NEW -├── skins/ -│ ├── frosted/ -│ │ ├── store.ts # NEW -│ │ ├── skin.tsx # NEW -│ │ └── index.ts # NEW -│ └── minimal/ -│ └── ... -└── index.ts - -packages/html/src/ -├── define/ -│ └── vjs-frosted-skin.ts # NEW -├── skins/ -│ ├── frosted/ -│ │ ├── store.ts # NEW -│ │ ├── skin.ts # NEW -│ │ ├── styles.css # NEW -│ │ └── index.ts # NEW -│ └── minimal/ -│ └── ... -└── index.ts -``` - ---- - -## Implementation Order - -1. **Phase 0**: Core utilities **[DONE - PR #283]** - - `uniqBy`, `composeCallbacks` utilities ✓ - - `extendConfig` ✓ - -2. **Phase 0.5**: Queue Task Refactor **[DONE - PR #287]** - - Unified `tasks` map with status discriminator ✓ - - `PendingTask`, `SuccessTask`, `ErrorTask` types ✓ - - `reset(key)` method ✓ - - Update existing tests ✓ - - Added `tryCatch` utility to `@videojs/utils/function` ✓ - -3. **Phase 1**: React Bindings (basic) **[DONE - PR #288]** - - Shared context, `useStoreContext` ✓ - - `createStore()` with `inherit` prop ✓ - - `useStore`, `useSelector`, `useRequest`, `useTasks` ✓ - - Base hooks for testing/advanced use ✓ - -4. **Phase 2**: Lit Bindings (basic) **[DONE - PR #289]** - - `createStore()` with mixins ✓ - - `SelectorController`, `RequestController`, `TasksController` ✓ - - `@lit/context` integration ✓ - -5. **Phase 3**: DOM Media Slices **[DONE - PR #292]** - - Modular slices: `playbackSlice`, `timeSlice`, `bufferSlice`, `volumeSlice`, `sourceSlice` ✓ - - `media` namespace export ✓ - - Type guards and utilities ✓ - -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 **[DONE - PR #291]** - - React: `useOptimistic(store, name, stateSelector)` - base hook ✓ - - Lit: `OptimisticController(host, store, name, stateSelector)` - already existed ✓ - - Shared types: `OptimisticResult` discriminated union in `src/shared/types.ts` ✓ - - Tests: 13 new tests for React hook ✓ - -8. **Phase 6**: Skins **[DONE - PR #298]** - - React skin (Provider, Skin, extendConfig) ✓ - - HTML skin (FrostedSkinElement, extendConfig) ✓ - -Each phase includes tests. - ---- - -## Package Configuration - -### tsdown.config.ts Updates - -Each package with new subpaths needs tsdown entry points: - -**`packages/html/tsdown.config.ts`:** - -```typescript -import { readdirSync } from 'node:fs'; - -// Dynamically gather define/ entries -const defineEntries = readdirSync('src/define') - .filter((f) => f.endsWith('.ts')) - .reduce( - (acc, f) => { - const name = f.replace('.ts', ''); - acc[`define/${name}`] = `src/define/${f}`; - return acc; - }, - {} as Record - ); - -export default { - entry: { - index: 'src/index.ts', - 'skins/frosted': 'src/skins/frosted/index.ts', - ...defineEntries, - }, -}; -``` - -**`packages/react/tsdown.config.ts`:** - -```typescript -export default { - entry: { - index: 'src/index.ts', - 'skins/frosted': 'src/skins/frosted/index.ts', - }, -}; -``` - -**`packages/store/tsdown.config.ts`:** - -```typescript -export default { - entry: { - index: 'src/index.ts', - react: 'src/react/index.ts', - lit: 'src/lit/index.ts', - }, -}; -``` - -### package.json Exports Updates - -Use `types` + `default` format to match existing packages. - -**`packages/html/package.json`:** - -```json -{ - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./define/*": { - "default": "./dist/define/*.js" - }, - "./skins/frosted": { - "types": "./dist/skins/frosted.d.ts", - "default": "./dist/skins/frosted.js" - } - } -} -``` - -**`packages/react/package.json`:** - -```json -{ - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./skins/frosted": { - "types": "./dist/skins/frosted.d.ts", - "default": "./dist/skins/frosted.js" - } - } -} -``` - -**`packages/store/package.json`:** - -```json -{ - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./react": { - "types": "./dist/react.d.ts", - "default": "./dist/react.js" - }, - "./lit": { - "types": "./dist/lit.d.ts", - "default": "./dist/lit.js" - } - } -} -``` - ---- - -## PR Coordination - -### Related Issues - -| Issue | Title | Description | Status | -| ----- | ------------------- | --------------------------------------- | ---------------- | -| #218 | Store | Parent tracking issue | Open | -| #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 | Closed | -| #239 | DOM Media Slices | media slices | Closed | -| #231 | Skin Stores | Skin store configuration | Closed (PR #298) | - -### PR Strategy - -``` -PR #283: Core Utilities [DONE] -├── uniqBy, composeCallbacks (utils) ✓ -├── extendConfig (store/core) ✓ -└── Tests ✓ - -PR #287: Queue Task Refactor [DONE] -├── Unified Task type with status discriminator ✓ -├── PendingTask, SuccessTask, ErrorTask ✓ -├── Single `tasks` map, `reset(key)` method ✓ -├── Update tests ✓ -├── Added tryCatch utility ✓ -└── Closes #285 - -PR #288: React Bindings (basic) [DONE] -├── createStore, Provider, useStore ✓ -├── useSelector, useRequest, useTasks ✓ -├── Base hooks for testing ✓ -├── References #218 -└── Closes #229 - -PR #289: Lit Bindings (basic) [DONE] -├── createStore with mixins ✓ -├── SelectorController, RequestController, TasksController ✓ -├── References #218 -└── Closes #230 - -PR #292: DOM Media Slices [DONE] -├── Modular slices: playback, time, buffer, volume, source ✓ -├── media namespace export ✓ -├── Type guards and utilities ✓ -├── References #218 -└── Closes #239 - -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 #291: Optimistic Hooks/Controllers [DONE] -├── React: useOptimistic(store, name, stateSelector) - base hook ✓ -├── Lit: OptimisticController(host, store, name, stateSelector) - already existed ✓ -├── Shared types: OptimisticResult discriminated union in src/shared/types.ts ✓ -├── Tests: 13 new tests for React hook ✓ -└── Closes #228 - -PR #298: Skins [DONE] -├── React skin (Provider, Skin, extendConfig) ✓ -├── HTML skin (FrostedSkinElement, extendConfig) ✓ -├── References #218 -└── Closes #231 -``` - -### Dependency Graph - -``` -PR #283 ───> PR #287 ───> PR #288 ───> PR #290 ───> PR #291 ───> PR #298 (Skins) - └──> PR #289 (done) ────────────────────────┘ - └──> PR #292 (done) ────────────────────────┘ -``` - -All PRs merged. Store bindings implementation complete. - ---- - -## Deferred - -- Testing utilities (`@videojs/store/testing`) - separate plan -- Minimal skin implementation diff --git a/.claude/plans/store-queue-simplification.md b/.claude/plans/store-queue-simplification.md deleted file mode 100644 index 40b9064b..00000000 --- a/.claude/plans/store-queue-simplification.md +++ /dev/null @@ -1,627 +0,0 @@ -# Store Queue Simplification Plan - -> **STATUS: COMPLETED** -> -> Final result: ~405 LOC total (328 queue.ts + 77 task.ts) down from ~524 LOC. -> Task types and guards extracted to separate `task.ts` module for better organization. - -## Overview - -Simplify the queue from ~524 LOC to ~200 LOC while preserving core value. - -**Goal:** Remove convenience features only used in tests/docs, keep what's essential for real-world async media operations. - ---- - -## Why the Queue Exists - -The store design: **all writes are async requests**. This isn't optional — it's the architecture. - -```ts -// Read path: sync state from target -store.state.paused; // Synced from video.paused - -// Write path: async requests to target -await store.request.play(); // Returns Promise, always -``` - -Media operations are inherently async and can fail: - -| Scenario | What Can Happen | -| ---------------------- | --------------------------------------------------- | -| **Chromecast/AirPlay** | 100-500ms+ network round-trip, device disconnect | -| **Source loading** | 1-5s+ load time, 404, DRM errors, codec unsupported | -| **Quality switching** | Segment fetching, ABR delays | -| **Network conditions** | Timeouts, flaky connections | -| **User behavior** | Impatient clicks, changing mind mid-operation | - -The queue handles these realities uniformly. - ---- - -## Real-World Scenarios - -### Chromecast Play - -```ts -// Command goes over network to Cast device -await store.request.play(); -``` - -**Without queue:** - -- How does UI show "Connecting..."? -- User taps 5 times impatiently → 5 network messages? -- Device disconnects mid-request → how to surface error? - -**With queue:** - -- `tasks['play'].status === 'pending'` → show loading -- Supersession → only 1 message sent -- `tasks['play'].status === 'error'` → show failure - -### Source Switching - -```ts -// User browses playlist quickly -store.request.setSource('a.mp4'); // Starts loading -store.request.setSource('b.mp4'); // User changed mind -store.request.setSource('c.mp4'); // Final choice -``` - -**Without queue:** - -- All three sources load simultaneously -- Wasted bandwidth, race conditions -- Which promise resolves? Which errors? - -**With queue:** - -- Supersession aborts a.mp4 and b.mp4 -- Only c.mp4 loads -- Clean promise semantics (superseded reject with SUPERSEDED) - -### Network Failure - -```ts -// Quality change needs segment fetch -await store.request.setQuality('1080p'); -// Network times out -``` - -**Without queue:** - -- Component needs try/catch + useState for error -- Every async operation repeats this boilerplate - -**With queue:** - -- `tasks['setQuality'].status === 'error'` -- `tasks['setQuality'].error` contains details -- `reset('setQuality')` + retry - -### Component Implementation Comparison - -**Without queue (manual):** - -```tsx -function SourceSelector() { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const handleChange = async (src) => { - setLoading(true); - setError(null); - try { - await loadSource(src); - } catch (e) { - setError(e); - } finally { - setLoading(false); - } - }; - - return ( - <> - source.mutate(e.target.value)} /> - {source.status === 'pending' && } - {source.status === 'error' && } - - ); -} -``` - ---- - -## Required Capabilities → Queue Features - -| Scenario | Required Capability | Queue Feature | -| ------------------------ | ---------------------- | ---------------------------------- | -| Cast/AirPlay round-trip | Loading indicator | `tasks[name].status === 'pending'` | -| Network/DRM failures | Error display | `tasks[name].status === 'error'` | -| User retry after failure | Error recovery | `reset()` + re-request | -| Impatient clicks | Prevent duplicate work | Supersession (same key) | -| User changes mind | Cancel in-flight | `abort()` + AbortSignal | -| Multiple operation types | Parallel execution | Key-based coordination | -| Async completion | Await result | Promise API | -| React/Lit integration | Observe changes | `subscribe()` | -| Debug production issues | Task visibility | `tasks` record | - ---- - -## What We Keep (And Why) - -### 1. Microtask Batching - -**Required for supersession to work.** - -```ts -// User drags volume slider rapidly -store.request.setVolume(0.3); -store.request.setVolume(0.5); -store.request.setVolume(0.7); -// Without batching: all three handlers execute (race condition) -// With batching: only 0.7 executes -``` - -Without batching, `target.volume = 0.3` runs before we know 0.5 is coming. - -### 2. Supersession (Same Key Cancels Previous) - -**Prevents race conditions on every slider, toggle, and rapid interaction.** - -```ts -// Seek bar scrubbing -store.request.seek(10); // User still dragging... -store.request.seek(15); // Previous aborted -store.request.seek(20); // Only this completes -``` - -```ts -// Play/pause rapid toggle -store.request.play(); // Queued -store.request.pause(); // play() superseded -store.request.play(); // pause() superseded — only final executes -``` - -Media Chrome lacks this — users implement manually or suffer bugs. - -### 3. AbortController Propagation - -**Graceful cancellation for long-running operations.** - -```ts -async setSource(src, { signal }) { - target.src = src; - await onEvent(target, 'loadeddata', { signal }); // Aborted if new source -} - -// User changes quality mid-load -store.request.setSource('720p.mp4'); // Loading... -store.request.setSource('1080p.mp4'); // 720p aborted, 1080p starts -``` - -- Prevents wasted bandwidth -- Prevents stale responses updating state -- Handlers can clean up resources on abort - -### 4. Lifecycle Tracking (pending/success/error) - -**Observability for UI and debugging.** - -```tsx -// Loading state -) => Promise; - } -``` - -### 3. Simplified Constructor - -```diff -- constructor(config: QueueConfig = {}) { -- this.#scheduler = config.scheduler ?? microtask; -- this.#onDispatch = tryCatch(config.onDispatch, logError); -- this.#onSettled = tryCatch(config.onSettled, logError); -- } -+ constructor() {} -``` - ---- - -## Final API Surface - -```ts -interface Queue { - // State - readonly tasks: TasksRecord; - readonly destroyed: boolean; - - // Core - enqueue(task: QueueTask): Promise; - - // Control - abort(name?: keyof Tasks): void; - reset(name?: keyof Tasks): void; - destroy(): void; - - // Observe - subscribe(listener: QueueListener): () => void; -} -``` - ---- - -## Migration Guide - -| Before | After | -| ------------------------- | ------------------------------------------- | -| `queue.isPending('play')` | `queue.tasks['play']?.status === 'pending'` | -| `queue.isSettled('play')` | `queue.tasks['play']?.status !== 'pending'` | -| `queue.isQueued('play')` | Remove (instant with microtask) | -| `queue.cancel('play')` | `queue.abort('play')` | -| `queue.flush('play')` | Remove (no configurable scheduling) | -| `queue.queued` | Remove (internal detail) | -| `schedule: delay(100)` | External: `debounce(request, 100)` | -| `onSettled: fn` | `queue.subscribe(tasks => ...)` | -| `createQueue({ ... })` | `createQueue()` | - ---- - -## Files to Change - -1. **`packages/store/src/core/queue.ts`** — Main simplification -2. **`packages/store/src/core/tests/queue.test.ts`** — Remove tests for removed features -3. **`packages/store/src/core/tests/queue.types.test.ts`** — Update type tests -4. **`packages/store/src/dom/schedulers.ts`** — Delete -5. **`packages/store/src/dom/index.ts`** — Delete (empty after scheduler removal) -6. **`packages/store/src/dom/tests/schedulers.test.ts`** — Delete -7. **`packages/store/src/core/index.ts`** — Remove scheduler exports -8. **`packages/store/README.md`** — Update docs - ---- - -## Type Changes - -Keep strong typing. Simplifications: - -```diff -- export type TaskScheduler = (flush: () => void) => (() => void) | void; - -- export interface QueueConfig { -- scheduler?: TaskScheduler; -- onDispatch?: ...; -- onSettled?: ...; -- } - -- export interface QueuedTaskId { ... } -- export type PublicQueuedRecord = { ... } -``` - -Keep all Task types (`PendingTask`, `SuccessTask`, `ErrorTask`, etc.) — used by hooks. - ---- - -## Estimated LOC - -| Section | Current | After | Actual | -| ----------- | -------- | -------- | -------- | -| Types | ~120 | ~80 | — | -| Schedulers | ~30 | 0 | 0 | -| Queue class | ~350 | ~120 | — | -| Factory | ~10 | ~5 | — | -| **Total** | **~524** | **~200** | **~405** | - -> **Note:** Final LOC higher than estimate because we kept more robust error handling, -> comprehensive type exports, and extracted task types to a separate module for better organization. -> The simplification goals were achieved — removed ~120 LOC of unnecessary features. - ---- - -## Verification - -After implementation: - -1. `pnpm -F @videojs/store test` — All 310 tests pass -2. `pnpm -F @videojs/store build` — Builds successfully -3. `pnpm typecheck` — No type errors -4. Hooks (`useMutation`, `useOptimistic`, `useTasks`) — Still work -5. Examples — Still work - ---- - -## Implementation Summary - -### Files Changed - -1. **`packages/store/src/core/queue.ts`** — Main simplification (queue-only, no task re-exports) -2. **`packages/store/src/core/task.ts`** — NEW: Extracted task types and type guards -3. **`packages/store/src/core/index.ts`** — Added `task.ts` export -4. **`packages/store/src/core/request.ts`** — Removed `schedule` field, import `TaskKey` from `task.ts` -5. **`packages/store/src/core/store.ts`** — Removed `schedule` from enqueue, import task types from `task.ts` -6. **`packages/store/src/core/errors.ts`** — Deprecated `REMOVED` error code -7. **`packages/store/src/core/tests/queue.test.ts`** — Removed tests for removed features -8. **`packages/store/src/core/tests/queue.types.test.ts`** — Updated type tests -9. **`packages/store/src/core/tests/task.test.ts`** — NEW: Task type guard tests -10. **`packages/store/src/core/tests/task.types.test.ts`** — NEW: Task type-level tests -11. **`packages/store/src/dom/`** — DELETED entire directory -12. **`packages/store/src/lit/controllers/*.ts`** — Updated imports (Task from `task.ts`) -13. **`packages/store/src/react/hooks/*.ts`** — Updated imports (Task from `task.ts`) -14. **`packages/store/README.md`** — Updated documentation -15. **`packages/store/package.json`** — Removed `./dom` export -16. **`packages/store/tsdown.config.ts`** — Removed `dom` entry -17. **`packages/store/vitest.config.ts`** — Removed `store/dom` test project -18. **`tsconfig.json`** (root) — Removed `packages/store/src/dom` reference - -### Features Removed - -- `cancel()` method -- `flush()` method -- `queued` getter -- `TaskScheduler` type -- `QueueConfig` interface -- `delay()`, `microtask` exports -- `schedule` param on `QueueTask` -- `onDispatch`, `onSettled` hooks -- DOM schedulers (`raf()`, `idle()`) - ---- - -## Bundle Size Analysis - -Tested with esbuild (minified + gzipped): - -| Scenario | Minified | Gzipped | -| --------------------------------------- | -------- | ---------- | -| **Minimal** (createStore + createSlice) | 6.2 KB | **2.3 KB** | -| **+ requests** (using queue) | 6.3 KB | 2.3 KB | -| **+ task guards** (isPendingTask, etc.) | 6.5 KB | 2.4 KB | -| **Full entry** (all exports) | 8.1 KB | 3.0 KB | - -### Tree-Shaking Results - -| Export | Tree-Shakes? | Notes | -| ------------------------------------------------------------------------------ | ------------ | ---------------------------------------- | -| Task guards (`isPendingTask`, `isSuccessTask`, `isErrorTask`, `isSettledTask`) | ✅ Yes | Removed when unused | -| `Queue` class | ❌ No | Store creates one internally (by design) | -| `StoreError` | ❌ No | Used by Queue for error handling | -| `State` class | ❌ No | Core dependency of Store | - -### What's Always Included - -``` -Queue class: ~159 lines -Store class: ~193 lines -State class: ~59 lines -StoreError: ~8 lines -Utils (@videojs/utils): isFunction, isNull, isUndefined, isObject, getSelectorKeys -``` - -### Notes - -- Queue is always present because the store architecture requires it — all writes are async requests -- Task types extracted to `task.ts` tree-shake properly when guards aren't used -- Savings of ~1.9 KB raw / ~700 bytes gzip when not importing everything diff --git a/.claude/plans/store/bindings.md b/.claude/plans/store/bindings.md new file mode 100644 index 00000000..1bca06bc --- /dev/null +++ b/.claude/plans/store/bindings.md @@ -0,0 +1,38 @@ +# Store React/DOM Bindings + +> **STATUS: COMPLETED** — Superseded by `proxies.md` for API changes. + +## Summary + +Implemented React and Lit bindings for `@videojs/store`: + +- `createStore()` factory returning Provider + hooks/controllers +- Skins define store configs, export Provider + Skin + extendConfig +- Base hooks/controllers for testing and advanced use + +## Completed Work + +| Phase | PR | Description | +| ----- | ----------------------------------------------- | ------------------------------------------------------------- | +| 0 | [#283](https://github.com/videojs/v10/pull/283) | `uniqBy`, `composeCallbacks`, `extendConfig` utilities | +| 0.5 | [#287](https://github.com/videojs/v10/pull/287) | Queue task refactor (unified tasks map, status discriminator) | +| 1 | [#288](https://github.com/videojs/v10/pull/288) | React bindings (createStore, Provider, hooks) | +| 2 | [#289](https://github.com/videojs/v10/pull/289) | Lit bindings (controllers, mixins, context) | +| 3 | [#292](https://github.com/videojs/v10/pull/292) | DOM media slices (playback, time, buffer, volume, source) | +| 4 | [#290](https://github.com/videojs/v10/pull/290) | Mutation hooks/controllers | +| 5 | [#291](https://github.com/videojs/v10/pull/291) | Optimistic hooks/controllers | +| 6 | [#298](https://github.com/videojs/v10/pull/298) | Frosted skin (React + HTML) | + +## Key Decisions + +- Hook naming: `useStore`, `useSelector`, `useRequest`, `useTasks`, `useMutation`, `useOptimistic` +- Controller naming: `SelectorController`, `RequestController`, `TasksController`, etc. +- Mutation/Optimistic: Discriminated union types with `status` field +- Lit mixins: `StoreMixin` (combined), `StoreProviderMixin`, `StoreAttachMixin` +- Provider resolution: Isolated by default; `inherit` prop for parent context +- Package structure: `store/react` and `store/lit` + +## Superseded By + +The selector-based APIs (`useSelector`, `SelectorController`, `store.subscribe()`) are being +replaced by proxy-based reactivity. See **`proxies.md`** for the migration plan. diff --git a/.claude/plans/store/queue-simplification.md b/.claude/plans/store/queue-simplification.md new file mode 100644 index 00000000..2ece7a08 --- /dev/null +++ b/.claude/plans/store/queue-simplification.md @@ -0,0 +1,60 @@ +# Queue Simplification + +> **STATUS: COMPLETED** + +## Summary + +Simplified Queue from ~524 LOC to ~405 LOC. Removed convenience features only used in tests/docs, kept what's essential for async media operations. + +## Why Queue Exists + +All writes are async requests — this is the architecture. Queue handles: + +- Supersession (rapid clicks → only last executes) +- AbortController propagation +- Task lifecycle tracking (pending/success/error) +- Error surfacing for UI + +## Features Removed + +- `cancel()` method (use `abort()`) +- `flush()` method (no configurable scheduling) +- `queued` getter (internal detail) +- `TaskScheduler` type, `QueueConfig` interface +- `delay()`, `microtask` exports +- `schedule` param on `QueueTask` +- `onDispatch`, `onSettled` hooks +- DOM schedulers (`raf()`, `idle()`) + +## Files Changed + +| File | Change | +| ------------------------- | ------------------------------------ | +| `core/queue.ts` | Main simplification | +| `core/task.ts` | NEW: Extracted task types and guards | +| `core/index.ts` | Added task.ts export | +| `core/request.ts` | Removed schedule field | +| `core/store.ts` | Removed schedule from enqueue | +| `dom/` | DELETED entire directory | +| Various controllers/hooks | Updated imports | + +## Final API + +```ts +interface Queue { + readonly tasks: TasksRecord; + readonly destroyed: boolean; + enqueue(task: QueueTask): Promise; + abort(name?: keyof Tasks): void; + reset(name?: keyof Tasks): void; + destroy(): void; + subscribe(listener: QueueListener): () => void; +} +``` + +## Bundle Size + +| Scenario | Gzipped | +| ----------------------------------- | ------- | +| Minimal (createStore + createSlice) | 2.3 KB | +| Full entry (all exports) | 3.0 KB |