mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(store): state subscription primitives (#528)
This commit is contained in:
@@ -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,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
|
||||
);
|
||||
}
|
||||
@@ -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,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';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function identity<T>(value: T): T {
|
||||
return value;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { composeCallbacks } from './compose-callbacks';
|
||||
export { identity } from './identity';
|
||||
export { noop } from './noop';
|
||||
export { tryCatch } from './try-catch';
|
||||
|
||||
Reference in New Issue
Block a user