docs(plan): store bindings (#283)

This commit is contained in:
rahim
2026-01-05 14:51:13 +11:00
committed by GitHub
parent 8a1c674dad
commit a014699c95
14 changed files with 1912 additions and 10 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -23,6 +23,7 @@ export default {
'html',
'icons',
'packages',
'plan',
'react-native',
'react',
'root',
+50
View File
@@ -0,0 +1,50 @@
import type { AnySlice } from './slice';
import type { StoreConfig } from './store';
import { uniqBy } from '@videojs/utils/array';
import { composeCallbacks } from '@videojs/utils/function';
/**
* Extends a base store config with additional configuration.
*
* Both configs must have slices targeting the same type (e.g., HTMLMediaElement).
*
* - **slices**: Deduplicated by id, keeping last occurrence (extension wins)
* - **onSetup/onAttach/onError**: Both called (base first, then extension)
* - **queue/state**: Extension overrides base if provided
*
* @example
* ```ts
* const baseConfig = { slices: [media.playback] };
*
* // Extend with custom slice (must target same type)
* const extendedConfig = extendConfig(baseConfig, {
* slices: [chaptersSlice],
* onSetup: (ctx) => console.log('Extended setup'),
* });
* ```
*/
export function extendConfig<Target, BaseSlices extends AnySlice<Target>[], ExtSlices extends AnySlice<Target>[] = []>(
base: StoreConfig<Target, BaseSlices>,
extension?: Partial<StoreConfig<Target, ExtSlices>>,
): StoreConfig<Target, [...BaseSlices, ...ExtSlices]> {
type MergedSlices = [...BaseSlices, ...ExtSlices];
type Result = StoreConfig<Target, MergedSlices>;
if (!extension) {
return base as unknown as Result;
}
return {
slices: uniqBy([...base.slices, ...(extension.slices ?? [])] as MergedSlices, slice => slice.id),
// Extension overrides if provided
queue: extension.queue ?? base.queue,
state: extension.state ?? base.state,
// Compose lifecycle hooks (both called, base first)
onSetup: composeCallbacks(base.onSetup, extension.onSetup as typeof base.onSetup),
onAttach: composeCallbacks(base.onAttach, extension.onAttach as typeof base.onAttach),
onError: composeCallbacks(base.onError, extension.onError as typeof base.onError),
} as unknown as Result;
}
+1
View File
@@ -1,4 +1,5 @@
export * from './errors';
export * from './extend-config';
export * from './guard';
export * from './queue';
export * from './request';
+2
View File
@@ -374,6 +374,8 @@ export interface SubscribeOptions<T> {
equalityFn?: (a: T, b: T) => boolean;
}
export type AnyStoreConfig = StoreConfig<any, AnySlice[]>;
export interface StoreConfig<Target, Slices extends AnySlice<Target>[]> {
slices: Slices;
queue?: Queue<UnionSliceTasks<Slices>>;
@@ -0,0 +1,230 @@
import type { StoreConfig } from '../store';
import { describe, expect, it, vi } from 'vitest';
import { extendConfig } from '../extend-config';
import { Queue } from '../queue';
import { createSlice } from '../slice';
import { State } from '../state';
// Test target type
interface TestTarget {
value: number;
}
// Helper to create test slices with explicit id
function createTestSlice(name: string, id?: symbol) {
const slice = createSlice<TestTarget>()({
initialState: { [`${name}State`]: 0 },
getSnapshot: () => ({ [`${name}State`]: 0 }),
subscribe: () => {},
request: {
[`${name}Action`]: () => {},
},
});
// Override id if provided (for deduplication tests)
if (id) {
return { ...slice, id };
}
return slice;
}
// Helper to create a typed base config
function createBaseConfig<S extends ReturnType<typeof createTestSlice>[]>(
slices: S,
extra?: Partial<StoreConfig<TestTarget, S>>,
): StoreConfig<TestTarget, S> {
return { slices, ...extra } as StoreConfig<TestTarget, S>;
}
describe('extendConfig', () => {
describe('no extension', () => {
it('returns base config unchanged when extension is undefined', () => {
const slice = createTestSlice('a');
const base = createBaseConfig([slice]);
const result = extendConfig(base);
expect(result).toBe(base);
});
it('returns base config unchanged when extension is empty object', () => {
const slice = createTestSlice('a');
const base = createBaseConfig([slice]);
const result = extendConfig(base, {});
expect(result.slices).toHaveLength(1);
expect(result.slices[0]).toBe(slice);
});
});
describe('slice merging', () => {
it('concatenates slices from base and extension', () => {
const sliceA = createTestSlice('a');
const sliceB = createTestSlice('b');
const base = createBaseConfig([sliceA]);
const extension = { slices: [sliceB] };
const result = extendConfig(base, extension);
expect(result.slices).toHaveLength(2);
expect(result.slices).toContain(sliceA);
expect(result.slices).toContain(sliceB);
});
it('deduplicates slices by id, keeping last occurrence', () => {
const sharedId = Symbol('shared');
const sliceA = createTestSlice('a', sharedId);
const sliceB = createTestSlice('b');
// Create a "new version" of sliceA with same id
const sliceAExtended = createTestSlice('aExtended', sharedId);
const base = createBaseConfig([sliceA, sliceB]);
const extension = { slices: [sliceAExtended] };
const result = extendConfig(base, extension);
// Should have sliceB and sliceAExtended (not original sliceA)
expect(result.slices).toHaveLength(2);
expect(result.slices).toContain(sliceB);
expect(result.slices).toContain(sliceAExtended);
expect(result.slices).not.toContain(sliceA);
});
});
describe('lifecycle hooks', () => {
it('composes onSetup hooks, calling base first', () => {
const order: string[] = [];
const baseOnSetup = vi.fn(() => order.push('base'));
const extOnSetup = vi.fn(() => order.push('ext'));
const slice = createTestSlice('a');
const base = createBaseConfig([slice], { onSetup: baseOnSetup });
const extension = { onSetup: extOnSetup };
const result = extendConfig(base, extension);
result.onSetup?.({} as any);
expect(order).toEqual(['base', 'ext']);
expect(baseOnSetup).toHaveBeenCalled();
expect(extOnSetup).toHaveBeenCalled();
});
it('composes onAttach hooks, calling base first', () => {
const order: string[] = [];
const baseOnAttach = vi.fn(() => order.push('base'));
const extOnAttach = vi.fn(() => order.push('ext'));
const slice = createTestSlice('a');
const base = createBaseConfig([slice], { onAttach: baseOnAttach });
const extension = { onAttach: extOnAttach };
const result = extendConfig(base, extension);
result.onAttach?.({} as any);
expect(order).toEqual(['base', 'ext']);
});
it('composes onError hooks, calling base first', () => {
const order: string[] = [];
const baseOnError = vi.fn(() => order.push('base'));
const extOnError = vi.fn(() => order.push('ext'));
const slice = createTestSlice('a');
const base = createBaseConfig([slice], { onError: baseOnError });
const extension = { onError: extOnError };
const result = extendConfig(base, extension);
result.onError?.({} as any);
expect(order).toEqual(['base', 'ext']);
});
it('returns base hook when extension has none', () => {
const baseOnSetup = vi.fn();
const slice = createTestSlice('a');
const base = createBaseConfig([slice], { onSetup: baseOnSetup });
const result = extendConfig(base, {});
expect(result.onSetup).toBe(baseOnSetup);
});
it('returns extension hook when base has none', () => {
const extOnSetup = vi.fn();
const slice = createTestSlice('a');
const base = createBaseConfig([slice]);
const extension = { onSetup: extOnSetup };
const result = extendConfig(base, extension);
expect(result.onSetup).toBe(extOnSetup);
});
it('returns undefined when neither has hook', () => {
const slice = createTestSlice('a');
const base = createBaseConfig([slice]);
const result = extendConfig(base, {});
expect(result.onSetup).toBeUndefined();
});
});
describe('queue and state', () => {
it('uses extension queue when provided', () => {
const baseQueue = new Queue();
const extQueue = new Queue();
const slice = createTestSlice('a');
const base = createBaseConfig([slice], { queue: baseQueue as any });
const extension = { queue: extQueue as any };
const result = extendConfig(base, extension);
expect(result.queue).toBe(extQueue);
});
it('falls back to base queue when extension has none', () => {
const baseQueue = new Queue();
const slice = createTestSlice('a');
const base = createBaseConfig([slice], { queue: baseQueue as any });
const result = extendConfig(base, {});
expect(result.queue).toBe(baseQueue);
});
it('uses extension state factory when provided', () => {
const baseState = (initial: any) => new State(initial);
const extState = (initial: any) => new State(initial);
const slice = createTestSlice('a');
const base = createBaseConfig([slice], { state: baseState });
const extension = { state: extState };
const result = extendConfig(base, extension);
expect(result.state).toBe(extState);
});
it('falls back to base state factory when extension has none', () => {
const baseState = (initial: any) => new State(initial);
const slice = createTestSlice('a');
const base = createBaseConfig([slice], { state: baseState });
const result = extendConfig(base, {});
expect(result.state).toBe(baseState);
});
});
});
+16 -8
View File
@@ -10,10 +10,26 @@
"videojs"
],
"exports": {
"./array": {
"types": "./dist/array.d.ts",
"default": "./dist/array.js"
},
"./dom": {
"types": "./dist/dom.d.ts",
"default": "./dist/dom.js"
},
"./events": {
"types": "./dist/events.d.ts",
"default": "./dist/events.js"
},
"./function": {
"types": "./dist/function.d.ts",
"default": "./dist/function.js"
},
"./object": {
"types": "./dist/object.d.ts",
"default": "./dist/object.js"
},
"./predicate": {
"types": "./dist/predicate.d.ts",
"default": "./dist/predicate.js"
@@ -21,14 +37,6 @@
"./types": {
"types": "./dist/types.d.ts",
"default": "./dist/types.js"
},
"./object": {
"types": "./dist/object.d.ts",
"default": "./dist/object.js"
},
"./dom": {
"types": "./dist/dom.d.ts",
"default": "./dist/dom.js"
}
},
"files": [
+1
View File
@@ -0,0 +1 @@
export { uniqBy } from './uniq-by';
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { uniqBy } from '../uniq-by';
describe('uniqBy', () => {
it('removes duplicates keeping last occurrence', () => {
const arr = [
{ id: 'a', v: 1 },
{ id: 'b', v: 2 },
{ id: 'a', v: 3 },
];
const result = uniqBy(arr, item => item.id);
expect(result).toEqual([
{ id: 'b', v: 2 },
{ id: 'a', v: 3 },
]);
});
it('returns empty array for empty input', () => {
const result = uniqBy([], item => item);
expect(result).toEqual([]);
});
it('returns same array when no duplicates', () => {
const arr = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const result = uniqBy(arr, item => item.id);
expect(result).toEqual(arr);
});
it('works with primitive values', () => {
const arr = [1, 2, 3, 2, 1];
const result = uniqBy(arr, item => item);
expect(result).toEqual([3, 2, 1]);
});
it('preserves order with last occurrence winning', () => {
const arr = [
{ id: 'x', order: 1 },
{ id: 'y', order: 2 },
{ id: 'z', order: 3 },
{ id: 'x', order: 4 },
{ id: 'y', order: 5 },
];
const result = uniqBy(arr, item => item.id);
expect(result).toEqual([
{ id: 'z', order: 3 },
{ id: 'x', order: 4 },
{ id: 'y', order: 5 },
]);
});
});
+16
View File
@@ -0,0 +1,16 @@
/**
* Returns array with duplicates removed, keeping the LAST occurrence.
* Useful for slice merging where extensions should override base slices.
*
* @example
* ```ts
* const slices = [{ id: 'a', v: 1 }, { id: 'b', v: 2 }, { id: 'a', v: 3 }];
* uniqBy(slices, s => s.id);
* // => [{ id: 'b', v: 2 }, { id: 'a', v: 3 }]
* ```
*/
export function uniqBy<T, K>(arr: T[], mapper: (item: T) => K): T[] {
const seen = new Map<K, number>();
arr.forEach((item, i) => seen.set(mapper(item), i));
return arr.filter((_, i) => [...seen.values()].includes(i));
}
@@ -0,0 +1,23 @@
import { isNil } from '../predicate';
/**
* Composes multiple callbacks into one. All callbacks receive same args, no return value.
* Returns undefined if no callbacks provided.
*
* @example
* ```ts
* const onSetup = composeCallbacks(base.onSetup, extension.onSetup);
* onSetup?.(ctx); // Calls both if defined
* ```
*/
export function composeCallbacks<T extends (...args: any[]) => void>(...fns: (T | undefined | null)[]): T | undefined {
const defined = fns.filter((fn): fn is T => !isNil(fn));
if (defined.length === 0) return undefined;
if (defined.length === 1) return defined[0];
return ((...args: Parameters<T>) => {
defined.forEach(fn => fn(...args));
}) as T;
}
+1
View File
@@ -0,0 +1 @@
export { composeCallbacks } from './compose-callbacks';
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest';
import { composeCallbacks } from '../compose-callbacks';
describe('composeCallbacks', () => {
it('returns undefined when no callbacks provided', () => {
const result = composeCallbacks();
expect(result).toBeUndefined();
});
it('returns undefined when all callbacks are null/undefined', () => {
const result = composeCallbacks(null, undefined, null);
expect(result).toBeUndefined();
});
it('returns single callback when only one provided', () => {
const fn = vi.fn();
const result = composeCallbacks(fn);
expect(result).toBe(fn);
});
it('returns single callback when others are null/undefined', () => {
const fn = vi.fn();
const result = composeCallbacks(null, fn, undefined);
expect(result).toBe(fn);
});
it('calls all callbacks with same args', () => {
const fn1 = vi.fn();
const fn2 = vi.fn();
const fn3 = vi.fn();
const composed = composeCallbacks(fn1, fn2, fn3);
composed?.('arg1', 'arg2');
expect(fn1).toHaveBeenCalledWith('arg1', 'arg2');
expect(fn2).toHaveBeenCalledWith('arg1', 'arg2');
expect(fn3).toHaveBeenCalledWith('arg1', 'arg2');
});
it('calls callbacks in order', () => {
const order: number[] = [];
const fn1 = () => order.push(1);
const fn2 = () => order.push(2);
const fn3 = () => order.push(3);
const composed = composeCallbacks(fn1, fn2, fn3);
composed?.();
expect(order).toEqual([1, 2, 3]);
});
it('skips null/undefined in the middle', () => {
const fn1 = vi.fn();
const fn2 = vi.fn();
const composed = composeCallbacks(fn1, null, undefined, fn2);
composed?.();
expect(fn1).toHaveBeenCalled();
expect(fn2).toHaveBeenCalled();
});
it('works with typed callbacks', () => {
type OnSetup = (ctx: { name: string }) => void;
const fn1: OnSetup = vi.fn();
const fn2: OnSetup = vi.fn();
const composed = composeCallbacks<OnSetup>(fn1, fn2);
const ctx = { name: 'test' };
composed?.(ctx);
expect(fn1).toHaveBeenCalledWith(ctx);
expect(fn2).toHaveBeenCalledWith(ctx);
});
});
+4 -2
View File
@@ -2,11 +2,13 @@ import { defineConfig } from 'tsdown';
export default defineConfig({
entry: {
array: './src/array/index.ts',
dom: './src/dom/index.ts',
events: './src/events/index.ts',
function: './src/function/index.ts',
object: './src/object/index.ts',
predicate: './src/predicate/index.ts',
types: './src/types/index.ts',
object: './src/object/index.ts',
dom: './src/dom/index.ts',
},
platform: 'neutral',
format: 'es',