docs(claude): compact old store plans

This commit is contained in:
Rahim
2026-01-19 21:12:42 +11:00
parent 72594eb3a2
commit eea4c512dd
4 changed files with 98 additions and 1441 deletions
-814
View File
@@ -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 (`<slot></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<HTMLVideoElement> {
ref?: RefCallback<HTMLVideoElement> | React.RefObject<HTMLVideoElement>;
}
/**
* 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<HTMLVideoElement> = useCallback((el) => {
if (el) {
const detach = store.attach(el);
// React 19: return cleanup function
return detach;
}
}, [store]);
const composedRef = useComposedRefs(ref, attachRef);
return (
<video ref={composedRef} {...props}>
{children}
</video>
);
}
```
### 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<S extends readonly AnySlice[] = readonly []>(extension?: Partial<StoreConfig<any, S>>) {
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 (
<div className={`vjs-frosted-skin ${className ?? ''}`}>
{children}
{/* Controls rendered here */}
</div>
);
}
/**
* 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 (
<Provider>
<Video src="video.mp4" />
<MyCustomControls />
</Provider>
);
}
function MyCustomControls() {
const currentTime = useSelector((s) => s.currentTime);
const seek = useRequest('seek');
return <button onClick={() => seek(0)}>Restart ({currentTime}s)</button>;
}
function PlayButton() {
const store = useStore();
const paused = useSelector((s) => s.paused);
const playResult = useMutation(store, 'play');
const pauseResult = useMutation(store, 'pause');
return (
<button
onClick={() => (paused ? playResult.mutate() : pauseResult.mutate())}
disabled={playResult.status === 'pending'}
>
{paused ? 'Play' : 'Pause'}
</button>
);
}
function VolumeSlider() {
const store = useStore();
const result = useOptimistic(store, 'setVolume', (s) => s.volume);
return (
<>
<input
type="range"
value={result.value}
onChange={(e) => result.setValue(Number(e.target.value))}
style={{ opacity: result.status === 'pending' ? 0.5 : 1 }}
/>
{result.status === 'error' && <span>Failed to change volume</span>}
</>
);
}
```
### 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 (
<Provider store={store}>
<Video src="video.mp4" />
<MyControls />
</Provider>
);
}
```
### React: Frosted skin
```tsx
import { Video } from '@videojs/react';
import { Provider, Skin } from '@videojs/react/skins/frosted';
function App() {
return (
<Provider>
<Skin>
<Video src="video.mp4" />
</Skin>
</Provider>
);
}
```
### 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 (
<Provider>
<Skin>
<Video src="video.mp4" />
</Skin>
<ChaptersPanel />
</Provider>
);
}
function ChaptersPanel() {
const chapters = useSlice(chaptersSlice);
if (!chapters.isAvailable) return null;
return <div>{chapters.state.markers.map(...)}</div>;
}
```
### HTML: Frosted skin (CDN)
```html
<script type="module">
import 'https://cdn.jsdelivr.net/npm/@videojs/html/define/vjs-frosted-skin.js';
</script>
<vjs-frosted-skin>
<video src="video.mp4"></video>
</vjs-frosted-skin>
```
### HTML: Custom provider element
```html
<script type="module" src="./my-player.js"></script>
<my-player>
<video src="video.mp4"></video>
<!-- custom controls here -->
</my-player>
```
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 = '<slot></slot>';
}
}
customElements.define('my-player', MyPlayer);
```
### HTML: Extending frosted with custom slices
```html
<script type="module" src="./my-extended-skin.js"></script>
<my-extended-skin>
<video src="video.mp4"></video>
</my-extended-skin>
```
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<string, string>
);
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
-627
View File
@@ -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 (
<>
<select disabled={loading} onChange={(e) => handleChange(e.target.value)} />
{loading && <Spinner />}
{error && <Error message={error.message} />}
</>
);
}
```
**With queue:**
```tsx
function SourceSelector() {
const source = useMutation(store, 'setSource');
return (
<>
<select disabled={source.status === 'pending'} onChange={(e) => source.mutate(e.target.value)} />
{source.status === 'pending' && <Spinner />}
{source.status === 'error' && <Error message={source.error.message} />}
</>
);
}
```
---
## 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
<select disabled={tasks['setSource']?.status === 'pending'}>
// Error display
{tasks['play']?.status === 'error' && <ErrorMessage />}
```
```ts
// Debugging
queue.subscribe((tasks) => {
console.table(
Object.entries(tasks).map(([name, t]) => ({
name,
status: t?.status,
duration: t?.settledAt ? t.settledAt - t.startedAt : '...',
}))
);
});
```
```ts
// Analytics
queue.subscribe((tasks) => {
for (const [name, task] of Object.entries(tasks)) {
if (task?.status !== 'pending') {
analytics.track('request', { name, status: task.status });
}
}
});
```
```ts
// Global error handler
queue.subscribe((tasks) => {
for (const task of Object.values(tasks)) {
if (task?.status === 'error' && !task.cancelled) {
toast.error(`${task.name} failed`);
}
}
});
```
Without lifecycle tracking, every component tracks its own loading/error state.
### 5. Subscribe
**Required for React/Lit hook integration.**
```ts
// useMutation implementation
const subscribe = (onStoreChange) =>
store.queue.subscribe((tasks) => {
if (tasks[name] !== taskRef.current) {
taskRef.current = tasks[name];
onStoreChange();
}
});
```
### 6. abort()
**User/system cancellation.**
```ts
// Component unmount
useEffect(() => () => store.queue.abort('setSource'), []);
// User clicks "Cancel"
<button onClick={() => store.queue.abort('upload')}>Cancel</button>
// Navigation
router.beforeEach(() => store.queue.abort());
```
### 7. reset()
**Clear settled state.**
```ts
// Error persists after retry without reset
store.request.play(); // Fails — tasks['play'].status === 'error'
store.queue.reset('play'); // Clear error
store.request.play(); // Fresh attempt
```
### 8. Meta Field
**Low cost (~1 field), useful for debugging/analytics.**
```ts
store.request.play(null, { source: 'user', reason: 'play-button' });
// In handler or subscriber
console.log(task.meta?.source); // 'user' vs 'system'
```
---
## What We Remove (And Why Safe)
### 1. Configurable Schedulers (`delay()`, `raf()`, `idle()`)
**~70 LOC saved.**
- Only used in tests and docs
- Use external debounce if needed: `debounce(store.request.seek, 100)`
- Microtask is sufficient for supersession
### 2. `flush()`
**~15 LOC saved.**
- Only meaningful with configurable schedulers
- Tests can use `await Promise.resolve()` or `vi.runAllTimersAsync()`
### 3. `cancel()`
**~25 LOC saved.**
- Redundant with `abort()`
- `cancel()` only removes queued tasks
- `abort()` handles both queued and pending
### 4. `isPending()`, `isQueued()`, `isSettled()` Methods
**~15 LOC saved from Queue class.**
- Move to type guard utility functions (reusable, better type narrowing)
- `isQueued()` removed entirely (meaningless with microtask scheduling)
### 5. `queued` Getter
**~10 LOC saved.**
- With fixed microtask scheduling, queued state is ~0ms
- By the time you check, it's already pending or gone
- Internal implementation detail
### 6. `onDispatch`, `onSettled` Hooks
**~20 LOC saved.**
- Only used in tests
- Derive from `subscribe()`:
```ts
queue.subscribe((tasks) => {
for (const [name, task] of Object.entries(tasks)) {
if (task?.status === 'pending') onDispatch(task);
if (task?.status !== 'pending') onSettled(task);
}
});
```
### 7. Config Object
**~15 LOC saved.**
- Only held `scheduler`, `onDispatch`, `onSettled`
- All removed
### 8. DOM Schedulers File
**Delete `packages/store/src/dom/schedulers.ts` and `index.ts`.**
- Only exported `raf()` and `idle()`
- Nothing left after scheduler removal
---
## Implementation Changes
### 1. Single Batched Microtask
**Current:** Each `enqueue()` schedules its own microtask.
```ts
enqueue(task1) → queueMicrotask(flush1)
enqueue(task2) → queueMicrotask(flush2)
enqueue(task3) → queueMicrotask(flush3)
// Three microtasks queued
```
**New:** Single microtask per sync batch.
```ts
#flushScheduled = false;
enqueue(task) {
this.#queued.set(key, task);
if (!this.#flushScheduled) {
this.#flushScheduled = true;
queueMicrotask(() => {
this.#flushScheduled = false;
this.#flushAll();
});
}
}
```
### 2. Remove `schedule` Parameter
```diff
interface QueueTask<...> {
name: string;
key: Key;
input?: Input;
meta?: RequestMeta | null;
- schedule?: TaskScheduler | undefined;
handler: (ctx: TaskContext<Input>) => Promise<Output>;
}
```
### 3. Simplified Constructor
```diff
- constructor(config: QueueConfig<Tasks> = {}) {
- this.#scheduler = config.scheduler ?? microtask;
- this.#onDispatch = tryCatch(config.onDispatch, logError);
- this.#onSettled = tryCatch(config.onSettled, logError);
- }
+ constructor() {}
```
---
## Final API Surface
```ts
interface Queue<Tasks> {
// State
readonly tasks: TasksRecord<Tasks>;
readonly destroyed: boolean;
// Core
enqueue<K extends keyof Tasks>(task: QueueTask<K>): Promise<Tasks[K]['output']>;
// Control
abort(name?: keyof Tasks): void;
reset(name?: keyof Tasks): void;
destroy(): void;
// Observe
subscribe(listener: QueueListener<Tasks>): () => 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<Tasks extends TaskRecord = DefaultTaskRecord> {
- scheduler?: TaskScheduler;
- onDispatch?: ...;
- onSettled?: ...;
- }
- export interface QueuedTaskId<Key extends TaskKey = TaskKey> { ... }
- export type PublicQueuedRecord<Tasks extends TaskRecord> = { ... }
```
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
+38
View File
@@ -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.
@@ -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<Tasks> {
readonly tasks: TasksRecord<Tasks>;
readonly destroyed: boolean;
enqueue<K>(task: QueueTask<K>): Promise<Tasks[K]['output']>;
abort(name?: keyof Tasks): void;
reset(name?: keyof Tasks): void;
destroy(): void;
subscribe(listener: QueueListener<Tasks>): () => void;
}
```
## Bundle Size
| Scenario | Gzipped |
| ----------------------------------- | ------- |
| Minimal (createStore + createSlice) | 2.3 KB |
| Full entry (all exports) | 3.0 KB |