refactor(store): simplify state management + computeds (#321)

This commit is contained in:
rahim
2026-01-22 21:56:47 +11:00
committed by GitHub
parent 78fab92a9d
commit e01ce5bc1c
23 changed files with 822 additions and 811 deletions
+56 -38
View File
@@ -28,9 +28,8 @@ const store = createStore({
store.attach(videoElement); // <video>
// State is synced
store.state.paused; // true
store.state.volume; // 1
// State is synced via .current
const { paused, volume } = store.state.current;
// Requests are coordinated async operations
await store.request.play();
@@ -233,9 +232,8 @@ type Requests = InferStoreRequests<typeof store>;
```ts
const detach = store.attach(videoElement);
// State syncs from target
store.state.paused;
store.state.volume;
// State syncs from target (access via .current)
const { paused, volume } = store.state.current;
// Requests go to target
store.request.play();
@@ -258,16 +256,16 @@ store.destroy();
State is reactive—subscribe to be notified when any property changes:
```ts
import { subscribe, subscribeKeys } from '@videojs/store';
// Subscribe to all state changes
const unsubscribe = subscribe(store.state, () => {
console.log('State changed:', store.state.volume);
const unsubscribe = store.state.subscribe(() => {
const { volume } = store.state.current;
console.log('State changed:', volume);
});
// Subscribe to specific keys only
subscribeKeys(store.state, ['volume', 'muted'], () => {
console.log('Audio changed:', store.state.volume, store.state.muted);
store.state.subscribe(['volume', 'muted'], () => {
const { volume, muted } = store.state.current;
console.log('Audio changed:', volume, muted);
});
```
@@ -525,9 +523,9 @@ queue.reset('seek'); // clear specific request
queue.reset(); // clear all settled
// Subscribe to task changes
subscribe(queue.tasks, () => {
const playTask = queue.tasks.play;
if (playTask?.status === 'pending') {
queue.tasks.subscribe(() => {
const { play } = queue.tasks.current;
if (play?.status === 'pending') {
console.log('Play in progress...');
}
});
@@ -540,7 +538,7 @@ Each request creates a task that transitions through states:
```ts
import { isErrorTask, isPendingTask, isSettledTask, isSuccessTask } from '@videojs/store';
const task = queue.tasks.play;
const { play: task } = queue.tasks.current;
// Type guards for status checking
if (isPendingTask(task)) {
@@ -586,10 +584,8 @@ await queue.enqueue({
Use `subscribe` to react to task changes—useful for loading states and error handling:
```ts
import { subscribe } from '@videojs/store';
subscribe(queue.tasks, () => {
for (const [name, task] of Object.entries(queue.tasks)) {
queue.tasks.subscribe(() => {
for (const [name, task] of Object.entries(queue.tasks.current)) {
if (task?.status === 'error' && !task.cancelled) {
toast.error(`${name} failed: ${task.error}`);
}
@@ -597,8 +593,8 @@ subscribe(queue.tasks, () => {
});
// Analytics
subscribe(queue.tasks, () => {
for (const task of Object.values(queue.tasks)) {
queue.tasks.subscribe(() => {
for (const task of Object.values(queue.tasks.current)) {
if (task && task.status !== 'pending') {
analytics.track('request', {
name: task.name,
@@ -612,37 +608,59 @@ subscribe(queue.tasks, () => {
## Advanced
### Reactive Primitives
### State Primitives
The store uses reactive state internally. You can also use these primitives directly:
The store uses explicit state containers internally. You can also use these primitives directly:
```ts
import { flush, isReactive, reactive, snapshot, subscribe, subscribeKeys } from '@videojs/store';
import { createState, flush, isState } from '@videojs/store';
// Create reactive state
const state = reactive({ volume: 1, muted: false });
// Create state container
const state = createState({ volume: 1, muted: false });
// Mutate directly - changes are auto-batched
state.volume = 0.5;
state.muted = true;
// Read via destructuring from .current
const { volume } = state.current; // 1
// Mutate via set() or patch() - changes are auto-batched
state.set('volume', 0.5);
state.patch({ volume: 0.5, muted: true });
// Only ONE notification fires (after microtask)
// Subscribe to all changes
subscribe(state, () => console.log('Changed:', state.volume));
state.subscribe(() => {
const { volume } = state.current;
console.log('Changed:', volume);
});
// Subscribe to specific keys
subscribeKeys(state, ['volume'], () => console.log('Volume:', state.volume));
state.subscribe(['volume'], () => {
const { volume } = state.current;
console.log('Volume:', volume);
});
// Check if value is reactive
isReactive(state); // true
// Get frozen snapshot
const snap = snapshot(state);
// Check if value is state
isState(state); // true
// Force immediate notification (mainly for tests)
flush();
```
### Computed Values
Derive reactive values from state:
```ts
import { createComputed } from '@videojs/store';
const effectiveVolume = createComputed(state, ['volume', 'muted'], ({ volume, muted }) => (muted ? 0 : volume));
effectiveVolume.current; // derived value
effectiveVolume.subscribe(() => console.log('changed'));
effectiveVolume.destroy(); // cleanup when done
```
Computed values only notify when the derived result actually changes.
### Capability Checking
Features can expose capability via state. UI components check before rendering.
@@ -723,7 +741,7 @@ dispatch(play());
// @videojs/store - working with the abstraction
await store.request.play(); // Resolves when actually playing
store.state.paused; // Always reflects video.paused
const { paused } = store.state.current; // Always reflects video.paused
```
## Community
+90
View File
@@ -0,0 +1,90 @@
import type { State } from './state';
/**
* Create a computed value derived from state.
*
* @example
* ```ts
* const effectiveVolume = createComputed(state, ['volume', 'muted'], ({ volume, muted }) => muted ? 0 : volume);
* ```
*/
export function createComputed<T extends object, K extends keyof T, R>(
state: State<T>,
keys: K[],
derive: (snapshot: Pick<T, K>) => R,
): Computed<T, K, R> {
return new Computed(state, keys, derive);
}
/**
* A computed value derived from state.
*
* The derived value is recomputed when any of the specified keys change.
* Subscribers are notified only when the computed value actually changes.
*
* @example
* ```ts
* const effectiveVolume = new Computed(
* state,
* ['volume', 'muted'],
* ({ volume, muted }) => muted ? 0 : volume
* );
*
* effectiveVolume.current; // derived value
* effectiveVolume.subscribe(() => console.log('changed'));
* effectiveVolume.destroy(); // cleanup when done
* ```
*/
export class Computed<T extends object, K extends keyof T, R> {
readonly #state: State<T>;
readonly #keys: K[];
readonly #derive: (snapshot: Pick<T, K>) => R;
readonly #listeners = new Set<() => void>();
readonly #unsubscribe: () => void;
#cached!: R;
#initialized = false;
constructor(state: State<T>, keys: K[], derive: (snapshot: Pick<T, K>) => R) {
this.#state = state;
this.#keys = keys;
this.#derive = derive;
this.#unsubscribe = state.subscribe(keys, () => {
if (this.#compute()) {
for (const fn of this.#listeners) fn();
}
});
}
get current(): R {
if (!this.#initialized) this.#compute();
return this.#cached;
}
subscribe(listener: () => void): () => void {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
destroy(): void {
this.#unsubscribe();
this.#listeners.clear();
}
#compute(): boolean {
const currentState = {} as Pick<T, K>;
for (const k of this.#keys) {
currentState[k] = this.#state.current[k];
}
const next = this.#derive(currentState);
const changed = !this.#initialized || !Object.is(this.#cached, next);
this.#cached = next;
this.#initialized = true;
return changed;
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from './computed';
export * from './errors';
export * from './extend-config';
export * from './feature';
+47 -37
View File
@@ -1,12 +1,12 @@
import type { Request, RequestMeta, RequestMode } from './request';
import type { Reactive } from './state';
import type { State, WritableState } from './state';
import type { ErrorTask, PendingTask, SuccessTask, Task, TaskContext, TaskKey } from './task';
import { abortable } from '@videojs/utils/events';
import { isUndefined } from '@videojs/utils/predicate';
import { StoreError } from './errors';
import { reactive } from './state';
import { createState } from './state';
// ----------------------------------------
// Types
@@ -38,15 +38,17 @@ export type TasksRecord<Tasks extends TaskRecord> = {
// ----------------------------------------
export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
/** Reactive tasks. Subscribe via `subscribe(queue.tasks, fn)`. */
readonly tasks: Reactive<TasksRecord<Tasks>>;
readonly #tasks: WritableState<TasksRecord<Tasks>>;
readonly #sharedPromises = new Map<TaskKey, Promise<unknown>>();
#destroyed = false;
get tasks(): State<TasksRecord<Tasks>> {
return this.#tasks;
}
constructor() {
this.tasks = reactive({} as TasksRecord<Tasks>);
this.#tasks = createState({});
}
get destroyed(): boolean {
@@ -56,17 +58,19 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
/** Clear settled task(s). If name provided, clears that task. If no name, clears all settled. */
reset(name?: keyof Tasks): void {
if (!isUndefined(name)) {
const task = this.tasks[name];
const task = this.tasks.current[name];
if (!task || task.status === 'pending') return;
delete this.tasks[name];
this.#tasks.delete(name);
return;
}
for (const key of Reflect.ownKeys(this.tasks) as (keyof Tasks)[]) {
const task = this.tasks[key];
for (const key of Reflect.ownKeys(this.tasks.current) as (keyof Tasks)[]) {
const task = this.tasks.current[key];
if (task && task.status !== 'pending') {
delete this.tasks[key];
this.#tasks.delete(key);
}
}
}
@@ -84,12 +88,12 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
if (mode === 'shared') {
const existingPromise = this.#sharedPromises.get(key);
if (existingPromise) {
return existingPromise as Promise<Tasks[K]['output']>;
return existingPromise;
}
}
// Supersede any pending task with the same key (may have different name)
for (const existingTask of Object.values(this.tasks) as Task[]) {
for (const existingTask of Object.values(this.tasks.current)) {
if (existingTask?.key === key && existingTask.status === 'pending') {
existingTask.abort.abort(new StoreError('SUPERSEDED'));
}
@@ -124,7 +128,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
/** Abort task(s). If name provided, aborts that task. If no name, aborts all. */
abort(name?: keyof Tasks): void {
if (!isUndefined(name)) {
const task = this.tasks[name];
const task = this.tasks.current[name];
if (task?.status === 'pending') {
task.abort.abort(new StoreError('ABORTED'));
}
@@ -134,7 +138,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
const error = new StoreError('ABORTED');
for (const task of Object.values(this.tasks) as Task[]) {
for (const task of Object.values(this.tasks.current)) {
if (task?.status === 'pending') {
task.abort.abort(error);
}
@@ -148,8 +152,8 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
this.abort();
// Clear all tasks
for (const key of Reflect.ownKeys(this.tasks) as (keyof Tasks)[]) {
delete this.tasks[key];
for (const key of Reflect.ownKeys(this.tasks.current)) {
this.#tasks.delete(key);
}
this.#sharedPromises.clear();
@@ -182,34 +186,44 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
};
// Store tasks by name for controller access
(this.tasks as TasksRecord<Tasks>)[name as keyof Tasks] = pendingTask;
this.#tasks.set(name as keyof Tasks, pendingTask);
try {
const result = await abortable(handler({ input, signal: abort.signal }), abort.signal);
resolve(result);
// Only update if we're still the current task for this name (compare by ID since reactive wraps tasks)
const currentTask = this.tasks[name as keyof Tasks];
// Only update if we're still the current task for this name
const currentTask = this.tasks.current[name];
if (currentTask?.id === id) {
Object.assign(currentTask, {
status: 'success',
settledAt: Date.now(),
output: result,
} satisfies Partial<SuccessTask>);
this.#tasks.set(
name as keyof Tasks,
{
...currentTask,
status: 'success',
settledAt: Date.now(),
output: result,
} satisfies SuccessTask,
);
}
} catch (error) {
reject(error);
// Only update if we're still the current task for this name (compare by ID since reactive wraps tasks)
const currentTask = this.tasks[name as keyof Tasks];
// Only update if we're still the current task for this name
const currentTask = this.tasks.current[name as keyof Tasks];
if (currentTask?.id === id) {
Object.assign(currentTask, {
status: 'error',
settledAt: Date.now(),
error,
cancelled: abort.signal.aborted,
} satisfies Partial<ErrorTask>);
this.#tasks.set(
name as keyof Tasks,
{
...currentTask,
status: 'error',
settledAt: Date.now(),
error,
cancelled: abort.signal.aborted,
} satisfies ErrorTask,
);
}
}
}
@@ -222,10 +236,6 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
/**
* Create a queue for managing task execution.
*
* - Tasks execute immediately when enqueued
* - Same key = supersede previous (abort pending)
* - Subscribe to task changes via `subscribe(queue.tasks, fn)`
*
* @example
* // Loose typing (default)
* const queue = createQueue();
+112 -231
View File
@@ -1,259 +1,140 @@
import { isObject, isPlainObject } from '@videojs/utils/predicate';
type Listener = (changedKeys: ReadonlySet<PropertyKey>) => void;
/** Symbol used to brand reactive objects. */
const REACTIVE_SYMBOL = Symbol('@videojs/reactive');
/** A reactive state object created by `reactive()`. */
export type Reactive<T extends object> = T & { readonly [REACTIVE_SYMBOL]: true };
/** Extract the underlying state type from a `Reactive<T>`. */
export type InferReactiveState<R> = R extends Reactive<infer T> ? T : never;
// Track which objects are reactive (for isReactive check)
const reactiveCache = new WeakSet<object>();
// Map from target -> reactive object (to find reactive from within set handler)
const reactiveMap = new WeakMap<object, object>();
// Global listeners per proxy
const listeners = new WeakMap<object, Set<Listener>>();
// Key-specific listeners per proxy
const keyListeners = new WeakMap<object, Map<PropertyKey, Set<Listener>>>();
// Parent references for bubbling (proxy -> parent proxy + key)
interface ParentInfo {
parent: object;
key: PropertyKey;
export interface State<T extends object> {
readonly current: Readonly<T>;
subscribe: ((listener: Listener) => () => void) & (<K extends keyof T>(keys: K[], listener: Listener) => () => void);
}
const parents = new WeakMap<object, ParentInfo>();
export interface WritableState<T extends object> extends State<T> {
set: <K extends keyof T>(key: K, value: T[K]) => void;
patch: (partial: Partial<T>) => void;
delete: <K extends keyof T>(key: K) => void;
}
// Pending changes (proxy -> keys that changed)
const pending = new Map<object, Set<PropertyKey>>();
// Batching
let batchDepth = 0;
let flushScheduled = false;
/** Create a reactive state object with optional parent for change bubbling. */
export function reactive<T extends object>(initial: T, parent?: object, parentKey?: PropertyKey): Reactive<T> {
const proxy = new Proxy(initial, {
set(target, prop, value, receiver) {
const prev = Reflect.get(target, prop, receiver);
if (Object.is(prev, value)) return true;
// Get the reactive object for this target
const currentReactive = reactiveMap.get(target)!;
// Auto-wrap nested plain objects with this as parent
if (isPlainObject(value) && !isReactive(value)) {
value = reactive(value, currentReactive, prop);
}
Reflect.set(target, prop, value, receiver);
// Mark this and all parents as pending
let current: object | undefined = currentReactive;
let changedKey: PropertyKey = prop;
while (current) {
let pendingKeys = pending.get(current);
if (!pendingKeys) pending.set(current, (pendingKeys = new Set()));
pendingKeys.add(changedKey);
const info = parents.get(current);
if (!info) break;
changedKey = info.key;
current = info.parent;
}
if (batchDepth === 0) scheduleFlush();
return true;
},
deleteProperty(target, prop) {
const hadProp = Reflect.has(target, prop);
const result = Reflect.deleteProperty(target, prop);
if (hadProp && result) {
const currentReactive = reactiveMap.get(target)!;
let current: object | undefined = currentReactive;
let changedKey: PropertyKey = prop;
while (current) {
let pendingKeys = pending.get(current);
if (!pendingKeys) pending.set(current, (pendingKeys = new Set()));
pendingKeys.add(changedKey);
const info = parents.get(current);
if (!info) break;
changedKey = info.key;
current = info.parent;
}
if (batchDepth === 0) scheduleFlush();
}
return result;
},
});
reactiveCache.add(proxy);
reactiveMap.set(initial, proxy);
if (parent && parentKey !== undefined) parents.set(proxy, { parent, key: parentKey });
// Auto-wrap nested plain objects after creation (so we can set parent)
for (const key in initial) {
if (!Object.prototype.hasOwnProperty.call(initial, key)) continue;
const value = initial[key as keyof T];
if (isPlainObject(value) && !isReactive(value)) {
(initial as Record<string, unknown>)[key] = reactive(value, proxy, key);
}
}
// Cast is safe: the proxy is branded at runtime via reactiveCache
return proxy as Reactive<T>;
}
/** Check if a value is reactive (created by this module). */
export function isReactive<T extends object>(value: T | unknown): value is Reactive<T> {
return isObject(value) && reactiveCache.has(value);
}
function scheduleFlush(): void {
if (flushScheduled) return;
flushScheduled = true;
queueMicrotask(flush);
}
/** Force pending notifications immediately. Mainly for tests. */
const pendingContainers = new Set<StateContainer<any>>();
export function flush(): void {
flushScheduled = false;
for (const [target, keys] of pending) {
// Notify global listeners for this target (with changed keys)
const targetListeners = listeners.get(target);
if (targetListeners) {
for (const fn of targetListeners) fn(keys);
for (const container of pendingContainers) {
container.flush();
}
pendingContainers.clear();
}
const hasOwnProp = Object.prototype.hasOwnProperty;
class StateContainer<T extends object> implements WritableState<T> {
#current: T;
#listeners = new Set<Listener>();
#keyListeners = new Map<PropertyKey, Set<Listener>>();
#pending = new Set<PropertyKey>();
constructor(initial: T) {
this.#current = Object.freeze({ ...initial });
}
get current(): Readonly<T> {
return this.#current;
}
set<K extends keyof T>(key: K, value: T[K]): void {
if (Object.is(this.#current[key], value)) return;
this.#current = Object.freeze({ ...this.#current, [key]: value });
this.#pending.add(key);
pendingContainers.add(this);
scheduleFlush();
}
delete<K extends keyof T>(key: K): void {
if (!(key in this.#current)) return;
const { [key]: _, ...rest } = this.#current;
this.#current = Object.freeze(rest as T);
this.#pending.add(key);
pendingContainers.add(this);
scheduleFlush();
}
patch(partial: Partial<T>): void {
const next = { ...this.#current };
for (const key in partial) {
if (!hasOwnProp.call(partial, key)) continue;
const value = partial[key];
if (!Object.is(this.#current[key], value)) {
next[key] = value!;
this.#pending.add(key);
}
}
// Notify key-specific listeners (no args - already filtered by key)
const targetKeyListeners = keyListeners.get(target);
if (targetKeyListeners) {
if (this.#pending.size > 0) {
this.#current = Object.freeze(next);
pendingContainers.add(this);
scheduleFlush();
}
}
subscribe(listener: Listener): () => void;
subscribe<K extends keyof T>(keys: K[], listener: Listener): () => void;
subscribe(first: Listener | PropertyKey[], second?: Listener): () => void {
// Key-specific subscription
if (Array.isArray(first)) {
const keys = first;
const listener = second!;
for (const key of keys) {
const keySet = targetKeyListeners.get(key);
if (keySet) {
for (const fn of keySet) fn(keys);
let set = this.#keyListeners.get(key);
if (!set) this.#keyListeners.set(key, (set = new Set()));
set.add(listener);
}
return () => {
for (const key of keys) {
this.#keyListeners.get(key)?.delete(listener);
}
};
}
// Global subscription
const listener = first;
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
flush(): void {
if (this.#pending.size === 0) return;
const keys: ReadonlySet<PropertyKey> = new Set(this.#pending);
this.#pending.clear();
for (const fn of this.#listeners) fn(keys);
for (const key of keys) {
const set = this.#keyListeners.get(key);
if (set) {
for (const fn of set) fn(keys);
}
}
}
pending.clear();
}
/** Group mutations; notifications fire after fn completes. */
export function batch<R>(fn: () => R): R {
batchDepth++;
try {
return fn();
} finally {
batchDepth--;
if (batchDepth === 0) scheduleFlush();
}
export function createState<T extends object>(initial: T): WritableState<T> {
return new StateContainer(initial);
}
/** Subscribe to all changes on a reactive state object. */
export function subscribe<T extends object>(state: Reactive<T>, fn: Listener): () => void {
let set = listeners.get(state);
if (!set) listeners.set(state, (set = new Set()));
set.add(fn);
return () => listeners.get(state)?.delete(fn);
}
/** Subscribe to changes on specific keys of a reactive state object. */
export function subscribeKeys<T extends object>(state: Reactive<T>, keys: (keyof T)[], fn: Listener): () => void {
let targetMap = keyListeners.get(state);
if (!targetMap) keyListeners.set(state, (targetMap = new Map()));
for (const key of keys) {
let set = targetMap.get(key);
if (!set) targetMap.set(key, (set = new Set()));
set.add(fn);
}
return () => {
for (const key of keys) {
targetMap.get(key)?.delete(fn);
}
};
}
/** Return a frozen shallow copy of the current state. */
export function snapshot<T extends object>(state: Reactive<T>): Readonly<T> {
return Object.freeze({ ...state });
}
export interface Tracker<T extends object> {
/** Tracking proxy that records which properties are accessed. */
tracked: T;
/** Subscribe function compatible with useSyncExternalStore. */
subscribe: (onStoreChange: () => void) => () => void;
/** Returns version that increments on relevant changes. */
getSnapshot: () => number;
/** Clear tracked keys for next render cycle. */
next: () => void;
}
/**
* Track property access on reactive state.
*
* Returns a tracker that records which properties are accessed and only triggers updates when
* those specific properties change. Designed for use with React's `useSyncExternalStore` or
* Lit's reactive controller pattern.
*/
export function track<T extends object>(state: Reactive<T>): Tracker<T> {
const accessed = new Set<PropertyKey>();
let version = 0;
const tracked = new Proxy(state, {
get(target, prop, receiver) {
if (typeof prop !== 'symbol') accessed.add(prop);
return Reflect.get(target, prop, receiver);
},
});
return {
tracked,
subscribe: notify =>
subscribe(state, (changedKeys) => {
let changed = accessed.size === 0;
if (!changed) {
for (const k of changedKeys) {
if (accessed.has(k)) {
changed = true;
break;
}
}
}
if (changed) {
version++;
notify();
}
}),
getSnapshot: () => version,
next: () => accessed.clear(),
};
export function isState(value: unknown): value is State<object> {
return value instanceof StateContainer;
}
+11 -9
View File
@@ -7,7 +7,7 @@ import type {
UnionFeatureTasks,
} from './feature';
import type { RequestMeta, RequestMetaInit, ResolvedRequestConfig } from './request';
import type { Reactive } from './state';
import type { State, WritableState } from './state';
import type { PendingTask, Task, TaskContext } from './task';
import { abortable } from '@videojs/utils/events';
@@ -16,7 +16,7 @@ import { isNull } from '@videojs/utils/predicate';
import { StoreError } from './errors';
import { Queue } from './queue';
import { CANCEL_ALL, createRequestMeta, resolveRequestCancel, resolveRequestKey } from './request';
import { reactive } from './state';
import { createState } from './state';
export class Store<Target, Features extends AnyFeature<Target>[] = AnyFeature<Target>[]> {
readonly #config: StoreConfig<Target, Features>;
@@ -25,9 +25,7 @@ export class Store<Target, Features extends AnyFeature<Target>[] = AnyFeature<Ta
readonly #request: UnionFeatureRequests<Features>;
readonly #requestConfigs: Map<string, ResolvedRequestConfig<Target>>;
readonly #setupAbort = new AbortController();
/** Reactive state. Subscribe via `subscribe(store.state, fn)`. */
readonly state: Reactive<UnionFeatureState<Features> & object>;
readonly #state: WritableState<UnionFeatureState<Features> & object>;
#target: Target | null = null;
#attachAbort: AbortController | null = null;
@@ -38,7 +36,7 @@ export class Store<Target, Features extends AnyFeature<Target>[] = AnyFeature<Ta
this.#features = config.features;
this.#queue = config.queue ?? new Queue<UnionFeatureTasks<Features>>();
this.state = reactive(this.#createInitialState() as UnionFeatureState<Features> & object);
this.#state = createState(this.#createInitialState() as UnionFeatureState<Features> & object);
this.#requestConfigs = this.#buildRequestConfigs();
this.#request = this.#buildRequestProxy();
@@ -61,6 +59,10 @@ export class Store<Target, Features extends AnyFeature<Target>[] = AnyFeature<Ta
return this.#target;
}
get state(): State<UnionFeatureState<Features> & object> {
return this.#state;
}
get request(): UnionFeatureRequests<Features> {
return this.#request;
}
@@ -166,7 +168,7 @@ export class Store<Target, Features extends AnyFeature<Target>[] = AnyFeature<Ta
initialState: feature.initialState,
});
Object.assign(this.state as object, snapshot);
this.#state.patch(snapshot);
} catch (error) {
this.#handleError({ error });
}
@@ -183,7 +185,7 @@ export class Store<Target, Features extends AnyFeature<Target>[] = AnyFeature<Ta
}
#resetState(): void {
Object.assign(this.state as object, this.#createInitialState());
this.#state.patch(this.#createInitialState() as Partial<UnionFeatureState<Features> & object>);
}
// ----------------------------------------
@@ -263,7 +265,7 @@ export class Store<Target, Features extends AnyFeature<Target>[] = AnyFeature<Ta
handler,
});
} catch (error) {
const tasks = this.#queue.tasks as Record<string | symbol, Task | undefined>;
const tasks = this.#queue.tasks.current as Record<string | symbol, Task | undefined>;
const task = tasks[name];
this.#handleError({
@@ -0,0 +1,184 @@
import { describe, expect, it, vi } from 'vitest';
import { Computed } from '../computed';
import { createState, flush } from '../state';
describe('computed', () => {
interface TestState {
volume: number;
muted: boolean;
currentTime: number;
}
const createTestState = () =>
createState<TestState>({
volume: 1,
muted: false,
currentTime: 0,
});
describe('current', () => {
it('returns derived value', () => {
const state = createTestState();
const effectiveVolume = new Computed(state, ['volume', 'muted'], ({ volume, muted }) => (muted ? 0 : volume));
expect(effectiveVolume.current).toBe(1);
});
it('updates when dependency changes', () => {
const state = createTestState();
const effectiveVolume = new Computed(state, ['volume', 'muted'], ({ volume, muted }) => (muted ? 0 : volume));
expect(effectiveVolume.current).toBe(1);
state.set('muted', true);
flush();
expect(effectiveVolume.current).toBe(0);
});
it('updates when volume changes', () => {
const state = createTestState();
const effectiveVolume = new Computed(state, ['volume', 'muted'], ({ volume, muted }) => (muted ? 0 : volume));
state.set('volume', 0.5);
flush();
expect(effectiveVolume.current).toBe(0.5);
});
it('does not recompute when unrelated keys change', () => {
const state = createTestState();
const deriveFn = vi.fn(({ volume, muted }: Pick<TestState, 'volume' | 'muted'>) => (muted ? 0 : volume));
const effectiveVolume = new Computed(state, ['volume', 'muted'], deriveFn);
// Initial access triggers first computation
void effectiveVolume.current;
expect(deriveFn).toHaveBeenCalledTimes(1);
// Changing unrelated key should not recompute
state.set('currentTime', 10);
flush();
void effectiveVolume.current;
expect(deriveFn).toHaveBeenCalledTimes(1);
});
});
describe('subscribe', () => {
it('notifies when computed value changes', () => {
const state = createTestState();
const effectiveVolume = new Computed(state, ['volume', 'muted'], ({ volume, muted }) => (muted ? 0 : volume));
const listener = vi.fn();
effectiveVolume.subscribe(listener);
state.set('muted', true);
flush();
expect(listener).toHaveBeenCalledOnce();
});
it('does not notify when computed value stays the same', () => {
const state = createTestState();
const effectiveVolume = new Computed(state, ['volume', 'muted'], ({ volume, muted }) => (muted ? 0 : volume));
const listener = vi.fn();
// Access to initialize
void effectiveVolume.current;
effectiveVolume.subscribe(listener);
// Already muted=false, volume=1, effective=1
// Setting volume to same value shouldn't even trigger state change
state.set('volume', 1);
flush();
expect(listener).not.toHaveBeenCalled();
});
it('does not notify when dependency changes but computed result is same', () => {
const state = createTestState();
// When muted, effective volume is always 0 regardless of volume value
const effectiveVolume = new Computed(state, ['volume', 'muted'], ({ volume, muted }) => (muted ? 0 : volume));
const listener = vi.fn();
// Mute first
state.set('muted', true);
flush();
// Access to initialize (should be 0)
expect(effectiveVolume.current).toBe(0);
effectiveVolume.subscribe(listener);
// Change volume while muted - computed stays 0
state.set('volume', 0.5);
flush();
expect(effectiveVolume.current).toBe(0);
expect(listener).not.toHaveBeenCalled();
});
it('returns unsubscribe function', () => {
const state = createTestState();
const effectiveVolume = new Computed(state, ['volume', 'muted'], ({ volume, muted }) => (muted ? 0 : volume));
const listener = vi.fn();
const unsub = effectiveVolume.subscribe(listener);
state.set('muted', true);
flush();
expect(listener).toHaveBeenCalledOnce();
unsub();
state.set('muted', false);
flush();
expect(listener).toHaveBeenCalledOnce(); // still 1
});
});
describe('edge cases', () => {
it('works with single key dependency', () => {
const state = createTestState();
const doubleVolume = new Computed(state, ['volume'], ({ volume }) => volume * 2);
expect(doubleVolume.current).toBe(2);
state.set('volume', 0.5);
flush();
expect(doubleVolume.current).toBe(1);
});
it('handles object return values', () => {
const state = createTestState();
const volumeInfo = new Computed(state, ['volume', 'muted'], ({ volume, muted }) => ({
effective: muted ? 0 : volume,
display: muted ? 'Muted' : `${Math.round(volume * 100)}%`,
}));
expect(volumeInfo.current).toEqual({
effective: 1,
display: '100%',
});
state.set('muted', true);
flush();
expect(volumeInfo.current).toEqual({
effective: 0,
display: 'Muted',
});
});
it('lazy initialization - does not compute until accessed', () => {
const state = createTestState();
const deriveFn = vi.fn(({ volume }: Pick<TestState, 'volume'>) => volume * 2);
const _ = new Computed(state, ['volume'], deriveFn);
void _; // intentionally unused - testing lazy init
expect(deriveFn).not.toHaveBeenCalled();
});
});
});
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { CANCEL_ALL, createFeature, createStore } from '../../index';
import { flush, subscribeKeys } from '../../state';
import { flush } from '../../state';
describe('store lifecycle integration', () => {
it('full lifecycle: create → attach → use → detach → destroy', async () => {
@@ -41,10 +41,10 @@ describe('store lifecycle integration', () => {
const detach = store.attach(target);
expect(events).toEqual(['setup', 'subscribe', 'attach']);
expect(store.state.count).toBe(5);
expect(store.state.current.count).toBe(5);
await store.request.increment();
expect(store.state.count).toBe(6);
expect(store.state.current.count).toBe(6);
expect(events).toContain('increment');
detach();
@@ -467,12 +467,12 @@ describe('state syncing', () => {
store.attach(new Target());
subscribeKeys(store.state, ['volume'], () => {
volumeUpdates.push(store.state.volume);
store.state.subscribe(['volume'], () => {
volumeUpdates.push(store.state.current.volume);
});
subscribeKeys(store.state, ['muted'], () => {
mutedUpdates.push(store.state.muted);
store.state.subscribe(['muted'], () => {
mutedUpdates.push(store.state.current.muted);
});
await store.request.setVolume(0.5);
@@ -508,7 +508,7 @@ describe('state syncing', () => {
const target = { volume: 0.5, rate: 1.5 };
store.attach(target);
expect(store.state).toEqual({
expect(store.state.current).toEqual({
volume: 0.5,
rate: 1.5,
});
@@ -545,13 +545,13 @@ describe('immediate execution', () => {
const target = new MockMedia();
store.attach(target);
expect(store.state.paused).toBe(true);
expect(store.state.current.paused).toBe(true);
store.request.play();
// Validates: handler ran → play() called → event fired → state synced
expect(target.paused).toBe(false);
expect(store.state.paused).toBe(false);
expect(store.state.current.paused).toBe(false);
});
it('task is pending synchronously after request', async () => {
@@ -573,9 +573,9 @@ describe('immediate execution', () => {
const promise = store.request.action();
// Synchronous check - task is pending immediately, no microtask needed
expect(store.queue.tasks.action?.status).toBe('pending');
expect(store.queue.tasks.current.action?.status).toBe('pending');
await promise;
expect(store.queue.tasks.action?.status).toBe('success');
expect(store.queue.tasks.current.action?.status).toBe('success');
});
});
+84 -58
View File
@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { createQueue } from '../queue';
import { flush, subscribe } from '../state';
/** Wait for microtask queue to flush. */
const flush = () => new Promise<void>(r => queueMicrotask(r));
describe('Queue', () => {
describe('enqueue', () => {
@@ -30,10 +32,13 @@ describe('Queue', () => {
});
// Synchronous check - task is pending immediately
expect(queue.tasks.test?.status).toBe('pending');
const { test: pendingTest } = queue.tasks.current;
expect(pendingTest?.status).toBe('pending');
await promise;
expect(queue.tasks.test?.status).toBe('success');
const { test: settledTest } = queue.tasks.current;
expect(settledTest?.status).toBe('success');
});
it('aborts pending task with same key', async () => {
@@ -163,11 +168,13 @@ describe('Queue', () => {
const queue = createQueue();
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
expect(queue.tasks.task?.status).toBe('success');
const { task } = queue.tasks.current;
expect(task?.status).toBe('success');
queue.destroy();
expect(Reflect.ownKeys(queue.tasks).length).toBe(0);
expect(Reflect.ownKeys(queue.tasks.current).length).toBe(0);
});
});
@@ -192,14 +199,18 @@ describe('Queue', () => {
});
await new Promise(r => setTimeout(r, 10));
expect(queue.tasks.task?.status).toBe('pending');
const { task: pendingTask } = queue.tasks.current;
expect(pendingTask?.status).toBe('pending');
queue.destroy();
await promise.catch(() => {});
expect(cleanupSpy).toHaveBeenCalledWith('aborted');
expect(cleanupSpy).toHaveBeenCalledWith('cleanup');
expect(queue.tasks.task).toBeUndefined();
const { task: destroyedTask } = queue.tasks.current;
expect(destroyedTask).toBeUndefined();
});
});
@@ -208,16 +219,16 @@ describe('Queue', () => {
const queue = createQueue();
const listener = vi.fn();
const unsubscribe = subscribe(queue.tasks, listener);
const unsubscribe = queue.tasks.subscribe(listener);
expect(unsubscribe).toBeTypeOf('function');
});
it('notifies when task becomes pending', async () => {
it('notifies when task changes', async () => {
const queue = createQueue();
const listener = vi.fn();
subscribe(queue.tasks, listener);
queue.tasks.subscribe(listener);
const promise = queue.enqueue({
name: 'test',
@@ -225,21 +236,18 @@ describe('Queue', () => {
handler: vi.fn().mockResolvedValue('result'),
});
// Flush to trigger notifications (auto-batched)
flush();
await promise;
flush();
await flush();
// Called when pending and when settled
expect(listener).toHaveBeenCalledTimes(2);
// Notified at least once (pending and settled may batch together)
expect(listener).toHaveBeenCalled();
});
it('unsubscribe stops notifications', async () => {
const queue = createQueue();
const listener = vi.fn();
const unsubscribe = subscribe(queue.tasks, listener);
const unsubscribe = queue.tasks.subscribe(listener);
unsubscribe();
await queue.enqueue({
@@ -247,7 +255,7 @@ describe('Queue', () => {
key: 'test-key',
handler: vi.fn().mockResolvedValue('result'),
});
flush();
await flush();
expect(listener).not.toHaveBeenCalled();
});
@@ -257,15 +265,15 @@ describe('Queue', () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
subscribe(queue.tasks, listener1);
subscribe(queue.tasks, listener2);
queue.tasks.subscribe(listener1);
queue.tasks.subscribe(listener2);
await queue.enqueue({
name: 'test',
key: 'test-key',
handler: vi.fn().mockResolvedValue('result'),
});
flush();
await flush();
// Called once per batch (pending + settled batched together)
expect(listener1).toHaveBeenCalled();
@@ -287,14 +295,16 @@ describe('Queue', () => {
});
await new Promise(r => setTimeout(r, 5));
const pendingTask = queue.tasks.task;
const { task: pendingTask } = queue.tasks.current;
expect(pendingTask?.status).toBe('pending');
expect(pendingTask?.name).toBe('task');
await promise;
const successTask = queue.tasks.task;
const { task: successTask } = queue.tasks.current;
expect(successTask?.status).toBe('success');
if (successTask?.status === 'success') {
expect(successTask.output).toBe('result');
expect(successTask.settledAt).toBeGreaterThan(successTask.startedAt);
@@ -315,12 +325,15 @@ describe('Queue', () => {
});
await new Promise(r => setTimeout(r, 5));
expect(queue.tasks.task?.status).toBe('pending');
const { task: pendingTask } = queue.tasks.current;
expect(pendingTask?.status).toBe('pending');
await expect(promise).rejects.toThrow('test error');
const errorTask = queue.tasks.task;
const { task: errorTask } = queue.tasks.current;
expect(errorTask?.status).toBe('error');
if (errorTask?.status === 'error') {
expect(errorTask.error).toBe(error);
expect(errorTask.cancelled).toBe(false);
@@ -342,13 +355,16 @@ describe('Queue', () => {
});
await new Promise(r => setTimeout(r, 10));
expect(queue.tasks.task?.status).toBe('pending');
const { task: pendingTask } = queue.tasks.current;
expect(pendingTask?.status).toBe('pending');
queue.abort('task');
await promise.catch(() => {});
const errorTask = queue.tasks.task;
const { task: errorTask } = queue.tasks.current;
expect(errorTask?.status).toBe('error');
if (errorTask?.status === 'error') {
expect(errorTask.cancelled).toBe(true);
}
@@ -363,9 +379,11 @@ describe('Queue', () => {
handler: async () => 'first-result',
});
expect(queue.tasks.first?.status).toBe('success');
if (queue.tasks.first?.status === 'success') {
expect(queue.tasks.first.output).toBe('first-result');
const { first } = queue.tasks.current;
expect(first?.status).toBe('success');
if (first?.status === 'success') {
expect(first.output).toBe('first-result');
}
await queue.enqueue({
@@ -374,10 +392,12 @@ describe('Queue', () => {
handler: async () => 'second-result',
});
expect(queue.tasks.second?.status).toBe('success');
if (queue.tasks.second?.status === 'success') {
expect(queue.tasks.second.output).toBe('second-result');
expect(queue.tasks.second.name).toBe('second');
const { second } = queue.tasks.current;
expect(second?.status).toBe('success');
if (second?.status === 'success') {
expect(second.output).toBe('second-result');
expect(second.name).toBe('second');
}
});
});
@@ -392,11 +412,13 @@ describe('Queue', () => {
handler: async () => 'result',
});
expect(queue.tasks.task?.status).toBe('success');
const { task: settledTask } = queue.tasks.current;
expect(settledTask?.status).toBe('success');
queue.reset('task');
expect(queue.tasks.task).toBeUndefined();
const { task: clearedTask } = queue.tasks.current;
expect(clearedTask).toBeUndefined();
});
it('is no-op when task is pending', async () => {
@@ -412,10 +434,14 @@ describe('Queue', () => {
});
await new Promise(r => setTimeout(r, 10));
expect(queue.tasks.task?.status).toBe('pending');
const { task: beforeReset } = queue.tasks.current;
expect(beforeReset?.status).toBe('pending');
queue.reset('task');
expect(queue.tasks.task?.status).toBe('pending');
const { task: afterReset } = queue.tasks.current;
expect(afterReset?.status).toBe('pending');
await promise;
});
@@ -425,7 +451,7 @@ describe('Queue', () => {
queue.reset('nonexistent');
expect(queue.tasks.nonexistent).toBeUndefined();
expect(queue.tasks.current.nonexistent).toBeUndefined();
});
it('notifies subscribers when reset clears a task', async () => {
@@ -438,22 +464,22 @@ describe('Queue', () => {
handler: async () => 'result',
});
subscribe(queue.tasks, listener);
queue.tasks.subscribe(listener);
queue.reset('task');
flush();
await flush();
expect(listener).toHaveBeenCalledTimes(1);
expect(queue.tasks.task).toBeUndefined();
expect(queue.tasks.current.task).toBeUndefined();
});
it('does not notify subscribers when task does not exist', () => {
it('does not notify subscribers when task does not exist', async () => {
const queue = createQueue();
const listener = vi.fn();
subscribe(queue.tasks, listener);
queue.tasks.subscribe(listener);
queue.reset('nonexistent');
flush();
await flush();
expect(listener).not.toHaveBeenCalled();
});
@@ -464,13 +490,13 @@ describe('Queue', () => {
await queue.enqueue({ name: 'a', key: 'a', handler: async () => 'a-result' });
await queue.enqueue({ name: 'b', key: 'b', handler: async () => 'b-result' });
expect(queue.tasks.a?.status).toBe('success');
expect(queue.tasks.b?.status).toBe('success');
expect(queue.tasks.current.a?.status).toBe('success');
expect(queue.tasks.current.b?.status).toBe('success');
queue.reset();
expect(queue.tasks.a).toBeUndefined();
expect(queue.tasks.b).toBeUndefined();
expect(queue.tasks.current.a).toBeUndefined();
expect(queue.tasks.current.b).toBeUndefined();
});
it('preserves pending tasks when resetting all', async () => {
@@ -488,13 +514,13 @@ describe('Queue', () => {
});
await new Promise(r => setTimeout(r, 10));
expect(queue.tasks.settled?.status).toBe('success');
expect(queue.tasks.pending?.status).toBe('pending');
expect(queue.tasks.current.settled?.status).toBe('success');
expect(queue.tasks.current.pending?.status).toBe('pending');
queue.reset();
expect(queue.tasks.settled).toBeUndefined();
expect(queue.tasks.pending?.status).toBe('pending');
expect(queue.tasks.current.settled).toBeUndefined();
expect(queue.tasks.current.pending?.status).toBe('pending');
await pendingPromise;
});
@@ -506,7 +532,7 @@ describe('Queue', () => {
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
// Tasks is now reactive state, not a frozen object
expect(queue.tasks.task?.status).toBe('success');
expect(queue.tasks.current.task?.status).toBe('success');
});
it('reflects changes immediately', async () => {
@@ -516,11 +542,11 @@ describe('Queue', () => {
await queue.enqueue({ name: 'first', key: 'k', handler: async () => 'first' });
// Same reference reflects updates
expect(tasks.first?.status).toBe('success');
expect(tasks.current.first?.status).toBe('success');
await queue.enqueue({ name: 'second', key: 'k', handler: async () => 'second' });
expect(tasks.second?.status).toBe('success');
expect(tasks.current.second?.status).toBe('success');
});
});
@@ -535,7 +561,7 @@ describe('Queue', () => {
handler: async () => 'result',
});
const task = queue.tasks[name as unknown as string];
const task = queue.tasks.current[name as unknown as string];
expect(task?.status).toBe('success');
if (task?.status === 'success') {
expect(task.output).toBe('result');
@@ -553,7 +579,7 @@ describe('Queue', () => {
handler: async () => 'result',
});
expect(queue.tasks.task?.meta).toBeNull();
expect(queue.tasks.current.task?.meta).toBeNull();
});
});
});
@@ -1,4 +1,4 @@
import type { TasksRecord } from '../queue';
import type { State } from '../state';
import { describe, expectTypeOf, it } from 'vitest';
@@ -6,10 +6,12 @@ import { createQueue } from '../queue';
describe('queue types', () => {
describe('createQueue', () => {
it('returns Queue with default task record', () => {
it('returns Queue with State for tasks', () => {
const queue = createQueue();
expectTypeOf(queue.tasks).toExtend<TasksRecord<any>>();
expectTypeOf(queue.tasks).toExtend<State<object>>();
expectTypeOf(queue.tasks.current).toBeObject();
expectTypeOf(queue.tasks.subscribe).toBeFunction();
expectTypeOf(queue.destroyed).toBeBoolean();
});
});
+139 -300
View File
@@ -1,390 +1,229 @@
import { describe, expect, it, vi } from 'vitest';
import { batch, flush, isReactive, reactive, snapshot, subscribe, subscribeKeys, track } from '../state';
import { createState, flush, isState } from '../state';
describe('reactive', () => {
describe('createState', () => {
interface TestState {
volume: number;
muted: boolean;
currentTime: number;
}
const createState = () =>
reactive<TestState>({
const createTestState = () =>
createState<TestState>({
volume: 1,
muted: false,
currentTime: 0,
});
describe('reactive', () => {
it('creates reactive state', () => {
const s = createState();
expect(s.volume).toBe(1);
expect(s.muted).toBe(false);
describe('current', () => {
it('returns the current state', () => {
const state = createTestState();
expect(state.current.volume).toBe(1);
expect(state.current.muted).toBe(false);
});
it('allows direct mutation', () => {
const s = createState();
s.volume = 0.5;
expect(s.volume).toBe(0.5);
it('reflects changes after set', () => {
const state = createTestState();
state.set('volume', 0.5);
expect(state.current.volume).toBe(0.5);
});
it('tracks reactive via isReactive', () => {
const s = createState();
expect(isReactive(s)).toBe(true);
expect(isReactive({})).toBe(false);
expect(isReactive(null)).toBe(false);
expect(isReactive(undefined)).toBe(false);
it('reflects changes after patch', () => {
const state = createTestState();
state.patch({ volume: 0.5, muted: true });
expect(state.current.volume).toBe(0.5);
expect(state.current.muted).toBe(true);
});
});
describe('set', () => {
it('updates a single key', () => {
const state = createTestState();
state.set('volume', 0.5);
expect(state.current.volume).toBe(0.5);
});
it('does not notify if value is the same', () => {
const state = createTestState();
const listener = vi.fn();
state.subscribe(listener);
state.set('volume', 1); // same as initial
flush();
expect(listener).not.toHaveBeenCalled();
});
});
describe('patch', () => {
it('updates multiple keys', () => {
const state = createTestState();
state.patch({ volume: 0.5, muted: true });
expect(state.current.volume).toBe(0.5);
expect(state.current.muted).toBe(true);
expect(state.current.currentTime).toBe(0); // unchanged
});
it('does not notify if no values changed', () => {
const state = createTestState();
const listener = vi.fn();
state.subscribe(listener);
state.patch({ volume: 1, muted: false }); // same as initial
flush();
expect(listener).not.toHaveBeenCalled();
});
});
describe('subscribe', () => {
it('notifies on change after microtask', async () => {
const p = createState();
const state = createTestState();
const listener = vi.fn();
subscribe(p, listener);
state.subscribe(listener);
p.volume = 0.5;
state.set('volume', 0.5);
// Not called yet (deferred to microtask)
expect(listener).not.toHaveBeenCalled();
// Wait for microtask
await Promise.resolve();
expect(listener).toHaveBeenCalledOnce();
});
it('can force immediate notification with flush()', () => {
const p = createState();
const state = createTestState();
const listener = vi.fn();
subscribe(p, listener);
state.subscribe(listener);
p.volume = 0.5;
state.set('volume', 0.5);
expect(listener).not.toHaveBeenCalled();
flush();
expect(listener).toHaveBeenCalledOnce();
});
it('does not notify on same value', () => {
const p = createState();
it('listener receives changed keys', () => {
const state = createTestState();
const listener = vi.fn();
subscribe(p, listener);
state.subscribe(listener);
p.volume = 1; // same as initial
state.set('volume', 0.5);
flush();
expect(listener).not.toHaveBeenCalled();
expect(listener).toHaveBeenCalledWith(new Set(['volume']));
});
it('listener receives multiple changed keys', () => {
const state = createTestState();
const listener = vi.fn();
state.subscribe(listener);
state.patch({ volume: 0.5, muted: true });
flush();
expect(listener).toHaveBeenCalledWith(new Set(['volume', 'muted']));
});
it('batches multiple mutations into one notification', () => {
const state = createTestState();
const listener = vi.fn();
state.subscribe(listener);
state.set('volume', 0.5);
state.set('muted', true);
state.set('currentTime', 10);
flush();
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith(new Set(['volume', 'muted', 'currentTime']));
});
it('returns unsubscribe function', () => {
const p = createState();
const state = createTestState();
const listener = vi.fn();
const unsub = subscribe(p, listener);
p.volume = 0.5;
const unsub = state.subscribe(listener);
state.set('volume', 0.5);
flush();
expect(listener).toHaveBeenCalledOnce();
unsub();
p.volume = 0.3;
state.set('volume', 0.3);
flush();
expect(listener).toHaveBeenCalledOnce(); // still 1
});
});
describe('subscribeKeys', () => {
describe('subscribe with keys', () => {
it('only notifies for specified keys', () => {
const p = createState();
const state = createTestState();
const volumeListener = vi.fn();
const mutedListener = vi.fn();
subscribeKeys(p, ['volume'], volumeListener);
subscribeKeys(p, ['muted'], mutedListener);
state.subscribe(['volume'], volumeListener);
state.subscribe(['muted'], mutedListener);
p.volume = 0.5;
state.set('volume', 0.5);
flush();
expect(volumeListener).toHaveBeenCalledOnce();
expect(mutedListener).not.toHaveBeenCalled();
p.muted = true;
state.set('muted', true);
flush();
expect(volumeListener).toHaveBeenCalledOnce();
expect(mutedListener).toHaveBeenCalledOnce();
});
it('unsubscribes from all keys', () => {
const p = createState();
it('notifies for multiple specified keys', () => {
const state = createTestState();
const listener = vi.fn();
const unsub = subscribeKeys(p, ['volume', 'muted'], listener);
state.subscribe(['volume', 'muted'], listener);
state.set('volume', 0.5);
flush();
expect(listener).toHaveBeenCalledOnce();
state.set('muted', true);
flush();
expect(listener).toHaveBeenCalledTimes(2);
state.set('currentTime', 10);
flush();
expect(listener).toHaveBeenCalledTimes(2); // not notified
});
it('unsubscribes from all keys', () => {
const state = createTestState();
const listener = vi.fn();
const unsub = state.subscribe(['volume', 'muted'], listener);
unsub();
p.volume = 0.5;
p.muted = true;
state.set('volume', 0.5);
state.set('muted', true);
flush();
expect(listener).not.toHaveBeenCalled();
});
});
describe('batch', () => {
it('batches multiple mutations into one notification', () => {
const p = createState();
const listener = vi.fn();
subscribe(p, listener);
p.volume = 0.5;
p.muted = true;
p.currentTime = 10;
flush();
expect(listener).toHaveBeenCalledOnce();
describe('isState', () => {
it('returns true for state created by createState', () => {
const state = createTestState();
expect(isState(state)).toBe(true);
});
it('explicit batch() groups mutations', () => {
const p = createState();
const listener = vi.fn();
subscribe(p, listener);
batch(() => {
p.volume = 0.5;
p.muted = true;
p.currentTime = 10;
});
flush();
expect(listener).toHaveBeenCalledOnce();
it('returns false for plain objects', () => {
expect(isState({})).toBe(false);
expect(isState({ current: {} })).toBe(false);
});
it('batch() returns the result of the function', () => {
const result = batch(() => 42);
expect(result).toBe(42);
});
});
describe('snapshot', () => {
it('returns a frozen shallow copy', () => {
const p = createState();
const snap = snapshot(p);
expect(snap).toEqual({ volume: 1, muted: false, currentTime: 0 });
expect(Object.isFrozen(snap)).toBe(true);
});
it('snapshot is independent of future changes', () => {
const p = createState();
const snap = snapshot(p);
p.volume = 0.5;
expect(snap.volume).toBe(1);
});
});
describe('parent bubbling', () => {
it('notifies parent when child changes', () => {
const parent = reactive<{ nested: { value: number } }>({
nested: { value: 0 },
});
const parentListener = vi.fn();
subscribe(parent, parentListener);
parent.nested.value = 42;
flush();
expect(parentListener).toHaveBeenCalledOnce();
});
it('auto-wraps nested objects', () => {
const s = reactive<{ nested?: { value: number } }>({});
s.nested = { value: 0 };
expect(isReactive(s.nested)).toBe(true);
const listener = vi.fn();
subscribe(s, listener);
s.nested.value = 42;
flush();
expect(listener).toHaveBeenCalledOnce();
});
it('subscribeKeys on parent fires when nested child changes', () => {
const s = reactive<{ nested: { value: number }; other: number }>({
nested: { value: 0 },
other: 0,
});
const nestedListener = vi.fn();
const otherListener = vi.fn();
subscribeKeys(s, ['nested'], nestedListener);
subscribeKeys(s, ['other'], otherListener);
s.nested.value = 42;
flush();
expect(nestedListener).toHaveBeenCalledOnce();
expect(otherListener).not.toHaveBeenCalled();
});
it('bubbles correct key through multiple levels', () => {
const s = reactive<{ a: { b: { c: number } } }>({
a: { b: { c: 0 } },
});
const listener = vi.fn();
subscribeKeys(s, ['a'], listener);
s.a.b.c = 42;
flush();
expect(listener).toHaveBeenCalledOnce();
});
});
describe('delete property', () => {
it('notifies on property deletion', () => {
const s = reactive<{ value?: number }>({ value: 1 });
const listener = vi.fn();
subscribe(s, listener);
delete s.value;
flush();
expect(listener).toHaveBeenCalledOnce();
expect(s.value).toBeUndefined();
});
});
describe('changedKeys', () => {
it('subscribe listener receives changed keys', () => {
const s = createState();
const listener = vi.fn();
subscribe(s, listener);
s.volume = 0.5;
flush();
expect(listener).toHaveBeenCalledWith(new Set(['volume']));
});
it('subscribe listener receives multiple changed keys', () => {
const s = createState();
const listener = vi.fn();
subscribe(s, listener);
s.volume = 0.5;
s.muted = true;
flush();
expect(listener).toHaveBeenCalledWith(new Set(['volume', 'muted']));
});
});
describe('track', () => {
it('tracks accessed properties', () => {
const s = createState();
const { tracked, subscribe: sub, getSnapshot, next } = track(s);
const listener = vi.fn();
// Access volume
void tracked.volume;
sub(listener);
// Change volume - should notify
s.volume = 0.5;
flush();
expect(listener).toHaveBeenCalledOnce();
expect(getSnapshot()).toBe(1);
// Change muted - should NOT notify (not accessed)
s.muted = true;
flush();
expect(listener).toHaveBeenCalledOnce(); // still 1
// Access muted, call next to clear, then access again
next();
void tracked.muted;
// Now muted change should notify
s.muted = false;
flush();
expect(listener).toHaveBeenCalledTimes(2);
expect(getSnapshot()).toBe(2);
});
it('notifies on first render when nothing tracked yet', () => {
const s = createState();
const { subscribe: sub, getSnapshot } = track(s);
const listener = vi.fn();
sub(listener);
// No properties accessed yet, but should still notify
s.volume = 0.5;
flush();
expect(listener).toHaveBeenCalledOnce();
expect(getSnapshot()).toBe(1);
});
it('next() clears tracked keys', () => {
const s = createState();
const { tracked, subscribe: sub, next } = track(s);
const listener = vi.fn();
// Access volume
void tracked.volume;
sub(listener);
// Clear tracked keys
next();
// Now volume change should NOT notify (nothing tracked after next())
// But actually it SHOULD because accessed.size === 0 triggers notification
s.volume = 0.5;
flush();
expect(listener).toHaveBeenCalledOnce();
// Access muted only
void tracked.muted;
// Volume change should NOT notify now
s.volume = 0.3;
flush();
expect(listener).toHaveBeenCalledOnce(); // still 1
});
it('getSnapshot increments only on relevant changes', () => {
const s = createState();
const { tracked, subscribe: sub, getSnapshot, next } = track(s);
// Access only volume
void tracked.volume;
sub(() => {});
expect(getSnapshot()).toBe(0);
s.volume = 0.5;
flush();
expect(getSnapshot()).toBe(1);
// Access muted now
next();
void tracked.muted;
// Change volume - not tracked anymore after next()
// But muted is tracked, and accessed.size > 0, so volume change won't trigger
s.volume = 0.3;
flush();
expect(getSnapshot()).toBe(1); // unchanged
// Change muted - should trigger
s.muted = true;
flush();
expect(getSnapshot()).toBe(2);
it('returns false for primitives', () => {
expect(isState(null)).toBe(false);
expect(isState(undefined)).toBe(false);
expect(isState(42)).toBe(false);
expect(isState('string')).toBe(false);
});
});
});
+8 -8
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { createFeature } from '../feature';
import { createQueue } from '../queue';
import { flush, subscribe } from '../state';
import { flush } from '../state';
import { createStore } from '../store';
describe('store', () => {
@@ -67,7 +67,7 @@ describe('store', () => {
features: [audioFeature, playbackFeature],
});
expect(store.state).toEqual({
expect(store.state.current).toEqual({
volume: 1,
muted: false,
paused: true,
@@ -119,7 +119,7 @@ describe('store', () => {
store.attach(media);
expect(store.state).toEqual({ volume: 0.5, muted: true });
expect(store.state.current).toEqual({ volume: 0.5, muted: true });
expect(store.target).toBe(media);
});
@@ -184,7 +184,7 @@ describe('store', () => {
store.attach(media2);
expect(store.target).toBe(media2);
expect(store.state.volume).toBe(0.3);
expect(store.state.current.volume).toBe(0.3);
expect(m1RemoveListenerSpy).toHaveBeenCalled();
});
});
@@ -266,7 +266,7 @@ describe('store', () => {
});
});
describe('subscribe (via reactive)', () => {
describe('subscribe', () => {
it('notifies on state change', async () => {
const store = createStore({
features: [audioFeature],
@@ -276,13 +276,13 @@ describe('store', () => {
store.attach(media);
const listener = vi.fn();
subscribe(store.state, listener);
store.state.subscribe(listener);
await store.request.setVolume(0.5);
flush();
expect(listener).toHaveBeenCalled();
expect(store.state.volume).toBe(0.5);
expect(store.state.current.volume).toBe(0.5);
});
it('unsubscribe stops notifications', async () => {
@@ -294,7 +294,7 @@ describe('store', () => {
store.attach(media);
const listener = vi.fn();
const unsubscribe = subscribe(store.state, listener);
const unsubscribe = store.state.subscribe(listener);
unsubscribe();
await store.request.setVolume(0.5);
@@ -1,5 +1,5 @@
import type { Queue, TasksRecord } from '../queue';
import type { Reactive } from '../state';
import type { Queue } from '../queue';
import type { State } from '../state';
import type { InferStoreRequests, InferStoreState, InferStoreTarget, InferStoreTasks } from '../store';
import { describe, expectTypeOf, it } from 'vitest';
@@ -51,9 +51,9 @@ describe('store types', () => {
it('state has union of all feature states', () => {
const store = createTestStore();
expectTypeOf(store.state.volume).toEqualTypeOf<number>();
expectTypeOf(store.state.muted).toEqualTypeOf<boolean>();
expectTypeOf(store.state.playing).toEqualTypeOf<boolean>();
expectTypeOf(store.state.current.volume).toEqualTypeOf<number>();
expectTypeOf(store.state.current.muted).toEqualTypeOf<boolean>();
expectTypeOf(store.state.current.playing).toEqualTypeOf<boolean>();
});
it('request has union of all feature requests', () => {
@@ -81,7 +81,8 @@ describe('store types', () => {
const store = createSingleFeatureStore();
expectTypeOf(store.queue).toExtend<Queue<any>>();
expectTypeOf(store.queue.tasks).toExtend<TasksRecord<any>>();
expectTypeOf(store.queue.tasks).toExtend<State<object>>();
expectTypeOf(store.queue.tasks.current).toBeObject();
});
it('target is nullable before attach', () => {
@@ -134,31 +135,31 @@ describe('store types', () => {
});
describe('subscribe', () => {
it('state is reactive', () => {
it('state is State interface', () => {
const store = createSingleFeatureStore();
expectTypeOf(store.state).toEqualTypeOf<Reactive<{ volume: number; muted: boolean } & object>>();
expectTypeOf(store.state).toEqualTypeOf<State<{ volume: number; muted: boolean } & object>>();
});
it('state properties have correct types', () => {
it('state.current properties have correct types', () => {
const store = createSingleFeatureStore();
expectTypeOf(store.state.volume).toEqualTypeOf<number>();
expectTypeOf(store.state.muted).toEqualTypeOf<boolean>();
expectTypeOf(store.state.current.volume).toEqualTypeOf<number>();
expectTypeOf(store.state.current.muted).toEqualTypeOf<boolean>();
});
});
describe('store queue integration types', () => {
it('queue.tasks has keys matching request names', () => {
it('queue.tasks.current has keys matching request names', () => {
const store = createSingleFeatureStore();
expectTypeOf(store.queue.tasks).toHaveProperty('setVolume');
expectTypeOf(store.queue.tasks).toHaveProperty('setMuted');
expectTypeOf(store.queue.tasks.current).toHaveProperty('setVolume');
expectTypeOf(store.queue.tasks.current).toHaveProperty('setMuted');
});
it('task input type matches request parameter', () => {
const store = createSingleFeatureStore();
const task = store.queue.tasks.setVolume;
const task = store.queue.tasks.current.setVolume;
if (task) {
expectTypeOf(task.input).toEqualTypeOf<number>();
@@ -167,7 +168,7 @@ describe('store types', () => {
it('task output type matches request return on success', () => {
const store = createSingleFeatureStore();
const task = store.queue.tasks.setVolume;
const task = store.queue.tasks.current.setVolume;
if (task?.status === 'success') {
expectTypeOf(task.output).toEqualTypeOf<number>();
@@ -177,10 +178,10 @@ describe('store types', () => {
it('multi-feature store has combined queue task types', () => {
const store = createTestStore();
expectTypeOf(store.queue.tasks).toHaveProperty('setVolume');
expectTypeOf(store.queue.tasks).toHaveProperty('setMuted');
expectTypeOf(store.queue.tasks).toHaveProperty('play');
expectTypeOf(store.queue.tasks).toHaveProperty('pause');
expectTypeOf(store.queue.tasks.current).toHaveProperty('setVolume');
expectTypeOf(store.queue.tasks.current).toHaveProperty('setMuted');
expectTypeOf(store.queue.tasks.current).toHaveProperty('play');
expectTypeOf(store.queue.tasks.current).toHaveProperty('pause');
});
it('queue.reset accepts request names', () => {
@@ -1,22 +1,17 @@
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
import type { Reactive, Tracker } from '../../core/state';
import type { State } from '../../core/state';
import { noop } from '@videojs/utils/function';
import { track } from '../../core/state';
export type SnapshotControllerHost = ReactiveControllerHost & HTMLElement;
export interface SnapshotControllerOptions<T extends object> {
/** Called when tracked state changes (after requestUpdate). */
/** Called when state changes (after requestUpdate). */
onChange?: (snapshot: T) => void;
}
/**
* Subscribes to reactive state and triggers host updates when tracked properties change.
*
* Automatically tracks which properties are accessed during render and only
* subscribes to changes on those specific keys.
* Subscribes to state and triggers host updates when state changes.
*
* @example Basic usage
* ```ts
@@ -41,45 +36,32 @@ export interface SnapshotControllerOptions<T extends object> {
*/
export class SnapshotController<T extends object> implements ReactiveController {
readonly #host: SnapshotControllerHost;
readonly #state: Reactive<T>;
readonly #state: State<T>;
readonly #onChange: ((snapshot: T) => void) | undefined;
#tracker: Tracker<T> | null = null;
#unsubscribe = noop;
constructor(host: SnapshotControllerHost, state: Reactive<T>, options?: SnapshotControllerOptions<T>) {
constructor(host: SnapshotControllerHost, state: State<T>, options?: SnapshotControllerOptions<T>) {
this.#host = host;
this.#state = state;
this.#onChange = options?.onChange;
host.addController(this);
}
/** Returns the tracking proxy. Access properties to subscribe to their changes. */
get value(): T {
if (!this.#tracker) {
this.#tracker = track(this.#state);
}
return this.#tracker.tracked;
return this.#state.current;
}
hostConnected(): void {
if (!this.#tracker) {
this.#tracker = track(this.#state);
}
this.#unsubscribe = this.#tracker.subscribe(() => {
this.#unsubscribe = this.#state.subscribe(() => {
this.#host.requestUpdate();
this.#onChange?.(this.#state);
this.#onChange?.(this.#state.current);
});
}
hostUpdated(): void {
this.#tracker?.next();
}
hostDisconnected(): void {
this.#unsubscribe();
this.#unsubscribe = noop;
this.#tracker = null;
}
}
@@ -5,7 +5,6 @@ import type { StoreSource } from '../store-accessor';
import { noop } from '@videojs/utils/function';
import { isNull } from '@videojs/utils/predicate';
import { subscribe } from '../../core/state';
import { StoreAccessor } from '../store-accessor';
export type TasksControllerHost = ReactiveControllerHost & HTMLElement;
@@ -42,26 +41,22 @@ export class TasksController<Store extends AnyStore> implements ReactiveControll
readonly #host: TasksControllerHost;
readonly #accessor: StoreAccessor<Store>;
#value: Store['queue']['tasks'] | undefined;
#unsubscribe = noop;
constructor(host: TasksControllerHost, source: StoreSource<Store>) {
this.#host = host;
this.#accessor = new StoreAccessor(host, source, store => this.#connect(store));
// Initialize value if store available immediately (direct store case)
const store = this.#accessor.value;
if (store) this.#value = store.queue.tasks;
host.addController(this);
}
get value(): Store['queue']['tasks'] {
get value(): Store['queue']['tasks']['current'] {
const store = this.#accessor.value;
if (isNull(store)) {
throw new Error('TasksController: Store not available from context');
}
return this.#value as Store['queue']['tasks'];
return store.queue.tasks.current;
}
hostConnected(): void {
@@ -75,9 +70,7 @@ export class TasksController<Store extends AnyStore> implements ReactiveControll
#connect(store: Store): void {
this.#unsubscribe();
this.#value = store.queue.tasks;
this.#unsubscribe = subscribe(store.queue.tasks, () => {
this.#value = store.queue.tasks;
this.#unsubscribe = store.queue.tasks.subscribe(() => {
this.#host.requestUpdate();
});
}
@@ -47,7 +47,8 @@ describe('controller types', () => {
const controller = new TasksController(host, store);
expectTypeOf(controller.value).toEqualTypeOf<typeof store.queue.tasks>();
// Value type matches store.queue.tasks.current
expectTypeOf(controller.value).toMatchTypeOf(store.queue.tasks.current);
});
});
});
+17 -7
View File
@@ -1,21 +1,29 @@
import type { Context } from '@lit/context';
import type { ReactiveControllerHost, ReactiveElement } from '@lit/reactive-element';
import type { Constructor } from '@videojs/utils/types';
import type { AnyFeature, UnionFeatureRequests, UnionFeatureState, UnionFeatureTarget, UnionFeatureTasks } from '../core/feature';
import type {
AnyFeature,
UnionFeatureRequests,
UnionFeatureState,
UnionFeatureTarget,
UnionFeatureTasks,
} from '../core/feature';
import type { TasksRecord } from '../core/queue';
import type { StoreConfig, StoreConsumer, StoreProvider } from '../core/store';
import { ContextConsumer, createContext } from '@lit/context';
import { noop } from '@videojs/utils/function';
import { subscribe } from '../core/state';
import { Store } from '../core/store';
import { RequestController as RequestControllerBase, TasksController as TasksControllerBase } from './controllers';
import { createStoreAttachMixin, createStoreMixin, createStoreProviderMixin } from './mixins';
export const contextKey = Symbol('@videojs/store');
export interface CreateStoreConfig<Features extends AnyFeature[]> extends StoreConfig<UnionFeatureTarget<Features>, Features> {}
export interface CreateStoreConfig<Features extends AnyFeature[]> extends StoreConfig<
UnionFeatureTarget<Features>,
Features
> {}
export type CreateStoreHost = ReactiveControllerHost & HTMLElement;
@@ -146,7 +154,7 @@ export interface CreateStoreResult<Features extends AnyFeature[]> {
* ```
*/
TasksController: new (host: CreateStoreHost) => {
value: TasksRecord<UnionFeatureTasks<Features>>;
value: Readonly<TasksRecord<UnionFeatureTasks<Features>>>;
hostConnected: () => void;
hostDisconnected: () => void;
};
@@ -191,7 +199,9 @@ export interface CreateStoreResult<Features extends AnyFeature[]> {
* </my-player>
* ```
*/
export function createStore<Features extends AnyFeature[]>(config: CreateStoreConfig<Features>): CreateStoreResult<Features> {
export function createStore<Features extends AnyFeature[]>(
config: CreateStoreConfig<Features>,
): CreateStoreResult<Features> {
type Target = UnionFeatureTarget<Features>;
type State = UnionFeatureState<Features>;
type Requests = UnionFeatureRequests<Features>;
@@ -227,7 +237,7 @@ export function createStore<Features extends AnyFeature[]>(config: CreateStoreCo
if (!store) {
throw new Error('StateController: Store not available from context');
}
return store.state;
return store.state.current as State;
}
hostConnected(): void {
@@ -242,7 +252,7 @@ export function createStore<Features extends AnyFeature[]>(config: CreateStoreCo
#connect(store: ProvidedStore | undefined): void {
this.#unsubscribe();
if (!store) return;
this.#unsubscribe = subscribe(store.state, () => this.#host.requestUpdate());
this.#unsubscribe = store.state.subscribe(() => this.#host.requestUpdate());
}
}
@@ -27,7 +27,7 @@ describe('createStoreMixin', () => {
await el.updateComplete;
expect(el.store).toBeDefined();
expect(el.store.state).toEqual({ volume: 1, muted: false });
expect(el.store.state.current).toEqual({ volume: 1, muted: false });
});
it('auto-attaches slotted video element', async () => {
@@ -17,7 +17,7 @@ describe('createStoreProviderMixin', () => {
await el.updateComplete;
expect(el.store).toBeDefined();
expect(el.store.state).toEqual({ volume: 1, muted: false });
expect(el.store.state.current).toEqual({ volume: 1, muted: false });
});
it('reuses same store instance', async () => {
@@ -40,7 +40,7 @@ describe('createStore', () => {
const store = create();
expect(store).toBeDefined();
expect(store.state).toEqual({ volume: 1, muted: false });
expect(store.state.current).toEqual({ volume: 1, muted: false });
});
it('creates independent store instances', () => {
+11 -19
View File
@@ -1,32 +1,24 @@
import type { Reactive } from '../../core/state';
import type { State } from '../../core/state';
import { useState, useSyncExternalStore } from 'react';
import { track } from '../../core/state';
import { useSyncExternalStore } from 'react';
/**
* Subscribe to reactive state and re-render when accessed properties change.
*
* Automatically tracks which properties are accessed during render and only
* re-renders when those specific properties change.
*
* @param state - Reactive state created by `reactive()`
* @returns The state, which triggers re-renders when accessed properties change
* Subscribe to state and re-render when state changes.
*
* @example
* ```tsx
* function VolumeDisplay() {
* const state = useSnapshot(store.state);
* return <span>{Math.round(state.volume * 100)}%</span>;
* const { volume } = useSnapshot(store.state);
* return <span>{Math.round(volume * 100)}%</span>;
* }
* ```
*/
export function useSnapshot<T extends object>(state: Reactive<T>): T {
const [{ tracked, subscribe, getSnapshot, next }] = useState(() => track(state));
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
next();
return tracked;
export function useSnapshot<T extends object>(state: State<T>): T {
return useSyncExternalStore(
onStoreChange => state.subscribe(onStoreChange),
() => state.current,
() => state.current,
);
}
export namespace useSnapshot {
+3 -24
View File
@@ -1,9 +1,7 @@
import type { TasksRecord } from '../../core/queue';
import type { AnyStore, InferStoreTasks } from '../../core/store';
import { useCallback, useRef, useSyncExternalStore } from 'react';
import { subscribe } from '../../core/state';
import { useSnapshot } from './use-snapshot';
/**
* Subscribe to task queue state.
@@ -11,9 +9,6 @@ import { subscribe } from '../../core/state';
* Returns a record of all tasks keyed by request name.
* Re-renders when any task is added, updated, or removed.
*
* @param store - The store instance to subscribe to
* @returns Record of tasks keyed by request name
*
* @example
* ```tsx
* function TaskList() {
@@ -31,22 +26,6 @@ import { subscribe } from '../../core/state';
* }
* ```
*/
export function useTasks<S extends AnyStore>(store: S): TasksRecord<InferStoreTasks<S>> {
const versionRef = useRef(0);
const subscribeToQueue = useCallback(
(onStoreChange: () => void) =>
subscribe(store.queue.tasks, () => {
versionRef.current++;
onStoreChange();
}),
[store],
);
const getSnapshot = useCallback(() => versionRef.current, []);
useSyncExternalStore(subscribeToQueue, getSnapshot, getSnapshot);
// Return the tasks proxy directly
return store.queue.tasks as TasksRecord<InferStoreTasks<S>>;
export function useTasks<Store extends AnyStore>(store: Store): TasksRecord<InferStoreTasks<Store>> {
return useSnapshot(store.queue.tasks) as TasksRecord<InferStoreTasks<Store>>;
}
@@ -42,7 +42,7 @@ describe('createStore', () => {
const store = create();
expect(store).toBeDefined();
expect(store.state).toEqual({ volume: 1, muted: false });
expect(store.state.current).toEqual({ volume: 1, muted: false });
});
});
@@ -55,7 +55,7 @@ describe('createStore', () => {
});
expect(result.current).toBeDefined();
expect(result.current.state).toEqual({ volume: 1, muted: false });
expect(result.current.state.current).toEqual({ volume: 1, muted: false });
});
it('destroys store on unmount', () => {