mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
docs(plan): add using slices
This commit is contained in:
@@ -1,752 +0,0 @@
|
||||
# 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)
|
||||
+153
-135
@@ -1,4 +1,4 @@
|
||||
# useSlice / SliceController
|
||||
# Using Slices
|
||||
|
||||
Slice-aware state access for primitives.
|
||||
|
||||
@@ -10,86 +10,68 @@ 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 }),
|
||||
initialState: { volume: 1, muted: false, volumeAvailability: 'unsupported' },
|
||||
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;
|
||||
},
|
||||
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.
|
||||
|
||||
### Missing Slice vs Unavailable Capability
|
||||
|
||||
Two different concepts:
|
||||
|
||||
| Concept | Meaning | Detection | Cause |
|
||||
| -------------------------- | -------------------------------------------- | -------------------------------------- | ---------------------------------------- |
|
||||
| **Missing slice** | Store wasn't configured with this slice | `useSlice()` returns `undefined` | Developer didn't include slice in config |
|
||||
| **Unavailable capability** | Slice exists but platform doesn't support it | `volumeAvailability === 'unsupported'` | Platform limitation (e.g., iOS volume) |
|
||||
|
||||
**Missing slice** is a composition/configuration issue. The primitive requires a slice that wasn't added to the store.
|
||||
|
||||
**Unavailable capability** is a platform limitation. The slice is configured, but the underlying media/platform can't perform the action (see `slice-availability.md`).
|
||||
|
||||
### Primitives Require Slices
|
||||
|
||||
UI primitives (PlayButton, VolumeSlider) need specific slices to function:
|
||||
UI primitives (PlayButton, VolumeSlider) need specific slices:
|
||||
|
||||
- VolumeSlider needs `volumeSlice` for state and requests
|
||||
- VolumeSlider needs `volumeSlice`
|
||||
- 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.
|
||||
When a slice is missing, `useSlice` returns `undefined`. The primitive decides how to handle it — typically throwing `StoreError('MISSING_SLICE')`.
|
||||
|
||||
---
|
||||
|
||||
## API Design
|
||||
|
||||
### Store Method
|
||||
|
||||
```ts
|
||||
store.hasSlice(slice): boolean
|
||||
```
|
||||
|
||||
Runtime check. Foundation for `useSlice` implementation.
|
||||
## API
|
||||
|
||||
### React
|
||||
|
||||
**Base hook** (accepts store directly):
|
||||
**Base hook** (explicit store):
|
||||
|
||||
```ts
|
||||
import { useSlice } from '@videojs/store/react';
|
||||
|
||||
const volume = useSlice(store, volumeSlice, (ctx) => ctx.state.volume);
|
||||
// Returns: number | undefined (undefined if slice missing)
|
||||
// Returns: number | undefined
|
||||
```
|
||||
|
||||
**Factory-bound hook** (from createStore):
|
||||
**Factory-bound hook** (store from context):
|
||||
|
||||
```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
|
||||
// Returns: number | undefined
|
||||
```
|
||||
|
||||
### Lit
|
||||
|
||||
**Base controller** (accepts store/context):
|
||||
**Base controller** (explicit store):
|
||||
|
||||
```ts
|
||||
import { SliceController } from '@videojs/store/lit';
|
||||
@@ -98,14 +80,13 @@ import { SliceController } from '@videojs/store/lit';
|
||||
// this.#volume.value: number | undefined
|
||||
```
|
||||
|
||||
**Factory-bound controller** (from createStore):
|
||||
**Factory-bound controller** (store from context):
|
||||
|
||||
```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
|
||||
// this.#volume.value: number | undefined
|
||||
```
|
||||
|
||||
### Selector
|
||||
@@ -120,7 +101,6 @@ interface SliceContext<S extends AnySlice> {
|
||||
|
||||
// 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);
|
||||
@@ -129,90 +109,64 @@ const changeVolume = useSlice(volumeSlice, (ctx) => ctx.request.changeVolume);
|
||||
const isSilent = useSlice(volumeSlice, (ctx) => ctx.state.muted || ctx.state.volume === 0);
|
||||
```
|
||||
|
||||
If selector returns state (not a function), subscribe to changes.
|
||||
### Subscription
|
||||
|
||||
Always subscribes when selector returns state (not a function). Request handlers are stable references — subscription is effectively a no-op for them.
|
||||
|
||||
---
|
||||
|
||||
## Type Safety
|
||||
## Usage in Primitives
|
||||
|
||||
### Factory-Bound: Compile-Time Validation
|
||||
```tsx
|
||||
function VolumeSlider() {
|
||||
const volume = useSlice(volumeSlice, (ctx) => ctx.state.volume);
|
||||
const availability = useSlice(volumeSlice, (ctx) => ctx.state.volumeAvailability);
|
||||
const changeVolume = useSlice(volumeSlice, (ctx) => ctx.request.changeVolume);
|
||||
|
||||
```ts
|
||||
const { useSlice } = createStore({ slices: [playbackSlice] }); // No volumeSlice
|
||||
// 1. Slice not in store (composition error)
|
||||
if (volume === undefined) {
|
||||
throw new StoreError('MISSING_SLICE', 'VolumeSlider requires volumeSlice');
|
||||
}
|
||||
|
||||
useSlice(volumeSlice, (ctx) => ctx.state.volume);
|
||||
// TS Error: volumeSlice is not in store's slice configuration
|
||||
// 2. Platform doesn't support volume (iOS, etc.)
|
||||
if (availability === 'unsupported') return null;
|
||||
if (availability === 'unavailable') return <Slider disabled />;
|
||||
|
||||
// 3. Ready to use
|
||||
return <Slider value={volume} onChange={changeVolume} />;
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: Factory captures `Slices` type parameter. `useSlice` constrains slice arg to `Slices[number]`.
|
||||
|
||||
### Standalone: Runtime Fallback
|
||||
|
||||
```ts
|
||||
import { useSlice } from '@videojs/store/react';
|
||||
// Lit
|
||||
class VolumeSlider extends LitElement {
|
||||
#volume = new SliceController(this, volumeSlice, (ctx) => ctx.state.volume);
|
||||
#availability = new SliceController(this, volumeSlice, (ctx) => ctx.state.volumeAvailability);
|
||||
#changeVolume = new SliceController(this, volumeSlice, (ctx) => ctx.request.changeVolume);
|
||||
|
||||
const volume = useSlice(dynamicStore, volumeSlice, (ctx) => ctx.state.volume);
|
||||
// Returns: number | undefined
|
||||
render() {
|
||||
const volume = this.#volume.value;
|
||||
const availability = this.#availability.value;
|
||||
|
||||
if (volume === undefined) {
|
||||
throw new StoreError('MISSING_SLICE', 'VolumeSlider requires volumeSlice');
|
||||
}
|
||||
|
||||
if (availability === 'unsupported') return nothing;
|
||||
if (availability === 'unavailable') return html`<vjs-slider disabled></vjs-slider>`;
|
||||
|
||||
return html`<vjs-slider value=${volume} @change=${this.#onChange}></vjs-slider>`;
|
||||
}
|
||||
|
||||
#onChange = (e: CustomEvent) => this.#changeVolume.value?.(e.detail);
|
||||
}
|
||||
```
|
||||
|
||||
For dynamic stores where compile-time checking isn't possible.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
## Implementation
|
||||
|
||||
### 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
|
||||
### Store: hasSlice
|
||||
|
||||
```ts
|
||||
class Store {
|
||||
@@ -228,37 +182,101 @@ class Store {
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### React: useSlice
|
||||
|
||||
## Usage in Primitives
|
||||
```ts
|
||||
function useSlice<S extends AnySlice, R>(
|
||||
store: AnyStore,
|
||||
slice: S,
|
||||
selector: (ctx: SliceContext<S>) => R
|
||||
): R | undefined {
|
||||
// Check slice presence
|
||||
if (!store.hasSlice(slice)) return undefined;
|
||||
|
||||
```tsx
|
||||
// React
|
||||
function VolumeSlider() {
|
||||
const volume = useSlice(volumeSlice, (ctx) => ctx.state.volume);
|
||||
const changeVolume = useSlice(volumeSlice, (ctx) => ctx.request.changeVolume);
|
||||
// Build context
|
||||
const ctx: SliceContext<S> = {
|
||||
state: store.state,
|
||||
request: store.request,
|
||||
};
|
||||
|
||||
return <Slider value={volume} onValueChange={changeVolume} />;
|
||||
}
|
||||
const selected = selector(ctx);
|
||||
|
||||
// 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)} />`;
|
||||
// Subscribe if not a function (state vs request)
|
||||
if (typeof selected !== 'function') {
|
||||
return useSyncExternalStore(
|
||||
(cb) => store.subscribe((state) => selector({ state, request: store.request }), cb),
|
||||
() => selector(ctx)
|
||||
);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
```
|
||||
|
||||
No defensive checks. If slice is missing, it's a composition error caught at compile time (factory-bound) or clearly undefined (standalone).
|
||||
### Lit: SliceController
|
||||
|
||||
```ts
|
||||
class SliceController<S extends AnySlice, R> implements ReactiveController {
|
||||
#host: ReactiveControllerHost & HTMLElement;
|
||||
#accessor: StoreAccessor;
|
||||
#slice: S;
|
||||
#selector: (ctx: SliceContext<S>) => R;
|
||||
#value: R | undefined;
|
||||
#unsubscribe = noop;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost & HTMLElement,
|
||||
source: StoreSource,
|
||||
slice: S,
|
||||
selector: (ctx: SliceContext<S>) => R
|
||||
) {
|
||||
this.#host = host;
|
||||
this.#slice = slice;
|
||||
this.#selector = selector;
|
||||
this.#accessor = new StoreAccessor(host, source, (store) => this.#connect(store));
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
get value(): R | undefined {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
this.#accessor.hostConnected();
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
this.#unsubscribe();
|
||||
this.#unsubscribe = noop;
|
||||
}
|
||||
|
||||
#connect(store: AnyStore): void {
|
||||
if (!store.hasSlice(this.#slice)) {
|
||||
this.#value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx: SliceContext<S> = { state: store.state, request: store.request };
|
||||
this.#value = this.#selector(ctx);
|
||||
|
||||
// Subscribe if not a function
|
||||
if (typeof this.#value !== 'function') {
|
||||
this.#unsubscribe = store.subscribe((state) => {
|
||||
const newCtx = { state, request: store.request };
|
||||
this.#value = this.#selector(newCtx);
|
||||
this.#host.requestUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
- `packages/store/src/core/store.ts` — add `hasSlice` method
|
||||
- `packages/store/src/core/errors.ts` — add `MISSING_SLICE` error code
|
||||
- `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
|
||||
|
||||
Reference in New Issue
Block a user