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
|
# 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
|
## Goal
|
||||||
|
|
||||||
Implement React and DOM bindings for Video.js 10's store, enabling:
|
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
|
## Key Decisions
|
||||||
|
|
||||||
| Decision | Resolution |
|
| Decision | Resolution |
|
||||||
| ------------------- | ------------------------------------------------------------------------------------- |
|
| ------------------- | ----------------------------------------------------------------------------------- |
|
||||||
| Store creation | `createStore({ slices, displayName? })` - types inferred from slices |
|
| Store creation | `createStore({ slices, displayName? })` - types inferred from slices |
|
||||||
| Hook naming | `useStore`, `useSelector`, `useRequest`, `usePending`, `useMutation`, `useOptimistic` |
|
| Hook naming | `useStore`, `useSelector`, `useRequest`, `useTasks`, `useMutation`, `useOptimistic` |
|
||||||
| Controller naming | `SelectorController`, `RequestController`, `PendingController`, etc |
|
| Controller naming | `SelectorController`, `RequestController`, `TasksController`, etc |
|
||||||
| Selector hook | `useSelector(selector)` - requires selector (Redux-style) |
|
| Selector hook | `useSelector(selector)` - requires selector (Redux-style) |
|
||||||
| Store hook | `useStore()` - returns store instance |
|
| Store hook | `useStore()` - returns store instance |
|
||||||
| Request hook | `useRequest()` or `useRequest(r => r.foo)` - full map or single request |
|
| Request hook | `useRequest()` or `useRequest(r => r.foo)` - full map or single request |
|
||||||
| Pending hook | `usePending()` - returns `store.queue.pending` (reactive) |
|
<<<<<<< Updated upstream
|
||||||
| Mutation hook | `useMutation(r => r.foo)` - status tracking (isPending, isError, error) |
|
| Tasks hook | `useTasks()` - returns `store.queue.tasks` (reactive) |
|
||||||
| 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 |
|
| Tasks hook | `useTasks()` - returns `store.queue.tasks` (reactive, full lifecycle) |
|
||||||
| Base hooks | All take store as first arg: `useSelector(store, sel)`, etc |
|
>>>>>>> Stashed changes
|
||||||
| createStore hooks | Returns all hooks including `useMutation` and `useOptimistic` |
|
| Mutation hook | `useMutation(r => r.foo)` - status tracking (isPending, isError, error) |
|
||||||
| Slice hook return | `{ state, request, isAvailable }` - state/request null when unavailable |
|
| Optimistic hook | `useOptimistic(r => r.foo, s => s.bar)` - optimistic value + status |
|
||||||
| Skin exports | `Provider`, `Skin`, `extendConfig` |
|
| Settled state | Core Queue tracks last result/error per key, cleared on next request |
|
||||||
| Slice namespace | `export * as media` → `media.playback` |
|
| Base hooks | All take store as first arg: `useSelector(store, sel)`, etc |
|
||||||
| Video component | Generic, exported from `@videojs/react` (not from skins) |
|
| createStore hooks | Returns all hooks including `useMutation` and `useOptimistic` |
|
||||||
| Lit mixins | `StoreMixin` (combined), `StoreProviderMixin`, `StoreAttachMixin` |
|
| Slice hook return | `{ state, request, isAvailable }` - state/request null when unavailable |
|
||||||
| Primitives context | `useStoreContext()` internal hook for primitive UI components |
|
| Skin exports | `Provider`, `Skin`, `extendConfig` |
|
||||||
| displayName | For React DevTools component naming |
|
| Slice namespace | `export * as media` → `media.playback` |
|
||||||
| Component types | Namespace pattern: `Skin.Props` via `namespace Skin { export type Props }` |
|
| Video component | Generic, exported from `@videojs/react` (not from skins) |
|
||||||
| Element define | `FrostedSkinElement.define(tagName, { mixins })` for declarative setup |
|
| Lit mixins | `StoreMixin` (combined), `StoreProviderMixin`, `StoreAttachMixin` |
|
||||||
| Config extension | `extendConfig()` uses `uniqBy` + `composeCallbacks` from utils |
|
| Primitives context | `useStoreContext()` internal hook for primitive UI components |
|
||||||
| Provider resolution | Isolated by default; `inherit` prop to use parent store from context |
|
| displayName | For React DevTools component naming |
|
||||||
| Store instance | `create()` method for imperative store creation |
|
| Component types | Namespace pattern: `Skin.Props` via `namespace Skin { export type Props }` |
|
||||||
| Package structure | `store/react` and `store/lit` (no `store/dom`) |
|
| 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]
|
## 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`
|
Added `uniqBy`, `composeCallbacks` utilities and `extendConfig` for store.
|
||||||
- `composeCallbacks` - `packages/utils/src/function/compose-callbacks.ts`
|
|
||||||
- `extendConfig` - `packages/store/src/core/extend-config.ts`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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`
|
Refactored Queue to use unified `tasks` map with status discriminator (`PendingTask | SuccessTask | ErrorTask`). Added `tryCatch` utility to `@videojs/utils/function`.
|
||||||
|
|
||||||
### 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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -191,8 +113,8 @@ export function StoreContextProvider({ store, children }: { store: AnyStore; chi
|
|||||||
**File:** `packages/store/src/react/create-store.ts`
|
**File:** `packages/store/src/react/create-store.ts`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import type { AnySlice, InferSliceTarget, StoreConfig } from '../core';
|
import type { AnySlice, InferSliceTarget, StoreConfig } from '../core';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
@@ -221,7 +143,7 @@ export interface CreateStoreResult<Slices extends AnySlice[]> {
|
|||||||
(): UnionSliceRequests<Slices>;
|
(): UnionSliceRequests<Slices>;
|
||||||
<T>(selector: (requests: UnionSliceRequests<Slices>) => T): T;
|
<T>(selector: (requests: UnionSliceRequests<Slices>) => T): T;
|
||||||
};
|
};
|
||||||
usePending: () => PendingRecord<UnionSliceTasks<Slices>>;
|
useTasks: () => TasksRecord<UnionSliceTasks<Slices>>;
|
||||||
useMutation: <K extends keyof UnionSliceRequests<Slices>>(
|
useMutation: <K extends keyof UnionSliceRequests<Slices>>(
|
||||||
selector: (requests: UnionSliceRequests<Slices>) => UnionSliceRequests<Slices>[K]
|
selector: (requests: UnionSliceRequests<Slices>) => UnionSliceRequests<Slices>[K]
|
||||||
) => MutationResult<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`
|
**File:** `packages/store/src/react/types.ts`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export type SliceResult<S extends AnySlice>
|
export type SliceResult<S extends AnySlice> =
|
||||||
= | { state: InferSliceState<S>; request: InferSliceRequests<S>; isAvailable: true }
|
| { state: InferSliceState<S>; request: InferSliceRequests<S>; isAvailable: true }
|
||||||
| { state: null; request: null; isAvailable: false };
|
| { state: null; request: null; isAvailable: false };
|
||||||
|
|
||||||
export interface MutationResult<Request extends (...args: any[]) => any> {
|
export interface MutationResult<Request extends (...args: any[]) => any> {
|
||||||
/** Trigger the request */
|
/** 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>(store: S): InferStoreRequests<S>;
|
||||||
export function useRequest<S extends AnyStore, T>(store: S, selector: (requests: InferStoreRequests<S>) => T): T;
|
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>(
|
export function useMutation<S extends AnyStore, R extends (...args: any[]) => any>(
|
||||||
store: S,
|
store: S,
|
||||||
@@ -332,7 +254,7 @@ export function useOptimistic<S extends AnyStore, R extends (...args: any[]) =>
|
|||||||
- `useStore()`: Returns typed store from `useStoreContext()`
|
- `useStore()`: Returns typed store from `useStoreContext()`
|
||||||
- `useSelector(selector)`: Uses `useSyncExternalStore` with selector
|
- `useSelector(selector)`: Uses `useSyncExternalStore` with selector
|
||||||
- `useRequest()`: Returns stable `store.request` from context
|
- `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
|
- `useSlice(slice)`: Returns `{ state, request, isAvailable }` with null narrowing
|
||||||
|
|
||||||
### 1.6 Exports
|
### 1.6 Exports
|
||||||
@@ -344,7 +266,7 @@ export function useOptimistic<S extends AnyStore, R extends (...args: any[]) =>
|
|||||||
export { useStoreContext } from './context';
|
export { useStoreContext } from './context';
|
||||||
export { createStore } from './create-store';
|
export { createStore } from './create-store';
|
||||||
// Base hooks for testing/advanced use (all take store as first arg)
|
// 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';
|
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`
|
**File:** `packages/store/src/lit/create-store.ts`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
import type { AnySlice, InferSliceTarget, StoreConfig } from '../core';
|
||||||
import type { Context } from '@lit/context';
|
import type { Context } from '@lit/context';
|
||||||
import type { ReactiveControllerHost } from '@lit/reactive-element';
|
import type { ReactiveControllerHost } from '@lit/reactive-element';
|
||||||
import type { AnySlice, InferSliceTarget, StoreConfig } from '../core';
|
|
||||||
|
|
||||||
import { createContext } from '@lit/context';
|
import { createContext } from '@lit/context';
|
||||||
|
|
||||||
@@ -458,10 +380,10 @@ export class RequestController<S extends AnyStore, T> implements ReactiveControl
|
|||||||
get value(): T;
|
get value(): T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PendingController - like usePending(store)
|
// TasksController - like useTasks(store)
|
||||||
export class PendingController<S extends AnyStore> implements ReactiveController {
|
export class TasksController<S extends AnyStore> implements ReactiveController {
|
||||||
constructor(host: ReactiveControllerHost, store: S);
|
constructor(host: ReactiveControllerHost, store: S);
|
||||||
get value(): PendingRecord<InferStoreTasks<S>>;
|
get value(): TasksRecord<InferStoreTasks<S>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MutationController - like useMutation(store, selector)
|
// MutationController - like useMutation(store, selector)
|
||||||
@@ -500,7 +422,7 @@ export class OptimisticController<
|
|||||||
export {
|
export {
|
||||||
MutationController,
|
MutationController,
|
||||||
OptimisticController,
|
OptimisticController,
|
||||||
PendingController,
|
TasksController,
|
||||||
RequestController,
|
RequestController,
|
||||||
SelectorController,
|
SelectorController,
|
||||||
} from './controllers';
|
} from './controllers';
|
||||||
@@ -928,16 +850,16 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MyCustomControls() {
|
function MyCustomControls() {
|
||||||
const currentTime = useSelector(s => s.currentTime);
|
const currentTime = useSelector((s) => s.currentTime);
|
||||||
const seek = useRequest(r => r.seek);
|
const seek = useRequest((r) => r.seek);
|
||||||
return <button onClick={() => seek(0)}>Restart ({currentTime}s)</button>;
|
return <button onClick={() => seek(0)}>Restart ({currentTime}s)</button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// With mutation status tracking
|
// With mutation status tracking
|
||||||
function PlayButton() {
|
function PlayButton() {
|
||||||
const paused = useSelector(s => s.paused);
|
const paused = useSelector((s) => s.paused);
|
||||||
const { mutate: play, isPending } = useMutation(r => r.play);
|
const { mutate: play, isPending } = useMutation((r) => r.play);
|
||||||
const { mutate: pause } = useMutation(r => r.pause);
|
const { mutate: pause } = useMutation((r) => r.pause);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button onClick={() => (paused ? play() : pause())} disabled={isPending}>
|
<button onClick={() => (paused ? play() : pause())} disabled={isPending}>
|
||||||
@@ -949,8 +871,8 @@ function PlayButton() {
|
|||||||
// With optimistic updates
|
// With optimistic updates
|
||||||
function VolumeSlider() {
|
function VolumeSlider() {
|
||||||
const { value, setValue, isPending, isError } = useOptimistic(
|
const { value, setValue, isPending, isError } = useOptimistic(
|
||||||
r => r.changeVolume,
|
(r) => r.changeVolume,
|
||||||
s => s.volume
|
(s) => s.volume
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -958,7 +880,7 @@ function VolumeSlider() {
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={e => setValue(Number(e.target.value))}
|
onChange={(e) => setValue(Number(e.target.value))}
|
||||||
style={{ opacity: isPending ? 0.5 : 1 }}
|
style={{ opacity: isPending ? 0.5 : 1 }}
|
||||||
/>
|
/>
|
||||||
{isError && <span>Failed to change volume</span>}
|
{isError && <span>Failed to change volume</span>}
|
||||||
@@ -970,10 +892,10 @@ function VolumeSlider() {
|
|||||||
### React: Pre-created store instance (imperative access)
|
### React: Pre-created store instance (imperative access)
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { createStore, media, Video } from '@videojs/react';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { createStore, media, Video } from '@videojs/react';
|
||||||
|
|
||||||
const { Provider, create, useSelector } = createStore({
|
const { Provider, create, useSelector } = createStore({
|
||||||
slices: [media.playback],
|
slices: [media.playback],
|
||||||
});
|
});
|
||||||
@@ -1186,21 +1108,22 @@ packages/html/src/
|
|||||||
- `uniqBy`, `composeCallbacks` utilities ✓
|
- `uniqBy`, `composeCallbacks` utilities ✓
|
||||||
- `extendConfig` ✓
|
- `extendConfig` ✓
|
||||||
|
|
||||||
2. **Phase 0.5**: Queue Task Refactor
|
2. **Phase 0.5**: Queue Task Refactor **[DONE - PR #287]**
|
||||||
- Unified `tasks` map with status discriminator
|
- Unified `tasks` map with status discriminator ✓
|
||||||
- `PendingTask`, `SuccessTask`, `ErrorTask` types
|
- `PendingTask`, `SuccessTask`, `ErrorTask` types ✓
|
||||||
- `reset(key)` method
|
- `reset(key)` method ✓
|
||||||
- Update existing tests
|
- Update existing tests ✓
|
||||||
|
- Added `tryCatch` utility to `@videojs/utils/function` ✓
|
||||||
|
|
||||||
3. **Phase 1**: React Bindings (basic)
|
3. **Phase 1**: React Bindings (basic)
|
||||||
- Shared context, `useStoreContext`
|
- Shared context, `useStoreContext`
|
||||||
- `createStore()` with `inherit` prop
|
- `createStore()` with `inherit` prop
|
||||||
- `useStore`, `useSelector`, `useRequest`, `usePending`
|
- `useStore`, `useSelector`, `useRequest`, `useTasks`
|
||||||
- `Video` component, package exports
|
- `Video` component, package exports
|
||||||
|
|
||||||
4. **Phase 2**: Lit Bindings (basic)
|
4. **Phase 2**: Lit Bindings (basic)
|
||||||
- `createStore()` with mixins
|
- `createStore()` with mixins
|
||||||
- `SelectorController`, `RequestController`, `PendingController`
|
- `SelectorController`, `RequestController`, `TasksController`
|
||||||
- `@lit/context` integration
|
- `@lit/context` integration
|
||||||
|
|
||||||
5. **Phase 3**: Mutation Hooks/Controllers
|
5. **Phase 3**: Mutation Hooks/Controllers
|
||||||
@@ -1235,7 +1158,7 @@ import { readdirSync } from 'node:fs';
|
|||||||
|
|
||||||
// Dynamically gather define/ entries
|
// Dynamically gather define/ entries
|
||||||
const defineEntries = readdirSync('src/define')
|
const defineEntries = readdirSync('src/define')
|
||||||
.filter(f => f.endsWith('.ts'))
|
.filter((f) => f.endsWith('.ts'))
|
||||||
.reduce(
|
.reduce(
|
||||||
(acc, f) => {
|
(acc, f) => {
|
||||||
const name = f.replace('.ts', '');
|
const name = f.replace('.ts', '');
|
||||||
@@ -1363,23 +1286,24 @@ PR #283: Core Utilities [DONE]
|
|||||||
├── extendConfig (store/core) ✓
|
├── extendConfig (store/core) ✓
|
||||||
└── Tests ✓
|
└── Tests ✓
|
||||||
|
|
||||||
PR A: Queue Task Refactor
|
PR #287: Queue Task Refactor [DONE]
|
||||||
├── Unified Task type with status discriminator
|
├── Unified Task type with status discriminator ✓
|
||||||
├── PendingTask, SuccessTask, ErrorTask
|
├── PendingTask, SuccessTask, ErrorTask ✓
|
||||||
├── Single `tasks` map, `reset(key)` method
|
├── Single `tasks` map, `reset(key)` method ✓
|
||||||
├── Update tests
|
├── Update tests ✓
|
||||||
|
├── Added tryCatch utility ✓
|
||||||
└── Closes #285
|
└── Closes #285
|
||||||
|
|
||||||
PR B: React Bindings (basic)
|
PR B: React Bindings (basic)
|
||||||
├── createStore, Provider, useStore
|
├── createStore, Provider, useStore
|
||||||
├── useSelector, useRequest, usePending
|
├── useSelector, useRequest, useTasks
|
||||||
├── Video component
|
├── Video component
|
||||||
├── References #218
|
├── References #218
|
||||||
└── Closes #229
|
└── Closes #229
|
||||||
|
|
||||||
PR C: Lit Bindings (basic)
|
PR C: Lit Bindings (basic)
|
||||||
├── createStore with mixins
|
├── createStore with mixins
|
||||||
├── SelectorController, RequestController, PendingController
|
├── SelectorController, RequestController, TasksController
|
||||||
├── References #218
|
├── References #218
|
||||||
└── Closes #230
|
└── Closes #230
|
||||||
|
|
||||||
@@ -1409,9 +1333,9 @@ PR G: Skins
|
|||||||
### Dependency Graph
|
### Dependency Graph
|
||||||
|
|
||||||
```
|
```
|
||||||
PR #283 ───> PR A ───> PR B ───> PR D ───> PR E ───> PR G
|
PR #283 ───> PR #287 ───> PR B ───> PR D ───> PR E ───> PR G
|
||||||
└──> PR C ──────────────────────────┘
|
└──> PR C ──────────────────────────┘
|
||||||
└──> PR F ──────────────────────────┘
|
└──> PR F ──────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
PRs are sequential. PR B, C, F can technically parallel after PR A, but we'll do them sequentially for easier review.
|
PRs are sequential. PR B, C, F can technically parallel after PR A, but we'll do them sequentially for easier review.
|
||||||
|
|||||||
@@ -94,11 +94,14 @@ pnpm clean
|
|||||||
## Dev Workflow
|
## Dev Workflow
|
||||||
|
|
||||||
1. Make changes.
|
1. Make changes.
|
||||||
2. Typecheck, fix all issues.
|
2. If you added/changed **exported types** in a package, run `pnpm -F <pkg> build` first.
|
||||||
3. Run test/s, fix all issues. If there are no tests add them.
|
- `pnpm typecheck` uses TypeScript project references against **built** `.d.ts` files.
|
||||||
4. Lint file/s, fix all issues.
|
- New/changed types won't be visible until `tsdown` builds them.
|
||||||
5. Run build/s, fix all errors.
|
3. Typecheck, fix all issues.
|
||||||
6. Before creating a PR `pnpm test`.
|
4. Run test/s, fix all issues. If there are no tests add them.
|
||||||
|
5. Lint file/s, fix all issues.
|
||||||
|
6. Run build/s, fix all errors.
|
||||||
|
7. Before creating a PR `pnpm test`.
|
||||||
|
|
||||||
Be efficient when running operations, see "Common Root Commands".
|
Be efficient when running operations, see "Common Root Commands".
|
||||||
|
|
||||||
@@ -158,3 +161,78 @@ When generating or editing code in this repository, follow these rules to ensure
|
|||||||
- Use semantic commit messages (enforced by `commitlint`).
|
- Use semantic commit messages (enforced by `commitlint`).
|
||||||
- One focused change per commit—no mixed updates.
|
- One focused change per commit—no mixed updates.
|
||||||
- Breaking changes use `!`.
|
- Breaking changes use `!`.
|
||||||
|
|
||||||
|
## Code Rules
|
||||||
|
|
||||||
|
Prefer existing utilities over inline implementations:
|
||||||
|
|
||||||
|
| Instead of | Use |
|
||||||
|
| ------------------------- | ------------------------------------------------------ |
|
||||||
|
| `x === undefined` | `isUndefined(x)` from `@videojs/utils/predicate` |
|
||||||
|
| `x === null` | `isNull(x)` from `@videojs/utils/predicate` |
|
||||||
|
| `typeof x === 'function'` | `isFunction(x)` from `@videojs/utils/predicate` |
|
||||||
|
| `typeof x === 'string'` | `isString(x)` from `@videojs/utils/predicate` |
|
||||||
|
|
||||||
|
Before writing new helpers, check `@videojs/utils` for existing utilities.
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
| Pattern | Prefix | Example |
|
||||||
|
| ------------------- | ---------- | -------------------------------- |
|
||||||
|
| Type inference | `Infer*` | `InferSliceState<S>` |
|
||||||
|
| Type resolution | `Resolve*` | `ResolveRequestHandler<R>` |
|
||||||
|
| Type constraint | `Ensure*` | `EnsureTaskRecord<T>` |
|
||||||
|
| Union type helpers | `Union*` | `UnionSliceState<Slices>` |
|
||||||
|
| Default loose types | `Default*` | `DefaultTaskRecord` |
|
||||||
|
| Type guards | `is*` | `isStoreError(error)` |
|
||||||
|
| Factory functions | `create*` | `createQueue()`, `createSlice()` |
|
||||||
|
|
||||||
|
### Type Guards
|
||||||
|
|
||||||
|
Always return `value is Type` for proper type narrowing:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function isStoreError(error: unknown): error is StoreError {
|
||||||
|
return error instanceof StoreError;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subscribe Pattern
|
||||||
|
|
||||||
|
Subscriptions return an unsubscribe function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
subscribe(listener: Listener): () => void {
|
||||||
|
this.#subscribers.add(listener);
|
||||||
|
return () => this.#subscribers.delete(listener);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Key Parameter
|
||||||
|
|
||||||
|
Methods that operate on one or all items use optional key:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// If key provided: operate on that item
|
||||||
|
// If no key: operate on all items
|
||||||
|
reset(key?: keyof Tasks): void {
|
||||||
|
if (!isUndefined(key)) {
|
||||||
|
// Single item
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// All items
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Destroy Pattern
|
||||||
|
|
||||||
|
Guard re-entry, set flag first, cleanup in order:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
destroy(): void {
|
||||||
|
if (this.#destroyed) return;
|
||||||
|
this.#destroyed = true;
|
||||||
|
this.abort();
|
||||||
|
this.#subscribers.clear();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
+29
-21
@@ -263,26 +263,26 @@ const unsubscribe = store.subscribe((state) => {
|
|||||||
|
|
||||||
// Single value - only fires when volume changes
|
// Single value - only fires when volume changes
|
||||||
store.subscribe(
|
store.subscribe(
|
||||||
s => s.volume,
|
(s) => s.volume,
|
||||||
volume => console.log('Volume:', volume)
|
(volume) => console.log('Volume:', volume)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Multiple values - auto-optimized with key-based subscription
|
// Multiple values - auto-optimized with key-based subscription
|
||||||
store.subscribe(
|
store.subscribe(
|
||||||
s => ({ volume: s.volume, muted: s.muted }),
|
(s) => ({ volume: s.volume, muted: s.muted }),
|
||||||
({ volume, muted }) => updateAudioUI(volume, muted)
|
({ volume, muted }) => updateAudioUI(volume, muted)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Derived value
|
// Derived value
|
||||||
store.subscribe(
|
store.subscribe(
|
||||||
s => Math.round(s.volume * 100),
|
(s) => Math.round(s.volume * 100),
|
||||||
percent => console.log(`${percent}%`)
|
(percent) => console.log(`${percent}%`)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Custom equality function
|
// Custom equality function
|
||||||
store.subscribe(
|
store.subscribe(
|
||||||
s => s.playlist,
|
(s) => s.playlist,
|
||||||
playlist => renderPlaylist(playlist),
|
(playlist) => renderPlaylist(playlist),
|
||||||
{ equalityFn: shallowEqual }
|
{ equalityFn: shallowEqual }
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
@@ -542,17 +542,17 @@ const store = createStore({
|
|||||||
],
|
],
|
||||||
queue: createQueue({
|
queue: createQueue({
|
||||||
// Default scheduler for requests without schedule
|
// Default scheduler for requests without schedule
|
||||||
scheduler: flush => queueMicrotask(flush),
|
scheduler: (flush) => queueMicrotask(flush),
|
||||||
|
|
||||||
// Lifecycle hooks
|
// Lifecycle hooks
|
||||||
onDispatch: (request) => {
|
onDispatch: (request) => {
|
||||||
console.log('Started:', request.name);
|
console.log('Started:', request.name);
|
||||||
},
|
},
|
||||||
|
|
||||||
onSettled: (request, { status, duration }) => {
|
onSettled: (task) => {
|
||||||
analytics.track(request.name, {
|
analytics.track(task.name, {
|
||||||
status,
|
status: task.status,
|
||||||
duration,
|
duration: task.settledAt - task.startedAt,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -564,21 +564,29 @@ const store = createStore({
|
|||||||
```ts
|
```ts
|
||||||
const queue = store.queue; // accessed on the store
|
const queue = store.queue; // accessed on the store
|
||||||
|
|
||||||
queue.queued; // object of requests waiting to execute
|
queue.queued; // tasks waiting to execute
|
||||||
queue.pending; // object of requests currently executing
|
queue.tasks; // task lifecycle map (pending/success/error)
|
||||||
|
|
||||||
// Check if a task is pending or queued
|
// Check task status
|
||||||
queue.isPending('playback'); // true if currently executing
|
queue.isPending('playback'); // true if currently executing
|
||||||
queue.isQueued('seek'); // true if waiting to execute
|
queue.isQueued('seek'); // true if waiting to execute
|
||||||
|
queue.isSettled('seek'); // true if completed (success or error)
|
||||||
|
|
||||||
queue.dequeue('seek'); // remove from queue without executing
|
// Cancel queued tasks (waiting to execute)
|
||||||
queue.clear(); // clear all queued
|
queue.cancel('seek'); // cancel specific
|
||||||
|
queue.cancel(); // cancel all queued
|
||||||
|
|
||||||
|
// Abort tasks (queued + executing)
|
||||||
|
queue.abort('playback'); // abort specific
|
||||||
|
queue.abort(); // abort all
|
||||||
|
|
||||||
|
// Clear settled tasks (success/error results)
|
||||||
|
queue.reset('seek'); // clear specific
|
||||||
|
queue.reset(); // clear all settled
|
||||||
|
|
||||||
|
// Execute queued tasks immediately
|
||||||
queue.flush(); // execute all queued now
|
queue.flush(); // execute all queued now
|
||||||
queue.flush('playback'); // execute specific key now
|
queue.flush('playback'); // execute specific key now
|
||||||
|
|
||||||
queue.abort('playback'); // abort executing request
|
|
||||||
queue.abortAll(); // abort all executing
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Direct Queue Usage
|
### Direct Queue Usage
|
||||||
@@ -620,7 +628,7 @@ const store = createStore({
|
|||||||
slices: [
|
slices: [
|
||||||
/* ... */
|
/* ... */
|
||||||
],
|
],
|
||||||
state: initial => new VueStateAdapter(initial),
|
state: (initial) => new VueStateAdapter(initial),
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Request, RequestMeta } from './request';
|
import type { Request, RequestMeta } from './request';
|
||||||
|
|
||||||
|
import { tryCatch } from '@videojs/utils/function';
|
||||||
import { isFunction, isUndefined } from '@videojs/utils/predicate';
|
import { isFunction, isUndefined } from '@videojs/utils/predicate';
|
||||||
|
|
||||||
import { StoreError } from './errors';
|
import { StoreError } from './errors';
|
||||||
@@ -37,18 +38,62 @@ export type DefaultTaskRecord = Record<TaskKey, Request<unknown, unknown>>;
|
|||||||
export type EnsureTaskRecord<T> = T extends TaskRecord ? T : never;
|
export type EnsureTaskRecord<T> = T extends TaskRecord ? T : never;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pending task info.
|
* Base fields shared by all task states.
|
||||||
*/
|
*/
|
||||||
export interface PendingTask<Key extends TaskKey = TaskKey, Input = unknown> {
|
export interface TaskBase<Key extends TaskKey = TaskKey, Input = unknown> {
|
||||||
id: symbol;
|
id: symbol;
|
||||||
name: string;
|
name: string;
|
||||||
key: Key;
|
key: Key;
|
||||||
input: Input;
|
input: Input;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
abort: AbortController;
|
|
||||||
meta: RequestMeta | null;
|
meta: RequestMeta | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending task - request in flight.
|
||||||
|
*/
|
||||||
|
export interface PendingTask<Key extends TaskKey = TaskKey, Input = unknown> extends TaskBase<Key, Input> {
|
||||||
|
status: 'pending';
|
||||||
|
abort: AbortController;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success task - completed successfully.
|
||||||
|
*/
|
||||||
|
export interface SuccessTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown> extends TaskBase<
|
||||||
|
Key,
|
||||||
|
Input
|
||||||
|
> {
|
||||||
|
status: 'success';
|
||||||
|
settledAt: number;
|
||||||
|
output: Output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error task - failed or cancelled.
|
||||||
|
*/
|
||||||
|
export interface ErrorTask<Key extends TaskKey = TaskKey, Input = unknown> extends TaskBase<Key, Input> {
|
||||||
|
status: 'error';
|
||||||
|
settledAt: number;
|
||||||
|
error: unknown;
|
||||||
|
cancelled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task with status discriminator.
|
||||||
|
*/
|
||||||
|
export type Task<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown>
|
||||||
|
= | PendingTask<Key, Input>
|
||||||
|
| SuccessTask<Key, Input, Output>
|
||||||
|
| ErrorTask<Key, Input>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settled task (success or error).
|
||||||
|
*/
|
||||||
|
export type SettledTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown>
|
||||||
|
= | SuccessTask<Key, Input, Output>
|
||||||
|
| ErrorTask<Key, Input>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context passed to task handler.
|
* Context passed to task handler.
|
||||||
*/
|
*/
|
||||||
@@ -90,13 +135,7 @@ export interface QueueConfig<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
/** Default scheduler when task has no schedule */
|
/** Default scheduler when task has no schedule */
|
||||||
scheduler?: TaskScheduler;
|
scheduler?: TaskScheduler;
|
||||||
onDispatch?: <K extends keyof Tasks>(task: PendingTask<TaskKey<K>, Tasks[K]['input']>) => void;
|
onDispatch?: <K extends keyof Tasks>(task: PendingTask<TaskKey<K>, Tasks[K]['input']>) => void;
|
||||||
onSettled?: <K extends keyof Tasks>(
|
onSettled?: <K extends keyof Tasks>(task: SettledTask<TaskKey<K>, Tasks[K]['input'], Tasks[K]['output']>) => void;
|
||||||
task: PendingTask<TaskKey<K>, Tasks[K]['input']>,
|
|
||||||
result:
|
|
||||||
| { status: 'success'; duration: number; output: Tasks[K]['output'] }
|
|
||||||
| { status: 'cancelled'; error: unknown; duration: number }
|
|
||||||
| { status: 'error'; error: unknown; duration: number },
|
|
||||||
) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueuedTaskId<Key extends TaskKey = TaskKey> {
|
export interface QueuedTaskId<Key extends TaskKey = TaskKey> {
|
||||||
@@ -112,16 +151,19 @@ export type QueuedRecord<Tasks extends TaskRecord> = {
|
|||||||
[K in keyof Tasks]?: QueuedTask<TaskKey<K>>;
|
[K in keyof Tasks]?: QueuedTask<TaskKey<K>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PendingRecord<Tasks extends TaskRecord> = {
|
/**
|
||||||
[K in keyof Tasks]?: PendingTask<TaskKey<K>, Tasks[K]['input']>;
|
* Map of task key -> task (pending, success, or error).
|
||||||
|
*/
|
||||||
|
export type TasksRecord<Tasks extends TaskRecord> = {
|
||||||
|
[K in keyof Tasks]?: Task<TaskKey<K>, Tasks[K]['input'], Tasks[K]['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listener callback for pending state changes.
|
* Listener callback for task state changes.
|
||||||
*
|
*
|
||||||
* Called when tasks are dispatched or settled.
|
* Called when tasks are dispatched, settled, or reset.
|
||||||
*/
|
*/
|
||||||
export type QueueListener<Tasks extends TaskRecord> = (pending: PendingRecord<Tasks>) => void;
|
export type QueueListener<Tasks extends TaskRecord> = (tasks: TasksRecord<Tasks>) => void;
|
||||||
|
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// Schedulers
|
// Schedulers
|
||||||
@@ -168,36 +210,27 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
readonly #subscribers = new Set<QueueListener<Tasks>>();
|
readonly #subscribers = new Set<QueueListener<Tasks>>();
|
||||||
|
|
||||||
#queued: QueuedRecord<Tasks> = {};
|
#queued: QueuedRecord<Tasks> = {};
|
||||||
#pending: PendingRecord<Tasks> = {};
|
#tasks: TasksRecord<Tasks> = {};
|
||||||
#destroyed = false;
|
#destroyed = false;
|
||||||
|
|
||||||
constructor(config: QueueConfig<Tasks> = {}) {
|
constructor(config: QueueConfig<Tasks> = {}) {
|
||||||
this.#scheduler = config.scheduler ?? microtask;
|
this.#scheduler = config.scheduler ?? microtask;
|
||||||
|
|
||||||
// Wrap callbacks to catch errors and prevent breaking queue/scheduler
|
// Wrap callbacks to catch errors and prevent breaking queue/scheduler
|
||||||
const safeCallback = <Args extends [PendingTask, ...unknown[]]>(
|
const logError = (e: unknown) => console.error('[vjs-queue]', e);
|
||||||
callback: ((...args: Args) => unknown) | undefined,
|
this.#onDispatch = tryCatch(config.onDispatch, logError);
|
||||||
) => {
|
this.#onSettled = tryCatch(config.onSettled, logError);
|
||||||
if (!callback) return undefined;
|
|
||||||
return (...args: Args) => {
|
|
||||||
try {
|
|
||||||
callback(...args);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[vjs-queue]', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
this.#onDispatch = safeCallback(config.onDispatch);
|
|
||||||
this.#onSettled = safeCallback(config.onSettled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get queued(): Readonly<PublicQueuedRecord<Tasks>> {
|
get queued(): Readonly<PublicQueuedRecord<Tasks>> {
|
||||||
return Object.freeze({ ...this.#queued });
|
return Object.freeze({ ...this.#queued });
|
||||||
}
|
}
|
||||||
|
|
||||||
get pending(): Readonly<PendingRecord<Tasks>> {
|
/**
|
||||||
return Object.freeze({ ...this.#pending });
|
* Map of task key -> task (pending, success, or error).
|
||||||
|
*/
|
||||||
|
get tasks(): Readonly<TasksRecord<Tasks>> {
|
||||||
|
return Object.freeze({ ...this.#tasks });
|
||||||
}
|
}
|
||||||
|
|
||||||
get destroyed(): boolean {
|
get destroyed(): boolean {
|
||||||
@@ -208,7 +241,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
* Check if a task with the given key is currently pending (executing).
|
* Check if a task with the given key is currently pending (executing).
|
||||||
*/
|
*/
|
||||||
isPending(key: keyof Tasks): boolean {
|
isPending(key: keyof Tasks): boolean {
|
||||||
return key in this.#pending;
|
return this.#tasks[key]?.status === 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,11 +252,53 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to pending state changes.
|
* Check if a task with the given key is settled (success or error).
|
||||||
|
*/
|
||||||
|
isSettled(key: keyof Tasks): boolean {
|
||||||
|
const task = this.#tasks[key];
|
||||||
|
return task?.status === 'success' || task?.status === 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear settled task(s).
|
||||||
*
|
*
|
||||||
* Fires when tasks are dispatched or settled.
|
* - If key provided: clears that specific settled task (no-op if pending or doesn't exist)
|
||||||
|
* - If no key: clears all settled tasks (pending tasks are preserved)
|
||||||
*
|
*
|
||||||
* @param listener - Callback receiving the current pending map
|
* @param key - Optional task key to reset. If omitted, resets all settled tasks.
|
||||||
|
*/
|
||||||
|
reset(key?: keyof Tasks): void {
|
||||||
|
if (!isUndefined(key)) {
|
||||||
|
const task = this.#tasks[key];
|
||||||
|
if (!task || task.status === 'pending') return;
|
||||||
|
|
||||||
|
delete this.#tasks[key];
|
||||||
|
this.#notifySubscribers();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset all settled tasks
|
||||||
|
let cleared = false;
|
||||||
|
for (const key of Reflect.ownKeys(this.#tasks)) {
|
||||||
|
const task = this.#tasks[key];
|
||||||
|
if (task && task.status !== 'pending') {
|
||||||
|
delete this.#tasks[key];
|
||||||
|
cleared = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleared) {
|
||||||
|
this.#notifySubscribers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to task state changes.
|
||||||
|
*
|
||||||
|
* Fires when tasks are dispatched, settled, or reset.
|
||||||
|
*
|
||||||
|
* @param listener - Callback receiving the current tasks map
|
||||||
* @returns Unsubscribe function
|
* @returns Unsubscribe function
|
||||||
*/
|
*/
|
||||||
subscribe(listener: QueueListener<Tasks>): () => void {
|
subscribe(listener: QueueListener<Tasks>): () => void {
|
||||||
@@ -236,7 +311,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
#notifySubscribers(): void {
|
#notifySubscribers(): void {
|
||||||
if (this.#subscribers.size === 0) return;
|
if (this.#subscribers.size === 0) return;
|
||||||
|
|
||||||
const snapshot = this.pending;
|
const snapshot = this.tasks;
|
||||||
for (const listener of this.#subscribers) {
|
for (const listener of this.#subscribers) {
|
||||||
try {
|
try {
|
||||||
listener(snapshot);
|
listener(snapshot);
|
||||||
@@ -262,7 +337,13 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
delete this.#queued[key];
|
delete this.#queued[key];
|
||||||
|
|
||||||
// Abort any pending task with same key
|
// Abort any pending task with same key
|
||||||
this.#pending[key]?.abort.abort(new StoreError('SUPERSEDED'));
|
const existing = this.#tasks[key];
|
||||||
|
if (existing?.status === 'pending') {
|
||||||
|
existing.abort.abort(new StoreError('SUPERSEDED'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any settled task for this key (new request replaces it)
|
||||||
|
delete this.#tasks[key];
|
||||||
|
|
||||||
return new Promise<Tasks[K]['output']>((resolve, reject) => {
|
return new Promise<Tasks[K]['output']>((resolve, reject) => {
|
||||||
const task: QueuedTask = {
|
const task: QueuedTask = {
|
||||||
@@ -307,24 +388,37 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
dequeue<K extends keyof Tasks>(key: K): boolean {
|
/**
|
||||||
const queued = this.#queued[key];
|
* Cancel queued task(s) waiting to execute.
|
||||||
if (!queued) return false;
|
*
|
||||||
|
* - If key provided: cancels that specific queued task
|
||||||
|
* - If no key: cancels all queued tasks
|
||||||
|
*
|
||||||
|
* @param key - Optional task key to cancel
|
||||||
|
* @returns true if any task was cancelled
|
||||||
|
*/
|
||||||
|
cancel(key?: keyof Tasks): boolean {
|
||||||
|
if (!isUndefined(key)) {
|
||||||
|
const queued = this.#queued[key];
|
||||||
|
if (!queued) return false;
|
||||||
|
|
||||||
queued.invalidate?.();
|
queued.invalidate?.();
|
||||||
queued.reject(new StoreError('REMOVED'));
|
queued.reject(new StoreError('REMOVED'));
|
||||||
delete this.#queued[key];
|
delete this.#queued[key];
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
// Cancel all queued
|
||||||
|
const hadQueued = Object.keys(this.#queued).length > 0;
|
||||||
for (const queued of Object.values(this.#queued)) {
|
for (const queued of Object.values(this.#queued)) {
|
||||||
queued.invalidate?.();
|
queued.invalidate?.();
|
||||||
queued.reject(new StoreError('REMOVED'));
|
queued.reject(new StoreError('REMOVED'));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#queued = {};
|
this.#queued = {};
|
||||||
|
|
||||||
|
return hadQueued;
|
||||||
}
|
}
|
||||||
|
|
||||||
async flush(key?: keyof Tasks): Promise<void> {
|
async flush(key?: keyof Tasks): Promise<void> {
|
||||||
@@ -338,18 +432,32 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
await Promise.allSettled(keys.map(k => this.#flushKey(k)));
|
await Promise.allSettled(keys.map(k => this.#flushKey(k)));
|
||||||
}
|
}
|
||||||
|
|
||||||
abort<K extends keyof Tasks>(key: K): void {
|
/**
|
||||||
// Reject queued
|
* Abort task(s) - both queued (waiting) and pending (executing).
|
||||||
const queued = this.#queued[key];
|
*
|
||||||
queued?.invalidate?.();
|
* - If key provided: aborts that specific task
|
||||||
queued?.reject(new StoreError('ABORTED'));
|
* - If no key: aborts all tasks
|
||||||
delete this.#queued[key];
|
*
|
||||||
|
* @param key - Optional task key to abort
|
||||||
|
*/
|
||||||
|
abort(key?: keyof Tasks): void {
|
||||||
|
if (!isUndefined(key)) {
|
||||||
|
// Reject queued
|
||||||
|
const queued = this.#queued[key];
|
||||||
|
queued?.invalidate?.();
|
||||||
|
queued?.reject(new StoreError('ABORTED'));
|
||||||
|
delete this.#queued[key];
|
||||||
|
|
||||||
// Abort pending
|
// Abort pending task
|
||||||
this.#pending[key]?.abort.abort(new StoreError('ABORTED'));
|
const task = this.#tasks[key];
|
||||||
}
|
if (task?.status === 'pending') {
|
||||||
|
task.abort.abort(new StoreError('ABORTED'));
|
||||||
|
}
|
||||||
|
|
||||||
abortAll(): void {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort all
|
||||||
const error = new StoreError('ABORTED');
|
const error = new StoreError('ABORTED');
|
||||||
|
|
||||||
// Reject all queued
|
// Reject all queued
|
||||||
@@ -360,9 +468,11 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
|
|
||||||
this.#queued = {};
|
this.#queued = {};
|
||||||
|
|
||||||
// Abort all pending
|
// Abort all pending tasks
|
||||||
for (const pending of Object.values(this.#pending)) {
|
for (const task of Object.values(this.#tasks)) {
|
||||||
pending.abort.abort(error);
|
if (task?.status === 'pending') {
|
||||||
|
task.abort.abort(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,8 +480,9 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
if (this.#destroyed) return;
|
if (this.#destroyed) return;
|
||||||
|
|
||||||
this.#destroyed = true;
|
this.#destroyed = true;
|
||||||
this.abortAll();
|
this.abort();
|
||||||
this.#subscribers.clear();
|
this.#subscribers.clear();
|
||||||
|
this.#tasks = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async #flushKey(key: keyof Tasks): Promise<void> {
|
async #flushKey(key: keyof Tasks): Promise<void> {
|
||||||
@@ -393,7 +504,8 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
||||||
const pending: PendingTask = {
|
const pendingTask: PendingTask = {
|
||||||
|
status: 'pending',
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
key,
|
key,
|
||||||
@@ -403,9 +515,9 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
meta,
|
meta,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.#pending[key as keyof Tasks] = pending;
|
this.#tasks[key as keyof Tasks] = pendingTask;
|
||||||
this.#notifySubscribers();
|
this.#notifySubscribers();
|
||||||
this.#onDispatch?.(pending);
|
this.#onDispatch?.(pendingTask);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (abort.signal.aborted) {
|
if (abort.signal.aborted) {
|
||||||
@@ -420,28 +532,38 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
|||||||
|
|
||||||
resolve(result);
|
resolve(result);
|
||||||
|
|
||||||
this.#onSettled?.(pending, {
|
const successTask: SuccessTask = {
|
||||||
|
...pendingTask,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
duration: Date.now() - startedAt,
|
settledAt: Date.now(),
|
||||||
output: result,
|
output: result,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Only update if we're still the current task for this key
|
||||||
|
if (this.#tasks[key as keyof Tasks] === pendingTask) {
|
||||||
|
this.#tasks[key as keyof Tasks] = successTask;
|
||||||
|
this.#notifySubscribers();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#onSettled?.(successTask);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
|
|
||||||
const cancelled = abort.signal.aborted;
|
const errorTask: ErrorTask = {
|
||||||
|
...pendingTask,
|
||||||
this.#onSettled?.(pending, {
|
status: 'error',
|
||||||
status: cancelled ? 'cancelled' : 'error',
|
settledAt: Date.now(),
|
||||||
duration: Date.now() - startedAt,
|
|
||||||
error,
|
error,
|
||||||
});
|
cancelled: abort.signal.aborted,
|
||||||
} finally {
|
};
|
||||||
const currentPending = this.#pending[key as keyof Tasks];
|
|
||||||
// Only remove if we're still the pending task for this key
|
// Only update if we're still the current task for this key
|
||||||
if (currentPending === pending) {
|
if (this.#tasks[key as keyof Tasks] === pendingTask) {
|
||||||
delete this.#pending[key];
|
this.#tasks[key as keyof Tasks] = errorTask;
|
||||||
this.#notifySubscribers();
|
this.#notifySubscribers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.#onSettled?.(errorTask);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PendingTask, TaskContext } from './queue';
|
import type { PendingTask, Task, TaskContext } from './queue';
|
||||||
import type { RequestMeta, RequestMetaInit, ResolvedRequestConfig } from './request';
|
import type { RequestMeta, RequestMetaInit, ResolvedRequestConfig } from './request';
|
||||||
import type { AnySlice, InferSliceTarget, Slice, UnionSliceRequests, UnionSliceState, UnionSliceTasks } from './slice';
|
import type { AnySlice, InferSliceTarget, Slice, UnionSliceRequests, UnionSliceState, UnionSliceTasks } from './slice';
|
||||||
import type { StateFactory } from './state';
|
import type { StateFactory } from './state';
|
||||||
@@ -138,7 +138,7 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
|
|||||||
this.#attachAbort?.abort();
|
this.#attachAbort?.abort();
|
||||||
this.#attachAbort = null;
|
this.#attachAbort = null;
|
||||||
this.#target = null;
|
this.#target = null;
|
||||||
this.#queue.abortAll();
|
this.#queue.abort();
|
||||||
this.#resetState();
|
this.#resetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,10 +318,11 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
|
|||||||
handler,
|
handler,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const pending = this.#queue.pending as Record<string | symbol, PendingTask | undefined>;
|
const tasks = this.#queue.tasks as Record<string | symbol, Task | undefined>;
|
||||||
|
const task = tasks[key];
|
||||||
|
|
||||||
this.#handleError({
|
this.#handleError({
|
||||||
request: pending[key],
|
request: task?.status === 'pending' ? task : undefined,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -163,8 +163,8 @@ describe('queue', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dequeue', () => {
|
describe('cancel', () => {
|
||||||
it('removes queued task', async () => {
|
it('cancel(key) removes specific queued task', async () => {
|
||||||
const queue = createQueue({
|
const queue = createQueue({
|
||||||
scheduler: delay(100),
|
scheduler: delay(100),
|
||||||
});
|
});
|
||||||
@@ -172,29 +172,33 @@ describe('queue', () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
const promise = queue.enqueue({ name: 'test', key: 'k', handler });
|
const promise = queue.enqueue({ name: 'test', key: 'k', handler });
|
||||||
|
|
||||||
expect(queue.dequeue('k')).toBe(true);
|
expect(queue.cancel('k')).toBe(true);
|
||||||
expect(queue.dequeue('k')).toBe(false);
|
expect(queue.cancel('k')).toBe(false);
|
||||||
|
|
||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
await expect(promise).rejects.toMatchObject({ code: 'REMOVED' });
|
await expect(promise).rejects.toMatchObject({ code: 'REMOVED' });
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('clear', () => {
|
it('cancel() clears all queued tasks', async () => {
|
||||||
it('clears all queued tasks', async () => {
|
|
||||||
const queue = createQueue({ scheduler: delay(100) });
|
const queue = createQueue({ scheduler: delay(100) });
|
||||||
|
|
||||||
const p1 = queue.enqueue({ name: 'a', key: 'a', handler: vi.fn() });
|
const p1 = queue.enqueue({ name: 'a', key: 'a', handler: vi.fn() });
|
||||||
const p2 = queue.enqueue({ name: 'b', key: 'b', handler: vi.fn() });
|
const p2 = queue.enqueue({ name: 'b', key: 'b', handler: vi.fn() });
|
||||||
|
|
||||||
queue.clear();
|
expect(queue.cancel()).toBe(true);
|
||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
await expect(p1).rejects.toMatchObject({ code: 'REMOVED' });
|
await expect(p1).rejects.toMatchObject({ code: 'REMOVED' });
|
||||||
await expect(p2).rejects.toMatchObject({ code: 'REMOVED' });
|
await expect(p2).rejects.toMatchObject({ code: 'REMOVED' });
|
||||||
expect(Reflect.ownKeys(queue.queued).length).toBe(0);
|
expect(Reflect.ownKeys(queue.queued).length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cancel() returns false when no queued tasks', () => {
|
||||||
|
const queue = createQueue();
|
||||||
|
|
||||||
|
expect(queue.cancel()).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('flush', () => {
|
describe('flush', () => {
|
||||||
@@ -280,7 +284,7 @@ describe('queue', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('onSettled called with success', async () => {
|
it('onSettled called with success task', async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
||||||
const onSettled = vi.fn();
|
const onSettled = vi.fn();
|
||||||
@@ -293,12 +297,11 @@ describe('queue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(onSettled).toHaveBeenCalledWith(
|
expect(onSettled).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ name: 'task' }),
|
expect.objectContaining({ name: 'task', status: 'success', output: 'done' }),
|
||||||
expect.objectContaining({ status: 'success' }),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('onSettled called with error status', async () => {
|
it('onSettled called with error task', async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
||||||
const onSettled = vi.fn();
|
const onSettled = vi.fn();
|
||||||
@@ -313,10 +316,10 @@ describe('queue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow('oops');
|
await expect(promise).rejects.toThrow('oops');
|
||||||
expect(onSettled).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: 'error' }));
|
expect(onSettled).toHaveBeenCalledWith(expect.objectContaining({ status: 'error', cancelled: false }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('onSettled called with cancelled status', async () => {
|
it('onSettled called with cancelled task', async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
||||||
const onSettled = vi.fn();
|
const onSettled = vi.fn();
|
||||||
@@ -339,8 +342,7 @@ describe('queue', () => {
|
|||||||
await promise.catch(() => {});
|
await promise.catch(() => {});
|
||||||
|
|
||||||
expect(onSettled).toHaveBeenCalledWith(
|
expect(onSettled).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ name: 'first' }),
|
expect.objectContaining({ name: 'first', status: 'error', cancelled: true }),
|
||||||
expect.objectContaining({ status: 'cancelled' }),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -436,7 +438,7 @@ describe('queue', () => {
|
|||||||
|
|
||||||
// Wait for task to start
|
// Wait for task to start
|
||||||
await new Promise(r => setTimeout(r, 10));
|
await new Promise(r => setTimeout(r, 10));
|
||||||
expect(Reflect.ownKeys(queue.pending).length).toBe(1);
|
expect(queue.tasks.k?.status).toBe('pending');
|
||||||
|
|
||||||
// Destroy queue
|
// Destroy queue
|
||||||
queue.destroy();
|
queue.destroy();
|
||||||
@@ -446,8 +448,8 @@ describe('queue', () => {
|
|||||||
expect(cleanupSpy).toHaveBeenCalledWith('aborted');
|
expect(cleanupSpy).toHaveBeenCalledWith('aborted');
|
||||||
expect(cleanupSpy).toHaveBeenCalledWith('cleanup');
|
expect(cleanupSpy).toHaveBeenCalledWith('cleanup');
|
||||||
|
|
||||||
// Pending object should be empty (self-cleaned)
|
// After destroy, all tasks are cleared for memory cleanup
|
||||||
expect(Reflect.ownKeys(queue.pending).length).toBe(0);
|
expect(queue.tasks.k).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles scheduler error without double-cleanup when already flushed', async () => {
|
it('handles scheduler error without double-cleanup when already flushed', async () => {
|
||||||
@@ -575,7 +577,7 @@ describe('queue', () => {
|
|||||||
expect(listener).toHaveBeenCalledTimes(2);
|
expect(listener).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('notifies with pending map when task dispatches', async () => {
|
it('notifies with tasks map on dispatch and settlement', async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
||||||
const queue = createQueue();
|
const queue = createQueue();
|
||||||
@@ -602,18 +604,19 @@ describe('queue', () => {
|
|||||||
|
|
||||||
// First call should have pending task
|
// First call should have pending task
|
||||||
expect(listener).toHaveBeenCalledTimes(1);
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
const pendingObj = listener.mock.calls[0]![0] as Record<string, unknown>;
|
const pendingSnapshot = listener.mock.calls[0]![0] as Record<string, { status: string }>;
|
||||||
expect(Reflect.ownKeys(pendingObj).length).toBe(1);
|
expect(Reflect.ownKeys(pendingSnapshot).length).toBe(1);
|
||||||
expect('test-key' in pendingObj).toBe(true);
|
expect(pendingSnapshot['test-key']?.status).toBe('pending');
|
||||||
|
|
||||||
// Complete the handler
|
// Complete the handler
|
||||||
resolveHandler!();
|
resolveHandler!();
|
||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
// Second call should have empty pending
|
// Second call should have settled task (success)
|
||||||
expect(listener).toHaveBeenCalledTimes(2);
|
expect(listener).toHaveBeenCalledTimes(2);
|
||||||
const settledObj = listener.mock.calls[1]![0] as Record<string, unknown>;
|
const settledSnapshot = listener.mock.calls[1]![0] as Record<string, { status: string }>;
|
||||||
expect(Reflect.ownKeys(settledObj).length).toBe(0);
|
expect(Reflect.ownKeys(settledSnapshot).length).toBe(1);
|
||||||
|
expect(settledSnapshot['test-key']?.status).toBe('success');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unsubscribe stops notifications', async () => {
|
it('unsubscribe stops notifications', async () => {
|
||||||
@@ -683,15 +686,15 @@ describe('queue', () => {
|
|||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('provides strongly typed pending object', async () => {
|
it('provides strongly typed tasks object', async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
||||||
// Use default queue - type safety is validated at compile time
|
// Use default queue - type safety is validated at compile time
|
||||||
const queue = createQueue();
|
const queue = createQueue();
|
||||||
|
|
||||||
queue.subscribe((pending) => {
|
queue.subscribe((tasks) => {
|
||||||
// Pending is a frozen object
|
// Tasks is a frozen object
|
||||||
const task = pending.playback;
|
const task = tasks.playback;
|
||||||
if (task) {
|
if (task) {
|
||||||
expect(task.key).toBe('playback');
|
expect(task.key).toBe('playback');
|
||||||
expect(task.name).toBeDefined();
|
expect(task.name).toBeDefined();
|
||||||
@@ -705,5 +708,406 @@ describe('queue', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('task lifecycle', () => {
|
||||||
|
it('task starts as pending and transitions to success', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
|
||||||
|
const promise = queue.enqueue({
|
||||||
|
name: 'task',
|
||||||
|
key: 'k',
|
||||||
|
handler: async () => {
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
return 'result';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task should be pending
|
||||||
|
await new Promise(r => setTimeout(r, 5));
|
||||||
|
const pendingTask = queue.tasks.k;
|
||||||
|
expect(pendingTask?.status).toBe('pending');
|
||||||
|
expect(pendingTask?.name).toBe('task');
|
||||||
|
|
||||||
|
// Wait for completion
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
// Task should be success
|
||||||
|
const successTask = queue.tasks.k;
|
||||||
|
expect(successTask?.status).toBe('success');
|
||||||
|
if (successTask?.status === 'success') {
|
||||||
|
expect(successTask.output).toBe('result');
|
||||||
|
expect(successTask.settledAt).toBeGreaterThan(successTask.startedAt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('task starts as pending and transitions to error', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
const error = new Error('test error');
|
||||||
|
|
||||||
|
const promise = queue.enqueue({
|
||||||
|
name: 'task',
|
||||||
|
key: 'k',
|
||||||
|
handler: async () => {
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task should be pending
|
||||||
|
await new Promise(r => setTimeout(r, 5));
|
||||||
|
expect(queue.tasks.k?.status).toBe('pending');
|
||||||
|
|
||||||
|
// Wait for failure
|
||||||
|
await expect(promise).rejects.toThrow('test error');
|
||||||
|
|
||||||
|
// Task should be error
|
||||||
|
const errorTask = queue.tasks.k;
|
||||||
|
expect(errorTask?.status).toBe('error');
|
||||||
|
if (errorTask?.status === 'error') {
|
||||||
|
expect(errorTask.error).toBe(error);
|
||||||
|
expect(errorTask.cancelled).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborted task has cancelled flag set to true', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
|
||||||
|
const promise = queue.enqueue({
|
||||||
|
name: 'task',
|
||||||
|
key: 'k',
|
||||||
|
handler: async ({ signal }) => {
|
||||||
|
await new Promise((_, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(signal.reason));
|
||||||
|
setTimeout(() => {}, 1000);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for task to start
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
expect(queue.tasks.k?.status).toBe('pending');
|
||||||
|
|
||||||
|
// Abort the task
|
||||||
|
queue.abort('k');
|
||||||
|
await promise.catch(() => {});
|
||||||
|
|
||||||
|
// Task should be error with cancelled=true
|
||||||
|
const errorTask = queue.tasks.k;
|
||||||
|
expect(errorTask?.status).toBe('error');
|
||||||
|
if (errorTask?.status === 'error') {
|
||||||
|
expect(errorTask.cancelled).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('new request replaces settled task', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
|
||||||
|
// First request
|
||||||
|
await queue.enqueue({
|
||||||
|
name: 'first',
|
||||||
|
key: 'k',
|
||||||
|
handler: async () => 'first-result',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queue.tasks.k?.status).toBe('success');
|
||||||
|
if (queue.tasks.k?.status === 'success') {
|
||||||
|
expect(queue.tasks.k.output).toBe('first-result');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second request replaces settled task
|
||||||
|
await queue.enqueue({
|
||||||
|
name: 'second',
|
||||||
|
key: 'k',
|
||||||
|
handler: async () => 'second-result',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queue.tasks.k?.status).toBe('success');
|
||||||
|
if (queue.tasks.k?.status === 'success') {
|
||||||
|
expect(queue.tasks.k.output).toBe('second-result');
|
||||||
|
expect(queue.tasks.k.name).toBe('second');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('clears settled task', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
|
||||||
|
await queue.enqueue({
|
||||||
|
name: 'task',
|
||||||
|
key: 'k',
|
||||||
|
handler: async () => 'result',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queue.tasks.k?.status).toBe('success');
|
||||||
|
|
||||||
|
queue.reset('k');
|
||||||
|
|
||||||
|
expect(queue.tasks.k).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is no-op when task is pending', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
|
||||||
|
const promise = queue.enqueue({
|
||||||
|
name: 'task',
|
||||||
|
key: 'k',
|
||||||
|
handler: async () => {
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
return 'result';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for task to start
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
expect(queue.tasks.k?.status).toBe('pending');
|
||||||
|
|
||||||
|
// Reset should be no-op
|
||||||
|
queue.reset('k');
|
||||||
|
expect(queue.tasks.k?.status).toBe('pending');
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is no-op when task does not exist', () => {
|
||||||
|
const queue = createQueue();
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
queue.reset('nonexistent');
|
||||||
|
|
||||||
|
expect(queue.tasks.nonexistent).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies subscribers when reset clears a task', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
await queue.enqueue({
|
||||||
|
name: 'task',
|
||||||
|
key: 'k',
|
||||||
|
handler: async () => 'result',
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.subscribe(listener);
|
||||||
|
|
||||||
|
queue.reset('k');
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
const snapshot = listener.mock.calls[0]![0] as Record<string, unknown>;
|
||||||
|
expect(snapshot.k).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not notify subscribers when task does not exist', () => {
|
||||||
|
const queue = createQueue();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
queue.subscribe(listener);
|
||||||
|
queue.reset('nonexistent');
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets all settled tasks when no key provided', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
|
||||||
|
// Create multiple settled tasks
|
||||||
|
await queue.enqueue({ name: 'a', key: 'a', handler: async () => 'a-result' });
|
||||||
|
await queue.enqueue({ name: 'b', key: 'b', handler: async () => 'b-result' });
|
||||||
|
|
||||||
|
expect(queue.tasks.a?.status).toBe('success');
|
||||||
|
expect(queue.tasks.b?.status).toBe('success');
|
||||||
|
|
||||||
|
// Reset all
|
||||||
|
queue.reset();
|
||||||
|
|
||||||
|
expect(queue.tasks.a).toBeUndefined();
|
||||||
|
expect(queue.tasks.b).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves pending tasks when resetting all', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
|
||||||
|
// Create a settled task
|
||||||
|
await queue.enqueue({ name: 'settled', key: 'settled', handler: async () => 'done' });
|
||||||
|
|
||||||
|
// Create a pending task
|
||||||
|
const pendingPromise = queue.enqueue({
|
||||||
|
name: 'pending',
|
||||||
|
key: 'pending',
|
||||||
|
handler: async () => {
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
return 'pending-done';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
expect(queue.tasks.settled?.status).toBe('success');
|
||||||
|
expect(queue.tasks.pending?.status).toBe('pending');
|
||||||
|
|
||||||
|
// Reset all - should only clear settled
|
||||||
|
queue.reset();
|
||||||
|
|
||||||
|
expect(queue.tasks.settled).toBeUndefined();
|
||||||
|
expect(queue.tasks.pending?.status).toBe('pending');
|
||||||
|
|
||||||
|
await pendingPromise;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSettled', () => {
|
||||||
|
it('returns true for success task', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
|
||||||
|
|
||||||
|
expect(queue.isSettled('k')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for error task', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
const promise = queue.enqueue({
|
||||||
|
name: 'task',
|
||||||
|
key: 'k',
|
||||||
|
handler: async () => {
|
||||||
|
throw new Error('fail');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await promise.catch(() => {});
|
||||||
|
|
||||||
|
expect(queue.isSettled('k')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for pending task', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
const promise = queue.enqueue({
|
||||||
|
name: 'task',
|
||||||
|
key: 'k',
|
||||||
|
handler: async () => {
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
return 'result';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
|
||||||
|
expect(queue.isSettled('k')).toBe(false);
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-existent task', () => {
|
||||||
|
const queue = createQueue();
|
||||||
|
|
||||||
|
expect(queue.isSettled('nonexistent')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destroy cleanup', () => {
|
||||||
|
it('clears all task references on destroy', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
|
||||||
|
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
|
||||||
|
expect(queue.tasks.k?.status).toBe('success');
|
||||||
|
|
||||||
|
queue.destroy();
|
||||||
|
|
||||||
|
expect(Reflect.ownKeys(queue.tasks).length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tasks getter', () => {
|
||||||
|
it('returns frozen snapshot', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
|
||||||
|
|
||||||
|
const tasks = queue.tasks;
|
||||||
|
|
||||||
|
expect(Object.isFrozen(tasks)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns independent snapshots', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
await queue.enqueue({ name: 'first', key: 'k', handler: async () => 'first' });
|
||||||
|
|
||||||
|
const snapshot1 = queue.tasks;
|
||||||
|
|
||||||
|
await queue.enqueue({ name: 'second', key: 'k', handler: async () => 'second' });
|
||||||
|
|
||||||
|
const snapshot2 = queue.tasks;
|
||||||
|
|
||||||
|
// Snapshots should be independent
|
||||||
|
expect(snapshot1).not.toBe(snapshot2);
|
||||||
|
if (snapshot1.k?.status === 'success' && snapshot2.k?.status === 'success') {
|
||||||
|
expect(snapshot1.k.output).toBe('first');
|
||||||
|
expect(snapshot2.k.output).toBe('second');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('symbol keys', () => {
|
||||||
|
it('supports symbol keys', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
const key = Symbol('task');
|
||||||
|
|
||||||
|
await queue.enqueue({
|
||||||
|
name: 'task',
|
||||||
|
key,
|
||||||
|
handler: async () => 'result',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queue.tasks[key]?.status).toBe('success');
|
||||||
|
if (queue.tasks[key]?.status === 'success') {
|
||||||
|
expect(queue.tasks[key].output).toBe('result');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('meta propagation', () => {
|
||||||
|
it('meta defaults to null when not provided', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const queue = createQueue();
|
||||||
|
|
||||||
|
await queue.enqueue({
|
||||||
|
name: 'task',
|
||||||
|
key: 'k',
|
||||||
|
handler: async () => 'result',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queue.tasks.k?.meta).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { composeCallbacks } from './compose-callbacks';
|
export { composeCallbacks } from './compose-callbacks';
|
||||||
|
export { tryCatch } from './try-catch';
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { tryCatch } from '../try-catch';
|
||||||
|
|
||||||
|
describe('tryCatch', () => {
|
||||||
|
it('returns undefined if fn is undefined', () => {
|
||||||
|
expect(tryCatch(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if fn is null', () => {
|
||||||
|
// @ts-expect-error - testing null input
|
||||||
|
expect(tryCatch(null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls the wrapped function with arguments', () => {
|
||||||
|
const fn = vi.fn((a: number, b: number) => a + b);
|
||||||
|
const wrapped = tryCatch(fn);
|
||||||
|
|
||||||
|
const result = wrapped?.(1, 2);
|
||||||
|
|
||||||
|
expect(fn).toHaveBeenCalledWith(1, 2);
|
||||||
|
expect(result).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the function result when no error', () => {
|
||||||
|
const fn = () => 'result';
|
||||||
|
const wrapped = tryCatch(fn);
|
||||||
|
|
||||||
|
expect(wrapped?.()).toBe('result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches errors and calls onError', () => {
|
||||||
|
const error = new Error('test error');
|
||||||
|
const fn = () => {
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
const onError = vi.fn();
|
||||||
|
|
||||||
|
const wrapped = tryCatch(fn, onError);
|
||||||
|
const result = wrapped?.();
|
||||||
|
|
||||||
|
expect(onError).toHaveBeenCalledWith(error);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses console.error as default onError', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const error = new Error('test error');
|
||||||
|
const fn = () => {
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = tryCatch(fn);
|
||||||
|
wrapped?.();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(error);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when wrapped function throws', () => {
|
||||||
|
const fn = () => {
|
||||||
|
throw new Error('should not propagate');
|
||||||
|
};
|
||||||
|
const onError = vi.fn();
|
||||||
|
|
||||||
|
const wrapped = tryCatch(fn, onError);
|
||||||
|
|
||||||
|
expect(() => wrapped?.()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves function type signature', () => {
|
||||||
|
const fn = (name: string, age: number): string => `${name} is ${age}`;
|
||||||
|
const wrapped = tryCatch(fn);
|
||||||
|
|
||||||
|
// TypeScript should infer correct types
|
||||||
|
const result: string | undefined = wrapped?.('Alice', 30);
|
||||||
|
expect(result).toBe('Alice is 30');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Wrap a function to catch and handle errors instead of throwing.
|
||||||
|
*
|
||||||
|
* @param fn - Function to wrap (can be undefined)
|
||||||
|
* @param onError - Error handler (defaults to console.error)
|
||||||
|
* @returns Wrapped function that never throws, or undefined if fn is undefined
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const safeFn = tryCatch(riskyFn, (e) => logger.error(e));
|
||||||
|
* safeFn?.(); // Never throws
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function tryCatch<T extends (...args: any[]) => unknown>(
|
||||||
|
fn: T | undefined,
|
||||||
|
onError: (error: unknown) => void = console.error,
|
||||||
|
): T | undefined {
|
||||||
|
if (!fn) return undefined;
|
||||||
|
|
||||||
|
return ((...args: Parameters<T>) => {
|
||||||
|
try {
|
||||||
|
return fn(...args);
|
||||||
|
} catch (error) {
|
||||||
|
onError(error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user