mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(store): queue task refactor (#287)
This commit is contained in:
+81
-157
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user