mirror of
https://github.com/zoriya/v10.git
synced 2026-08-09 15:46:42 +00:00
docs(claude): compact old store plans
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
Reference in New Issue
Block a user