mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
docs(plan): store bindings (#283)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ export default {
|
||||
'html',
|
||||
'icons',
|
||||
'packages',
|
||||
'plan',
|
||||
'react-native',
|
||||
'react',
|
||||
'root',
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
export * from './errors';
|
||||
export * from './extend-config';
|
||||
export * from './guard';
|
||||
export * from './queue';
|
||||
export * from './request';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user