feat(store): add error codes (#284)

This commit is contained in:
rahim
2026-01-05 11:06:57 +11:00
committed by GitHub
parent 4c7ba6df23
commit 8a1c674dad
11 changed files with 990 additions and 52 deletions
+827
View File
@@ -0,0 +1,827 @@
# Store React/DOM Bindings
## 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`, `usePending`, `useSlice` |
| Controller naming | `StoreController`, `SelectorController`, `RequestController`, etc |
| Selector hook | `useSelector(selector)` - requires selector (Redux-style) |
| Store hook | `useStore()` - returns store instance |
| Pending hook | `usePending()` - returns `store.queue.pending` (reactive) |
| Slice hook return | `{ state, request, isAvailable }` - state/request null when unavailable |
| Skin exports | `Provider`, `Skin`, hooks/controllers |
| Slice namespace | `export * as media``media.playbackSlice` |
| Video component | Generic, exported from `@videojs/react` (not from skins) |
| DOM provider | `withStoreProvider` mixin |
| Base hooks/controllers | Take store explicitly, for testing/advanced use |
| displayName | For React DevTools component naming |
---
## Phase 1: React Bindings (`@videojs/store/react`)
### 1.1 `createStore`
**File:** `packages/store/src/react/create-store.ts`
```typescript
import type { AnySlice, StoreConfig } from '../core';
import type { ReactNode } from 'react';
export interface CreateStoreConfig<Slices extends AnySlice[]> {
slices: Slices;
displayName?: string;
}
export interface CreateStoreResult<Slices extends AnySlice[]> {
Provider: FC<{ children: ReactNode }>;
useStore: () => Store<InferSliceTarget<Slices[number]>, Slices>;
useSelector: <T>(selector: (state: UnionSliceState<Slices>) => T) => T;
useRequest: () => UnionSliceRequests<Slices>;
usePending: () => PendingRecord<UnionSliceTasks<Slices>>;
useSlice: <S extends Slices[number]>(slice: S) => SliceResult<S>;
}
export function createStore<Slices extends AnySlice[]>(config: CreateStoreConfig<Slices>): CreateStoreResult<Slices>;
```
### 1.2 Types
**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 };
```
### 1.3 Base hooks
**File:** `packages/store/src/react/hooks.ts`
```typescript
// Base hooks - take store explicitly (for testing/advanced use)
export function useSelector<S extends AnyStore, T>(store: S, selector: (state: InferStoreState<S>) => T): T;
export function useRequest<S extends AnyStore>(store: S): InferStoreRequests<S>;
export function usePending<S extends AnyStore>(store: S): PendingRecord<InferStoreTasks<S>>;
export function useSlice<S extends AnyStore, Slice extends AnySlice>(store: S, slice: Slice): SliceResult<Slice>;
```
**Implementation details:**
- `Provider`: Creates store via `useState(() => new Store(config))`, provides via context
- `useStore()`: Returns store instance from context
- `useSelector(selector)`: Uses `useSyncExternalStore` with selector
- `useRequest()`: Returns stable `store.request` from context
- `usePending()`: Subscribes to `store.queue`, returns `queue.pending`
- `useSlice(slice)`: Returns `{ state, request, isAvailable }` with null narrowing
- Cleanup: `useEffect` calls `store.destroy()` on unmount
### 1.4 Exports
**File:** `packages/store/src/react/index.ts`
```typescript
export { createStore } from './create-store';
// Base hooks for testing/advanced use
export { usePending, useRequest, useSelector, useSlice } from './hooks';
export type { CreateStoreConfig, CreateStoreResult, SliceResult } from './types';
```
---
## Phase 2: DOM Bindings (`@videojs/store/dom`)
### 2.0 @lit/context Research
Key findings from analyzing `@lit/context@1.1.6`:
**Dynamic Value Updates:**
- `ContextProvider.setValue(newValue, force?)` notifies all subscribed consumers
- Uses `Object.is()` for equality - swapping store objects triggers updates automatically
- `force = true` needed only for in-place mutations (same reference)
**Subscription Model:**
- Consumers must opt-in: `subscribe: true` in `@consume()` or `ContextConsumer`
- Without subscription, consumers only receive initial value
- Provider stores callbacks in `Map<ContextCallback, CallbackInfo>`
- `updateObservers()` iterates all callbacks on value change
**Store Swapping Pattern (validated):**
```typescript
class MySkin extends HTMLElement {
#provider = new ContextProvider(this, { context: storeContext });
set store(newStore: Store) {
this.#provider.setValue(newStore); // Notifies all subscribers
}
}
```
### 2.1 `createStore`
**File:** `packages/store/src/dom/create-store.ts`
```typescript
import type { Context } from '@lit/context';
import type { ReactiveControllerHost } from '@lit/reactive-element';
import { createContext } from '@lit/context';
export interface CreateStoreConfig<Slices extends AnySlice[]> {
slices: Slices;
}
export interface CreateStoreResult<Slices extends AnySlice[]> {
defineStoreProvider: (tagName: string) => void;
withStoreProvider: <T extends Constructor<HTMLElement>>(Base: T) => T;
StoreController: new (host: ReactiveControllerHost) => StoreControllerInstance<Slices>;
SelectorController: <T>(
host: ReactiveControllerHost,
selector: (state: UnionSliceState<Slices>) => T
) => SelectorControllerInstance<T>;
RequestController: new (host: ReactiveControllerHost) => RequestControllerInstance<Slices>;
PendingController: new (host: ReactiveControllerHost) => PendingControllerInstance<Slices>;
SliceController: <S extends Slices[number]>(host: ReactiveControllerHost, slice: S) => SliceControllerInstance<S>;
context: Context<Store<InferSliceTarget<Slices[number]>, Slices>>;
}
export function createStore<Slices extends AnySlice[]>(config: CreateStoreConfig<Slices>): CreateStoreResult<Slices>;
```
### 2.2 Base controllers
**File:** `packages/store/src/dom/controllers.ts`
```typescript
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
// Base controllers - take store explicitly (for testing/advanced use)
export class SelectorController<S extends AnyStore, T> implements ReactiveController {
constructor(host: ReactiveControllerHost, store: S, selector: (state: InferStoreState<S>) => T);
get value(): T;
}
export class RequestController<S extends AnyStore> implements ReactiveController {
constructor(host: ReactiveControllerHost, store: S);
get value(): InferStoreRequests<S>;
}
export class PendingController<S extends AnyStore> implements ReactiveController {
constructor(host: ReactiveControllerHost, store: S);
get value(): PendingRecord<InferStoreTasks<S>>;
}
export class SliceController<S extends AnyStore, Slice extends AnySlice> implements ReactiveController {
constructor(host: ReactiveControllerHost, store: S, slice: Slice);
get state(): InferSliceState<Slice> | null;
get request(): InferSliceRequests<Slice> | null;
get isAvailable(): this is this & { state: InferSliceState<Slice>; request: InferSliceRequests<Slice> };
}
```
**Implementation details:**
- Uses `@lit/context` for W3C Context Protocol
- Context key auto-generated per `createStore()` call (unique Symbol)
- `withStoreProvider`: Mixin that:
- Creates store instance
- Uses `ContextProvider` internally
- Exposes `store` setter that calls `provider.setValue(newStore)`
- Controllers:
- Consume store via `ContextConsumer` with `subscribe: true`
- Subscribe to store changes, call `host.requestUpdate()` on change
- Cleanup on `hostDisconnected` (automatic via ContextConsumer)
- `SelectorController`: One-step instantiation `new SelectorController(this, selector)`
### 2.3 Exports
**File:** `packages/store/src/dom/index.ts`
```typescript
// Base controllers for testing/advanced use
export { PendingController, RequestController, SelectorController, SliceController } from './controllers';
export { createStore } from './create-store';
export type { CreateStoreConfig, CreateStoreResult } from './types';
```
---
## Phase 3: Playback Slice (`@videojs/core/dom`)
Based on [Issue #239](https://github.com/videojs/v10/issues/239).
### 3.1 Playback slice
**File:** `packages/core/src/dom/slices/playback.ts`
```typescript
interface PlaybackState {
paused: boolean;
ended: boolean;
started: boolean;
waiting: boolean;
currentTime: number;
duration: number;
buffered: Array<[number, number]>;
seekable: Array<[number, number]>;
volume: number;
muted: boolean;
canPlay: boolean;
source: unknown;
streamType: 'on-demand' | 'live' | 'live-dvr' | 'unknown';
}
interface PlaybackRequests {
play: Request<void>;
pause: Request<void>;
seek: Request<number>;
changeVolume: Request<number>;
toggleMute: Request<void>;
changeSource: Request<string>;
}
export const playbackSlice = createSlice<HTMLMediaElement>()({
initialState: {
/* ... */
},
getSnapshot: ({ target }) => ({
/* ... */
}),
subscribe: ({ target, update, signal }) => {
/* ... */
},
request: {
/* ... */
},
});
```
### 3.2 Namespace export
**File:** `packages/core/src/dom/slices/index.ts`
```typescript
export { playbackSlice } from './playback';
// Namespace export
export * as media from './index';
```
Usage:
```typescript
import { media, playbackSlice } from '@videojs/core/dom';
media.playbackSlice; // via namespace
playbackSlice; // standalone export
```
### 3.3 Utilities
**File:** `packages/core/src/dom/slices/utils.ts`
```typescript
export function serializeTimeRanges(ranges: TimeRanges): Array<[number, number]>;
```
### 3.4 Type guards
**File:** `packages/core/src/dom/guards.ts`
```typescript
export function isHTMLVideo(target: unknown): target is HTMLVideoElement;
export function isHTMLAudio(target: unknown): target is HTMLAudioElement;
export function isHTMLMedia(target: unknown): target is HTMLMediaElement;
```
---
## 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';
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 { media } from '@videojs/core/dom';
import { createStore } from '@videojs/store/react';
export const storeConfig = {
slices: [media.playback] as const,
displayName: 'FrostedSkin',
};
export const { Provider, useStore, useSelector, useRequest, usePending, useSlice } = createStore(storeConfig);
```
### 5.2 Skin component
**File:** `packages/react/src/skins/frosted/Skin.tsx`
```typescript
import type { PropsWithChildren } from 'react';
import { useRequest, useSelector } from './store';
export interface SkinProps extends PropsWithChildren<{
className?: string;
theme?: 'light' | 'dark';
}> {}
export function Skin({ children, className, theme }: SkinProps): JSX.Element {
return (
<div className={`vjs-frosted-skin ${className ?? ''}`} data-theme={theme}>
{children}
<Controls />
</div>
);
}
function Controls() {
const paused = useSelector((s) => s.paused);
const { play, pause } = useRequest();
// ... render controls
}
```
### 5.3 Exports
**File:** `packages/react/src/skins/frosted/index.ts`
```typescript
export { Skin } from './Skin';
export type { SkinProps } from './Skin';
export { Provider, storeConfig, usePending, useRequest, useSelector, useSlice, useStore } from './store';
```
---
## Phase 6: Frosted Skin (HTML)
### 6.1 Store config
**File:** `packages/html/src/skins/frosted/store.ts`
```typescript
import { media } from '@videojs/core/dom';
import { createStore } from '@videojs/store/dom';
export const storeConfig = {
slices: [media.playback] as const,
};
export const {
defineStoreProvider,
withStoreProvider,
StoreController,
SelectorController,
RequestController,
PendingController,
SliceController,
context,
} = createStore(storeConfig);
```
### 6.2 Skin component
**File:** `packages/html/src/skins/frosted/Skin.ts`
```typescript
import { RequestController, SelectorController, withStoreProvider } from './store';
export class FrostedSkinElement extends HTMLElement {
#paused = new SelectorController(this, (s) => s.paused);
#request = new RequestController(this);
/**
* Define a custom element with this skin, optionally with a custom provider mixin.
* Useful for extending the skin with additional slices.
*/
static define(tagName: string, providerMixin = withStoreProvider) {
const SkinWithProvider = providerMixin(this);
customElements.define(tagName, SkinWithProvider);
}
connectedCallback() {
this.innerHTML = `
<slot></slot>
<div class="controls">
<button class="play-pause"></button>
</div>
`;
this.#updatePlayButton();
this.querySelector('button')?.addEventListener('click', this.#handleClick);
}
#handleClick = () => {
this.#paused.value ? this.#request.value.play() : this.#request.value.pause();
};
#updatePlayButton() {
const btn = this.querySelector('.play-pause');
if (btn) btn.textContent = this.#paused.value ? 'Play' : 'Pause';
}
}
#handleClick = () => {
this.#paused.value ? this.#request.value.play() : this.#request.value.pause();
};
#updatePlayButton() {
const btn = this.querySelector('.play-pause');
if (btn) btn.textContent = this.#paused.value ? 'Play' : 'Pause';
}
}
```
### 6.3 Define export
**File:** `packages/html/src/define/vjs-frosted-skin.ts`
```typescript
import { FrostedSkinElement } from '../skins/frosted/Skin';
customElements.define('vjs-frosted-skin', FrostedSkinElement);
```
### 6.4 Exports
**File:** `packages/html/src/skins/frosted/index.ts`
```typescript
export { FrostedSkinElement } from './Skin';
export {
context,
PendingController,
RequestController,
SelectorController,
SliceController,
storeConfig,
StoreController,
withStoreProvider,
} from './store';
```
---
## Usage Examples
### React: Custom UI
```tsx
import { createStore, media, Video } from '@videojs/react';
const { Provider, useSelector, useRequest } = createStore({
slices: [media.playbackSlice],
});
function App() {
return (
<Provider>
<Video src="video.mp4" />
<MyCustomControls />
</Provider>
);
}
function MyCustomControls() {
const currentTime = useSelector((s) => s.currentTime);
const { seek } = useRequest();
return <button onClick={() => seek(0)}>Restart ({currentTime}s)</button>;
}
```
### 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 { Skin, storeConfig } from '@videojs/react/skins/frosted';
import { chaptersSlice } from './slices/chapters';
// Extend frosted config with custom slice
const { Provider, useSlice } = createStore({
...storeConfig,
slices: [...storeConfig.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" src="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 (CDN)
```html
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/define/my-player.js"></script>
<my-player>
<video src="video.mp4"></video>
<!-- custom controls here -->
</my-player>
```
Where `my-player.js` contains:
```typescript
import { createStore, playbackSlice } from '@videojs/html';
const { defineStoreProvider } = createStore({
slices: [playbackSlice],
});
defineStoreProvider('my-player');
```
### 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 { FrostedSkinElement, storeConfig } from '@videojs/html/skins/frosted';
import { createStore } from '@videojs/store/dom';
import { chaptersSlice } from './slices/chapters.js';
// Extend frosted config with custom slice
const { withStoreProvider } = createStore({
...storeConfig,
slices: [...storeConfig.slices, chaptersSlice],
});
FrostedSkinElement.define('my-extended-skin', withStoreProvider);
```
---
## File Structure
```
packages/store/src/
├── core/
│ ├── store.ts # existing
│ ├── slice.ts # existing
│ ├── queue.ts # existing
│ └── index.ts
├── react/
│ ├── create-store.ts # NEW
│ ├── hooks.ts # NEW (base hooks)
│ ├── types.ts # NEW
│ └── index.ts
└── dom/
├── create-store.ts # NEW
├── controllers.ts # NEW (base controllers)
├── types.ts # NEW
└── index.ts
packages/core/src/dom/
├── slices/
│ ├── playback.ts # NEW
│ ├── utils.ts # NEW
│ └── index.ts # NEW (+ media namespace)
├── guards.ts # NEW
└── index.ts
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 1**: React bindings (`@videojs/store/react`)
- `createStore()`, base hooks, types
2. **Phase 2**: DOM bindings (`@videojs/store/dom`)
- `createStore()`, `defineStoreProvider()`, base controllers, types, `@lit/context` integration
3. **Phase 3**: Playback slice (`@videojs/core/dom`)
- `media.playback`, utils, guards
4. **Phase 4**: React package setup (`@videojs/react`)
- `Video`, package exports
5. **Phase 5**: Frosted skin React (`@videojs/react/skins/frosted`)
6. **Phase 6**: Frosted skin HTML (`@videojs/html/skins/frosted`)
Each phase includes tests.
---
## PR Coordination
### Related Issues
| Issue | Title | Description |
| ----- | ------------------------ | ----------------------------- |
| #218 | Store | Parent tracking issue |
| #229 | React Bindings | `createStore`, hooks, context |
| #230 | ReactiveElement Bindings | Controllers, context |
| #231 | Frosted Skin | Frosted skin implementation |
| #239 | Playback Feature | Consolidated playback slice |
### Existing PR
- **PR #281** (`videojs-store-lit`) - Draft with WIP Lit controllers
- Has: `StoreController`, `SliceController`, `PendingController`
- Missing: `SelectorController`, `RequestController`, context-based factory, `withStoreProvider` mixin
- Action: Update with new API design
### PR Strategy
```
PR 1: Store React Bindings
├── createStore (store/react)
├── Base hooks (store/react)
├── Types
├── References #218
└── Closes #229
PR 2: Store DOM Bindings (update PR #281)
├── createStore (store/dom)
├── defineStoreProvider
├── Base controllers (store/dom)
├── withStoreProvider mixin
├── @lit/context integration
├── References #218
└── Closes #230
PR 3: Playback Slice
├── media.playbackSlice (core/dom/slices)
├── Utils (serializeTimeRanges)
├── Type guards
├── References #218
└── Closes #239
PR 4: React Package Setup
├── Video component
├── Package exports
└── References #218
PR 5: Frosted Skin
├── React skin (Provider, Skin, storeConfig, hooks)
├── HTML skin (FrostedSkinElement.define, storeConfig, controllers)
├── define/vjs-frosted-skin.ts
├── References #218
└── Closes #231
```
### Dependency Graph
```
PR 1 ──┬──> PR 2 (can parallel)
└──> PR 3 (can parallel)
└──> PR 4 (needs PR 1, PR 3)
└──> PR 5 (needs PR 2, PR 4)
```
---
## Deferred
- Testing utilities (`@videojs/store/testing`) - separate plan
- Minimal skin implementation
+43 -2
View File
@@ -416,7 +416,18 @@ request: {
### Guards
Guards gate request execution. A guard returns truthy to proceed, falsy to cancel.
Guards gate request execution. A guard returns a `GuardResult`:
```ts
import type { Guard, GuardResult } from '@videojs/store';
// GuardResult = boolean | Promise<unknown>
// - Truthy → proceed
// - Falsy → cancel (throws REJECTED)
// - Promise resolves truthy → proceed
// - Promise resolves falsy → cancel
// - Promise rejects → cancel
```
```ts
import { timeout } from '@videojs/store';
@@ -464,9 +475,25 @@ const timedPlay = timeout(canMediaPlay, 5000);
## Error Handling
All store errors include a `code` for programmatic handling:
| Code | Description |
| ------------ | ---------------------------- |
| `ABORTED` | Request aborted via signal |
| `CANCELLED` | Cancelled by another request |
| `DESTROYED` | Store or queue destroyed |
| `DETACHED` | Target detached |
| `NO_TARGET` | No target attached |
| `REJECTED` | Guard returned falsy |
| `REMOVED` | Task dequeued or cleared |
| `SUPERSEDED` | Replaced by same-key request |
| `TIMEOUT` | Guard timed out |
Catch errors locally via the promise, or globally via `onError`:
```ts
import { isStoreError } from '@videojs/store';
// 1. Global Error Handling
const store = createStore({
slices: [playbackSlice],
@@ -483,7 +510,21 @@ const store = createStore({
try {
await store.request.play();
} catch (error) {
// ...
if (isStoreError(error)) {
switch (error.code) {
case 'SUPERSEDED':
// Another play/pause request took over - expected
break;
case 'REJECTED':
// Guard failed - blocked
break;
case 'TIMEOUT':
// Guard timed out waiting
break;
default:
console.error(`[${error.code}]`, error.message);
}
}
}
```
+43 -3
View File
@@ -1,13 +1,53 @@
interface ErrorOptions {
/**
* Error codes for store operations.
*
* @example
* ```ts
* if (isStoreError(error)) {
* switch (error.code) {
* case 'SUPERSEDED':
* // Request was replaced by another - expected behavior
* break;
* case 'REJECTED':
* // Guard condition failed
* break;
* }
* }
* ```
*/
export type StoreErrorCode
/** Request was aborted via AbortSignal - user or system requested cancellation. */
= | 'ABORTED'
/** Request was cancelled by another request's `cancel` configuration. */
| 'CANCELLED'
/** Store or queue was destroyed - lifecycle ended. */
| 'DESTROYED'
/** Target was detached while request was in flight. */
| 'DETACHED'
/** No target is attached to the store. */
| 'NO_TARGET'
/** Guard condition returned falsy - request preconditions not met. */
| 'REJECTED'
/** Task was removed from queue via `dequeue()` or `clear()`. */
| 'REMOVED'
/** Request was replaced by a newer request with the same key. */
| 'SUPERSEDED'
/** Guard condition timed out waiting for a truthy result. */
| 'TIMEOUT';
export interface StoreErrorOptions {
cause?: unknown;
message?: string;
}
export class StoreError extends Error {
readonly code: StoreErrorCode;
cause?: unknown;
constructor(message: string, options?: ErrorOptions) {
super(message);
constructor(code: StoreErrorCode, options?: StoreErrorOptions) {
super(options?.message ?? code);
this.name = 'StoreError';
this.code = code;
this.cause = options?.cause;
}
}
+16 -3
View File
@@ -3,7 +3,7 @@ import { isBoolean } from '@videojs/utils/predicate';
import { StoreError } from './errors';
/**
* A guard gates request execution.
* Result of a guard check.
*
* - Truthy proceed
* - Falsy cancel
@@ -11,7 +11,20 @@ import { StoreError } from './errors';
* - Promise resolves falsy cancel
* - Promise rejects cancel
*/
export type Guard<Target> = (ctx: { target: Target; signal: AbortSignal }) => boolean | Promise<unknown>;
export type GuardResult = boolean | Promise<unknown>;
/**
* Context passed to guard functions.
*/
export interface GuardContext<Target> {
target: Target;
signal: AbortSignal;
}
/**
* A guard gates request execution.
*/
export type Guard<Target> = (ctx: GuardContext<Target>) => GuardResult;
/**
* Combine guards: All must pass (truthy).
@@ -69,7 +82,7 @@ export function timeout<Target>(guard: Guard<Target>, ms: number, name = 'guard'
return Promise.race([
result,
new Promise<never>((_, reject) => {
const timer = setTimeout(() => reject(new StoreError(`Timeout: ${name}`)), ms);
const timer = setTimeout(() => reject(new StoreError('TIMEOUT', { message: `Timeout: ${name}` })), ms);
ctx.signal.addEventListener('abort', () => clearTimeout(timer));
}),
]);
+15 -15
View File
@@ -252,17 +252,17 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
const { name, key, input, schedule, meta = null, handler } = task;
if (this.#destroyed) {
return Promise.reject(new StoreError('Queue destroyed'));
return Promise.reject(new StoreError('DESTROYED'));
}
// Cancel any queued task with same key
const queued = this.#queued[key];
queued?.invalidate?.();
queued?.reject(new StoreError('Superseded'));
queued?.reject(new StoreError('SUPERSEDED'));
delete this.#queued[key];
// Abort any pending task with same key
this.#pending[key]?.abort.abort(new StoreError('Superseded'));
this.#pending[key]?.abort.abort(new StoreError('SUPERSEDED'));
return new Promise<Tasks[K]['output']>((resolve, reject) => {
const task: QueuedTask = {
@@ -312,7 +312,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
if (!queued) return false;
queued.invalidate?.();
queued.reject(new StoreError('Dequeued'));
queued.reject(new StoreError('REMOVED'));
delete this.#queued[key];
return true;
@@ -321,7 +321,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
clear(): void {
for (const queued of Object.values(this.#queued)) {
queued.invalidate?.();
queued.reject(new StoreError('Cleared'));
queued.reject(new StoreError('REMOVED'));
}
this.#queued = {};
@@ -338,19 +338,19 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
await Promise.allSettled(keys.map(k => this.#flushKey(k)));
}
abort<K extends keyof Tasks>(key: K, reason = 'Aborted'): void {
abort<K extends keyof Tasks>(key: K): void {
// Reject queued
const queued = this.#queued[key];
queued?.invalidate?.();
queued?.reject(new StoreError(reason));
queued?.reject(new StoreError('ABORTED'));
delete this.#queued[key];
// Abort pending with reason
this.#pending[key]?.abort.abort(new StoreError(reason));
// Abort pending
this.#pending[key]?.abort.abort(new StoreError('ABORTED'));
}
abortAll(reason = 'All requests aborted'): void {
const error = new StoreError(reason);
abortAll(): void {
const error = new StoreError('ABORTED');
// Reject all queued
for (const queued of Object.values(this.#queued)) {
@@ -360,7 +360,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
this.#queued = {};
// Abort all pending with reason
// Abort all pending
for (const pending of Object.values(this.#pending)) {
pending.abort.abort(error);
}
@@ -370,7 +370,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
if (this.#destroyed) return;
this.#destroyed = true;
this.abortAll('Queue destroyed');
this.abortAll();
this.#subscribers.clear();
}
@@ -409,13 +409,13 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
try {
if (abort.signal.aborted) {
throw abort.signal.reason || new StoreError('Aborted');
throw abort.signal.reason || new StoreError('ABORTED');
}
const result = await handler({ input, signal: abort.signal });
if (abort.signal.aborted) {
throw abort.signal.reason || new StoreError('Aborted');
throw abort.signal.reason || new StoreError('ABORTED');
}
resolve(result);
+7 -7
View File
@@ -81,7 +81,7 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
attach(newTarget: Target): () => void {
if (this.#destroyed) {
throw new StoreError('Store destroyed');
throw new StoreError('DESTROYED');
}
this.#attachAbort?.abort();
@@ -138,7 +138,7 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
this.#attachAbort?.abort();
this.#attachAbort = null;
this.#target = null;
this.#queue.abortAll('Target detached');
this.#queue.abortAll();
this.#resetState();
}
@@ -264,7 +264,7 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
for (const [name, config] of this.#requestConfigs) {
proxy[name] = (input?: unknown, meta?: RequestMetaInit) => {
if (this.#destroyed) {
return Promise.reject(new StoreError('Store destroyed'));
return Promise.reject(new StoreError('DESTROYED'));
}
return this.#execute(name, config, input, meta ? createRequestMeta(meta) : null);
@@ -283,25 +283,25 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
const key = resolveRequestKey(config.key, input);
for (const cancelKey of resolveRequestCancelKeys(config.cancel, input)) {
this.#queue.abort(cancelKey, `Cancelled by ${name}`);
this.#queue.abort(cancelKey);
}
const handler = async ({ input, signal }: TaskContext) => {
const target = this.#target;
if (!target) {
throw new StoreError('No target attached');
throw new StoreError('NO_TARGET');
}
for (const guard of config.guard) {
if (signal.aborted) {
throw new StoreError('Aborted');
throw new StoreError('ABORTED');
}
const result = await guard({ target, signal });
if (!result) {
throw new StoreError('Rejected');
throw new StoreError('REJECTED');
}
}
+22 -6
View File
@@ -4,24 +4,40 @@ import { isStoreError, StoreError } from '../errors';
describe('errors', () => {
describe('storeError', () => {
it('creates error with message', () => {
const error = new StoreError('test message');
expect(error.message).toBe('test message');
it('creates error with code only', () => {
const error = new StoreError('ABORTED');
expect(error.code).toBe('ABORTED');
expect(error.message).toBe('ABORTED');
expect(error.name).toBe('StoreError');
expect(error).toBeInstanceOf(Error);
});
it('creates error with code and message', () => {
const error = new StoreError('TIMEOUT', { message: 'Timeout: canPlay' });
expect(error.code).toBe('TIMEOUT');
expect(error.message).toBe('Timeout: canPlay');
});
it('supports cause for error chaining', () => {
const cause = new Error('original error');
const error = new StoreError('wrapped error', { cause });
expect(error.message).toBe('wrapped error');
const error = new StoreError('ABORTED', { cause });
expect(error.code).toBe('ABORTED');
expect(error.cause).toBe(cause);
});
it('supports both message and cause', () => {
const cause = new Error('original');
const error = new StoreError('TIMEOUT', { message: 'Timeout: guard', cause });
expect(error.code).toBe('TIMEOUT');
expect(error.message).toBe('Timeout: guard');
expect(error.cause).toBe(cause);
});
});
describe('type guard', () => {
it('isStoreError identifies store errors', () => {
expect(isStoreError(new StoreError('test'))).toBe(true);
expect(isStoreError(new StoreError('ABORTED'))).toBe(true);
expect(isStoreError(new StoreError('REJECTED'))).toBe(true);
expect(isStoreError(new Error('regular'))).toBe(false);
expect(isStoreError(null)).toBe(false);
});
+2 -1
View File
@@ -111,7 +111,7 @@ describe('guard', () => {
expect(await guard(createContext())).toBe(true);
});
it('throws StoreError on timeout', async () => {
it('throws StoreError with TIMEOUT code', async () => {
vi.useFakeTimers();
const guard = timeout(
@@ -125,6 +125,7 @@ describe('guard', () => {
await expect(promise).rejects.toThrow(StoreError);
await expect(promise).rejects.toMatchObject({
code: 'TIMEOUT',
message: 'Timeout: waitForReady',
});
@@ -83,7 +83,7 @@ describe('store lifecycle integration', () => {
// Test 1: Guard rejects when not ready
const failPromise = store.request.delayedAction().catch(e => e);
await vi.runAllTimersAsync();
await expect(failPromise).resolves.toMatchObject({ message: 'Rejected' });
await expect(failPromise).resolves.toMatchObject({ code: 'REJECTED' });
// Test 2: Guard passes when ready
ready = true;
+12 -11
View File
@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { StoreError } from '../errors';
import { createQueue, delay } from '../queue';
describe('queue', () => {
@@ -89,7 +88,7 @@ describe('queue', () => {
const promise1 = queue.enqueue({ name: 'a', key: 'same', handler: first });
const promise2 = queue.enqueue({ name: 'b', key: 'same', handler: second });
await expect(promise1).rejects.toThrow(StoreError);
await expect(promise1).rejects.toMatchObject({ code: 'SUPERSEDED' });
await expect(promise2).resolves.toBe('second');
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledOnce();
@@ -177,7 +176,7 @@ describe('queue', () => {
expect(queue.dequeue('k')).toBe(false);
vi.advanceTimersByTime(100);
await expect(promise).rejects.toThrow(StoreError);
await expect(promise).rejects.toMatchObject({ code: 'REMOVED' });
expect(handler).not.toHaveBeenCalled();
});
});
@@ -192,8 +191,8 @@ describe('queue', () => {
queue.clear();
vi.advanceTimersByTime(100);
await expect(p1).rejects.toThrow(StoreError);
await expect(p2).rejects.toThrow(StoreError);
await expect(p1).rejects.toMatchObject({ code: 'REMOVED' });
await expect(p2).rejects.toMatchObject({ code: 'REMOVED' });
expect(Reflect.ownKeys(queue.queued).length).toBe(0);
});
});
@@ -243,7 +242,7 @@ describe('queue', () => {
await new Promise((_, reject) => {
signal.addEventListener('abort', () => {
aborted = true;
reject(new Error('aborted'));
reject(signal.reason);
});
setTimeout(() => {}, 1000);
});
@@ -251,9 +250,9 @@ describe('queue', () => {
});
await new Promise(r => setTimeout(r, 10));
queue.abort('k', 'test abort');
queue.abort('k');
await expect(promise).rejects.toThrow();
await expect(promise).rejects.toMatchObject({ code: 'ABORTED' });
expect(aborted).toBe(true);
});
});
@@ -351,7 +350,9 @@ describe('queue', () => {
const queue = createQueue();
queue.destroy();
await expect(queue.enqueue({ name: 't', key: 'k', handler: vi.fn() })).rejects.toThrow('Queue destroyed');
await expect(queue.enqueue({ name: 't', key: 'k', handler: vi.fn() })).rejects.toMatchObject({
code: 'DESTROYED',
});
});
it('aborts all pending on destroy', async () => {
@@ -372,7 +373,7 @@ describe('queue', () => {
await new Promise(r => setTimeout(r, 10));
queue.destroy();
await expect(promise).rejects.toThrow();
await expect(promise).rejects.toMatchObject({ code: 'ABORTED' });
expect(aborted).toHaveBeenCalled();
expect(queue.destroyed).toBe(true);
});
@@ -401,7 +402,7 @@ describe('queue', () => {
});
// First should be superseded
await expect(promise1).rejects.toMatchObject({ message: 'Superseded' });
await expect(promise1).rejects.toMatchObject({ code: 'SUPERSEDED' });
// Queue should only have the second task (first was explicitly deleted)
expect(Reflect.ownKeys(queue.queued).length).toBe(1);
+2 -3
View File
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { StoreError } from '../errors';
import { createQueue } from '../queue';
import { createSlice } from '../slice';
import { createStore } from '../store';
@@ -210,7 +209,7 @@ describe('store', () => {
onError: () => {}, // silence errors
});
await expect(store.request.setVolume(0.5)).rejects.toThrow(StoreError);
await expect(store.request.setVolume(0.5)).rejects.toMatchObject({ code: 'NO_TARGET' });
});
it('coordinates requests with same key', async () => {
@@ -225,7 +224,7 @@ describe('store', () => {
const playPromise = store.request.play();
const pausePromise = store.request.pause();
await expect(playPromise).rejects.toThrow(StoreError);
await expect(playPromise).rejects.toMatchObject({ code: 'SUPERSEDED' });
await pausePromise;
expect(media.paused).toBe(true);