mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(store): simplify state management + computeds (#321)
This commit is contained in:
+56
-38
@@ -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
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from './computed';
|
||||
export * from './errors';
|
||||
export * from './extend-config';
|
||||
export * from './feature';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user