feat(store): queue task refactor (#287)

This commit is contained in:
rahim
2026-01-05 16:24:18 +11:00
committed by GitHub
parent 44cd824f3b
commit 6a7acdabfe
9 changed files with 941 additions and 296 deletions
+81 -157
View File
@@ -1,5 +1,7 @@
# Store React/DOM Bindings
> **For AI agents:** When marking a phase as complete, remove detailed API specs and replace with a PR reference (e.g., "Refer to PR #XXX for implementation details"). The PR is the source of truth for completed work.
## Goal
Implement React and DOM bindings for Video.js 10's store, enabling:
@@ -11,133 +13,53 @@ Implement React and DOM bindings for Video.js 10's store, enabling:
## Key Decisions
| Decision | Resolution |
| ------------------- | ------------------------------------------------------------------------------------- |
| Store creation | `createStore({ slices, displayName? })` - types inferred from slices |
| Hook naming | `useStore`, `useSelector`, `useRequest`, `usePending`, `useMutation`, `useOptimistic` |
| Controller naming | `SelectorController`, `RequestController`, `PendingController`, etc |
| Selector hook | `useSelector(selector)` - requires selector (Redux-style) |
| Store hook | `useStore()` - returns store instance |
| Request hook | `useRequest()` or `useRequest(r => r.foo)` - full map or single request |
| Pending hook | `usePending()` - returns `store.queue.pending` (reactive) |
| Mutation hook | `useMutation(r => r.foo)` - status tracking (isPending, isError, error) |
| Optimistic hook | `useOptimistic(r => r.foo, s => s.bar)` - optimistic value + status |
| Settled state | Core Queue tracks last result/error per key, cleared on next request |
| Base hooks | All take store as first arg: `useSelector(store, sel)`, etc |
| createStore hooks | Returns all hooks including `useMutation` and `useOptimistic` |
| Slice hook return | `{ state, request, isAvailable }` - state/request null when unavailable |
| Skin exports | `Provider`, `Skin`, `extendConfig` |
| Slice namespace | `export * as media``media.playback` |
| Video component | Generic, exported from `@videojs/react` (not from skins) |
| Lit mixins | `StoreMixin` (combined), `StoreProviderMixin`, `StoreAttachMixin` |
| Primitives context | `useStoreContext()` internal hook for primitive UI components |
| displayName | For React DevTools component naming |
| Component types | Namespace pattern: `Skin.Props` via `namespace Skin { export type Props }` |
| Element define | `FrostedSkinElement.define(tagName, { mixins })` for declarative setup |
| Config extension | `extendConfig()` uses `uniqBy` + `composeCallbacks` from utils |
| Provider resolution | Isolated by default; `inherit` prop to use parent store from context |
| Store instance | `create()` method for imperative store creation |
| Package structure | `store/react` and `store/lit` (no `store/dom`) |
| Decision | Resolution |
| ------------------- | ----------------------------------------------------------------------------------- |
| Store creation | `createStore({ slices, displayName? })` - types inferred from slices |
| Hook naming | `useStore`, `useSelector`, `useRequest`, `useTasks`, `useMutation`, `useOptimistic` |
| Controller naming | `SelectorController`, `RequestController`, `TasksController`, etc |
| Selector hook | `useSelector(selector)` - requires selector (Redux-style) |
| Store hook | `useStore()` - returns store instance |
| Request hook | `useRequest()` or `useRequest(r => r.foo)` - full map or single request |
<<<<<<< Updated upstream
| Tasks hook | `useTasks()` - returns `store.queue.tasks` (reactive) |
=======
| Tasks hook | `useTasks()` - returns `store.queue.tasks` (reactive, full lifecycle) |
>>>>>>> Stashed changes
| Mutation hook | `useMutation(r => r.foo)` - status tracking (isPending, isError, error) |
| Optimistic hook | `useOptimistic(r => r.foo, s => s.bar)` - optimistic value + status |
| Settled state | Core Queue tracks last result/error per key, cleared on next request |
| Base hooks | All take store as first arg: `useSelector(store, sel)`, etc |
| createStore hooks | Returns all hooks including `useMutation` and `useOptimistic` |
| Slice hook return | `{ state, request, isAvailable }` - state/request null when unavailable |
| Skin exports | `Provider`, `Skin`, `extendConfig` |
| Slice namespace | `export * as media``media.playback` |
| Video component | Generic, exported from `@videojs/react` (not from skins) |
| Lit mixins | `StoreMixin` (combined), `StoreProviderMixin`, `StoreAttachMixin` |
| Primitives context | `useStoreContext()` internal hook for primitive UI components |
| displayName | For React DevTools component naming |
| Component types | Namespace pattern: `Skin.Props` via `namespace Skin { export type Props }` |
| Element define | `FrostedSkinElement.define(tagName, { mixins })` for declarative setup |
| Config extension | `extendConfig()` uses `uniqBy` + `composeCallbacks` from utils |
| Provider resolution | Isolated by default; `inherit` prop to use parent store from context |
| Store instance | `create()` method for imperative store creation |
| Package structure | `store/react` and `store/lit` (no `store/dom`) |
---
## Phase 0: Core Utilities [DONE]
> Implemented in PR #283.
> Refer to [PR #283](https://github.com/videojs/v10/pull/283) for implementation details.
- `uniqBy` - `packages/utils/src/array/uniq-by.ts`
- `composeCallbacks` - `packages/utils/src/function/compose-callbacks.ts`
- `extendConfig` - `packages/store/src/core/extend-config.ts`
Added `uniqBy`, `composeCallbacks` utilities and `extendConfig` for store.
---
## Phase 0.5: Queue Task Refactor
## Phase 0.5: Queue Task Refactor [DONE]
Refactor Queue to use a unified `tasks` map with status discriminator. This enables `useMutation` and `useOptimistic` hooks to track request lifecycle.
> Refer to [PR #287](https://github.com/videojs/v10/pull/287) for implementation details.
**File:** `packages/store/src/core/queue.ts`
### Task Types (Discriminated Union)
```typescript
// Base fields shared by all task states
interface TaskBase<Key, Input> {
id: symbol;
name: string;
key: Key;
input: Input;
startedAt: number;
meta: RequestMeta | null;
}
// Pending - request in flight
interface PendingTask<Key, Input> extends TaskBase<Key, Input> {
status: 'pending';
abort: AbortController;
}
// Success - completed successfully
interface SuccessTask<Key, Input, Output> extends TaskBase<Key, Input> {
status: 'success';
settledAt: number;
duration: number;
output: Output;
}
// Error - failed or cancelled
interface ErrorTask<Key, Input> extends TaskBase<Key, Input> {
status: 'error';
settledAt: number;
duration: number;
error: unknown;
cancelled: boolean; // true if aborted, false if actual error
}
// Union types
type Task<Key, Input, Output> = PendingTask<Key, Input> | SuccessTask<Key, Input, Output> | ErrorTask<Key, Input>;
type SettledTask<Key, Input, Output> = SuccessTask<Key, Input, Output> | ErrorTask<Key, Input>;
```
### Queue API
```typescript
export class Queue<Tasks extends TaskRecord> {
// Single source of truth - one task per key (pending OR settled)
get tasks(): Readonly<TasksRecord<Tasks>>;
// Clear settled task for a key (no-op if pending)
reset(key: keyof Tasks): void;
// Subscribe to task changes
subscribe(listener: (tasks: TasksRecord<Tasks>) => void): () => void;
}
```
### Lifecycle
1. `enqueue()` → task added with `status: 'pending'`
2. Task completes → same entry updated to `status: 'success'` or `status: 'error'`
3. New request for same key → replaces previous (pending aborted, settled cleared)
4. `reset(key)` → removes settled task
### Usage
```typescript
const task = queue.tasks.changeVolume;
// TypeScript narrows based on status
if (task?.status === 'pending') {
task.abort; // available
}
if (task?.status === 'success') {
task.output; // available
}
if (task?.status === 'error') {
task.error; // available
task.cancelled; // true if aborted
}
```
Refactored Queue to use unified `tasks` map with status discriminator (`PendingTask | SuccessTask | ErrorTask`). Added `tryCatch` utility to `@videojs/utils/function`.
---
@@ -191,8 +113,8 @@ export function StoreContextProvider({ store, children }: { store: AnyStore; chi
**File:** `packages/store/src/react/create-store.ts`
```typescript
import type { ReactNode } from 'react';
import type { AnySlice, InferSliceTarget, StoreConfig } from '../core';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
@@ -221,7 +143,7 @@ export interface CreateStoreResult<Slices extends AnySlice[]> {
(): UnionSliceRequests<Slices>;
<T>(selector: (requests: UnionSliceRequests<Slices>) => T): T;
};
usePending: () => PendingRecord<UnionSliceTasks<Slices>>;
useTasks: () => TasksRecord<UnionSliceTasks<Slices>>;
useMutation: <K extends keyof UnionSliceRequests<Slices>>(
selector: (requests: UnionSliceRequests<Slices>) => UnionSliceRequests<Slices>[K]
) => MutationResult<UnionSliceRequests<Slices>[K]>;
@@ -241,9 +163,9 @@ export function createStore<Slices extends AnySlice[]>(config: CreateStoreConfig
**File:** `packages/store/src/react/types.ts`
```typescript
export type SliceResult<S extends AnySlice>
= | { state: InferSliceState<S>; request: InferSliceRequests<S>; isAvailable: true }
| { state: null; request: null; isAvailable: false };
export type SliceResult<S extends AnySlice> =
| { state: InferSliceState<S>; request: InferSliceRequests<S>; isAvailable: true }
| { state: null; request: null; isAvailable: false };
export interface MutationResult<Request extends (...args: any[]) => any> {
/** Trigger the request */
@@ -285,7 +207,7 @@ export function useSelector<S extends AnyStore, T>(store: S, selector: (state: I
export function useRequest<S extends AnyStore>(store: S): InferStoreRequests<S>;
export function useRequest<S extends AnyStore, T>(store: S, selector: (requests: InferStoreRequests<S>) => T): T;
export function usePending<S extends AnyStore>(store: S): PendingRecord<InferStoreTasks<S>>;
export function useTasks<S extends AnyStore>(store: S): TasksRecord<InferStoreTasks<S>>;
export function useMutation<S extends AnyStore, R extends (...args: any[]) => any>(
store: S,
@@ -332,7 +254,7 @@ export function useOptimistic<S extends AnyStore, R extends (...args: any[]) =>
- `useStore()`: Returns typed store from `useStoreContext()`
- `useSelector(selector)`: Uses `useSyncExternalStore` with selector
- `useRequest()`: Returns stable `store.request` from context
- `usePending()`: Subscribes to `store.queue`, returns `queue.pending`
- `useTasks()`: Subscribes to `store.queue`, returns `queue.tasks`
- `useSlice(slice)`: Returns `{ state, request, isAvailable }` with null narrowing
### 1.6 Exports
@@ -344,7 +266,7 @@ export function useOptimistic<S extends AnyStore, R extends (...args: any[]) =>
export { useStoreContext } from './context';
export { createStore } from './create-store';
// Base hooks for testing/advanced use (all take store as first arg)
export { useMutation, useOptimistic, usePending, useRequest, useSelector } from './hooks';
export { useMutation, useOptimistic, useTasks, useRequest, useSelector } from './hooks';
export type { CreateStoreConfig, CreateStoreResult, MutationResult, OptimisticResult, SliceResult } from './types';
```
@@ -389,9 +311,9 @@ class MySkin extends HTMLElement {
**File:** `packages/store/src/lit/create-store.ts`
```typescript
import type { AnySlice, InferSliceTarget, StoreConfig } from '../core';
import type { Context } from '@lit/context';
import type { ReactiveControllerHost } from '@lit/reactive-element';
import type { AnySlice, InferSliceTarget, StoreConfig } from '../core';
import { createContext } from '@lit/context';
@@ -458,10 +380,10 @@ export class RequestController<S extends AnyStore, T> implements ReactiveControl
get value(): T;
}
// PendingController - like usePending(store)
export class PendingController<S extends AnyStore> implements ReactiveController {
// TasksController - like useTasks(store)
export class TasksController<S extends AnyStore> implements ReactiveController {
constructor(host: ReactiveControllerHost, store: S);
get value(): PendingRecord<InferStoreTasks<S>>;
get value(): TasksRecord<InferStoreTasks<S>>;
}
// MutationController - like useMutation(store, selector)
@@ -500,7 +422,7 @@ export class OptimisticController<
export {
MutationController,
OptimisticController,
PendingController,
TasksController,
RequestController,
SelectorController,
} from './controllers';
@@ -928,16 +850,16 @@ function App() {
}
function MyCustomControls() {
const currentTime = useSelector(s => s.currentTime);
const seek = useRequest(r => r.seek);
const currentTime = useSelector((s) => s.currentTime);
const seek = useRequest((r) => r.seek);
return <button onClick={() => seek(0)}>Restart ({currentTime}s)</button>;
}
// With mutation status tracking
function PlayButton() {
const paused = useSelector(s => s.paused);
const { mutate: play, isPending } = useMutation(r => r.play);
const { mutate: pause } = useMutation(r => r.pause);
const paused = useSelector((s) => s.paused);
const { mutate: play, isPending } = useMutation((r) => r.play);
const { mutate: pause } = useMutation((r) => r.pause);
return (
<button onClick={() => (paused ? play() : pause())} disabled={isPending}>
@@ -949,8 +871,8 @@ function PlayButton() {
// With optimistic updates
function VolumeSlider() {
const { value, setValue, isPending, isError } = useOptimistic(
r => r.changeVolume,
s => s.volume
(r) => r.changeVolume,
(s) => s.volume
);
return (
@@ -958,7 +880,7 @@ function VolumeSlider() {
<input
type="range"
value={value}
onChange={e => setValue(Number(e.target.value))}
onChange={(e) => setValue(Number(e.target.value))}
style={{ opacity: isPending ? 0.5 : 1 }}
/>
{isError && <span>Failed to change volume</span>}
@@ -970,10 +892,10 @@ function VolumeSlider() {
### React: Pre-created store instance (imperative access)
```tsx
import { createStore, media, Video } from '@videojs/react';
import { useState } from 'react';
import { createStore, media, Video } from '@videojs/react';
const { Provider, create, useSelector } = createStore({
slices: [media.playback],
});
@@ -1186,21 +1108,22 @@ packages/html/src/
- `uniqBy`, `composeCallbacks` utilities ✓
- `extendConfig` ✓
2. **Phase 0.5**: Queue Task Refactor
- Unified `tasks` map with status discriminator
- `PendingTask`, `SuccessTask`, `ErrorTask` types
- `reset(key)` method
- Update existing tests
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)
- Shared context, `useStoreContext`
- `createStore()` with `inherit` prop
- `useStore`, `useSelector`, `useRequest`, `usePending`
- `useStore`, `useSelector`, `useRequest`, `useTasks`
- `Video` component, package exports
4. **Phase 2**: Lit Bindings (basic)
- `createStore()` with mixins
- `SelectorController`, `RequestController`, `PendingController`
- `SelectorController`, `RequestController`, `TasksController`
- `@lit/context` integration
5. **Phase 3**: Mutation Hooks/Controllers
@@ -1235,7 +1158,7 @@ import { readdirSync } from 'node:fs';
// Dynamically gather define/ entries
const defineEntries = readdirSync('src/define')
.filter(f => f.endsWith('.ts'))
.filter((f) => f.endsWith('.ts'))
.reduce(
(acc, f) => {
const name = f.replace('.ts', '');
@@ -1363,23 +1286,24 @@ PR #283: Core Utilities [DONE]
├── extendConfig (store/core) ✓
└── Tests ✓
PR A: Queue Task Refactor
├── Unified Task type with status discriminator
├── PendingTask, SuccessTask, ErrorTask
├── Single `tasks` map, `reset(key)` method
├── Update 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 B: React Bindings (basic)
├── createStore, Provider, useStore
├── useSelector, useRequest, usePending
├── useSelector, useRequest, useTasks
├── Video component
├── References #218
└── Closes #229
PR C: Lit Bindings (basic)
├── createStore with mixins
├── SelectorController, RequestController, PendingController
├── SelectorController, RequestController, TasksController
├── References #218
└── Closes #230
@@ -1409,9 +1333,9 @@ PR G: Skins
### Dependency Graph
```
PR #283 ───> PR A ───> PR B ───> PR D ───> PR E ───> PR G
└──> PR C ──────────────────────────┘
└──> PR F ──────────────────────────┘
PR #283 ───> PR #287 ───> PR B ───> PR D ───> PR E ───> PR G
└──> PR C ──────────────────────────┘
└──> PR F ──────────────────────────┘
```
PRs are sequential. PR B, C, F can technically parallel after PR A, but we'll do them sequentially for easier review.