chore(packages): remove isolatedDeclarations for store type inference support (#295)

This commit is contained in:
rahim
2026-01-06 14:09:36 +11:00
committed by GitHub
parent 47659f5352
commit 0354e3ef9c
32 changed files with 137 additions and 512 deletions
+83 -2
View File
@@ -199,8 +199,8 @@ Before writing new helpers, check `@videojs/utils` for existing utilities.
Always return `value is Type` for proper type narrowing:
```ts
function isStoreError(error: unknown): error is StoreError {
return error instanceof StoreError;
function isStoreError(value: unknown): value is StoreError {
return value instanceof StoreError;
}
```
@@ -243,3 +243,84 @@ destroy(): void {
this.#subscribers.clear();
}
```
### No Hungarian Type Notation
Never prefix type parameters with `T`. Use descriptive names instead:
```ts
// Bad
type Mixin<TBase extends Constructor> = ...
function createStore<TSlices extends AnySlice[]>(...) { ... }
// Good
type Mixin<Base extends Constructor> = ...
function createStore<Slices extends AnySlice[]>(...) { ... }
```
### No Obvious Comments
Don't write comments that restate what the code does. Comments should explain _why_, not _what_:
```ts
// Bad
// Create the store
const store = createStore(config);
// Loop through items
for (const item of items) { ... }
// Good
// Create store before rendering to allow pre-hydration
const store = createStore(config);
```
### No Pointless Type Casts
Avoid casts that don't add value. If TypeScript can infer the type, don't cast:
```ts
// Bad - already typed
const value = someFunction() as SomeType;
// Bad - use generic type argument
const media = node.querySelector('video, audio') as HTMLMediaElement | null;
```
### Minimal JSDoc
JSDoc should add value, not restate what TypeScript already shows:
**No redundant @param/@returns** — TypeScript signatures are the documentation:
```ts
// Bad
/**
* @param callback - The callback to invoke
* @returns A cleanup function
*/
export function animationFrame(callback: FrameRequestCallback): () => void
// Good
/** Request an animation frame with cleanup. */
export function animationFrame(callback: FrameRequestCallback): () => void
```
**Single JSDoc for overloads** — Document the first overload only:
```ts
/** Wait for an event to occur on a target. */
export function onEvent<K extends keyof HTMLMediaElementEventMap>(...): Promise<...>;
export function onEvent<K extends keyof HTMLElementEventMap>(...): Promise<...>;
```
**One example per function** — Consolidate into a single representative example.
**No JSDoc for self-documenting code** — Skip JSDoc when names are clear:
```ts
// No JSDoc needed
export function supportsIdleCallback(): boolean { ... }
get size(): number { ... }
add(cleanup: CleanupFn): void { ... }
```
+1 -1
View File
@@ -40,7 +40,7 @@
},
"devDependencies": {
"jsdom": "^26.1.0",
"tsdown": "^0.15.9",
"tsdown": "^0.15.12",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
},
+2 -2
View File
@@ -11,9 +11,9 @@ import { listen, serializeTimeRanges } from '@videojs/utils/dom';
export const bufferSlice = createSlice<HTMLMediaElement>()({
initialState: {
/** Buffered time ranges as [start, end] tuples. */
buffered: [] as Array<[number, number]>,
buffered: [] as [number, number][],
/** Seekable time ranges as [start, end] tuples. */
seekable: [] as Array<[number, number]>,
seekable: [] as [number, number][],
},
getSnapshot: ({ target }) => ({
+1 -2
View File
@@ -3,8 +3,7 @@
"compilerOptions": {
"composite": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"declarationDir": "../../types/dom",
"isolatedDeclarations": false
"declarationDir": "../../types/dom"
},
"references": [{ "path": "../.." }],
"include": ["./**/*.ts"]
+1 -2
View File
@@ -2,8 +2,7 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declarationDir": "types",
"isolatedDeclarations": false
"declarationDir": "types"
},
"references": [{ "path": "../utils" }, { "path": "../store" }],
"include": ["src/core/**/*.ts"]
+1 -1
View File
@@ -37,7 +37,7 @@
"@videojs/utils": "workspace:*"
},
"devDependencies": {
"tsdown": "^0.15.9",
"tsdown": "^0.15.12",
"typescript": "^5.9.3"
},
"publishConfig": {
+1 -3
View File
@@ -21,7 +21,5 @@ export default defineConfig({
alias: {
'@': new URL('./src', import.meta.url).pathname,
},
dts: {
oxc: true,
},
dts: true,
});
+1 -1
View File
@@ -6,7 +6,7 @@
"license": "Apache-2.0",
"files": [],
"devDependencies": {
"tsdown": "^0.15.9",
"tsdown": "^0.15.12",
"typescript": "^5.9.3"
},
"publishConfig": {
+1 -1
View File
@@ -42,7 +42,7 @@
"devDependencies": {
"@types/react": "^18.0.0",
"react": "^18.0.0",
"tsdown": "^0.15.9",
"tsdown": "^0.15.12",
"typescript": "^5.9.3"
},
"publishConfig": {
+1 -3
View File
@@ -13,7 +13,5 @@ export default defineConfig({
alias: {
'@': new URL('./src', import.meta.url).pathname,
},
dts: {
oxc: true,
},
dts: true,
});
+1 -1
View File
@@ -70,7 +70,7 @@
"jsdom": "^26.1.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tsdown": "^0.15.9",
"tsdown": "^0.15.12",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
},
+5 -99
View File
@@ -20,26 +20,14 @@ export type EnsureTaskKey<T> = T extends string | symbol ? T : never;
*/
export type TaskScheduler = (flush: () => void) => (() => void) | void;
/**
* Map of task key -> input/output types.
*/
export type TaskRecord = {
[K in TaskKey]: Request<any, any>;
};
/**
* Default loose task types.
*/
export type DefaultTaskRecord = Record<TaskKey, Request<unknown, unknown>>;
/**
* Ensure T is a TaskRecord.
*/
export type EnsureTaskRecord<T> = T extends TaskRecord ? T : never;
/**
* Base fields shared by all task states.
*/
export interface TaskBase<Key extends TaskKey = TaskKey, Input = unknown> {
id: symbol;
name: string;
@@ -49,17 +37,11 @@ export interface TaskBase<Key extends TaskKey = TaskKey, Input = unknown> {
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
@@ -69,9 +51,6 @@ export interface SuccessTask<Key extends TaskKey = TaskKey, Input = unknown, Out
output: Output;
}
/**
* Error task - failed or cancelled.
*/
export interface ErrorTask<Key extends TaskKey = TaskKey, Input = unknown> extends TaskBase<Key, Input> {
status: 'error';
settledAt: number;
@@ -79,32 +58,20 @@ export interface ErrorTask<Key extends TaskKey = TaskKey, Input = unknown> exten
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.
*/
export interface TaskContext<Input = unknown> {
input: Input;
signal: AbortSignal;
}
/**
* Task to enqueue.
*/
export interface QueueTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown> {
name: string;
key: Key;
@@ -114,9 +81,6 @@ export interface QueueTask<Key extends TaskKey = TaskKey, Input = unknown, Outpu
handler: (ctx: TaskContext<Input>) => Promise<Output>;
}
/**
* Queued task waiting to execute.
*/
interface QueuedTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown> {
id: symbol;
name: string;
@@ -127,7 +91,6 @@ interface QueuedTask<Key extends TaskKey = TaskKey, Input = unknown, Output = un
handler: (ctx: TaskContext<Input>) => Promise<Output>;
resolve: (value: Output) => void;
reject: (error: unknown) => void;
/* Cancel scheduled execution. */
invalidate?: () => void;
}
@@ -151,18 +114,10 @@ export type QueuedRecord<Tasks extends TaskRecord> = {
[K in keyof Tasks]?: QueuedTask<TaskKey<K>>;
};
/**
* 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 task state changes.
*
* Called when tasks are dispatched, settled, or reset.
*/
export type QueueListener<Tasks extends TaskRecord> = (tasks: TasksRecord<Tasks>) => void;
// ----------------------------------------
@@ -226,9 +181,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
return Object.freeze({ ...this.#queued });
}
/**
* Map of task key -> task (pending, success, or error).
*/
get tasks(): Readonly<TasksRecord<Tasks>> {
return Object.freeze({ ...this.#tasks });
}
@@ -237,35 +189,21 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
return this.#destroyed;
}
/**
* Check if a task with the given key is currently pending (executing).
*/
isPending(key: keyof Tasks): boolean {
return this.#tasks[key]?.status === 'pending';
}
/**
* Check if a task with the given key is currently queued (waiting to execute).
*/
isQueued(key: keyof Tasks): boolean {
return key in this.#queued;
}
/**
* 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).
*
* - 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 key - Optional task key to reset. If omitted, resets all settled tasks.
* Clear settled task(s). If key provided, clears that task. If no key, clears all settled.
*/
reset(key?: keyof Tasks): void {
if (!isUndefined(key)) {
@@ -278,7 +216,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
return;
}
// Reset all settled tasks
let cleared = false;
for (const key of Reflect.ownKeys(this.#tasks)) {
const task = this.#tasks[key];
@@ -293,14 +230,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
}
}
/**
* Subscribe to task state changes.
*
* Fires when tasks are dispatched, settled, or reset.
*
* @param listener - Callback receiving the current tasks map
* @returns Unsubscribe function
*/
subscribe(listener: QueueListener<Tasks>): () => void {
this.#subscribers.add(listener);
return () => {
@@ -330,19 +259,16 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
return Promise.reject(new StoreError('DESTROYED'));
}
// Cancel any queued task with same key
const queued = this.#queued[key];
queued?.invalidate?.();
queued?.reject(new StoreError('SUPERSEDED'));
delete this.#queued[key];
// Abort any pending task with same key
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) => {
@@ -365,7 +291,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
try {
const scheduleFlush = schedule ?? this.#scheduler;
// Guard against multiple flushes
const safeFlush = () => {
if (flushed) return;
flushed = true;
@@ -374,7 +299,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
const cancel = scheduleFlush(safeFlush);
// Only set invalidate if we haven't already flushed
if (!flushed && isFunction(cancel)) {
task.invalidate = cancel;
}
@@ -389,13 +313,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
}
/**
* Cancel queued task(s) waiting to execute.
*
* - 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 queued task(s). If key provided, cancels that task. If no key, cancels all.
*/
cancel(key?: keyof Tasks): boolean {
if (!isUndefined(key)) {
@@ -409,7 +327,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
return true;
}
// Cancel all queued
const hadQueued = Object.keys(this.#queued).length > 0;
for (const queued of Object.values(this.#queued)) {
queued.invalidate?.();
@@ -427,28 +344,20 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
return;
}
// Flush all
const keys = Reflect.ownKeys(this.#queued);
await Promise.allSettled(keys.map(k => this.#flushKey(k)));
}
/**
* Abort task(s) - both queued (waiting) and pending (executing).
*
* - If key provided: aborts that specific task
* - If no key: aborts all tasks
*
* @param key - Optional task key to abort
* Abort task(s). If key provided, aborts that task. If no key, aborts all.
*/
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 task
const task = this.#tasks[key];
if (task?.status === 'pending') {
task.abort.abort(new StoreError('ABORTED'));
@@ -457,10 +366,8 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
return;
}
// Abort all
const error = new StoreError('ABORTED');
// Reject all queued
for (const queued of Object.values(this.#queued)) {
queued.invalidate?.();
queued.reject(error);
@@ -468,7 +375,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
this.#queued = {};
// Abort all pending tasks
for (const task of Object.values(this.#tasks)) {
if (task?.status === 'pending') {
task.abort.abort(error);
@@ -540,7 +446,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
};
// Only update if we're still the current task for this key
if (this.#tasks[key as keyof Tasks] === pendingTask) {
if (this.#tasks[key] === pendingTask) {
this.#tasks[key as keyof Tasks] = successTask;
this.#notifySubscribers();
}
@@ -558,7 +464,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
};
// Only update if we're still the current task for this key
if (this.#tasks[key as keyof Tasks] === pendingTask) {
if (this.#tasks[key] === pendingTask) {
this.#tasks[key as keyof Tasks] = errorTask;
this.#notifySubscribers();
}
-46
View File
@@ -23,41 +23,23 @@ export type RequestRecord = {
[K in string]: Request<any, any>;
};
/**
* Default loose request types.
*/
export type DefaultRequestRecord = Record<string, Request>;
/**
* Context passed to request handlers.
*/
export interface RequestContext<Target> {
target: Target;
signal: AbortSignal;
meta: RequestMeta | null;
}
/**
* Request key - static or derived from input.
*/
export type RequestKey<Input = unknown> = TaskKey | ((input: Input) => TaskKey);
/**
* Request cancel config.
*/
export type RequestCancel<Input = unknown> = TaskKey | TaskKey[] | ((input: Input) => TaskKey | TaskKey[]);
/**
* Request handler function.
*/
export type RequestHandler<Target, Input = unknown, Output = unknown> = (
input: Input,
ctx: RequestContext<Target>,
) => Output | Promise<Output>;
/**
* Full request config.
*/
export interface RequestConfig<Target, Input = unknown, Output = unknown> {
key?: RequestKey<Input>;
schedule?: TaskScheduler;
@@ -66,9 +48,6 @@ export interface RequestConfig<Target, Input = unknown, Output = unknown> {
handler: RequestHandler<Target, Input, Output>;
}
/**
* Resolved request config (after normalization).
*/
export interface ResolvedRequestConfig<Target, Input = unknown, Output = unknown> {
key: RequestKey<Input>;
schedule?: TaskScheduler | undefined;
@@ -81,19 +60,12 @@ export type RequestHandlerRecord = {
[K in string]: RequestHandler<any, any, any>;
};
/**
* Map of request names to handlers or configs. This is the config passed to `createSlice`.
*/
export type RequestConfigMap<Target, Requests extends { [K in keyof Requests]: Request<any, any> }> = {
[K in keyof Requests]: Requests[K] extends Request<infer I, infer O>
? RequestHandler<Target, I, O> | RequestConfig<Target, I, O>
: never;
};
/**
* Map of request config objects to resolved configs. This is the config stored internally in
* the store.
*/
export type ResolvedRequestConfigMap<Target, Requests extends { [K in keyof Requests]: Request<any, any> }> = {
[K in keyof Requests]: Requests[K] extends Request<infer I, infer O> ? ResolvedRequestConfig<Target, I, O> : never;
};
@@ -102,9 +74,6 @@ export type ResolvedRequestConfigMap<Target, Requests extends { [K in keyof Requ
// Type Inference
// ----------------------------------------
/**
* Infer the input type of a RequestHandler.
*/
export type InferRequestHandlerInput<Handler> = Handler extends () => any
? void
: Handler extends (input: infer I, ctx?: any) => any
@@ -115,25 +84,16 @@ export type InferRequestHandlerInput<Handler> = Handler extends () => any
? I
: void;
/**
* Infer the output type of a RequestHandler.
*/
export type InferRequestHandlerOutput<Handler> = Handler extends (...args: any[]) => infer O
? Awaited<O>
: Handler extends { handler: (...args: any[]) => infer O }
? Awaited<O>
: void;
/**
* Resolve a RequestHandlerRecord to a RequestRecord.
*/
export type ResolveRequestMap<Requests> = {
[K in keyof Requests]: Request<InferRequestHandlerInput<Requests[K]>, InferRequestHandlerOutput<Requests[K]>>;
};
/**
* Resolve a Request (input/output) to its function signature.
*/
export type ResolveRequestHandler<R>
= R extends Request<infer I, infer O>
? [I] extends [void]
@@ -203,16 +163,10 @@ export function createRequestMeta<Context = unknown>(init: RequestMetaInit<Conte
};
}
/**
* Check if a value is a RequestMeta object.
*/
export function isRequestMeta(value: unknown): value is RequestMeta {
return isObject(value) && REQUEST_META in value;
}
/**
* Convert an event-like object to RequestMeta.
*/
export function createRequestMetaFromEvent<Context = unknown>(
event: EventLike,
context?: Context,
-15
View File
@@ -30,7 +30,6 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
this.#queue = config.queue ?? new Queue<UnionSliceTasks<Slices>>();
// Use provided factory or default
const factory = config.state ?? (initial => new State(initial));
this.#state = factory(this.#createInitialState());
@@ -157,12 +156,10 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
maybeListener?: (selected: Selected) => void,
options?: SubscribeOptions<Selected>,
): () => void {
// Full state subscription (single argument)
if (!maybeListener) {
return this.#state.subscribe(selectorOrListener);
}
// Selector-based subscription
const selector = selectorOrListener as Selector<UnionSliceState<Slices>, Selected>;
const listener = maybeListener;
const equalityFn = options?.equalityFn ?? Object.is;
@@ -176,11 +173,9 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
}
};
// Optimization: use key-based subscription if selector returns object
const keys = getSelectorKeys(selector, this.#state.value);
if (keys) {
// Note: subscribeKeys listener receives full state at runtime, type is narrowed for safety
return this.#state.subscribeKeys(
keys as (keyof UnionSliceState<Slices>)[],
handler as (state: Pick<UnionSliceState<Slices>, keyof UnionSliceState<Slices>>) => void,
@@ -359,19 +354,9 @@ export function createStore<Slices extends AnySlice[]>(
export type AnyStore<Target = any> = Store<Target, AnySlice<Target>[]>;
/**
* A selector function that extracts a subset of state.
*/
export type Selector<State, Selected> = (state: State) => Selected;
/**
* Options for selector-based subscriptions.
*/
export interface SubscribeOptions<T> {
/**
* Custom equality function for comparing selected values.
* Defaults to `Object.is`.
*/
equalityFn?: (a: T, b: T) => boolean;
}
+4 -52
View File
@@ -3,28 +3,11 @@ import type { TaskScheduler } from '../core/queue';
import { animationFrame, idleCallback } from '@videojs/utils/dom';
/**
* Create a scheduler that delays task execution until the next animation frame.
*
* Uses `requestAnimationFrame` under the hood. Ideal for UI updates that should
* sync with the browser's repaint cycle.
*
* @returns A TaskScheduler for use with the queue
* Scheduler using `requestAnimationFrame`. Ideal for UI updates.
*
* @example
* ```ts
* import { createQueue } from '@videojs/store';
* import { raf } from '@videojs/store/dom';
*
* const queue = createQueue();
*
* queue.enqueue({
* name: 'update-ui',
* key: 'ui',
* schedule: raf(),
* handler: async () => {
* // This runs on the next animation frame
* },
* });
* queue.enqueue({ key: 'ui', schedule: raf(), handler: async () => {} });
* ```
*/
export function raf(): TaskScheduler {
@@ -32,42 +15,11 @@ export function raf(): TaskScheduler {
}
/**
* Create a scheduler that delays task execution until the browser is idle.
*
* Uses `requestIdleCallback` under the hood (with `setTimeout` fallback for Safari).
* Ideal for non-critical background work.
*
* @param options - Optional idle callback options (e.g., timeout)
* @returns A TaskScheduler for use with the queue
* Scheduler using `requestIdleCallback`. Ideal for background work.
*
* @example
* ```ts
* import { createQueue } from '@videojs/store';
* import { idle } from '@videojs/store/dom';
*
* const queue = createQueue();
*
* queue.enqueue({
* name: 'analytics',
* key: 'analytics',
* schedule: idle(),
* handler: async () => {
* // This runs when the browser is idle
* },
* });
* ```
*
* @example
* ```ts
* // With timeout to ensure execution within 2 seconds
* queue.enqueue({
* name: 'critical-background',
* key: 'bg',
* schedule: idle({ timeout: 2000 }),
* handler: async () => {
* // Runs when idle, or after 2 seconds
* },
* });
* queue.enqueue({ key: 'bg', schedule: idle({ timeout: 2000 }), handler: async () => {} });
* ```
*/
export function idle(options?: IdleRequestOptions): TaskScheduler {
+1 -42
View File
@@ -15,9 +15,6 @@ import { useRequest as useRequestBase, useSelector as useSelectorBase, useTasks
// Types
// ----------------------------------------
/**
* Configuration for `createStore`.
*/
export interface CreateStoreConfig<Slices extends AnySlice[]> extends StoreConfig<UnionSliceTarget<Slices>, Slices> {
/**
* Display name for React DevTools.
@@ -25,9 +22,6 @@ export interface CreateStoreConfig<Slices extends AnySlice[]> extends StoreConfi
displayName?: string;
}
/**
* Props for the Provider component returned by `createStore`.
*/
export interface ProviderProps<Slices extends AnySlice[]> {
children: ReactNode;
/**
@@ -44,9 +38,6 @@ export interface ProviderProps<Slices extends AnySlice[]> {
inherit?: boolean;
}
/**
* Result of `createStore`.
*/
export interface CreateStoreResult<Slices extends AnySlice[]> {
/**
* Provider component that creates and manages the store lifecycle.
@@ -100,21 +91,6 @@ export interface CreateStoreResult<Slices extends AnySlice[]> {
* const { Provider, useStore, useSelector, useRequest, useTasks, create } = createStore({
* slices: [playbackSlice, presentationSlice],
* });
*
* function App() {
* return (
* <Provider>
* <Video />
* <Controls />
* </Provider>
* );
* }
*
* function Controls() {
* const paused = useSelector((s) => s.paused);
* const play = useRequest((r) => r.play);
* return <button onClick={() => play()}>{paused ? 'Play' : 'Pause'}</button>;
* }
* ```
*/
export function createStore<Slices extends AnySlice[]>(config: CreateStoreConfig<Slices>): CreateStoreResult<Slices> {
@@ -124,9 +100,6 @@ export function createStore<Slices extends AnySlice[]>(config: CreateStoreConfig
type Tasks = UnionSliceTasks<Slices>;
type StoreType = Store<Target, Slices>;
/**
* Creates a new store instance.
*/
function create(): StoreType {
return new Store(config);
}
@@ -174,31 +147,20 @@ export function createStore<Slices extends AnySlice[]>(config: CreateStoreConfig
Provider.displayName = `${config.displayName}.Provider`;
}
/**
* Returns the typed store instance from context.
*/
function useStore(): StoreType {
return useStoreContext() as StoreType;
}
/**
* Subscribes to a selected portion of state.
*/
function useSelector<T>(selector: (state: State) => T): T {
const store = useStore();
return useSelectorBase(store, selector);
}
/**
* Returns the request map or a selected request.
*/
function useRequest(): Requests;
function useRequest<T>(selector: (requests: Requests) => T): T;
function useRequest<T>(selector?: (requests: Requests) => T): Requests | T {
const store = useStore();
// useRequestBase doesn't use React hooks internally, but we always call it
// to maintain consistent hook call order (even though it's technically not required)
const requests = useRequestBase(store) as Requests;
const requests = useRequestBase(store);
if (isUndefined(selector)) {
return requests;
@@ -207,9 +169,6 @@ export function createStore<Slices extends AnySlice[]>(config: CreateStoreConfig
return selector(requests);
}
/**
* Subscribes to task state changes.
*/
function useTasks(): TasksRecord<Tasks> {
const store = useStore();
return useTasksBase(store);
+3 -25
View File
@@ -6,12 +6,7 @@ import { isUndefined } from '@videojs/utils/predicate';
import { useCallback, useRef, useSyncExternalStore } from 'react';
/**
* Subscribes to a selected portion of state.
* Re-renders only when the selected value changes.
*
* @param store - The store instance
* @param selector - Function to select a portion of state
* @returns The selected value
* Subscribe to selected state. Re-renders only when selected value changes.
*/
export function useSelector<S extends AnyStore, T>(store: S, selector: (state: InferStoreState<S>) => T): T {
const subscribe = useCallback(
@@ -26,22 +21,10 @@ export function useSelector<S extends AnyStore, T>(store: S, selector: (state: I
}
/**
* Returns the request map from the store.
*
* @param store - The store instance
* @returns The request map
* Get request map or select a specific request.
*/
export function useRequest<S extends AnyStore>(store: S): InferStoreRequests<S>;
/**
* Returns a selected request from the store.
*
* @param store - The store instance
* @param selector - Function to select a request
* @returns The selected request
*/
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,
@@ -56,14 +39,9 @@ export function useRequest<S extends AnyStore, T>(
}
/**
* Subscribes to task state changes.
* Returns the current tasks map from the queue.
*
* @param store - The store instance
* @returns The tasks record
* Subscribe to task state changes.
*/
export function useTasks<S extends AnyStore>(store: S): TasksRecord<InferStoreTasks<S>> {
// Cache the tasks snapshot to ensure referential stability
const tasksRef = useRef(store.queue.tasks);
const subscribe = useCallback(
+1 -3
View File
@@ -14,7 +14,5 @@ export default defineConfig({
alias: {
'@': new URL('./src/core', import.meta.url).pathname,
},
dts: {
oxc: true,
},
dts: true,
});
+1 -1
View File
@@ -51,7 +51,7 @@
"clean": "rm -rf dist types"
},
"devDependencies": {
"tsdown": "^0.15.9",
"tsdown": "^0.15.12",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
},
+3 -10
View File
@@ -1,17 +1,10 @@
/**
* Request an animation frame and return a cleanup function to cancel it.
*
* @param callback - The callback to invoke on the next animation frame
* @returns A cleanup function that cancels the animation frame request
* Request an animation frame with cleanup.
*
* @example
* ```ts
* const cancel = animationFrame((time) => {
* console.log('Frame at', time);
* });
*
* // Later, cancel if needed
* cancel();
* const cancel = animationFrame((time) => console.log('Frame at', time));
* cancel(); // Cancel if needed
* ```
*/
export function animationFrame(callback: FrameRequestCallback): () => void {
+4 -59
View File
@@ -10,10 +10,10 @@ export interface OnEventOptions extends AddEventListenerOptions {
/**
* Wait for an event to occur on a target.
*
* @param target - The event target (HTMLMediaElement)
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
* @example
* ```ts
* const event = await onEvent(video, 'seeked');
* ```
*/
export function onEvent<K extends keyof HTMLMediaElementEventMap>(
target: HTMLMediaElement,
@@ -21,79 +21,24 @@ export function onEvent<K extends keyof HTMLMediaElementEventMap>(
options?: OnEventOptions,
): Promise<HTMLMediaElementEventMap[K]>;
/**
* Wait for an event to occur on a target.
*
* @param target - The event target (HTMLElement)
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*/
export function onEvent<K extends keyof HTMLElementEventMap>(
target: HTMLElement,
type: K,
options?: OnEventOptions,
): Promise<HTMLElementEventMap[K]>;
/**
* Wait for an event to occur on a target.
*
* @param target - The event target (Window)
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*/
export function onEvent<K extends keyof WindowEventMap>(
target: Window,
type: K,
options?: OnEventOptions,
): Promise<WindowEventMap[K]>;
/**
* Wait for an event to occur on a target.
*
* @param target - The event target (Document)
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*/
export function onEvent<K extends keyof DocumentEventMap>(
target: Document,
type: K,
options?: OnEventOptions,
): Promise<DocumentEventMap[K]>;
/**
* Wait for an event to occur on a target.
*
* @param target - The event target
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*
* @example
* ```ts
* // Wait for video to be seeked
* const event = await onEvent(video, 'seeked');
* ```
*
* @example
* ```ts
* // With AbortSignal for cancellation
* const controller = new AbortController();
*
* try {
* const event = await onEvent(video, 'seeked', { signal: controller.signal });
* } catch (e) {
* if (e.name === 'AbortError') {
* console.log('Cancelled waiting for event');
* }
* }
*
* // Cancel from elsewhere
* controller.abort();
* ```
*/
export function onEvent(target: EventTarget, type: string, options?: OnEventOptions): Promise<Event>;
export function onEvent(target: EventTarget, type: string, options?: OnEventOptions): Promise<Event> {
+2 -19
View File
@@ -1,29 +1,12 @@
import { supportsIdleCallback } from './supports';
/**
* Request an idle callback and return a cleanup function to cancel it.
*
* Falls back to `setTimeout` with 1ms delay in environments that don't
* support `requestIdleCallback` (e.g., Safari).
*
* @param callback - The callback to invoke when the browser is idle
* @param options - Optional idle callback options (timeout, etc.)
* @returns A cleanup function that cancels the idle callback request
* Request an idle callback with cleanup. Falls back to setTimeout for Safari.
*
* @example
* ```ts
* const cancel = idleCallback((deadline) => {
* console.log('Time remaining:', deadline.timeRemaining());
* });
*
* // Later, cancel if needed
* cancel();
* ```
*
* @example
* ```ts
* // With timeout option
* const cancel = idleCallback(doWork, { timeout: 1000 });
* cancel(); // Cancel if needed
* ```
*/
export function idleCallback(callback: IdleRequestCallback, options?: IdleRequestOptions): () => void {
+5 -65
View File
@@ -1,11 +1,11 @@
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target (HTMLMediaElement)
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
* @example
* ```ts
* const cleanup = listen(video, 'play', () => console.log('playing'));
* cleanup(); // Remove listener
* ```
*/
export function listen<K extends keyof HTMLMediaElementEventMap>(
target: HTMLMediaElement,
@@ -14,15 +14,6 @@ export function listen<K extends keyof HTMLMediaElementEventMap>(
options?: AddEventListenerOptions,
): () => void;
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target (HTMLElement)
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*/
export function listen<K extends keyof HTMLElementEventMap>(
target: HTMLElement,
type: K,
@@ -30,15 +21,6 @@ export function listen<K extends keyof HTMLElementEventMap>(
options?: AddEventListenerOptions,
): () => void;
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target (Window)
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*/
export function listen<K extends keyof WindowEventMap>(
target: Window,
type: K,
@@ -46,15 +28,6 @@ export function listen<K extends keyof WindowEventMap>(
options?: AddEventListenerOptions,
): () => void;
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target (Document)
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*/
export function listen<K extends keyof DocumentEventMap>(
target: Document,
type: K,
@@ -62,39 +35,6 @@ export function listen<K extends keyof DocumentEventMap>(
options?: AddEventListenerOptions,
): () => void;
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*
* @example
* ```ts
* const cleanup = listen(video, 'play', () => console.log('playing'));
*
* // Later, remove the listener
* cleanup();
* ```
*
* @example
* ```ts
* // With options
* const cleanup = listen(video, 'play', handler, { once: true, passive: true });
* ```
*
* @example
* ```ts
* // With AbortSignal (native browser support)
* const controller = new AbortController();
* listen(video, 'play', handler, { signal: controller.signal });
*
* // Later, abort to remove the listener
* controller.abort();
* ```
*/
export function listen(
target: EventTarget,
type: string,
-10
View File
@@ -1,17 +1,7 @@
/**
* Check if `requestIdleCallback` is supported.
*
* @returns `true` if `requestIdleCallback` is available
*/
export function supportsIdleCallback(): boolean {
return typeof requestIdleCallback === 'function';
}
/**
* Check if `requestAnimationFrame` is supported.
*
* @returns `true` if `requestAnimationFrame` is available
*/
export function supportsAnimationFrame(): boolean {
return typeof requestAnimationFrame === 'function';
}
+1 -6
View File
@@ -1,9 +1,4 @@
/**
* Converts a TimeRanges object to an array of [start, end] tuples.
*
* @param ranges - The TimeRanges object to serialize
* @returns An array of [start, end] tuples
*/
/** Converts a TimeRanges object to an array of [start, end] tuples. */
export function serializeTimeRanges(ranges: TimeRanges): Array<[number, number]> {
const result: Array<[number, number]> = [];
+1 -15
View File
@@ -25,26 +25,15 @@ export type CleanupFn = () => void | Promise<void>;
export class Disposer {
#cleanups = new Set<CleanupFn>();
/**
* Number of registered cleanup functions.
*/
get size(): number {
return this.#cleanups.size;
}
/**
* Add a cleanup function to the collection.
*/
add(cleanup: CleanupFn): void {
this.#cleanups.add(cleanup);
}
/**
* Run all cleanup functions synchronously.
*
* Note: If any cleanup functions return promises, they will not be awaited.
* Use `disposeAsync()` if you have async cleanup functions.
*/
/** Run all cleanups sync. Use `disposeAsync()` for async cleanups. */
dispose(): void {
for (const cleanup of this.#cleanups) {
cleanup();
@@ -52,9 +41,6 @@ export class Disposer {
this.#cleanups.clear();
}
/**
* Run all cleanup functions, awaiting any promises.
*/
async disposeAsync(): Promise<void> {
await Promise.all([...this.#cleanups].map(cleanup => cleanup()));
this.#cleanups.clear();
-4
View File
@@ -1,10 +1,6 @@
/**
* 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));
+2 -10
View File
@@ -6,18 +6,10 @@ import { isObject } from '../predicate/predicate';
export type Selector<State, Selected> = (state: State) => Selected;
/**
* Extracts the state keys a selector depends on by running it once
* and inspecting the result object's keys.
*
* Returns `null` if the selector returns a primitive or array
* (keys cannot be determined).
* Extract state keys a selector depends on. Returns null for primitives/arrays.
*
* @example
* const selector = (s: State) => ({ volume: s.volume, muted: s.muted });
* getSelectorKeys(selector, state); // ['volume', 'muted']
*
* const primitiveSelector = (s: State) => s.volume;
* getSelectorKeys(primitiveSelector, state); // null
* getSelectorKeys((s) => ({ volume: s.volume }), state); // ['volume']
*/
export function getSelectorKeys<State, Selected>(
selector: Selector<State, Selected>,
+2 -2
View File
@@ -19,8 +19,8 @@ describe('pick', () => {
});
it('ignores non-existent keys', () => {
const obj = { a: 1, b: 2 } as Record<string, number>;
expect(pick(obj, ['a', 'nonexistent'] as (keyof typeof obj)[])).toEqual({ a: 1 });
const obj = { a: 1, b: 2 };
expect(pick(obj, ['a', 'nonexistent' as keyof typeof obj])).toEqual({ a: 1 });
});
it('handles nested objects (shallow copy)', () => {
+1 -3
View File
@@ -17,7 +17,5 @@ export default defineConfig({
alias: {
'@': new URL('./src', import.meta.url).pathname,
},
dts: {
oxc: true,
},
dts: true,
});
+6 -6
View File
@@ -319,7 +319,7 @@ importers:
specifier: ^26.1.0
version: 26.1.0
tsdown:
specifier: ^0.15.9
specifier: ^0.15.12
version: 0.15.12(typescript@5.9.3)
typescript:
specifier: ^5.9.3
@@ -341,7 +341,7 @@ importers:
version: link:../utils
devDependencies:
tsdown:
specifier: ^0.15.9
specifier: ^0.15.12
version: 0.15.12(typescript@5.9.3)
typescript:
specifier: ^5.9.3
@@ -350,7 +350,7 @@ importers:
packages/icons:
devDependencies:
tsdown:
specifier: ^0.15.9
specifier: ^0.15.12
version: 0.15.12(typescript@5.9.3)
typescript:
specifier: ^5.9.3
@@ -375,7 +375,7 @@ importers:
specifier: ^18.0.0
version: 18.3.1
tsdown:
specifier: ^0.15.9
specifier: ^0.15.12
version: 0.15.12(typescript@5.9.3)
typescript:
specifier: ^5.9.3
@@ -409,7 +409,7 @@ importers:
specifier: ^19.2.1
version: 19.2.3(react@19.2.3)
tsdown:
specifier: ^0.15.9
specifier: ^0.15.12
version: 0.15.12(typescript@5.9.3)
typescript:
specifier: ^5.9.3
@@ -421,7 +421,7 @@ importers:
packages/utils:
devDependencies:
tsdown:
specifier: ^0.15.9
specifier: ^0.15.12
version: 0.15.12(typescript@5.9.3)
typescript:
specifier: ^5.9.3
+1 -1
View File
@@ -25,7 +25,7 @@
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedDeclarations": true,
"isolatedDeclarations": false,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true