docs(claude): add symbol identification pattern

This commit is contained in:
Rahim
2026-01-07 21:18:11 +11:00
parent b2e2b88e19
commit 4acf01e030
5 changed files with 1141 additions and 6 deletions
+752
View File
@@ -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)
+92
View File
@@ -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
+266
View File
@@ -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
+29
View File
@@ -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:
+2 -6
View File
@@ -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;
}
// ----------------------------------------