diff --git a/.claude/plans/computed-layer.md b/.claude/plans/computed-layer.md deleted file mode 100644 index ab65eba9..00000000 --- a/.claude/plans/computed-layer.md +++ /dev/null @@ -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()({ - // ... - 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(computed: Computed): T { - return useSyncExternalStore( - computed.subscribe, - computed.get, - computed.get - ); -} - -// Usage -function ProgressBar() { - const progress = useComputed(progressAtom); - return
; -} - -// From registry -function useRegistryValue(registry: Registry, key: K): Registry[K] { - const computed = registry.computed(key); - return useComputed(computed); -} -``` - -#### Lit Integration - -```ts -class ComputedController implements ReactiveController { - #computed: Computed; - #value: T; - #unsubscribe = noop; - - constructor(host: ReactiveControllerHost, computed: Computed) { - 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 { - /** 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: State) => T; - -export type ComputedDeps = Computed[] | readonly Computed[]; - -export type InferComputedValues = { - [K in keyof Deps]: Deps[K] extends Computed ? T : never; -}; - -// ---------------------------------------- -// createComputed (from store) -// ---------------------------------------- - -export function createComputed( - store: S, - selector: ComputedSelector, T> -): Computed; - -export function createComputed( - deps: Deps, - combiner: (...values: InferComputedValues) => T -): Computed; - -export function createComputed( - source: S | ComputedDeps, - selectorOrCombiner: ComputedSelector, T> | ((...args: any[]) => T) -): Computed { - if (isStore(source)) { - return createStoreComputed(source, selectorOrCombiner as ComputedSelector, T>); - } - return createDerivedComputed(source as ComputedDeps, selectorOrCombiner); -} - -// ---------------------------------------- -// Store-based Computed -// ---------------------------------------- - -function createStoreComputed( - store: S, - selector: ComputedSelector, T> -): Computed { - 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: Deps, - combiner: (...values: InferComputedValues) => T -): Computed { - 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; - 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: State) => T) | ((state: State, computed: Registry) => T); - -export type ComputedDefRecord = { - [K: string]: ComputedDef; -}; - -export type InferComputedRegistry> = { - [K in keyof Defs]: Defs[K] extends ComputedDef ? T : never; -}; - -export interface ComputedRegistry> { - /** Get a computed value by key */ - get(key: K): InferComputedRegistry[K]; - - /** Get the Computed instance for a key */ - computed(key: K): Computed[K]>; - - /** Subscribe to a computed value by key */ - subscribe(key: K, listener: (value: InferComputedRegistry[K]) => void): () => void; - - /** Get all computed values as a snapshot */ - snapshot(): InferComputedRegistry; -} - -// ---------------------------------------- -// createComputedRegistry -// ---------------------------------------- - -export function createComputedRegistry< - S extends AnyStore, - Defs extends ComputedDefRecord, InferComputedRegistry>, ->(store: S, defs: Defs): ComputedRegistry { - type State = InferStoreState; - type Registry = InferComputedRegistry; - - // Build dependency graph and create computed values - const computedMap = new Map>(); - - // 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; - - // 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
; - * } - */ -export function useComputed(computed: Computed): T { - return useSyncExternalStore(computed.subscribe, computed.get, computed.get); -} - -export namespace useComputed { - export type Result = 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`
`; - * } - * } - */ -export class ComputedController implements ReactiveController { - readonly #host: ComputedControllerHost; - readonly #computed: Computed; - - #value: T; - #unsubscribe = noop; - - constructor(host: ComputedControllerHost, computed: Computed) { - 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()({ - // ... - 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
; -} - -// From registry -function BufferIndicator() { - const show = useComputed(computed.computed('showBufferIndicator')); - if (!show) return null; - return ; -} -``` - -### Lit Usage - -```ts -import { ComputedController } from '@videojs/store/lit'; - -class ProgressBar extends LitElement { - #progress = new ComputedController(this, progressAtom); - - render() { - return html`
`; - } -} -``` - -### 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) diff --git a/.claude/plans/use-slice.md b/.claude/plans/use-slice.md index 354ce739..0fcde603 100644 --- a/.claude/plans/use-slice.md +++ b/.claude/plans/use-slice.md @@ -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()({ - 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 { // 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 ; + + // 3. Ready to use + return ; +} ``` -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``; + + return html``; + } + + #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( - store: AnyStore, - slice: S, - selector: (ctx: SliceContext) => 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 implements ReactiveController { - #value: R | undefined; - - constructor( - host: ReactiveControllerHost & HTMLElement, - source: StoreSource, - slice: S, - selector: (ctx: SliceContext) => 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( + store: AnyStore, + slice: S, + selector: (ctx: SliceContext) => 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 = { + state: store.state, + request: store.request, + }; - return ; -} + 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` 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 implements ReactiveController { + #host: ReactiveControllerHost & HTMLElement; + #accessor: StoreAccessor; + #slice: S; + #selector: (ctx: SliceContext) => R; + #value: R | undefined; + #unsubscribe = noop; + + constructor( + host: ReactiveControllerHost & HTMLElement, + source: StoreSource, + slice: S, + selector: (ctx: SliceContext) => 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 = { 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