refactor(store): queue simplification (#302)

This commit is contained in:
rahim
2026-01-12 22:36:36 +11:00
committed by GitHub
parent 644aa303b8
commit 15a611ded5
27 changed files with 1754 additions and 1717 deletions
-2
View File
@@ -28,8 +28,6 @@ export type StoreErrorCode
| '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. */
+1
View File
@@ -6,3 +6,4 @@ export * from './request';
export * from './slice';
export * from './state';
export * from './store';
export * from './task';
+30 -240
View File
@@ -1,7 +1,7 @@
import type { Request, RequestMeta } from './request';
import type { ErrorTask, PendingTask, SuccessTask, Task, TaskContext, TaskKey } from './task';
import { tryCatch } from '@videojs/utils/function';
import { isFunction, isUndefined } from '@videojs/utils/predicate';
import { isUndefined } from '@videojs/utils/predicate';
import { StoreError } from './errors';
@@ -9,17 +9,6 @@ import { StoreError } from './errors';
// Types
// ----------------------------------------
export type TaskKey<T = string | symbol> = T & (string | symbol);
export type EnsureTaskKey<T> = T extends string | symbol ? T : never;
/**
* A task scheduler controls when a task flushes.
*
* Returns an optional cancel function.
*/
export type TaskScheduler = (flush: () => void) => (() => void) | void;
export type TaskRecord = {
[K in TaskKey]: Request<any, any>;
};
@@ -28,56 +17,11 @@ export type DefaultTaskRecord = Record<TaskKey, Request<unknown, unknown>>;
export type EnsureTaskRecord<T> = T extends TaskRecord ? T : never;
export interface TaskBase<Key extends TaskKey = TaskKey, Input = unknown> {
id: symbol;
name: string;
key: Key;
input: Input;
startedAt: number;
meta: RequestMeta | null;
}
export interface PendingTask<Key extends TaskKey = TaskKey, Input = unknown> extends TaskBase<Key, Input> {
status: 'pending';
abort: AbortController;
}
export interface SuccessTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown> extends TaskBase<
Key,
Input
> {
status: 'success';
settledAt: number;
output: Output;
}
export interface ErrorTask<Key extends TaskKey = TaskKey, Input = unknown> extends TaskBase<Key, Input> {
status: 'error';
settledAt: number;
error: unknown;
cancelled: boolean;
}
export type Task<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown>
= | PendingTask<Key, Input>
| SuccessTask<Key, Input, Output>
| ErrorTask<Key, Input>;
export type SettledTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown>
= | SuccessTask<Key, Input, Output>
| ErrorTask<Key, Input>;
export interface TaskContext<Input = unknown> {
input: Input;
signal: AbortSignal;
}
export interface QueueTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown> {
name: string;
key: Key;
input?: Input;
meta?: RequestMeta | null;
schedule?: TaskScheduler | undefined;
handler: (ctx: TaskContext<Input>) => Promise<Output>;
}
@@ -87,30 +31,12 @@ interface QueuedTask<Key extends TaskKey = TaskKey, Input = unknown, Output = un
key: Key;
input: Input;
meta: RequestMeta | null;
schedule: TaskScheduler | undefined;
handler: (ctx: TaskContext<Input>) => Promise<Output>;
resolve: (value: Output) => void;
reject: (error: unknown) => void;
invalidate?: () => void;
}
export interface QueueConfig<Tasks extends TaskRecord = DefaultTaskRecord> {
/** Default scheduler when task has no schedule */
scheduler?: TaskScheduler;
onDispatch?: <K extends keyof Tasks>(task: PendingTask<TaskKey<K>, Tasks[K]['input']>) => void;
onSettled?: <K extends keyof Tasks>(task: SettledTask<TaskKey<K>, Tasks[K]['input'], Tasks[K]['output']>) => void;
}
export interface QueuedTaskId<Key extends TaskKey = TaskKey> {
key: Key;
name: string;
}
export type PublicQueuedRecord<Tasks extends TaskRecord> = {
readonly [K in keyof Tasks]?: QueuedTaskId<TaskKey<K>>;
};
export type QueuedRecord<Tasks extends TaskRecord> = {
type QueuedRecord<Tasks extends TaskRecord> = {
[K in keyof Tasks]?: QueuedTask<TaskKey<K>>;
};
@@ -120,66 +46,17 @@ export type TasksRecord<Tasks extends TaskRecord> = {
export type QueueListener<Tasks extends TaskRecord> = (tasks: TasksRecord<Tasks>) => void;
// ----------------------------------------
// Schedulers
// ----------------------------------------
/**
* Default scheduler, delay to next microtask.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide}
*/
export const microtask: TaskScheduler = (flush) => {
let cancelled = false;
queueMicrotask(() => {
if (!cancelled) flush();
});
return () => {
cancelled = true;
};
};
/**
* Delay execution by ms. Resets on each new task.
*
* @param ms - Milliseconds to delay
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout}
*/
export function delay(ms: number): TaskScheduler {
return (flush) => {
const id = setTimeout(flush, ms);
return () => clearTimeout(id);
};
}
// ----------------------------------------
// Implementation
// ----------------------------------------
export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
readonly #scheduler: TaskScheduler;
readonly #onDispatch: QueueConfig<Tasks>['onDispatch'];
readonly #onSettled: QueueConfig<Tasks>['onSettled'];
readonly #subscribers = new Set<QueueListener<Tasks>>();
#queued: QueuedRecord<Tasks> = {};
#tasks: TasksRecord<Tasks> = {};
#destroyed = false;
constructor(config: QueueConfig<Tasks> = {}) {
this.#scheduler = config.scheduler ?? microtask;
// Wrap callbacks to catch errors and prevent breaking queue/scheduler
const logError = (e: unknown) => console.error('[vjs-queue]', e);
this.#onDispatch = tryCatch(config.onDispatch, logError);
this.#onSettled = tryCatch(config.onSettled, logError);
}
get queued(): Readonly<PublicQueuedRecord<Tasks>> {
return Object.freeze({ ...this.#queued });
}
#flushScheduled = false;
get tasks(): Readonly<TasksRecord<Tasks>> {
return Object.freeze({ ...this.#tasks });
@@ -189,26 +66,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
return this.#destroyed;
}
isPending(name: keyof Tasks): boolean {
return this.#tasks[name]?.status === 'pending';
}
isQueued(name: keyof Tasks): boolean {
// Note: #queued is keyed by key, but we need to search by name
for (const task of Object.values(this.#queued)) {
if (task?.name === name) return true;
}
return false;
}
isSettled(name: keyof Tasks): boolean {
const task = this.#tasks[name];
return task?.status === 'success' || task?.status === 'error';
}
/**
* Clear settled task(s). If name provided, clears that task. If no name, clears all settled.
*/
/** Clear settled task(s). If name provided, clears that task. If no name, clears all settled. */
reset(name?: keyof Tasks): void {
if (!isUndefined(name)) {
const task = this.#tasks[name];
@@ -257,7 +115,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
enqueue<K extends keyof Tasks>(
task: QueueTask<TaskKey<K>, Tasks[K]['input'], Tasks[K]['output']>,
): Promise<Tasks[K]['output']> {
const { name, key, input, schedule, meta = null, handler } = task;
const { name, key, input, meta = null, handler } = task;
if (this.#destroyed) {
return Promise.reject(new StoreError('DESTROYED'));
@@ -265,7 +123,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
// Supersede any queued task with the same key
const queued = this.#queued[key];
queued?.invalidate?.();
queued?.reject(new StoreError('SUPERSEDED'));
delete this.#queued[key];
@@ -278,99 +135,51 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
}
return new Promise<Tasks[K]['output']>((resolve, reject) => {
const task: QueuedTask = {
const queuedTask: QueuedTask = {
id: Symbol('@videojs/task'),
name,
key,
input,
meta,
schedule,
handler,
resolve,
reject,
};
this.#queued[key as keyof Tasks] = task;
let flushed = false;
try {
const scheduleFlush = schedule ?? this.#scheduler;
const safeFlush = () => {
if (flushed) return;
flushed = true;
this.#flushKey(key);
};
const cancel = scheduleFlush(safeFlush);
if (!flushed && isFunction(cancel)) {
task.invalidate = cancel;
}
} catch (err) {
if (!flushed) {
delete this.#queued[key];
}
reject(err);
}
this.#queued[key as keyof Tasks] = queuedTask;
this.#scheduleFlush();
});
}
/**
* Cancel queued task(s). If name provided, cancels that task. If no name, cancels all.
*/
cancel(name?: keyof Tasks): boolean {
if (!isUndefined(name)) {
// Find queued task by name (#queued is keyed by key)
for (const [key, queued] of Object.entries(this.#queued)) {
if (queued?.name === name) {
queued.invalidate?.();
queued.reject(new StoreError('REMOVED'));
delete this.#queued[key as keyof Tasks];
return true;
}
}
return false;
}
#scheduleFlush(): void {
if (this.#flushScheduled) return;
const hadQueued = Object.keys(this.#queued).length > 0;
for (const queued of Object.values(this.#queued)) {
queued.invalidate?.();
queued.reject(new StoreError('REMOVED'));
}
this.#queued = {};
return hadQueued;
this.#flushScheduled = true;
queueMicrotask(() => {
this.#flushScheduled = false;
this.#flushAll();
});
}
async flush(name?: keyof Tasks): Promise<void> {
if (!isUndefined(name)) {
// Find queued task by name and flush by its key
for (const [key, queued] of Object.entries(this.#queued)) {
if (queued?.name === name) {
await this.#flushKey(key as keyof Tasks);
return;
}
}
return;
}
#flushAll(): void {
if (this.#destroyed) return;
const keys = Reflect.ownKeys(this.#queued);
await Promise.allSettled(keys.map(k => this.#flushKey(k)));
const keys = Reflect.ownKeys(this.#queued) as (keyof Tasks)[];
for (const key of keys) {
const task = this.#queued[key];
if (task) {
delete this.#queued[key];
this.#executeNow(task);
}
}
}
/**
* Abort task(s). If name provided, aborts that task. If no name, aborts all.
*/
/** Abort task(s). If name provided, aborts that task. If no name, aborts all. */
abort(name?: keyof Tasks): void {
if (!isUndefined(name)) {
// Find and abort queued task by name
for (const [key, queued] of Object.entries(this.#queued)) {
if (queued?.name === name) {
queued.invalidate?.();
queued.reject(new StoreError('ABORTED'));
delete this.#queued[key as keyof Tasks];
break;
@@ -389,7 +198,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
const error = new StoreError('ABORTED');
for (const queued of Object.values(this.#queued)) {
queued.invalidate?.();
queued.reject(error);
}
@@ -411,17 +219,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
this.#tasks = {};
}
async #flushKey(key: keyof Tasks): Promise<void> {
if (this.#destroyed) return;
const task = this.#queued[key];
if (!task) return;
delete this.#queued[key];
await this.#executeNow(task);
}
async #executeNow<K extends keyof Tasks>(
task: QueuedTask<TaskKey<K>, Tasks[K]['input'], Tasks[K]['output']>,
): Promise<void> {
@@ -444,7 +241,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
// Store tasks by name for controller access (different names can share same key)
this.#tasks[name as keyof Tasks] = pendingTask;
this.#notifySubscribers();
this.#onDispatch?.(pendingTask);
try {
if (abort.signal.aborted) {
@@ -471,8 +267,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
this.#tasks[name as keyof Tasks] = successTask;
this.#notifySubscribers();
}
this.#onSettled?.(successTask);
} catch (error) {
reject(error);
@@ -489,8 +283,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
this.#tasks[name as keyof Tasks] = errorTask;
this.#notifySubscribers();
}
this.#onSettled?.(errorTask);
}
}
}
@@ -503,7 +295,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
* Create a queue for managing task execution.
*
* - Same key = supersede previous (cancel queued, abort pending)
* - Tasks scheduled via schedule function (default: microtask)
* - Tasks batched via microtask for supersession
*
* @example
* // Loose typing (default)
@@ -516,8 +308,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
* 'volume': Request<number>;
* }>();
*/
export function createQueue<Tasks extends TaskRecord = DefaultTaskRecord>(
config: QueueConfig<Tasks> = {},
): Queue<Tasks> {
return new Queue<Tasks>(config);
export function createQueue<Tasks extends TaskRecord = DefaultTaskRecord>(): Queue<Tasks> {
return new Queue<Tasks>();
}
+1 -4
View File
@@ -1,6 +1,6 @@
import type { EventLike } from '@videojs/utils/events';
import type { Guard } from './guard';
import type { TaskKey, TaskScheduler } from './queue';
import type { TaskKey } from './task';
import { isFunction, isObject } from '@videojs/utils/predicate';
@@ -42,7 +42,6 @@ export type RequestHandler<Target, Input = unknown, Output = unknown> = (
export interface RequestConfig<Target, Input = unknown, Output = unknown> {
key?: RequestKey<Input>;
schedule?: TaskScheduler;
guard?: Guard<Target> | Guard<Target>[];
cancel?: RequestCancel<Input>;
handler: RequestHandler<Target, Input, Output>;
@@ -50,7 +49,6 @@ export interface RequestConfig<Target, Input = unknown, Output = unknown> {
export interface ResolvedRequestConfig<Target, Input = unknown, Output = unknown> {
key: RequestKey<Input>;
schedule?: TaskScheduler | undefined;
guard: Guard<Target>[];
cancel?: RequestCancel<Input> | undefined;
handler: RequestHandler<Target, Input, Output>;
@@ -120,7 +118,6 @@ export function resolveRequests<Target, Requests extends { [K in keyof Requests]
} else {
resolved[name] = {
key: config.key ?? name,
schedule: config.schedule,
cancel: config.cancel,
handler: config.handler,
guard: config.guard ? (Array.isArray(config.guard) ? config.guard : [config.guard]) : [],
+1 -2
View File
@@ -1,4 +1,3 @@
import type { PendingTask, Task, TaskContext } from './queue';
import type { RequestMeta, RequestMetaInit, ResolvedRequestConfig } from './request';
import type {
AnySlice,
@@ -9,6 +8,7 @@ import type {
UnionSliceTasks,
} from './slice';
import type { StateFactory } from './state';
import type { PendingTask, Task, TaskContext } from './task';
import { getSelectorKeys } from '@videojs/utils/object';
import { isNull } from '@videojs/utils/predicate';
@@ -306,7 +306,6 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
key,
input,
meta,
schedule: config.schedule,
handler,
});
} catch (error) {
+77
View File
@@ -0,0 +1,77 @@
import type { RequestMeta } from './request';
// ----------------------------------------
// Types
// ----------------------------------------
export type TaskKey<T = string | symbol> = T & (string | symbol);
export type EnsureTaskKey<T> = T extends string | symbol ? T : never;
export interface TaskBase<Key extends TaskKey = TaskKey, Input = unknown> {
id: symbol;
name: string;
key: Key;
input: Input;
startedAt: number;
meta: RequestMeta | null;
}
export interface PendingTask<Key extends TaskKey = TaskKey, Input = unknown> extends TaskBase<Key, Input> {
status: 'pending';
abort: AbortController;
}
export interface SuccessTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown> extends TaskBase<
Key,
Input
> {
status: 'success';
settledAt: number;
output: Output;
}
export interface ErrorTask<Key extends TaskKey = TaskKey, Input = unknown> extends TaskBase<Key, Input> {
status: 'error';
settledAt: number;
error: unknown;
cancelled: boolean;
}
export type Task<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown>
= | PendingTask<Key, Input>
| SuccessTask<Key, Input, Output>
| ErrorTask<Key, Input>;
export type SettledTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown>
= | SuccessTask<Key, Input, Output>
| ErrorTask<Key, Input>;
export interface TaskContext<Input = unknown> {
input: Input;
signal: AbortSignal;
}
// ----------------------------------------
// Type Guards
// ----------------------------------------
/** Check if task is pending (in-flight). */
export function isPendingTask<K extends TaskKey, I, O>(task: Task<K, I, O> | undefined): task is PendingTask<K, I> {
return task?.status === 'pending';
}
/** Check if task is settled (success or error). */
export function isSettledTask<K extends TaskKey, I, O>(task: Task<K, I, O> | undefined): task is SettledTask<K, I, O> {
return task?.status === 'success' || task?.status === 'error';
}
/** Check if task is a success. */
export function isSuccessTask<K extends TaskKey, I, O>(task: Task<K, I, O> | undefined): task is SuccessTask<K, I, O> {
return task?.status === 'success';
}
/** Check if task is an error. */
export function isErrorTask<K extends TaskKey, I>(task: Task<K, I> | undefined): task is ErrorTask<K, I> {
return task?.status === 'error';
}
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { createSlice, createStore, delay } from '../../index';
import { createSlice, createStore } from '../../index';
describe('store lifecycle integration', () => {
it('full lifecycle: create → attach → use → detach → destroy', async () => {
@@ -54,9 +54,7 @@ describe('store lifecycle integration', () => {
expect(store.destroyed).toBe(true);
});
it('request with guards and scheduling', async () => {
vi.useFakeTimers();
it('request with guards', async () => {
let ready = false;
const isReady = () => ready;
@@ -65,8 +63,7 @@ describe('store lifecycle integration', () => {
getSnapshot: () => ({}),
subscribe: () => {},
request: {
delayedAction: {
schedule: delay(100),
guardedAction: {
guard: [isReady],
handler: (_, _ctx) => 'completed',
},
@@ -81,17 +78,13 @@ describe('store lifecycle integration', () => {
store.attach({});
// Test 1: Guard rejects when not ready
const failPromise = store.request.delayedAction().catch(e => e);
await vi.runAllTimersAsync();
const failPromise = store.request.guardedAction().catch(e => e);
await expect(failPromise).resolves.toMatchObject({ code: 'REJECTED' });
// Test 2: Guard passes when ready
ready = true;
const successPromise = store.request.delayedAction();
await vi.runAllTimersAsync();
const successPromise = store.request.guardedAction();
await expect(successPromise).resolves.toBe('completed');
vi.useRealTimers();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,42 +1,10 @@
import type { ErrorTask, PendingTask, SuccessTask, Task, TasksRecord } from '../queue';
import type { TasksRecord } from '../queue';
import { describe, expectTypeOf, it } from 'vitest';
import { createQueue } from '../queue';
describe('queue types', () => {
describe('Task', () => {
it('is discriminated union of task states', () => {
const task: Task = {} as Task;
if (task.status === 'pending') {
expectTypeOf(task).toExtend<PendingTask>();
expectTypeOf(task.abort).toExtend<AbortController>();
}
if (task.status === 'success') {
expectTypeOf(task).toExtend<SuccessTask>();
expectTypeOf(task.output).toBeUnknown();
expectTypeOf(task.settledAt).toBeNumber();
}
if (task.status === 'error') {
expectTypeOf(task).toExtend<ErrorTask>();
expectTypeOf(task.error).toBeUnknown();
expectTypeOf(task.cancelled).toBeBoolean();
expectTypeOf(task.settledAt).toBeNumber();
}
});
it('has common properties across all states', () => {
const task: Task = {} as Task;
expectTypeOf(task.id).toEqualTypeOf<symbol>();
expectTypeOf(task.name).toEqualTypeOf<string>();
expectTypeOf(task.key).toExtend<string | symbol>();
expectTypeOf(task.startedAt).toBeNumber();
});
});
describe('createQueue', () => {
it('returns Queue with default task record', () => {
const queue = createQueue();
@@ -47,27 +15,6 @@ describe('queue types', () => {
});
describe('Queue methods', () => {
it('isPending takes name parameter', () => {
const queue = createQueue();
expectTypeOf(queue.isPending).toBeFunction();
expectTypeOf(queue.isPending).returns.toBeBoolean();
});
it('isQueued takes name parameter', () => {
const queue = createQueue();
expectTypeOf(queue.isQueued).toBeFunction();
expectTypeOf(queue.isQueued).returns.toBeBoolean();
});
it('isSettled takes name parameter', () => {
const queue = createQueue();
expectTypeOf(queue.isSettled).toBeFunction();
expectTypeOf(queue.isSettled).returns.toBeBoolean();
});
it('reset takes optional name parameter', () => {
const queue = createQueue();
@@ -75,13 +22,6 @@ describe('queue types', () => {
expectTypeOf(queue.reset).returns.toBeVoid();
});
it('cancel takes optional name parameter and returns boolean', () => {
const queue = createQueue();
expectTypeOf(queue.cancel).toBeFunction();
expectTypeOf(queue.cancel).returns.toBeBoolean();
});
it('abort takes optional name parameter', () => {
const queue = createQueue();
@@ -89,13 +29,6 @@ describe('queue types', () => {
expectTypeOf(queue.abort).returns.toBeVoid();
});
it('flush takes optional name parameter and returns promise', () => {
const queue = createQueue();
expectTypeOf(queue.flush).toBeFunction();
expectTypeOf(queue.flush).returns.toExtend<Promise<void>>();
});
it('subscribe takes listener and returns unsubscribe', () => {
const queue = createQueue();
@@ -49,10 +49,6 @@ describe('slice', () => {
it('preserves full config options', () => {
const guard = () => true;
const schedule = (flush: () => void) => {
setTimeout(flush, 100);
};
const slice = createSlice({
initialState: {},
getSnapshot: () => ({}),
@@ -61,7 +57,6 @@ describe('slice', () => {
configured: {
key: 'custom-key',
guard: [guard],
schedule,
handler: () => {},
},
},
@@ -69,7 +64,6 @@ describe('slice', () => {
expect(slice.request.configured.key).toBe('custom-key');
expect(slice.request.configured.guard).toEqual([guard]);
expect(slice.request.configured.schedule).toBe(schedule);
});
});
});
@@ -188,28 +188,6 @@ describe('store types', () => {
expectTypeOf(store.queue.tasks).toHaveProperty('pause');
});
it('queue.isPending accepts request names', () => {
const store = createSingleSliceStore();
// Should compile - valid request names
store.queue.isPending('setVolume');
store.queue.isPending('setMuted');
});
it('queue.isQueued accepts request names', () => {
const store = createSingleSliceStore();
store.queue.isQueued('setVolume');
store.queue.isQueued('setMuted');
});
it('queue.isSettled accepts request names', () => {
const store = createSingleSliceStore();
store.queue.isSettled('setVolume');
store.queue.isSettled('setMuted');
});
it('queue.reset accepts request names', () => {
const store = createSingleSliceStore();
@@ -218,14 +196,6 @@ describe('store types', () => {
store.queue.reset(); // all
});
it('queue.cancel accepts request names', () => {
const store = createSingleSliceStore();
store.queue.cancel('setVolume');
store.queue.cancel('setMuted');
store.queue.cancel(); // all
});
it('queue.abort accepts request names', () => {
const store = createSingleSliceStore();
@@ -234,14 +204,6 @@ describe('store types', () => {
store.queue.abort(); // all
});
it('queue.flush accepts request names', () => {
const store = createSingleSliceStore();
store.queue.flush('setVolume');
store.queue.flush('setMuted');
store.queue.flush(); // all
});
it('InferStoreTasks matches queue task record keys', () => {
const _store = createSingleSliceStore();
type Tasks = InferStoreTasks<typeof _store>;
+240
View File
@@ -0,0 +1,240 @@
import type { ErrorTask, PendingTask, SuccessTask, Task } from '../task';
import { describe, expect, it } from 'vitest';
import { isErrorTask, isPendingTask, isSettledTask, isSuccessTask } from '../task';
describe('task', () => {
describe('isPendingTask', () => {
it('returns true for pending task', () => {
const task: Task = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'pending',
abort: new AbortController(),
};
expect(isPendingTask(task)).toBe(true);
});
it('returns false for success task', () => {
const task: Task = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'success',
settledAt: Date.now(),
output: 'result',
};
expect(isPendingTask(task)).toBe(false);
});
it('returns false for error task', () => {
const task: Task = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'error',
settledAt: Date.now(),
error: new Error('test'),
cancelled: false,
};
expect(isPendingTask(task)).toBe(false);
});
it('returns false for undefined', () => {
expect(isPendingTask(undefined)).toBe(false);
});
});
describe('isSettledTask', () => {
it('returns true for success task', () => {
const task: Task = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'success',
settledAt: Date.now(),
output: 'result',
};
expect(isSettledTask(task)).toBe(true);
});
it('returns true for error task', () => {
const task: Task = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'error',
settledAt: Date.now(),
error: new Error('test'),
cancelled: false,
};
expect(isSettledTask(task)).toBe(true);
});
it('returns false for pending task', () => {
const task: Task = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'pending',
abort: new AbortController(),
};
expect(isSettledTask(task)).toBe(false);
});
it('returns false for undefined', () => {
expect(isSettledTask(undefined)).toBe(false);
});
});
describe('isSuccessTask', () => {
it('returns true for success task', () => {
const task: SuccessTask = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'success',
settledAt: Date.now(),
output: 'result',
};
expect(isSuccessTask(task)).toBe(true);
});
it('returns false for error task', () => {
const task: ErrorTask = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'error',
settledAt: Date.now(),
error: new Error('test'),
cancelled: false,
};
expect(isSuccessTask(task)).toBe(false);
});
it('returns false for pending task', () => {
const task: PendingTask = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'pending',
abort: new AbortController(),
};
expect(isSuccessTask(task)).toBe(false);
});
it('returns false for undefined', () => {
expect(isSuccessTask(undefined)).toBe(false);
});
});
describe('isErrorTask', () => {
it('returns true for error task', () => {
const task: ErrorTask = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'error',
settledAt: Date.now(),
error: new Error('test'),
cancelled: false,
};
expect(isErrorTask(task)).toBe(true);
});
it('returns true for cancelled error task', () => {
const task: ErrorTask = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'error',
settledAt: Date.now(),
error: new Error('aborted'),
cancelled: true,
};
expect(isErrorTask(task)).toBe(true);
});
it('returns false for success task', () => {
const task: SuccessTask = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'success',
settledAt: Date.now(),
output: 'result',
};
expect(isErrorTask(task)).toBe(false);
});
it('returns false for pending task', () => {
const task: PendingTask = {
id: Symbol('task'),
name: 'test',
key: 'test',
input: undefined,
startedAt: Date.now(),
meta: null,
status: 'pending',
abort: new AbortController(),
};
expect(isErrorTask(task)).toBe(false);
});
it('returns false for undefined', () => {
expect(isErrorTask(undefined)).toBe(false);
});
});
});
@@ -0,0 +1,76 @@
import type { ErrorTask, PendingTask, SuccessTask, Task } from '../task';
import { describe, expectTypeOf, it } from 'vitest';
import { isErrorTask, isPendingTask, isSettledTask, isSuccessTask } from '../task';
describe('task types', () => {
describe('Task', () => {
it('is discriminated union of task states', () => {
const task: Task = {} as Task;
if (task.status === 'pending') {
expectTypeOf(task).toExtend<PendingTask>();
expectTypeOf(task.abort).toExtend<AbortController>();
}
if (task.status === 'success') {
expectTypeOf(task).toExtend<SuccessTask>();
expectTypeOf(task.output).toBeUnknown();
expectTypeOf(task.settledAt).toBeNumber();
}
if (task.status === 'error') {
expectTypeOf(task).toExtend<ErrorTask>();
expectTypeOf(task.error).toBeUnknown();
expectTypeOf(task.cancelled).toBeBoolean();
expectTypeOf(task.settledAt).toBeNumber();
}
});
it('has common properties across all states', () => {
const task: Task = {} as Task;
expectTypeOf(task.id).toEqualTypeOf<symbol>();
expectTypeOf(task.name).toEqualTypeOf<string>();
expectTypeOf(task.key).toExtend<string | symbol>();
expectTypeOf(task.startedAt).toBeNumber();
});
});
describe('type guards', () => {
it('isPendingTask narrows to PendingTask', () => {
const task: Task = {} as Task;
if (isPendingTask(task)) {
expectTypeOf(task).toExtend<PendingTask>();
}
});
it('isSettledTask narrows to SuccessTask | ErrorTask', () => {
const task: Task = {} as Task;
if (isSettledTask(task)) {
expectTypeOf(task.settledAt).toBeNumber();
}
});
it('isSuccessTask narrows to SuccessTask', () => {
const task: Task = {} as Task;
if (isSuccessTask(task)) {
expectTypeOf(task).toExtend<SuccessTask>();
expectTypeOf(task.output).toBeUnknown();
}
});
it('isErrorTask narrows to ErrorTask', () => {
const task: Task = {} as Task;
if (isErrorTask(task)) {
expectTypeOf(task).toExtend<ErrorTask>();
expectTypeOf(task.error).toBeUnknown();
}
});
});
});
-1
View File
@@ -1 +0,0 @@
export { idle, raf } from './schedulers';
-27
View File
@@ -1,27 +0,0 @@
import type { TaskScheduler } from '../core/queue';
import { animationFrame, idleCallback } from '@videojs/utils/dom';
/**
* Scheduler using `requestAnimationFrame`. Ideal for UI updates.
*
* @example
* ```ts
* queue.enqueue({ key: 'ui', schedule: raf(), handler: async () => {} });
* ```
*/
export function raf(): TaskScheduler {
return flush => animationFrame(flush);
}
/**
* Scheduler using `requestIdleCallback`. Ideal for background work.
*
* @example
* ```ts
* queue.enqueue({ key: 'bg', schedule: idle({ timeout: 2000 }), handler: async () => {} });
* ```
*/
export function idle(options?: IdleRequestOptions): TaskScheduler {
return flush => idleCallback(flush, options);
}
@@ -1,176 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createQueue } from '../../core/queue';
import { idle, raf } from '../schedulers';
describe('dom schedulers', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('raf', () => {
it('creates a TaskScheduler', () => {
const scheduler = raf();
expect(scheduler).toBeTypeOf('function');
});
it('schedules flush on animation frame', async () => {
const flush = vi.fn();
const scheduler = raf();
scheduler(flush);
expect(flush).not.toHaveBeenCalled();
await vi.runAllTimersAsync();
expect(flush).toHaveBeenCalledOnce();
});
it('returns cancel function', async () => {
const flush = vi.fn();
const scheduler = raf();
const cancel = scheduler(flush);
expect(cancel).toBeTypeOf('function');
cancel!();
await vi.runAllTimersAsync();
expect(flush).not.toHaveBeenCalled();
});
it('works with queue', async () => {
const queue = createQueue();
const handler = vi.fn().mockResolvedValue('result');
const promise = queue.enqueue({
name: 'raf-task',
key: 'raf',
schedule: raf(),
handler,
});
expect(handler).not.toHaveBeenCalled();
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('result');
});
});
describe('idle', () => {
it('creates a TaskScheduler', () => {
const scheduler = idle();
expect(scheduler).toBeTypeOf('function');
});
it('schedules flush when idle', async () => {
const flush = vi.fn();
const scheduler = idle();
scheduler(flush);
expect(flush).not.toHaveBeenCalled();
await vi.runAllTimersAsync();
expect(flush).toHaveBeenCalledOnce();
});
it('returns cancel function', async () => {
const flush = vi.fn();
const scheduler = idle();
const cancel = scheduler(flush);
expect(cancel).toBeTypeOf('function');
cancel!();
await vi.runAllTimersAsync();
expect(flush).not.toHaveBeenCalled();
});
it('accepts options', async () => {
const flush = vi.fn();
const scheduler = idle({ timeout: 1000 });
scheduler(flush);
await vi.runAllTimersAsync();
expect(flush).toHaveBeenCalledOnce();
});
it('works with queue', async () => {
const queue = createQueue();
const handler = vi.fn().mockResolvedValue('idle-result');
const promise = queue.enqueue({
name: 'idle-task',
key: 'idle',
schedule: idle(),
handler,
});
expect(handler).not.toHaveBeenCalled();
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('idle-result');
});
});
describe('integration', () => {
it('different schedulers can coexist in same queue', async () => {
const queue = createQueue();
const order: string[] = [];
queue.enqueue({
name: 'raf-task',
key: 'raf',
schedule: raf(),
handler: async () => {
order.push('raf');
},
});
queue.enqueue({
name: 'idle-task',
key: 'idle',
schedule: idle(),
handler: async () => {
order.push('idle');
},
});
await vi.runAllTimersAsync();
expect(order).toContain('raf');
expect(order).toContain('idle');
});
it('superseding works with raf scheduler', async () => {
const queue = createQueue();
const first = vi.fn().mockResolvedValue('first');
const second = vi.fn().mockResolvedValue('second');
const promise1 = queue.enqueue({
name: 'first',
key: 'shared',
schedule: raf(),
handler: first,
});
const promise2 = queue.enqueue({
name: 'second',
key: 'shared',
schedule: raf(),
handler: second,
});
// Handle the rejection immediately to avoid unhandled rejection warning
promise1.catch(() => {});
await vi.runAllTimersAsync();
await expect(promise1).rejects.toThrow();
await expect(promise2).resolves.toBe('second');
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledOnce();
});
});
});
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"declarationDir": "../../types/dom"
},
"references": [{ "path": "../.." }],
"include": ["./**/*.ts"]
}
@@ -1,7 +1,7 @@
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
import type { EnsureFunction } from '@videojs/utils/types';
import type { Task } from '../../core/queue';
import type { AnyStore, InferStoreRequests } from '../../core/store';
import type { Task } from '../../core/task';
import type { MutationResult } from '../../shared/types';
import type { StoreSource } from '../store-accessor';
@@ -1,7 +1,7 @@
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
import type { EnsureFunction } from '@videojs/utils/types';
import type { Task } from '../../core/queue';
import type { AnyStore, InferStoreRequests, InferStoreState } from '../../core/store';
import type { Task } from '../../core/task';
import type { OptimisticResult } from '../../shared/types';
import type { StoreSource } from '../store-accessor';
@@ -1,6 +1,6 @@
import type { EnsureFunction } from '@videojs/utils/types';
import type { Task } from '../../core/queue';
import type { AnyStore, InferStoreRequests } from '../../core/store';
import type { Task } from '../../core/task';
import type { MutationResult } from '../../shared/types';
import { useCallback, useRef, useSyncExternalStore } from 'react';
@@ -1,6 +1,6 @@
import type { EnsureFunction } from '@videojs/utils/types';
import type { Task } from '../../core/queue';
import type { AnyStore, InferStoreRequests, InferStoreState } from '../../core/store';
import type { Task } from '../../core/task';
import type { OptimisticResult } from '../../shared/types';
import { useCallback, useReducer, useRef, useSyncExternalStore } from 'react';