mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 09:59:44 +00:00
docs(claude): add symbol identification pattern
This commit is contained in:
@@ -0,0 +1,752 @@
|
||||
# Computed Layer Design
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Derived slices are too heavy for simple computed values, but inline selectors don't solve:
|
||||
|
||||
1. **No memoization** - same derivation recomputes per-subscriber
|
||||
2. **No derived-from-derived** - can't build on other computed values
|
||||
3. **Guards can't access** - computed values not available in request guards
|
||||
4. **Framework coupling** - each framework re-implements memoization
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Description |
|
||||
| --------------------- | -------------------------------------------------------- |
|
||||
| Lives alongside store | Not part of slice model, optional layer |
|
||||
| Memoization | Same derivation computed once, shared across subscribers |
|
||||
| Works in guards | Guards can access computed values synchronously |
|
||||
| Derived-from-derived | Computed values can depend on other computed |
|
||||
| Tree-shakeable | Users who don't use it don't pay for it |
|
||||
| Framework-agnostic | Works with React, Lit, and vanilla JS |
|
||||
|
||||
---
|
||||
|
||||
## Option Analysis
|
||||
|
||||
### Option A: Factory Function (Record-based)
|
||||
|
||||
```ts
|
||||
const computed = createComputed(store, {
|
||||
progress: (s) => (s.duration > 0 ? s.currentTime / s.duration : 0),
|
||||
isBuffering: (s) => s.waiting && !s.paused,
|
||||
});
|
||||
// Usage: computed.progress, computed.subscribe('progress', cb)
|
||||
```
|
||||
|
||||
| Aspect | Analysis |
|
||||
| -------------------- | --------------------------------------------------- |
|
||||
| Type inference | Simple - infer return types from selector functions |
|
||||
| Guards | Easy - `computed.progress` is synchronous getter |
|
||||
| React/Lit | Straightforward - subscribe to specific keys |
|
||||
| Memoization | Per-key caching, invalidate on dependency change |
|
||||
| Bundle size | Single import, tree-shakes if unused |
|
||||
| Derived-from-derived | Awkward - need to reference sibling selectors |
|
||||
|
||||
**Verdict:** Clean API but limited composability. Derived-from-derived requires awkward patterns.
|
||||
|
||||
### Option B: Individual Atoms (Jotai-style)
|
||||
|
||||
```ts
|
||||
const progressAtom = computed(store, (s) => s.currentTime / s.duration);
|
||||
const isBufferingAtom = computed(store, (s) => s.waiting && !s.paused);
|
||||
|
||||
// Derived from derived:
|
||||
const showBufferIndicator = computed(
|
||||
[isBufferingAtom, progressAtom],
|
||||
(buffering, progress) => buffering && progress > 0.1
|
||||
);
|
||||
```
|
||||
|
||||
| Aspect | Analysis |
|
||||
| -------------------- | ------------------------------------------------ |
|
||||
| Type inference | Complex - must handle single store vs atom array |
|
||||
| Guards | Requires atom registry or passing atoms to guard |
|
||||
| React/Lit | Each atom is independent, compose as needed |
|
||||
| Memoization | Each atom self-memoizes |
|
||||
| Bundle size | Most tree-shakeable - only used atoms included |
|
||||
| Derived-from-derived | Natural composition pattern |
|
||||
|
||||
**Verdict:** Maximum flexibility, but fragmented. Guard integration awkward without shared registry.
|
||||
|
||||
### Option C: Store Extension
|
||||
|
||||
```ts
|
||||
const extended = extendStore(store, {
|
||||
computed: {
|
||||
progress: (s) => s.currentTime / s.duration,
|
||||
},
|
||||
});
|
||||
// extended.state includes { currentTime, duration, progress }
|
||||
```
|
||||
|
||||
| Aspect | Analysis |
|
||||
| -------------------- | --------------------------------------------- |
|
||||
| Type inference | Complex - must merge computed into state type |
|
||||
| Guards | Natural - computed in state, guards see it |
|
||||
| React/Lit | Seamless - selectors just work |
|
||||
| Memoization | Must compute during state updates |
|
||||
| Bundle size | Less tree-shakeable - computed tied to store |
|
||||
| Derived-from-derived | Requires topological sort of computed fields |
|
||||
|
||||
**Verdict:** Most seamless, but couples computed to store lifecycle. Harder to tree-shake.
|
||||
|
||||
---
|
||||
|
||||
## Recommendation: Hybrid Approach (Option B + Registry)
|
||||
|
||||
Combine atomic composability with a registry pattern for guard access:
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Atoms for composition** - Each computed value is independent
|
||||
2. **Registry for guards** - Shared registry enables guard access
|
||||
3. **Lazy evaluation** - Computed values only calculated when accessed
|
||||
4. **Smart invalidation** - Track dependencies, recompute only when needed
|
||||
|
||||
### API Design
|
||||
|
||||
#### Core: `createComputed`
|
||||
|
||||
```ts
|
||||
// Single computed value from store state
|
||||
const progress = createComputed(store, (state) => {
|
||||
return state.duration > 0 ? state.currentTime / state.duration : 0;
|
||||
});
|
||||
|
||||
// Access computed value
|
||||
progress.get(); // 0.5
|
||||
progress.subscribe(cb); // () => unsubscribe
|
||||
|
||||
// Derived from another computed
|
||||
const isNearEnd = createComputed(progress, (progress) => progress > 0.9);
|
||||
|
||||
// Derived from multiple sources
|
||||
const showBufferUI = createComputed(
|
||||
[isBufferingAtom, progressAtom],
|
||||
(buffering, progress) => buffering && progress > 0.1
|
||||
);
|
||||
```
|
||||
|
||||
#### Registry for Guards: `createComputedRegistry`
|
||||
|
||||
```ts
|
||||
const registry = createComputedRegistry(store, {
|
||||
progress: (s) => (s.duration > 0 ? s.currentTime / s.duration : 0),
|
||||
isBuffering: (s) => s.waiting && !s.paused,
|
||||
// Derived from computed
|
||||
showBufferIndicator: (_, computed) => computed.isBuffering && computed.progress > 0.1,
|
||||
});
|
||||
|
||||
// Guards receive registry
|
||||
const playSlice = createSlice<HTMLVideoElement>()({
|
||||
// ...
|
||||
request: {
|
||||
seek: {
|
||||
guard: (ctx) => {
|
||||
const progress = registry.get('progress');
|
||||
return progress < 0.95; // Can't seek in last 5%
|
||||
},
|
||||
handler: (time, { target }) => (target.currentTime = time),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### React Integration
|
||||
|
||||
```ts
|
||||
// Hook for single computed
|
||||
function useComputed<T>(computed: Computed<T>): T {
|
||||
return useSyncExternalStore(
|
||||
computed.subscribe,
|
||||
computed.get,
|
||||
computed.get
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
function ProgressBar() {
|
||||
const progress = useComputed(progressAtom);
|
||||
return <div style={{ width: `${progress * 100}%` }} />;
|
||||
}
|
||||
|
||||
// From registry
|
||||
function useRegistryValue<K extends keyof Registry>(registry: Registry, key: K): Registry[K] {
|
||||
const computed = registry.computed(key);
|
||||
return useComputed(computed);
|
||||
}
|
||||
```
|
||||
|
||||
#### Lit Integration
|
||||
|
||||
```ts
|
||||
class ComputedController<T> implements ReactiveController {
|
||||
#computed: Computed<T>;
|
||||
#value: T;
|
||||
#unsubscribe = noop;
|
||||
|
||||
constructor(host: ReactiveControllerHost, computed: Computed<T>) {
|
||||
this.#computed = computed;
|
||||
this.#value = computed.get();
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
this.#unsubscribe = this.#computed.subscribe((value) => {
|
||||
this.#value = value;
|
||||
this.#host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
this.#unsubscribe();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### File: `packages/store/src/core/computed.ts`
|
||||
|
||||
```ts
|
||||
import type { AnyStore, InferStoreState } from './store';
|
||||
|
||||
// ----------------------------------------
|
||||
// Types
|
||||
// ----------------------------------------
|
||||
|
||||
export interface Computed<T> {
|
||||
/** Get current memoized value */
|
||||
get(): T;
|
||||
/** Subscribe to value changes */
|
||||
subscribe(listener: (value: T) => void): () => void;
|
||||
/** Force recomputation on next access */
|
||||
invalidate(): void;
|
||||
}
|
||||
|
||||
export type ComputedSelector<State, T> = (state: State) => T;
|
||||
|
||||
export type ComputedDeps = Computed<any>[] | readonly Computed<any>[];
|
||||
|
||||
export type InferComputedValues<Deps extends ComputedDeps> = {
|
||||
[K in keyof Deps]: Deps[K] extends Computed<infer T> ? T : never;
|
||||
};
|
||||
|
||||
// ----------------------------------------
|
||||
// createComputed (from store)
|
||||
// ----------------------------------------
|
||||
|
||||
export function createComputed<S extends AnyStore, T>(
|
||||
store: S,
|
||||
selector: ComputedSelector<InferStoreState<S>, T>
|
||||
): Computed<T>;
|
||||
|
||||
export function createComputed<Deps extends ComputedDeps, T>(
|
||||
deps: Deps,
|
||||
combiner: (...values: InferComputedValues<Deps>) => T
|
||||
): Computed<T>;
|
||||
|
||||
export function createComputed<S extends AnyStore, T>(
|
||||
source: S | ComputedDeps,
|
||||
selectorOrCombiner: ComputedSelector<InferStoreState<S>, T> | ((...args: any[]) => T)
|
||||
): Computed<T> {
|
||||
if (isStore(source)) {
|
||||
return createStoreComputed(source, selectorOrCombiner as ComputedSelector<InferStoreState<S>, T>);
|
||||
}
|
||||
return createDerivedComputed(source as ComputedDeps, selectorOrCombiner);
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// Store-based Computed
|
||||
// ----------------------------------------
|
||||
|
||||
function createStoreComputed<S extends AnyStore, T>(
|
||||
store: S,
|
||||
selector: ComputedSelector<InferStoreState<S>, T>
|
||||
): Computed<T> {
|
||||
let cachedValue: T;
|
||||
let isValid = false;
|
||||
const listeners = new Set<(value: T) => void>();
|
||||
|
||||
const compute = () => {
|
||||
const newValue = selector(store.state);
|
||||
if (!isValid || !Object.is(cachedValue, newValue)) {
|
||||
cachedValue = newValue;
|
||||
isValid = true;
|
||||
}
|
||||
return cachedValue;
|
||||
};
|
||||
|
||||
// Subscribe to store changes
|
||||
let storeUnsub: (() => void) | null = null;
|
||||
|
||||
const ensureSubscribed = () => {
|
||||
if (!storeUnsub) {
|
||||
storeUnsub = store.subscribe(() => {
|
||||
const prev = cachedValue;
|
||||
isValid = false;
|
||||
const next = compute();
|
||||
if (!Object.is(prev, next)) {
|
||||
for (const listener of listeners) {
|
||||
listener(next);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get() {
|
||||
ensureSubscribed();
|
||||
if (!isValid) compute();
|
||||
return cachedValue;
|
||||
},
|
||||
subscribe(listener) {
|
||||
ensureSubscribed();
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
// Optionally: unsubscribe from store when no listeners
|
||||
// if (listeners.size === 0 && storeUnsub) {
|
||||
// storeUnsub();
|
||||
// storeUnsub = null;
|
||||
// }
|
||||
};
|
||||
},
|
||||
invalidate() {
|
||||
isValid = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// Derived Computed (from other Computed)
|
||||
// ----------------------------------------
|
||||
|
||||
function createDerivedComputed<Deps extends ComputedDeps, T>(
|
||||
deps: Deps,
|
||||
combiner: (...values: InferComputedValues<Deps>) => T
|
||||
): Computed<T> {
|
||||
let cachedValue: T;
|
||||
let isValid = false;
|
||||
const listeners = new Set<(value: T) => void>();
|
||||
const unsubscribers: (() => void)[] = [];
|
||||
|
||||
const compute = () => {
|
||||
const values = deps.map((d) => d.get()) as InferComputedValues<Deps>;
|
||||
const newValue = combiner(...values);
|
||||
if (!isValid || !Object.is(cachedValue, newValue)) {
|
||||
cachedValue = newValue;
|
||||
isValid = true;
|
||||
}
|
||||
return cachedValue;
|
||||
};
|
||||
|
||||
const ensureSubscribed = () => {
|
||||
if (unsubscribers.length === 0) {
|
||||
for (const dep of deps) {
|
||||
unsubscribers.push(
|
||||
dep.subscribe(() => {
|
||||
const prev = cachedValue;
|
||||
isValid = false;
|
||||
const next = compute();
|
||||
if (!Object.is(prev, next)) {
|
||||
for (const listener of listeners) {
|
||||
listener(next);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get() {
|
||||
ensureSubscribed();
|
||||
if (!isValid) compute();
|
||||
return cachedValue;
|
||||
},
|
||||
subscribe(listener) {
|
||||
ensureSubscribed();
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
invalidate() {
|
||||
isValid = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### File: `packages/store/src/core/computed-registry.ts`
|
||||
|
||||
```ts
|
||||
import type { Computed } from './computed';
|
||||
import type { AnyStore, InferStoreState } from './store';
|
||||
|
||||
import { createComputed } from './computed';
|
||||
|
||||
// ----------------------------------------
|
||||
// Types
|
||||
// ----------------------------------------
|
||||
|
||||
export type ComputedDef<State, Registry, T> = ((state: State) => T) | ((state: State, computed: Registry) => T);
|
||||
|
||||
export type ComputedDefRecord<State, Registry = {}> = {
|
||||
[K: string]: ComputedDef<State, Registry, any>;
|
||||
};
|
||||
|
||||
export type InferComputedRegistry<Defs extends ComputedDefRecord<any, any>> = {
|
||||
[K in keyof Defs]: Defs[K] extends ComputedDef<any, any, infer T> ? T : never;
|
||||
};
|
||||
|
||||
export interface ComputedRegistry<Defs extends ComputedDefRecord<any, any>> {
|
||||
/** Get a computed value by key */
|
||||
get<K extends keyof Defs>(key: K): InferComputedRegistry<Defs>[K];
|
||||
|
||||
/** Get the Computed instance for a key */
|
||||
computed<K extends keyof Defs>(key: K): Computed<InferComputedRegistry<Defs>[K]>;
|
||||
|
||||
/** Subscribe to a computed value by key */
|
||||
subscribe<K extends keyof Defs>(key: K, listener: (value: InferComputedRegistry<Defs>[K]) => void): () => void;
|
||||
|
||||
/** Get all computed values as a snapshot */
|
||||
snapshot(): InferComputedRegistry<Defs>;
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// createComputedRegistry
|
||||
// ----------------------------------------
|
||||
|
||||
export function createComputedRegistry<
|
||||
S extends AnyStore,
|
||||
Defs extends ComputedDefRecord<InferStoreState<S>, InferComputedRegistry<Defs>>,
|
||||
>(store: S, defs: Defs): ComputedRegistry<Defs> {
|
||||
type State = InferStoreState<S>;
|
||||
type Registry = InferComputedRegistry<Defs>;
|
||||
|
||||
// Build dependency graph and create computed values
|
||||
const computedMap = new Map<keyof Defs, Computed<any>>();
|
||||
|
||||
// Proxy for accessing computed values during definition
|
||||
const registryProxy = new Proxy({} as Registry, {
|
||||
get(_, key: string) {
|
||||
const computed = computedMap.get(key as keyof Defs);
|
||||
if (!computed) {
|
||||
throw new Error(`Computed "${key}" accessed before definition`);
|
||||
}
|
||||
return computed.get();
|
||||
},
|
||||
});
|
||||
|
||||
// Topological sort - defs that don't depend on others first
|
||||
// For simplicity, we process in definition order and assume user orders correctly
|
||||
// A more robust impl would detect cycles and reorder
|
||||
for (const [key, def] of Object.entries(defs)) {
|
||||
const selector = def as ComputedDef<State, Registry, any>;
|
||||
|
||||
// Wrap to provide both state and registry
|
||||
const computed = createComputed(store, (state) => {
|
||||
if (selector.length === 1) {
|
||||
return (selector as (s: State) => any)(state);
|
||||
}
|
||||
return (selector as (s: State, r: Registry) => any)(state, registryProxy);
|
||||
});
|
||||
|
||||
computedMap.set(key as keyof Defs, computed);
|
||||
}
|
||||
|
||||
return {
|
||||
get(key) {
|
||||
const computed = computedMap.get(key);
|
||||
if (!computed) throw new Error(`Unknown computed: ${String(key)}`);
|
||||
return computed.get();
|
||||
},
|
||||
|
||||
computed(key) {
|
||||
const computed = computedMap.get(key);
|
||||
if (!computed) throw new Error(`Unknown computed: ${String(key)}`);
|
||||
return computed;
|
||||
},
|
||||
|
||||
subscribe(key, listener) {
|
||||
const computed = computedMap.get(key);
|
||||
if (!computed) throw new Error(`Unknown computed: ${String(key)}`);
|
||||
return computed.subscribe(listener);
|
||||
},
|
||||
|
||||
snapshot() {
|
||||
const result = {} as Registry;
|
||||
for (const [key, computed] of computedMap) {
|
||||
(result as any)[key] = computed.get();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### File: `packages/store/src/react/hooks/use-computed.ts`
|
||||
|
||||
```ts
|
||||
import type { Computed } from '../../core/computed';
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* Subscribe to a computed value.
|
||||
*
|
||||
* @example
|
||||
* const progressAtom = createComputed(store, s => s.currentTime / s.duration);
|
||||
*
|
||||
* function ProgressBar() {
|
||||
* const progress = useComputed(progressAtom);
|
||||
* return <div style={{ width: `${progress * 100}%` }} />;
|
||||
* }
|
||||
*/
|
||||
export function useComputed<T>(computed: Computed<T>): T {
|
||||
return useSyncExternalStore(computed.subscribe, computed.get, computed.get);
|
||||
}
|
||||
|
||||
export namespace useComputed {
|
||||
export type Result<T> = T;
|
||||
}
|
||||
```
|
||||
|
||||
### File: `packages/store/src/lit/controllers/computed-controller.ts`
|
||||
|
||||
```ts
|
||||
import type { Computed } from '../../core/computed';
|
||||
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
|
||||
|
||||
import { noop } from '@videojs/utils/function';
|
||||
|
||||
export type ComputedControllerHost = ReactiveControllerHost & HTMLElement;
|
||||
|
||||
/**
|
||||
* Subscribes to a computed value and triggers host updates on change.
|
||||
*
|
||||
* @example
|
||||
* const progressAtom = createComputed(store, s => s.currentTime / s.duration);
|
||||
*
|
||||
* class ProgressBar extends LitElement {
|
||||
* #progress = new ComputedController(this, progressAtom);
|
||||
*
|
||||
* render() {
|
||||
* return html`<div style="width: ${this.#progress.value * 100}%"></div>`;
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export class ComputedController<T> implements ReactiveController {
|
||||
readonly #host: ComputedControllerHost;
|
||||
readonly #computed: Computed<T>;
|
||||
|
||||
#value: T;
|
||||
#unsubscribe = noop;
|
||||
|
||||
constructor(host: ComputedControllerHost, computed: Computed<T>) {
|
||||
this.#host = host;
|
||||
this.#computed = computed;
|
||||
this.#value = computed.get();
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
this.#value = this.#computed.get();
|
||||
this.#unsubscribe = this.#computed.subscribe((value) => {
|
||||
this.#value = value;
|
||||
this.#host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
this.#unsubscribe();
|
||||
this.#unsubscribe = noop;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Computed Values
|
||||
|
||||
```ts
|
||||
import { createComputed } from '@videojs/store';
|
||||
|
||||
// From store state
|
||||
const progress = createComputed(store, (s) => {
|
||||
return s.duration > 0 ? s.currentTime / s.duration : 0;
|
||||
});
|
||||
|
||||
const isBuffering = createComputed(store, (s) => s.waiting && !s.paused);
|
||||
|
||||
// Derived from other computed
|
||||
const showBufferIndicator = createComputed([isBuffering, progress], (buffering, p) => buffering && p > 0.1);
|
||||
|
||||
// Read values
|
||||
console.log(progress.get()); // 0.5
|
||||
console.log(showBufferIndicator.get()); // true
|
||||
```
|
||||
|
||||
### Registry for Guard Access
|
||||
|
||||
```ts
|
||||
import { createComputedRegistry, createSlice } from '@videojs/store';
|
||||
|
||||
// Create registry
|
||||
const computed = createComputedRegistry(store, {
|
||||
progress: (s) => (s.duration > 0 ? s.currentTime / s.duration : 0),
|
||||
isBuffering: (s) => s.waiting && !s.paused,
|
||||
// Access other computed values
|
||||
showBufferIndicator: (s, c) => c.isBuffering && c.progress > 0.1,
|
||||
canSeek: (s) => !s.seeking && s.duration > 0,
|
||||
});
|
||||
|
||||
// Use in guards
|
||||
const timeSlice = createSlice<HTMLVideoElement>()({
|
||||
// ...
|
||||
request: {
|
||||
seek: {
|
||||
guard: () => computed.get('canSeek'),
|
||||
handler: (time, { target }) => {
|
||||
target.currentTime = time;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### React Usage
|
||||
|
||||
```tsx
|
||||
import { useComputed } from '@videojs/store/react';
|
||||
|
||||
// Individual atoms
|
||||
function ProgressBar() {
|
||||
const progress = useComputed(progressAtom);
|
||||
return <div style={{ width: `${progress * 100}%` }} />;
|
||||
}
|
||||
|
||||
// From registry
|
||||
function BufferIndicator() {
|
||||
const show = useComputed(computed.computed('showBufferIndicator'));
|
||||
if (!show) return null;
|
||||
return <Spinner />;
|
||||
}
|
||||
```
|
||||
|
||||
### Lit Usage
|
||||
|
||||
```ts
|
||||
import { ComputedController } from '@videojs/store/lit';
|
||||
|
||||
class ProgressBar extends LitElement {
|
||||
#progress = new ComputedController(this, progressAtom);
|
||||
|
||||
render() {
|
||||
return html`<div style="width: ${this.#progress.value * 100}%"></div>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Vanilla JS
|
||||
|
||||
```ts
|
||||
// Subscribe directly
|
||||
const unsub = progress.subscribe((value) => {
|
||||
progressEl.style.width = `${value * 100}%`;
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
unsub();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison with Alternatives
|
||||
|
||||
| Aspect | Derived Slice | Inline Selector | Computed Layer |
|
||||
| -------------------- | ------------------ | --------------- | -------------- |
|
||||
| Memoization | Yes | No | Yes |
|
||||
| Derived-from-derived | Via state | No | Yes |
|
||||
| Guard access | Yes (state) | No | Yes (registry) |
|
||||
| Bundle impact | Heavy | None | Light |
|
||||
| Framework agnostic | No (part of slice) | Yes | Yes |
|
||||
| Setup cost | High | None | Low |
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### From Inline Selectors
|
||||
|
||||
```ts
|
||||
// Before: Recalculates per subscriber
|
||||
store.subscribe((s) => s.currentTime / s.duration, updateUI);
|
||||
|
||||
// After: Memoized, shared
|
||||
const progress = createComputed(store, (s) => s.currentTime / s.duration);
|
||||
progress.subscribe(updateUI);
|
||||
```
|
||||
|
||||
### From Derived Slices (if we had them)
|
||||
|
||||
```ts
|
||||
// Before: Derived slice
|
||||
const progressSlice = createDerivedSlice([timeSlice], (state) => ({
|
||||
progress: state.duration > 0 ? state.currentTime / state.duration : 0,
|
||||
}));
|
||||
|
||||
// After: Computed
|
||||
const progress = createComputed(store, (s) => (s.duration > 0 ? s.currentTime / s.duration : 0));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| `packages/store/src/core/computed.ts` | Core `createComputed` and `Computed` type |
|
||||
| `packages/store/src/core/computed-registry.ts` | Registry for guard access |
|
||||
| `packages/store/src/core/tests/computed.test.ts` | Core tests |
|
||||
| `packages/store/src/core/tests/computed-registry.test.ts` | Registry tests |
|
||||
| `packages/store/src/react/hooks/use-computed.ts` | React hook |
|
||||
| `packages/store/src/react/hooks/tests/use-computed.test.tsx` | React hook tests |
|
||||
| `packages/store/src/lit/controllers/computed-controller.ts` | Lit controller |
|
||||
| `packages/store/src/lit/controllers/tests/computed-controller.test.ts` | Lit controller tests |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Lazy vs Eager subscription**: Should computed values subscribe to store immediately or only when first subscriber attaches?
|
||||
- Recommendation: Lazy (subscribe on first access) for tree-shaking benefits
|
||||
|
||||
2. **Cleanup on zero subscribers**: Should computed unsubscribe from store when all subscribers leave?
|
||||
- Recommendation: Optional via config, default to keeping subscription
|
||||
|
||||
3. **Equality function**: Should computed values support custom equality?
|
||||
- Recommendation: Yes, optional second arg `createComputed(store, selector, equalityFn)`
|
||||
|
||||
4. **Registry dependency detection**: Auto-detect dependencies or require explicit ordering?
|
||||
- Recommendation: Explicit ordering (simpler, avoids magic)
|
||||
|
||||
5. **Store reference**: Should computed hold strong or weak reference to store?
|
||||
- Recommendation: Strong reference (computed lifetime tied to store)
|
||||
@@ -0,0 +1,92 @@
|
||||
# Slice Availability Design
|
||||
|
||||
## The Problem
|
||||
|
||||
Slices may target capabilities the media doesn't support (e.g., `qualitySlice` on native `<video>`, `volumeSlice` on iOS).
|
||||
|
||||
## Decisions
|
||||
|
||||
### Single Availability Type
|
||||
|
||||
```typescript
|
||||
type Availability = 'available' | 'unavailable' | 'unsupported';
|
||||
```
|
||||
|
||||
| Value | Meaning |
|
||||
| --------------- | ---------------------------------------------------------- |
|
||||
| `'unsupported'` | Platform/target can never do this |
|
||||
| `'unavailable'` | Could work, not ready yet (e.g., waiting for HLS manifest) |
|
||||
| `'available'` | Ready to use |
|
||||
|
||||
### Naming Convention
|
||||
|
||||
Property: `{feature}Availability`
|
||||
|
||||
- `volumeAvailability`
|
||||
- `qualityAvailability`
|
||||
- `pipAvailability`
|
||||
|
||||
### Default Value
|
||||
|
||||
Always start `'unsupported'` (pessimistic). Must be proven otherwise.
|
||||
|
||||
### Async Capability Detection
|
||||
|
||||
Use module-level cache + `update()` pattern. No API changes needed.
|
||||
|
||||
```typescript
|
||||
let volumeSupportCache: Availability = 'unsupported';
|
||||
|
||||
const volumeSlice = createSlice<HTMLMediaElement>()({
|
||||
initialState: {
|
||||
volume: 1,
|
||||
volumeAvailability: 'unsupported',
|
||||
},
|
||||
|
||||
getSnapshot: ({ target }) => ({
|
||||
volume: target.volume,
|
||||
volumeAvailability: volumeSupportCache,
|
||||
}),
|
||||
|
||||
subscribe: ({ target, update, signal }) => {
|
||||
listen(target, 'volumechange', update, { signal });
|
||||
|
||||
// Async detection
|
||||
canChangeVolume().then((supported) => {
|
||||
if (signal.aborted) return;
|
||||
volumeSupportCache = supported ? 'available' : 'unsupported';
|
||||
update();
|
||||
});
|
||||
},
|
||||
|
||||
request: {
|
||||
setVolume: {
|
||||
guard: () => volumeSupportCache === 'available',
|
||||
handler: (vol, { target }) => {
|
||||
target.volume = vol;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Guards
|
||||
|
||||
Guards receive `{ target, signal }`, not state. Check capability on target directly.
|
||||
|
||||
### UI Usage
|
||||
|
||||
```tsx
|
||||
function VolumeSlider() {
|
||||
const { volume, volumeAvailability } = useStore((s) => s);
|
||||
|
||||
if (volumeAvailability === 'unsupported') return null;
|
||||
if (volumeAvailability === 'unavailable') return <Slider disabled />;
|
||||
return <Slider value={volume} />;
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Media Chrome uses similar pattern with `*Unavailable` properties
|
||||
- Vidstack uses `canSetVolume`, `canSetQuality` computed properties
|
||||
@@ -0,0 +1,266 @@
|
||||
# useSlice / SliceController
|
||||
|
||||
Slice-aware state access for primitives.
|
||||
|
||||
## Background
|
||||
|
||||
### What is a Slice?
|
||||
|
||||
A slice is a unit of state + behavior for a specific concern:
|
||||
|
||||
```ts
|
||||
const volumeSlice = createSlice<HTMLMediaElement>()({
|
||||
initialState: { volume: 1, muted: false },
|
||||
getSnapshot: ({ target }) => ({ volume: target.volume, muted: target.muted }),
|
||||
subscribe: ({ target, update, signal }) => listen(target, 'volumechange', update, { signal }),
|
||||
request: {
|
||||
changeVolume: (volume, { target }) => {
|
||||
target.volume = volume;
|
||||
},
|
||||
toggleMute: (_, { target }) => {
|
||||
target.muted = !target.muted;
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Stores are composed of slices. Slices are optional — users include what they need.
|
||||
|
||||
### Primitives Require Slices
|
||||
|
||||
UI primitives (PlayButton, VolumeSlider) need specific slices to function:
|
||||
|
||||
- VolumeSlider needs `volumeSlice` for state and requests
|
||||
- PlayButton needs `playbackSlice`
|
||||
- TimeDisplay needs `timeSlice`
|
||||
|
||||
### The Design Question
|
||||
|
||||
What happens when a primitive's required slice isn't in the store?
|
||||
|
||||
| Approach | Problem |
|
||||
| -------------------- | ----------------------------------------------------------------------------- |
|
||||
| Return defaults | **Dangerous.** User thinks volume works, but nothing happens. Silent failure. |
|
||||
| Return undefined | Every access becomes defensive. Loses type narrowing. |
|
||||
| Graceful degradation | Primitives render nothing or fallback. Boilerplate everywhere. |
|
||||
| **Error** | Invalid composition caught early. Clean primitive code. |
|
||||
|
||||
### Our Decision: Missing Slice = Invalid Composition
|
||||
|
||||
If you render VolumeSlider, you need volumeSlice. Period.
|
||||
|
||||
- **Compile-time error** for factory-bound hooks (ideal)
|
||||
- **Runtime error** for standalone hooks (escape hatch)
|
||||
|
||||
Primitives don't handle missing slices. Invalid compositions fail loudly.
|
||||
|
||||
---
|
||||
|
||||
## API Design
|
||||
|
||||
### Store Method
|
||||
|
||||
```ts
|
||||
store.hasSlice(slice): boolean
|
||||
```
|
||||
|
||||
Runtime check. Foundation for `useSlice` implementation.
|
||||
|
||||
### React
|
||||
|
||||
**Base hook** (accepts store directly):
|
||||
|
||||
```ts
|
||||
import { useSlice } from '@videojs/store/react';
|
||||
|
||||
const volume = useSlice(store, volumeSlice, (ctx) => ctx.state.volume);
|
||||
// Returns: number | undefined (undefined if slice missing)
|
||||
```
|
||||
|
||||
**Factory-bound hook** (from createStore):
|
||||
|
||||
```ts
|
||||
const { useSlice } = createStore({ slices: [volumeSlice, playbackSlice] });
|
||||
|
||||
const volume = useSlice(volumeSlice, (ctx) => ctx.state.volume);
|
||||
// Returns: number
|
||||
// TypeScript ERROR if volumeSlice not in store config
|
||||
```
|
||||
|
||||
### Lit
|
||||
|
||||
**Base controller** (accepts store/context):
|
||||
|
||||
```ts
|
||||
import { SliceController } from '@videojs/store/lit';
|
||||
|
||||
#volume = new SliceController(this, store, volumeSlice, ctx => ctx.state.volume);
|
||||
// this.#volume.value: number | undefined
|
||||
```
|
||||
|
||||
**Factory-bound controller** (from createStore):
|
||||
|
||||
```ts
|
||||
const { SliceController } = createStore({ slices: [volumeSlice] });
|
||||
|
||||
#volume = new SliceController(this, volumeSlice, ctx => ctx.state.volume);
|
||||
// this.#volume.value: number
|
||||
// TypeScript ERROR if volumeSlice not in store config
|
||||
```
|
||||
|
||||
### Selector
|
||||
|
||||
Always required. Receives slice context, returns selected value:
|
||||
|
||||
```ts
|
||||
interface SliceContext<S extends AnySlice> {
|
||||
state: InferSliceState<S>;
|
||||
request: ResolveSliceRequestHandlers<S>;
|
||||
}
|
||||
|
||||
// Select state
|
||||
const volume = useSlice(volumeSlice, (ctx) => ctx.state.volume);
|
||||
const { volume, muted } = useSlice(volumeSlice, (ctx) => ctx.state);
|
||||
|
||||
// Select request
|
||||
const changeVolume = useSlice(volumeSlice, (ctx) => ctx.request.changeVolume);
|
||||
|
||||
// Derived values
|
||||
const isSilent = useSlice(volumeSlice, (ctx) => ctx.state.muted || ctx.state.volume === 0);
|
||||
```
|
||||
|
||||
If selector returns state (not a function), subscribe to changes.
|
||||
|
||||
---
|
||||
|
||||
## Type Safety
|
||||
|
||||
### Factory-Bound: Compile-Time Validation
|
||||
|
||||
```ts
|
||||
const { useSlice } = createStore({ slices: [playbackSlice] }); // No volumeSlice
|
||||
|
||||
useSlice(volumeSlice, (ctx) => ctx.state.volume);
|
||||
// TS Error: volumeSlice is not in store's slice configuration
|
||||
```
|
||||
|
||||
Implementation: Factory captures `Slices` type parameter. `useSlice` constrains slice arg to `Slices[number]`.
|
||||
|
||||
### Standalone: Runtime Fallback
|
||||
|
||||
```ts
|
||||
import { useSlice } from '@videojs/store/react';
|
||||
|
||||
const volume = useSlice(dynamicStore, volumeSlice, (ctx) => ctx.state.volume);
|
||||
// Returns: number | undefined
|
||||
```
|
||||
|
||||
For dynamic stores where compile-time checking isn't possible.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### React
|
||||
|
||||
```ts
|
||||
// Base
|
||||
function useSlice<S extends AnySlice, R>(
|
||||
store: AnyStore,
|
||||
slice: S,
|
||||
selector: (ctx: SliceContext<S>) => R
|
||||
): R | undefined {
|
||||
if (!store.hasSlice(slice)) return undefined;
|
||||
|
||||
const ctx = useMemo(() => ({
|
||||
state: /* proxy to store.state filtered by slice */,
|
||||
request: /* proxy to store.request filtered by slice */,
|
||||
}), [store, slice]);
|
||||
|
||||
const selected = selector(ctx);
|
||||
|
||||
// If selected is not a function, subscribe
|
||||
if (typeof selected !== 'function') {
|
||||
return useSyncExternalStore(
|
||||
cb => store.subscribe(/* selector that maps to selected */, cb),
|
||||
() => selector(ctx),
|
||||
);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
```
|
||||
|
||||
### Lit
|
||||
|
||||
```ts
|
||||
class SliceController<S extends AnySlice, R> implements ReactiveController {
|
||||
#value: R | undefined;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost & HTMLElement,
|
||||
source: StoreSource,
|
||||
slice: S,
|
||||
selector: (ctx: SliceContext<S>) => R
|
||||
) {
|
||||
// Similar logic: check hasSlice, build context, subscribe if state
|
||||
}
|
||||
|
||||
get value(): R | undefined {
|
||||
return this.#value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### hasSlice Implementation
|
||||
|
||||
```ts
|
||||
class Store {
|
||||
#sliceIds: Set<symbol>;
|
||||
|
||||
constructor(config) {
|
||||
this.#sliceIds = new Set(config.slices.map((s) => s.id));
|
||||
}
|
||||
|
||||
hasSlice(slice: AnySlice): boolean {
|
||||
return this.#sliceIds.has(slice.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage in Primitives
|
||||
|
||||
```tsx
|
||||
// React
|
||||
function VolumeSlider() {
|
||||
const volume = useSlice(volumeSlice, (ctx) => ctx.state.volume);
|
||||
const changeVolume = useSlice(volumeSlice, (ctx) => ctx.request.changeVolume);
|
||||
|
||||
return <Slider value={volume} onValueChange={changeVolume} />;
|
||||
}
|
||||
|
||||
// Lit
|
||||
class VolumeSlider extends LitElement {
|
||||
#volume = new SliceController(this, volumeSlice, (ctx) => ctx.state.volume);
|
||||
#changeVolume = new SliceController(this, volumeSlice, (ctx) => ctx.request.changeVolume);
|
||||
|
||||
render() {
|
||||
return html`<vjs-slider value=${this.#volume.value} @change=${(e) => this.#changeVolume.value(e.detail)} />`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No defensive checks. If slice is missing, it's a composition error caught at compile time (factory-bound) or clearly undefined (standalone).
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
- `packages/store/src/core/store.ts` — add `hasSlice` method
|
||||
- `packages/store/src/react/hooks/use-slice.ts` — base hook
|
||||
- `packages/store/src/react/create-store.tsx` — factory-bound hook
|
||||
- `packages/store/src/lit/controllers/slice-controller.ts` — base controller
|
||||
- `packages/store/src/lit/create-store.ts` — factory-bound controller
|
||||
- Tests for each
|
||||
@@ -250,6 +250,35 @@ function isStoreError(value: unknown): value is StoreError {
|
||||
}
|
||||
```
|
||||
|
||||
### Symbol Identification Pattern
|
||||
|
||||
Use symbols to identify objects when `instanceof` isn't reliable (e.g., cross-realm, serialization boundaries):
|
||||
|
||||
```ts
|
||||
const QUEUE_SYMBOL = Symbol('@videojs/queue');
|
||||
|
||||
interface Queue {
|
||||
[QUEUE_SYMBOL]: true;
|
||||
// ...
|
||||
}
|
||||
|
||||
function createQueue(): Queue {
|
||||
return {
|
||||
[QUEUE_SYMBOL]: true,
|
||||
// ...
|
||||
};
|
||||
}
|
||||
|
||||
function isQueue(value: unknown): value is Queue {
|
||||
return isObject(value) && QUEUE_SYMBOL in value;
|
||||
}
|
||||
```
|
||||
|
||||
- Symbol constant named `*_SYMBOL` in SCREAMING_CASE
|
||||
- Symbol description is `@videojs/*`
|
||||
- Add `[SYMBOL]: true` property to the object/interface
|
||||
- Type guard checks `isObject(value) && SYMBOL in value`
|
||||
|
||||
### Subscribe Pattern
|
||||
|
||||
Subscriptions return an unsubscribe function:
|
||||
|
||||
@@ -11,18 +11,14 @@ import type {
|
||||
import type { StateFactory } from './state';
|
||||
|
||||
import { getSelectorKeys } from '@videojs/utils/object';
|
||||
import { isNull, isObject } from '@videojs/utils/predicate';
|
||||
import { isNull } from '@videojs/utils/predicate';
|
||||
|
||||
import { StoreError } from './errors';
|
||||
import { Queue } from './queue';
|
||||
import { createRequestMeta, resolveRequestCancel, resolveRequestKey } from './request';
|
||||
import { State } from './state';
|
||||
|
||||
/** Used below in `isStore` to identify store objects. */
|
||||
const STORE_KEY = Symbol('@videojs/store');
|
||||
|
||||
export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[]> {
|
||||
readonly [STORE_KEY] = true;
|
||||
readonly #config: StoreConfig<Target, Slices>;
|
||||
readonly #slices: Slices;
|
||||
readonly #queue: Queue<UnionSliceTasks<Slices>>;
|
||||
@@ -340,7 +336,7 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
|
||||
}
|
||||
|
||||
export function isStore(value: unknown): value is AnyStore {
|
||||
return isObject(value) && STORE_KEY in value;
|
||||
return value instanceof Store;
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user