diff --git a/packages/store/src/core/config.ts b/packages/store/src/core/config.ts index 6e4f1bb7..24dc8798 100644 --- a/packages/store/src/core/config.ts +++ b/packages/store/src/core/config.ts @@ -1,4 +1,4 @@ -import type { AnyFeature, UnionFeatureTarget } from './feature'; +import type { AnyFeature, UnionFeatureState, UnionFeatureTarget } from './feature'; import type { TaskKey } from './queue'; import type { RequestMeta } from './request'; import type { Store } from './store'; @@ -9,28 +9,32 @@ export interface PendingTask { startedAt: number; } -export interface StoreConfig { +export interface StoreConfig + extends StoreCallbacks, UnionFeatureState> { features: Features; - onSetup?: (ctx: StoreSetupContext) => void; - onAttach?: (ctx: StoreAttachContext) => void; - onError?: (ctx: StoreErrorContext) => void; +} + +export interface StoreCallbacks { + onSetup?: (ctx: StoreSetupContext) => void; + onAttach?: (ctx: StoreAttachContext) => void; + onError?: (ctx: StoreErrorContext) => void; onTaskStart?: (ctx: StoreTaskContext) => void; onTaskEnd?: (ctx: StoreTaskContext & { error?: unknown }) => void; } -export interface StoreSetupContext { - store: Store; +export interface StoreSetupContext { + store: Store; signal: AbortSignal; } -export interface StoreAttachContext { - store: Store; - target: UnionFeatureTarget; +export interface StoreAttachContext { + store: Store; + target: Target; signal: AbortSignal; } -export interface StoreErrorContext { - store: Store; +export interface StoreErrorContext { + store: Store; error: unknown; } diff --git a/packages/store/src/core/feature-selector.ts b/packages/store/src/core/feature-selector.ts new file mode 100644 index 00000000..30462662 --- /dev/null +++ b/packages/store/src/core/feature-selector.ts @@ -0,0 +1,46 @@ +import { pick } from '@videojs/utils/object'; +import { StoreError } from './errors'; +import type { AnyFeature, InferFeatureState, StateFactoryContext } from './feature'; + +const stateContext: StateFactoryContext = { + task: () => { + throw new StoreError('NO_TARGET'); + }, + target: () => { + throw new StoreError('NO_TARGET'); + }, +}; + +/** + * Create a type-safe selector for a feature's state. + * + * The selector returns the feature's state slice, or `undefined` if the feature + * is not configured in the store. + * + * @example + * ```ts + * const selectPlayback = createFeatureSelector(playbackFeature); + * + * function PlayButton() { + * const playback = usePlayer(selectPlayback); + * if (!playback) return null; // Feature not configured + * + * return ; + * } + * ``` + */ +export function createFeatureSelector( + feature: F +): (state: Record) => InferFeatureState | undefined { + const initialState = feature.state(stateContext); + const keys = Object.keys(initialState); + + const firstKey = keys[0]; + if (!firstKey) return () => undefined; + + return (state) => { + // WARN: Could be the source of a bug if two features have overlapping state keys + if (!(firstKey in state)) return undefined; + return pick(state, keys) as InferFeatureState; + }; +} diff --git a/packages/store/src/core/feature.ts b/packages/store/src/core/feature.ts index de0faeb0..0b750e33 100644 --- a/packages/store/src/core/feature.ts +++ b/packages/store/src/core/feature.ts @@ -9,21 +9,21 @@ const FEATURE_SYMBOL = Symbol('@videojs/feature'); // Task // ---------------------------------------- -export type Task = { +export type Task = { (handler: TaskHandler): Promise>; (options: TaskOptions): Promise>; }; -export interface TaskOptions { +export interface TaskOptions { key?: TaskKey; mode?: TaskMode; cancels?: TaskKey[]; handler: TaskHandler; } -export type TaskHandler = (ctx: TaskContext) => Output; +export type TaskHandler = (ctx: TaskContext) => Output; -export interface TaskContext { +export interface TaskContext { target: Target; signal: AbortSignal; get: () => Readonly; @@ -34,13 +34,15 @@ export interface TaskContext { // Attach // ---------------------------------------- -export type Attach = (ctx: AttachContext) => void; +export type Attach = (ctx: AttachContext) => void; -export interface AttachContext { +export interface AttachContext { target: Target; signal: AbortSignal; get: () => Readonly; set: (partial: Partial) => void; + /** Store instance for cross-feature access via selectors. */ + store: { readonly state: Readonly; subscribe: (callback: () => void) => () => void }; } // ---------------------------------------- @@ -57,14 +59,14 @@ export interface StateFactoryContext { // Feature // ---------------------------------------- -export type StateFactory = (ctx: StateFactoryContext) => State; +export type StateFactory = (ctx: StateFactoryContext) => State; -export interface FeatureConfig { +export interface FeatureConfig { state: StateFactory; attach?: Attach; } -export interface Feature extends FeatureConfig { +export interface Feature extends FeatureConfig { [FEATURE_SYMBOL]: true; } @@ -74,10 +76,8 @@ export type AnyFeature = Feature; // Factory // ---------------------------------------- -export function defineFeature(): ( - config: FeatureConfig -) => Feature { - return (config: FeatureConfig): Feature => ({ +export function defineFeature(): (config: FeatureConfig) => Feature { + return (config: FeatureConfig): Feature => ({ [FEATURE_SYMBOL]: true, ...config, }); diff --git a/packages/store/src/core/index.ts b/packages/store/src/core/index.ts index e923d82f..abdbb108 100644 --- a/packages/store/src/core/index.ts +++ b/packages/store/src/core/index.ts @@ -1,8 +1,10 @@ export * from './config'; export * from './errors'; export * from './feature'; +export { createFeatureSelector } from './feature-selector'; export type { TaskKey, TaskMode } from './queue'; export { CANCEL_ALL } from './queue'; export * from './request'; +export { shallowEqual } from './shallow-equal'; export * from './state'; export * from './store'; diff --git a/packages/store/src/core/shallow-equal.ts b/packages/store/src/core/shallow-equal.ts new file mode 100644 index 00000000..f248d2bf --- /dev/null +++ b/packages/store/src/core/shallow-equal.ts @@ -0,0 +1,22 @@ +const hasOwn = Object.prototype.hasOwnProperty; + +export function shallowEqual(a: T, b: T): boolean { + if (Object.is(a, b)) return true; + + if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) { + return false; + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) return false; + + for (const key of keysA) { + if (!hasOwn.call(b, key) || !Object.is((a as Record)[key], (b as Record)[key])) { + return false; + } + } + + return true; +} diff --git a/packages/store/src/core/state.ts b/packages/store/src/core/state.ts index 3ff80b0c..37143c55 100644 --- a/packages/store/src/core/state.ts +++ b/packages/store/src/core/state.ts @@ -1,11 +1,13 @@ export type StateChange = () => void; -export interface State { +export type UnknownState = Record; + +export interface State { readonly current: Readonly; subscribe(callback: StateChange): () => void; } -export interface WritableState extends State { +export interface WritableState extends State { patch: (partial: Partial) => void; } @@ -26,7 +28,7 @@ export function flush(): void { const hasOwnProp = Object.prototype.hasOwnProperty; -class StateContainer implements WritableState { +class StateContainer implements WritableState { #current: T; #listeners = new Set(); #pending = false; @@ -79,7 +81,7 @@ class StateContainer implements WritableState { } } -export function createState(initial: T): WritableState { +export function createState(initial: T): WritableState { return new StateContainer(initial); } diff --git a/packages/store/src/core/store.ts b/packages/store/src/core/store.ts index 6ad7f19b..ad900285 100644 --- a/packages/store/src/core/store.ts +++ b/packages/store/src/core/store.ts @@ -15,14 +15,15 @@ import type { import { CANCEL_ALL, Queue } from './queue'; import type { RequestMeta, RequestMetaInit } from './request'; import { createRequestMeta, createRequestMetaFromEvent } from './request'; -import type { StateChange, WritableState } from './state'; +import type { StateChange, UnknownState, WritableState } from './state'; import { createState } from './state'; const STORE_SYMBOL = Symbol('@videojs/store'); -export function createStore(config: StoreConfig): Store { - type Target = UnionFeatureTarget; - type State = UnionFeatureState; +export function createStore(config: StoreConfig): FeatureStore { + type Store = FeatureStore; + type Target = InferStoreTarget; + type State = UnknownState; const { features } = config; @@ -38,15 +39,14 @@ export function createStore(config: StoreConfig; - const stateFactoryContext: StateFactoryContext = { + const initialState = createInitialState({ task: executeTask, target: () => { if (!target) throw new StoreError('NO_TARGET'); return target; }, - }; + }); - const initialState = createInitialState(stateFactoryContext); state = createState(initialState); const store = { @@ -67,11 +67,11 @@ export function createStore(config: StoreConfig; + } as unknown as Store; for (const key of Object.keys(initialState)) { Object.defineProperty(store, key, { - get: () => (state.current as Record)[key], + get: () => state.current[key], enumerable: true, }); } @@ -116,6 +116,12 @@ export function createStore(config: StoreConfig state.current, set: (partial) => state.patch(partial), + store: { + get state() { + return state.current; + }, + subscribe, + }, }; for (const feature of features) { @@ -127,7 +133,11 @@ export function createStore(config: StoreConfig(config: StoreConfig { + function meta(eventOrMeta: EventLike | RequestMetaInit): Store { currentMeta = 'isTrusted' in eventOrMeta ? createRequestMetaFromEvent(eventOrMeta as EventLike) : createRequestMeta(eventOrMeta as RequestMetaInit); - return metaProxy as Store; + + return metaProxy as Store; } function createInitialState(ctx: StateFactoryContext): State { @@ -260,24 +271,29 @@ export function isStore(value: unknown): value is AnyStore { // Types // ---------------------------------------- -export interface StoreAPI[]> { +export interface BaseStore { + [key: string]: unknown; readonly target: Target | null; readonly destroyed: boolean; readonly pending: Readonly>; - readonly state: UnionFeatureState; + readonly state: State; attach(target: Target): () => void; destroy(): void; subscribe(callback: StateChange): () => void; - meta(eventOrMeta: EventLike | RequestMetaInit): this; + meta(eventOrMeta: EventLike | RequestMetaInit): Store; } -export type Store = StoreAPI, Features> & - UnionFeatureState; +export type Store = BaseStore & State; -export type AnyStore = StoreAPI[]>; +export type FeatureStore = Store< + UnionFeatureTarget, + UnionFeatureState +>; -export type InferStoreTarget = S extends StoreAPI ? Target : never; +export type AnyStore = BaseStore; -export type InferStoreFeatures = S extends StoreAPI ? Features : never; +export type UnknownStore = Store; -export type InferStoreState = UnionFeatureState>; +export type InferStoreTarget = S extends Store ? T : never; + +export type InferStoreState = S extends Store ? State : never; diff --git a/packages/store/src/core/tests/feature-selector.test.ts b/packages/store/src/core/tests/feature-selector.test.ts new file mode 100644 index 00000000..a708902a --- /dev/null +++ b/packages/store/src/core/tests/feature-selector.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { defineFeature } from '../feature'; +import { createFeatureSelector } from '../feature-selector'; + +interface MockMedia { + volume: number; +} + +describe('createFeatureSelector', () => { + const volumeFeature = defineFeature()({ + state: ({ task }) => ({ + volume: 1, + muted: false, + setVolume(value: number) { + return task(({ target }) => { + target.volume = value; + return value; + }); + }, + }), + }); + + const playbackFeature = defineFeature()({ + state: () => ({ + paused: true, + ended: false, + }), + }); + + it('selects feature state from store state', () => { + const selectVolume = createFeatureSelector(volumeFeature); + const state = { volume: 0.5, muted: true, setVolume: () => Promise.resolve(0.5) }; + + const selected = selectVolume(state); + + expect(selected).toEqual({ + volume: 0.5, + muted: true, + setVolume: state.setVolume, + }); + }); + + it('returns undefined when feature is not configured', () => { + const selectVolume = createFeatureSelector(volumeFeature); + const state = { paused: true, ended: false }; // No volume keys + + const selected = selectVolume(state); + + expect(selected).toBeUndefined(); + }); + + it('creates separate selectors for different features', () => { + const selectVolume = createFeatureSelector(volumeFeature); + const selectPlayback = createFeatureSelector(playbackFeature); + const state = { + volume: 0.75, + muted: false, + setVolume: () => Promise.resolve(0.75), + paused: false, + ended: false, + }; + + const volume = selectVolume(state); + const playback = selectPlayback(state); + + expect(volume).toEqual({ + volume: 0.75, + muted: false, + setVolume: state.setVolume, + }); + expect(playback).toEqual({ + paused: false, + ended: false, + }); + }); + + it('returns stable references when state values are the same', () => { + const selectVolume = createFeatureSelector(volumeFeature); + const setVolume = () => Promise.resolve(1); + const state1 = { volume: 1, muted: false, setVolume }; + const state2 = { volume: 1, muted: false, setVolume }; + + const selected1 = selectVolume(state1); + const selected2 = selectVolume(state2); + + // Different object references (new object created each call) + expect(selected1).not.toBe(selected2); + // But structurally equal (for shallowEqual comparison) + expect(selected1).toEqual(selected2); + }); +}); diff --git a/packages/store/src/core/tests/integration/store.test.ts b/packages/store/src/core/tests/integration/store.test.ts index e5ab977e..4a357f03 100644 --- a/packages/store/src/core/tests/integration/store.test.ts +++ b/packages/store/src/core/tests/integration/store.test.ts @@ -1,3 +1,4 @@ +import { noop } from '@videojs/utils/function'; import { describe, expect, it } from 'vitest'; import { createStore, defineFeature } from '../../index'; @@ -500,7 +501,7 @@ describe('sync actions', () => { }), }); - const store = createStore({ features: [feature] }); + const store = createStore({ features: [feature], onError: noop }); await expect(store.doSomething()).rejects.toThrow('NO_TARGET'); }); diff --git a/packages/store/src/core/tests/shallow-equal.test.ts b/packages/store/src/core/tests/shallow-equal.test.ts new file mode 100644 index 00000000..9de49ef1 --- /dev/null +++ b/packages/store/src/core/tests/shallow-equal.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { shallowEqual } from '../shallow-equal'; + +describe('shallowEqual', () => { + it('returns true for identical primitives', () => { + expect(shallowEqual(1, 1)).toBe(true); + expect(shallowEqual('a', 'a')).toBe(true); + expect(shallowEqual(true, true)).toBe(true); + expect(shallowEqual(null, null)).toBe(true); + expect(shallowEqual(undefined, undefined)).toBe(true); + }); + + it('returns false for different primitives', () => { + expect(shallowEqual(1, 2)).toBe(false); + expect(shallowEqual('a', 'b')).toBe(false); + expect(shallowEqual(true, false)).toBe(false); + expect(shallowEqual(null, undefined)).toBe(false); + }); + + it('returns true for same reference', () => { + const obj = { a: 1 }; + expect(shallowEqual(obj, obj)).toBe(true); + }); + + it('returns true for objects with same keys and values', () => { + expect(shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true); + }); + + it('returns false for objects with different values', () => { + expect(shallowEqual({ a: 1 }, { a: 2 })).toBe(false); + }); + + it('returns false for objects with different keys', () => { + expect(shallowEqual({ a: 1 }, { b: 1 })).toBe(false); + expect(shallowEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + }); + + it('returns false for nested objects with different references', () => { + expect(shallowEqual({ a: { b: 1 } }, { a: { b: 1 } })).toBe(false); + }); + + it('returns true for nested objects with same reference', () => { + const nested = { b: 1 }; + expect(shallowEqual({ a: nested }, { a: nested })).toBe(true); + }); + + it('handles NaN correctly', () => { + expect(shallowEqual(NaN, NaN)).toBe(true); + expect(shallowEqual({ a: NaN }, { a: NaN })).toBe(true); + }); + + it('handles +0 and -0', () => { + expect(shallowEqual(0, -0)).toBe(false); + expect(shallowEqual({ a: 0 }, { a: -0 })).toBe(false); + }); + + it('returns false when comparing object to null', () => { + expect(shallowEqual({ a: 1 }, null)).toBe(false); + expect(shallowEqual(null, { a: 1 })).toBe(false); + }); + + it('returns false when comparing object to primitive', () => { + expect(shallowEqual({ a: 1 }, 1 as any)).toBe(false); + expect(shallowEqual(1 as any, { a: 1 })).toBe(false); + }); +}); diff --git a/packages/store/src/lit/create-store.ts b/packages/store/src/lit/create-store.ts index a191f52a..92775e6f 100644 --- a/packages/store/src/lit/create-store.ts +++ b/packages/store/src/lit/create-store.ts @@ -4,8 +4,8 @@ import type { ReactiveControllerHost, ReactiveElement } from '@lit/reactive-elem import { noop } from '@videojs/utils/function'; import type { Constructor } from '@videojs/utils/types'; import type { StoreConfig } from '../core/config'; -import type { AnyFeature, UnionFeatureState } from '../core/feature'; -import type { Store } from '../core/store'; +import type { AnyFeature } from '../core/feature'; +import type { AnyStore, FeatureStore, InferStoreState } from '../core/store'; import { createStore as createCoreStore } from '../core/store'; import { createContainerMixin, createProviderMixin, createStoreMixin } from './mixins'; import type { StoreConsumer, StoreProvider } from './types'; @@ -16,9 +16,9 @@ export interface CreateStoreConfig extends StoreC export type CreateStoreHost = ReactiveControllerHost & HTMLElement; -export type StoreControllerValue = UnionFeatureState; +export type StoreControllerValue = InferStoreState; -export interface CreateStoreResult { +export interface CreateStoreResult { /** * Combined mixin: provides store via context AND auto-attaches slotted media. * @@ -27,7 +27,7 @@ export interface CreateStoreResult { * class MyPlayer extends StoreMixin(LitElement) {} * ``` */ - StoreMixin: >(Base: T) => T & Constructor>; + StoreMixin: >(Base: T) => T & Constructor>; /** * Mixin that provides store via context (no auto-attach). @@ -39,7 +39,7 @@ export interface CreateStoreResult { * class MyProvider extends ProviderMixin(LitElement) {} * ``` */ - ProviderMixin: >(Base: T) => T & Constructor>; + ProviderMixin: >(Base: T) => T & Constructor>; /** * Mixin that auto-attaches slotted media elements (requires store from context). @@ -51,7 +51,7 @@ export interface CreateStoreResult { * class MyControls extends ContainerMixin(LitElement) {} * ``` */ - ContainerMixin: >(Base: T) => T & Constructor>; + ContainerMixin: >(Base: T) => T & Constructor>; /** * Context for consuming store in controllers. @@ -66,7 +66,7 @@ export interface CreateStoreResult { * } * ``` */ - context: Context>; + context: Context; /** * Creates a store instance for imperative access. @@ -79,7 +79,7 @@ export interface CreateStoreResult { * store.attach(videoElement); * ``` */ - create: () => Store; + create: () => Store; /** * Store controller bound to this store's context. @@ -105,7 +105,7 @@ export interface CreateStoreResult { StoreController: new ( host: CreateStoreHost ) => { - value: StoreControllerValue; + value: StoreControllerValue; hostConnected: () => void; hostDisconnected: () => void; }; @@ -145,8 +145,8 @@ export interface CreateStoreResult { */ export function createStore( config: CreateStoreConfig -): CreateStoreResult { - type ProvidedStore = Store; +): CreateStoreResult> { + type ProvidedStore = FeatureStore; const context = createContext(contextKey); @@ -154,9 +154,9 @@ export function createStore( return createCoreStore(config); } - const ProviderMixin = createProviderMixin(context, create); - const ContainerMixin = createContainerMixin(context); - const StoreMixin = createStoreMixin(context, create); + const ProviderMixin = createProviderMixin(context, create); + const ContainerMixin = createContainerMixin(context); + const StoreMixin = createStoreMixin(context, create); class StoreController { readonly #host: CreateStoreHost; @@ -175,7 +175,7 @@ export function createStore( host.addController(this); } - get value(): StoreControllerValue { + get value(): StoreControllerValue { const store = this.#consumer.value; if (!store) { @@ -183,7 +183,7 @@ export function createStore( } // In v2, state and actions are directly on the store object - return store as unknown as StoreControllerValue; + return store as unknown as StoreControllerValue; } hostConnected(): void { diff --git a/packages/store/src/lit/mixins/container-mixin.ts b/packages/store/src/lit/mixins/container-mixin.ts index 84d661cb..8a6c7985 100644 --- a/packages/store/src/lit/mixins/container-mixin.ts +++ b/packages/store/src/lit/mixins/container-mixin.ts @@ -6,19 +6,12 @@ import { Disposer } from '@videojs/utils/events'; import { noop } from '@videojs/utils/function'; import { isNull } from '@videojs/utils/predicate'; import type { Constructor, Mixin } from '@videojs/utils/types'; -import type { AnyFeature, UnionFeatureTarget } from '../../core/feature'; -import type { Store } from '../../core/store'; +import type { AnyStore, InferStoreTarget } from '../../core/store'; import type { StoreConsumer } from '../types'; /** * Creates a mixin that consumes a store from context and auto-attaches media elements. * - * - Requests store from context (must have a provider ancestor) - * - Observes slotted elements for `