From 839a7e43bff6b420de3fd53c1b7847aece1f0a1c Mon Sep 17 00:00:00 2001 From: rahim Date: Sat, 14 Feb 2026 00:30:38 +1100 Subject: [PATCH] feat(store): state subscription primitives (#528) --- packages/store/src/core/store.ts | 6 +- packages/store/src/core/tests/store.test.ts | 18 ++ packages/store/src/html/controllers/index.ts | 4 + .../html/controllers/snapshot-controller.ts | 85 +++++++++ .../src/html/controllers/store-controller.ts | 42 ++--- .../tests/snapshot-controller.test.ts | 165 ++++++++++++++++++ packages/store/src/react/hooks/index.ts | 1 + .../react/hooks/tests/use-snapshot.test.tsx | 149 ++++++++++++++++ .../store/src/react/hooks/use-snapshot.ts | 17 ++ packages/store/src/react/hooks/use-store.ts | 5 +- packages/store/src/react/index.ts | 1 + packages/utils/src/function/identity.ts | 3 + packages/utils/src/function/index.ts | 1 + 13 files changed, 463 insertions(+), 34 deletions(-) create mode 100644 packages/store/src/html/controllers/snapshot-controller.ts create mode 100644 packages/store/src/html/controllers/tests/snapshot-controller.test.ts create mode 100644 packages/store/src/react/hooks/tests/use-snapshot.test.tsx create mode 100644 packages/store/src/react/hooks/use-snapshot.ts create mode 100644 packages/utils/src/function/identity.ts diff --git a/packages/store/src/core/store.ts b/packages/store/src/core/store.ts index a1da3147..773bf604 100644 --- a/packages/store/src/core/store.ts +++ b/packages/store/src/core/store.ts @@ -3,7 +3,7 @@ import { AbortControllerRegistry } from './abort-controller-registry'; import type { StoreCallbacks } from './config'; import { throwDestroyedError, throwNoTargetError } from './errors'; import type { AttachContext, Slice, StateContext } from './slice'; -import type { StateChange, SubscribeOptions, UnknownState, WritableState } from './state'; +import type { StateChange, State as StateContainer, SubscribeOptions, UnknownState, WritableState } from './state'; import { createState } from './state'; const STORE_SYMBOL = Symbol('@videojs/store'); @@ -44,6 +44,9 @@ export function createStore(): ( const store = { [STORE_SYMBOL]: true, + get $state() { + return state; + }, get target() { return target; }, @@ -152,6 +155,7 @@ export function isStore(value: unknown): value is AnyStore { export interface BaseStore { [key: string]: unknown; + readonly $state: StateContainer; readonly target: Target | null; readonly destroyed: boolean; readonly state: State; diff --git a/packages/store/src/core/tests/store.test.ts b/packages/store/src/core/tests/store.test.ts index 2b96cc74..adfbfbbd 100644 --- a/packages/store/src/core/tests/store.test.ts +++ b/packages/store/src/core/tests/store.test.ts @@ -70,6 +70,24 @@ describe('store', () => { }); }); + it('exposes $state container matching store.state', () => { + const store = createStore()(audioSlice); + const media = new MockMedia(); + store.attach(media); + + expect(store.$state.current).toBe(store.state); + + const callback = vi.fn(); + store.$state.subscribe(callback); + + media.volume = 0.5; + media.dispatchEvent(new Event('volumechange')); + flush(); + + expect(callback).toHaveBeenCalled(); + expect(store.$state.current.volume).toBe(0.5); + }); + it('calls onSetup', () => { const onSetup = vi.fn(); const store = createStore()(audioSlice, { onSetup }); diff --git a/packages/store/src/html/controllers/index.ts b/packages/store/src/html/controllers/index.ts index 57c4d30a..2c9691f6 100644 --- a/packages/store/src/html/controllers/index.ts +++ b/packages/store/src/html/controllers/index.ts @@ -1,3 +1,7 @@ +export { + SnapshotController, + type SnapshotControllerHost, +} from './snapshot-controller'; export { StoreController, type StoreControllerHost, diff --git a/packages/store/src/html/controllers/snapshot-controller.ts b/packages/store/src/html/controllers/snapshot-controller.ts new file mode 100644 index 00000000..cf3bf4b3 --- /dev/null +++ b/packages/store/src/html/controllers/snapshot-controller.ts @@ -0,0 +1,85 @@ +import type { ReactiveController, ReactiveControllerHost } from '@videojs/element'; +import { noop } from '@videojs/utils/function'; +import type { Selector } from '../../core/shallow-equal'; +import { shallowEqual } from '../../core/shallow-equal'; +import type { State } from '../../core/state'; + +export type SnapshotControllerHost = ReactiveControllerHost & HTMLElement; + +/** + * Subscribe to a `State` container with optional selector. + * + * Without selector: returns full state, re-renders on any state change. + * With selector: returns selected slice, re-renders only when the slice changes (shallowEqual). + * + * @example + * ```ts + * #state = new SnapshotController(this, sliderState, (s) => s.value); + * ``` + */ +export class SnapshotController implements ReactiveController { + readonly #host: ReactiveControllerHost; + readonly #selector: Selector | undefined; + + #state: State; + #cached: R | undefined; + #unsubscribe = noop; + + constructor(host: ReactiveControllerHost, state: State); + constructor(host: ReactiveControllerHost, state: State, selector: Selector); + constructor(host: ReactiveControllerHost, state: State, selector?: Selector) { + this.#host = host; + this.#state = state; + this.#selector = selector; + host.addController(this); + } + + get value(): R { + if (!this.#selector) { + return this.#state.current as unknown as R; + } + + this.#cached ??= this.#selector(this.#state.current); + return this.#cached; + } + + /** Switch to tracking a different state container. */ + track(state: State): void { + this.#state = state; + this.#subscribe(); + } + + hostConnected(): void { + this.#subscribe(); + } + + hostDisconnected(): void { + this.#unsubscribe(); + this.#unsubscribe = noop; + this.#cached = undefined; + } + + #subscribe(): void { + this.#unsubscribe(); + + if (!this.#selector) { + this.#unsubscribe = this.#state.subscribe(() => this.#host.requestUpdate()); + return; + } + + const selector = this.#selector; + this.#cached = selector(this.#state.current); + + this.#unsubscribe = this.#state.subscribe(() => { + const next = selector(this.#state.current); + if (!shallowEqual(this.#cached, next)) { + this.#cached = next; + this.#host.requestUpdate(); + } + }); + } +} + +export namespace SnapshotController { + export type Host = SnapshotControllerHost; +} diff --git a/packages/store/src/html/controllers/store-controller.ts b/packages/store/src/html/controllers/store-controller.ts index c76b4d7a..869a8c2d 100644 --- a/packages/store/src/html/controllers/store-controller.ts +++ b/packages/store/src/html/controllers/store-controller.ts @@ -1,14 +1,12 @@ import type { ReactiveController, ReactiveControllerHost } from '@videojs/element'; -import { noop } from '@videojs/utils/function'; import { isNull, isUndefined } from '@videojs/utils/predicate'; -import { shallowEqual } from '../../core/shallow-equal'; +import type { Selector } from '../../core/shallow-equal'; import type { AnyStore, InferStoreState } from '../../core/store'; import { StoreAccessor, type StoreSource } from '../store-accessor'; +import { SnapshotController } from './snapshot-controller'; export type StoreControllerHost = ReactiveControllerHost & HTMLElement; -export type Selector = (state: State) => Result; - /** * Access store state and actions. * @@ -45,8 +43,7 @@ export class StoreController implements readonly #selector: Selector, Result> | undefined; readonly #accessor: StoreAccessor; - #cached: Result | undefined; - #unsubscribe = noop; + #snapshot: SnapshotController | null = null; constructor(host: StoreControllerHost, source: StoreSource); constructor( @@ -77,37 +74,22 @@ export class StoreController implements return store as unknown as Result; } - // With selector: return cached selected value - this.#cached ??= this.#selector(store.state as InferStoreState); - return this.#cached; + // With selector: delegate to snapshot controller + return this.#snapshot!.value; } - hostDisconnected(): void { - this.#unsubscribe(); - this.#unsubscribe = noop; - this.#cached = undefined; + hostConnected(): void { + // StoreAccessor + SnapshotController handle their own lifecycle. } #connect(store: Store): void { - this.#unsubscribe(); + if (isUndefined(this.#selector)) return; - // Without selector: no subscription - if (isUndefined(this.#selector)) { - return; + if (!this.#snapshot) { + this.#snapshot = new SnapshotController(this.#host, store.$state, this.#selector as Selector); + } else { + this.#snapshot.track(store.$state); } - - // With selector: subscribe with shallowEqual comparison - const selector = this.#selector; - - this.#cached = selector(store.state as InferStoreState); - - this.#unsubscribe = store.subscribe(() => { - const next = selector(store.state as InferStoreState); - if (!shallowEqual(this.#cached, next)) { - this.#cached = next; - this.#host.requestUpdate(); - } - }); } } diff --git a/packages/store/src/html/controllers/tests/snapshot-controller.test.ts b/packages/store/src/html/controllers/tests/snapshot-controller.test.ts new file mode 100644 index 00000000..d734f565 --- /dev/null +++ b/packages/store/src/html/controllers/tests/snapshot-controller.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createState, flush } from '../../../core/state'; +import { createTestHost } from '../../tests/test-utils'; +import { SnapshotController } from '../snapshot-controller'; + +describe('SnapshotController', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('without selector', () => { + it('returns full state', () => { + const state = createState({ volume: 0.8, muted: false }); + const host = createTestHost(); + + const controller = new SnapshotController(host, state); + document.body.appendChild(host); + + expect(controller.value).toEqual({ volume: 0.8, muted: false }); + }); + + it('triggers update on any state change', async () => { + const state = createState({ volume: 1, muted: false }); + const host = createTestHost(); + + new SnapshotController(host, state); + document.body.appendChild(host); + + await Promise.resolve(); + const initialCount = host.updateCount; + + state.patch({ volume: 0.5 }); + flush(); + await Promise.resolve(); + + expect(host.updateCount).toBeGreaterThan(initialCount); + }); + }); + + describe('with selector', () => { + it('returns selected value', () => { + const state = createState({ volume: 0.7, muted: true }); + const host = createTestHost(); + + const controller = new SnapshotController(host, state, (s) => s.volume); + document.body.appendChild(host); + + expect(controller.value).toBe(0.7); + }); + + it('triggers update when selected state changes', async () => { + const state = createState({ volume: 1, muted: false }); + const host = createTestHost(); + + const controller = new SnapshotController(host, state, (s) => s.volume); + document.body.appendChild(host); + + expect(controller.value).toBe(1); + + state.patch({ volume: 0.5 }); + flush(); + await Promise.resolve(); + + expect(controller.value).toBe(0.5); + expect(host.updateCount).toBeGreaterThan(0); + }); + + it('does not trigger update when unrelated state changes', async () => { + const state = createState({ volume: 1, muted: false }); + const host = createTestHost(); + + new SnapshotController(host, state, (s) => s.volume); + document.body.appendChild(host); + + await Promise.resolve(); + const initialCount = host.updateCount; + + state.patch({ muted: true }); + flush(); + await Promise.resolve(); + + expect(host.updateCount).toBe(initialCount); + }); + }); + + describe('lifecycle', () => { + it('unsubscribes on disconnect', async () => { + const state = createState({ volume: 1, muted: false }); + const host = createTestHost(); + + new SnapshotController(host, state, (s) => s.volume); + document.body.appendChild(host); + host.remove(); + + const updateCountBefore = host.updateCount; + + state.patch({ volume: 0.5 }); + flush(); + await Promise.resolve(); + + expect(host.updateCount).toBe(updateCountBefore); + }); + + it('resubscribes on reconnect', async () => { + const state = createState({ volume: 1, muted: false }); + const host = createTestHost(); + + const controller = new SnapshotController(host, state, (s) => s.volume); + document.body.appendChild(host); + + expect(controller.value).toBe(1); + + host.remove(); + + state.patch({ volume: 0.8 }); + flush(); + + // Reconnect + document.body.appendChild(host); + + expect(controller.value).toBe(0.8); + }); + }); + + describe('track', () => { + it('switches to a different state container', async () => { + const state1 = createState({ volume: 1, muted: false }); + const state2 = createState({ volume: 0.3, muted: true }); + const host = createTestHost(); + + const controller = new SnapshotController(host, state1, (s) => s.volume); + document.body.appendChild(host); + + expect(controller.value).toBe(1); + + controller.track(state2); + + expect(controller.value).toBe(0.3); + }); + + it('unsubscribes from previous state on track', async () => { + const state1 = createState({ volume: 1, muted: false }); + const state2 = createState({ volume: 0.5, muted: false }); + const host = createTestHost(); + + const controller = new SnapshotController(host, state1, (s) => s.volume); + document.body.appendChild(host); + + await Promise.resolve(); + + // Switch to state2 + controller.track(state2); + + const countAfterTrack = host.updateCount; + + // Mutate state1 — should NOT trigger update + state1.patch({ volume: 0.2 }); + flush(); + await Promise.resolve(); + + expect(host.updateCount).toBe(countAfterTrack); + }); + }); +}); diff --git a/packages/store/src/react/hooks/index.ts b/packages/store/src/react/hooks/index.ts index 64df4ad5..8da2bb8d 100644 --- a/packages/store/src/react/hooks/index.ts +++ b/packages/store/src/react/hooks/index.ts @@ -1,2 +1,3 @@ export { useSelector } from './use-selector'; +export { useSnapshot } from './use-snapshot'; export { useStore } from './use-store'; diff --git a/packages/store/src/react/hooks/tests/use-snapshot.test.tsx b/packages/store/src/react/hooks/tests/use-snapshot.test.tsx new file mode 100644 index 00000000..a8b97f4b --- /dev/null +++ b/packages/store/src/react/hooks/tests/use-snapshot.test.tsx @@ -0,0 +1,149 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { createState, flush } from '../../../core/state'; +import { useSnapshot } from '../use-snapshot'; + +describe('useSnapshot', () => { + describe('without selector', () => { + it('returns the full state', () => { + const state = createState({ volume: 0.8, muted: false }); + + const { result } = renderHook(() => useSnapshot(state)); + + expect(result.current.volume).toBe(0.8); + expect(result.current.muted).toBe(false); + }); + + it('re-renders when state changes', async () => { + const state = createState({ volume: 1, muted: false }); + let renderCount = 0; + + const { result } = renderHook(() => { + renderCount++; + return useSnapshot(state); + }); + + expect(renderCount).toBe(1); + expect(result.current.volume).toBe(1); + + await act(async () => { + state.patch({ volume: 0.5 }); + flush(); + }); + + expect(renderCount).toBe(2); + expect(result.current.volume).toBe(0.5); + }); + + it('does not re-render when patched values are identical', async () => { + const state = createState({ volume: 1, muted: false }); + let renderCount = 0; + + renderHook(() => { + renderCount++; + return useSnapshot(state); + }); + + expect(renderCount).toBe(1); + + await act(async () => { + state.patch({ volume: 1 }); + flush(); + }); + + expect(renderCount).toBe(1); + }); + }); + + describe('with selector', () => { + it('returns the selected value', () => { + const state = createState({ volume: 0.7, muted: true }); + + const { result } = renderHook(() => useSnapshot(state, (s) => s.volume)); + + expect(result.current).toBe(0.7); + }); + + it('re-renders only when selected value changes', async () => { + const state = createState({ volume: 1, muted: false }); + let renderCount = 0; + + const { result } = renderHook(() => { + renderCount++; + return useSnapshot(state, (s) => s.volume); + }); + + expect(renderCount).toBe(1); + expect(result.current).toBe(1); + + // Change unrelated state — should NOT re-render + await act(async () => { + state.patch({ muted: true }); + flush(); + }); + + expect(renderCount).toBe(1); + + // Change selected state — should re-render + await act(async () => { + state.patch({ volume: 0.3 }); + flush(); + }); + + expect(renderCount).toBe(2); + expect(result.current).toBe(0.3); + }); + }); + + describe('with custom comparator', () => { + it('uses custom equality to suppress re-renders', async () => { + const state = createState({ volume: 1, muted: false }); + let renderCount = 0; + + const alwaysEqual = () => true; + + const { result } = renderHook(() => { + renderCount++; + return useSnapshot(state, (s) => s.volume, alwaysEqual); + }); + + expect(renderCount).toBe(1); + expect(result.current).toBe(1); + + await act(async () => { + state.patch({ volume: 0.5 }); + flush(); + }); + + // Should NOT re-render because custom comparator says values are equal + expect(renderCount).toBe(1); + expect(result.current).toBe(1); + }); + }); + + describe('microtask batching', () => { + it('batches multiple patches into a single re-render', async () => { + const state = createState({ volume: 1, muted: false }); + let renderCount = 0; + + const { result } = renderHook(() => { + renderCount++; + return useSnapshot(state); + }); + + expect(renderCount).toBe(1); + + await act(async () => { + state.patch({ volume: 0.5 }); + state.patch({ muted: true }); + flush(); + }); + + // Should have batched into a single re-render + expect(renderCount).toBe(2); + expect(result.current.volume).toBe(0.5); + expect(result.current.muted).toBe(true); + }); + }); +}); diff --git a/packages/store/src/react/hooks/use-snapshot.ts b/packages/store/src/react/hooks/use-snapshot.ts new file mode 100644 index 00000000..49e7a9e0 --- /dev/null +++ b/packages/store/src/react/hooks/use-snapshot.ts @@ -0,0 +1,17 @@ +import { identity } from '@videojs/utils/function'; +import type { State } from '../../core/state'; +import { type Comparator, type Selector, useSelector } from './use-selector'; + +/** Subscribe to a State container's current value. */ +export function useSnapshot(state: State): T; + +export function useSnapshot(state: State, selector: Selector, isEqual?: Comparator): R; + +export function useSnapshot(state: State, selector?: Selector, isEqual?: Comparator) { + return useSelector( + (cb) => state.subscribe(cb), + () => state.current, + selector ?? identity, + isEqual + ); +} diff --git a/packages/store/src/react/hooks/use-store.ts b/packages/store/src/react/hooks/use-store.ts index 5e751396..831c590b 100644 --- a/packages/store/src/react/hooks/use-store.ts +++ b/packages/store/src/react/hooks/use-store.ts @@ -1,9 +1,8 @@ -import { noop } from '@videojs/utils/function'; +import { identity, noop } from '@videojs/utils/function'; import type { AnyStore, InferStoreState } from '../../core/store'; import { type Comparator, type Selector, useSelector } from './use-selector'; -const identity = (s: any) => s, - noopSubscribe = () => noop; +const noopSubscribe = () => noop; /** * Access store state and actions. diff --git a/packages/store/src/react/index.ts b/packages/store/src/react/index.ts index f83385fa..106a3e7a 100644 --- a/packages/store/src/react/index.ts +++ b/packages/store/src/react/index.ts @@ -1,2 +1,3 @@ export { type Comparator, type Selector, useSelector } from './hooks/use-selector'; +export { useSnapshot } from './hooks/use-snapshot'; export { useStore } from './hooks/use-store'; diff --git a/packages/utils/src/function/identity.ts b/packages/utils/src/function/identity.ts new file mode 100644 index 00000000..5aca6fd9 --- /dev/null +++ b/packages/utils/src/function/identity.ts @@ -0,0 +1,3 @@ +export function identity(value: T): T { + return value; +} diff --git a/packages/utils/src/function/index.ts b/packages/utils/src/function/index.ts index eb44c56b..45c9292d 100644 --- a/packages/utils/src/function/index.ts +++ b/packages/utils/src/function/index.ts @@ -1,3 +1,4 @@ export { composeCallbacks } from './compose-callbacks'; +export { identity } from './identity'; export { noop } from './noop'; export { tryCatch } from './try-catch';