feat(store): state subscription primitives (#528)

This commit is contained in:
rahim
2026-02-14 00:30:38 +11:00
committed by GitHub
parent aa0fb7ca70
commit 839a7e43bf
13 changed files with 463 additions and 34 deletions
+5 -1
View File
@@ -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<Target = unknown>(): <State>(
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<Target = unknown, State = UnknownState> {
[key: string]: unknown;
readonly $state: StateContainer<State>;
readonly target: Target | null;
readonly destroyed: boolean;
readonly state: State;
@@ -70,6 +70,24 @@ describe('store', () => {
});
});
it('exposes $state container matching store.state', () => {
const store = createStore<MockMedia>()(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<MockMedia>()(audioSlice, { onSetup });
@@ -1,3 +1,7 @@
export {
SnapshotController,
type SnapshotControllerHost,
} from './snapshot-controller';
export {
StoreController,
type StoreControllerHost,
@@ -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<T>` 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<T extends object, R = T> implements ReactiveController {
readonly #host: ReactiveControllerHost;
readonly #selector: Selector<T, R> | undefined;
#state: State<T>;
#cached: R | undefined;
#unsubscribe = noop;
constructor(host: ReactiveControllerHost, state: State<T>);
constructor(host: ReactiveControllerHost, state: State<T>, selector: Selector<T, R>);
constructor(host: ReactiveControllerHost, state: State<T>, selector?: Selector<T, R>) {
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<T>): 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;
}
@@ -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, Result> = (state: State) => Result;
/**
* Access store state and actions.
*
@@ -45,8 +43,7 @@ export class StoreController<Store extends AnyStore, Result = Store> implements
readonly #selector: Selector<InferStoreState<Store>, Result> | undefined;
readonly #accessor: StoreAccessor<Store>;
#cached: Result | undefined;
#unsubscribe = noop;
#snapshot: SnapshotController<object, Result> | null = null;
constructor(host: StoreControllerHost, source: StoreSource<Store>);
constructor(
@@ -77,37 +74,22 @@ export class StoreController<Store extends AnyStore, Result = Store> implements
return store as unknown as Result;
}
// With selector: return cached selected value
this.#cached ??= this.#selector(store.state as InferStoreState<Store>);
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<object, Result>);
} else {
this.#snapshot.track(store.$state);
}
// With selector: subscribe with shallowEqual comparison
const selector = this.#selector;
this.#cached = selector(store.state as InferStoreState<Store>);
this.#unsubscribe = store.subscribe(() => {
const next = selector(store.state as InferStoreState<Store>);
if (!shallowEqual(this.#cached, next)) {
this.#cached = next;
this.#host.requestUpdate();
}
});
}
}
@@ -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);
});
});
});
+1
View File
@@ -1,2 +1,3 @@
export { useSelector } from './use-selector';
export { useSnapshot } from './use-snapshot';
export { useStore } from './use-store';
@@ -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);
});
});
});
@@ -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<T extends object>(state: State<T>): T;
export function useSnapshot<T extends object, R>(state: State<T>, selector: Selector<T, R>, isEqual?: Comparator<R>): R;
export function useSnapshot(state: State<object>, selector?: Selector<any, any>, isEqual?: Comparator<any>) {
return useSelector(
(cb) => state.subscribe(cb),
() => state.current,
selector ?? identity,
isEqual
);
}
+2 -3
View File
@@ -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.
+1
View File
@@ -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';
+3
View File
@@ -0,0 +1,3 @@
export function identity<T>(value: T): T {
return value;
}
+1
View File
@@ -1,3 +1,4 @@
export { composeCallbacks } from './compose-callbacks';
export { identity } from './identity';
export { noop } from './noop';
export { tryCatch } from './try-catch';