diff --git a/.claude/plans/spf/discrete-signals-and-behavior-objects.md b/.claude/plans/spf/discrete-signals-and-behavior-objects.md new file mode 100644 index 00000000..dc599ba4 --- /dev/null +++ b/.claude/plans/spf/discrete-signals-and-behavior-objects.md @@ -0,0 +1,463 @@ +--- +status: stages-A-B-C-D-shipped-pivoted-no-count-invariant +branch: refactor/spf-discrete-signals-and-behavior-objects +--- + +# SPF: Discrete Signals + Behavior-as-Object + +> Captures a meeting follow-up on a coordinated set of architectural shifts in SPF. Stages A, B, C, and a scoped-down Stage D have landed. Stage D originally proposed a compose-time **writer-count invariant** (0-or-1 writer per signal) on top of per-slot read/write annotations — that part was dropped after the writer audit revealed multiple legitimate multi-writer patterns (intent+default, pipeline, two-way DOM sync). What shipped is the per-slot annotation work + writer audit; multi-writer slots are accepted as legitimate shapes. Findings feed back into `internal/design/spf/primitives.md` and `packages/spf/docs/hls-engine.md`. + +## Status snapshot + +- **Branch:** `refactor/spf-discrete-signals-and-behavior-objects` (off `docs/spf-hls-engine-composition`). Renamed from `refactor/spf-discrete-signals-stage-a` once scope expanded beyond Stage A. +- **Stages A + B + C + D (scoped-down) complete** + engine-wrapper revisit + `buildSignalMap` export + Stage D-pre input mechanism (`shareSignals`) + per-slot read/write annotation propagation across all 17 behaviors. Tests green: 49 files, 759 tests passed. +- **Build clean.** `pnpm typecheck`, `pnpm -F @videojs/spf test`, `pnpm exec biome check`, `pnpm check:workspace`, `pnpm build:packages` all pass. +- **Stage D pivoted (no count invariant).** Per-slot `Signal` (writable) / `ReadonlySignal` (read-only) annotations are the contract — body-level write enforcement is shipped (commit `b7866e6a`). The originally-planned compose-time writer-count invariant ("0-or-1 writer behaviors per signal") was **dropped** once the writer audit (commit `c29fea1e`) confirmed that multi-writer slots are legitimate patterns, not violations. See `### Why we dropped the writer-count invariant` for the patterns and reasoning. +- **Stage D-pre shipped.** The adapter's external writes flow through a generic `shareSignals` behavior factory: the engine instantiates `makeShareSignals()` and the consumer captures writable signal refs via `config.onSignalsReady` at composition setup. Adapter migrated; sandbox harness rebuilt (also fixed: it had been on the pre-Stage-A monolithic API since Stage A landed). The 2-writer cases on `selectedVideoTrackId` / `selectedTextTrackId` are now documented in the writer audit as legitimate intent+default patterns; the harness's direct writes via `engine.state` retain `TODO(stage-d)` markers as cleanup hints (decompose into manual + ABR slots), but are no longer Stage D-blocking. +- **Future follow-up:** custom linter rule that warns on multi-writer slots with an ignore-comment mechanism for intentional cases (`// writer-audit-allow: `). Captures the original Stage D intent (visibility into multi-writer cases) without forcing decomposition through types. See `### Follow-up: writer-audit lint rule`. +- **Not yet:** PR opened; merge to main; doc updates to `hls-engine.md` / `fundamentals.md`. +- **Memory:** `project_spf_stage_a_revisit.md` has the deeper "why we did it this way" notes for Stage A; updated for B/C carryovers. + +## Resuming — where to start the next session + +1. **Verify branch state.** `git log --oneline` should show recent commits including `c29fea1e docs(spf): add per-behavior + shareSignals writer audit to plan doc`, `b7866e6a refactor(spf): make per-slot read/write intent explicit in behaviors`, and `6b9801a4 refactor(spf): parameterize Behavior types over slot-map shapes`. +2. **No major Stage D work pending.** The scoped-down Stage D shipped; the count invariant is dropped. Stage D's open questions (`### Open questions for Stage D implementation`) are largely resolved or moot. +3. **Independent grooming work** (any order, can interleave): + - **Writer-audit lint rule.** The future follow-up — a custom rule that warns on multi-writer slots with a comment-based ignore mechanism for intentional cases. See `### Follow-up: writer-audit lint rule` for the sketch. + - **`selectedVideoTrackId` decomposition.** Replace the `abrDisabled` flag with a clean intent-vs-default split: separate `abrSelectedVideoTrackId` (written by `switchQuality`) and `manualSelectedVideoTrackId` (written by external code), derive `selectedVideoTrackId` as `manual ?? abr`. Already noted as a TODO at `quality-switching.ts:18`. Independent of Stage D. + - **Doc updates.** `packages/spf/docs/hls-engine.md` (parent `docs/spf-hls-engine-composition` branch) and `internal/design/spf/fundamentals.md` are stale. Refresh post-merge. + - **Code-reuse follow-up.** Three behavior modules (select-tracks, resolve-track, load-segments) lost some code reuse during the engine-wrapper revisit. Commit `77601054` documents the trade-off. + - **Open the PR.** Branch is shippable. + +## Stage A — what landed (early commits, pre-this-session) + +Stage A is on `refactor/spf-discrete-signals-stage-a` (10+ commits from `b00fa1a1` through `ad863bf1`). Key deviations from the original plan and details worth preserving live in the project memory: `project_spf_stage_a_revisit.md`. Highlights: + +- **Discrete signals + `owners` → `context`** as planned. Engine builds the signal maps externally and passes them to `createComposition`. +- **Compose-time conflict detection preserved + tightened.** The first pass dropped the inferred-overload type machinery; we restored it and unified context conflicts with state (intersection-based; sibling-type context now correctly conflicts). The owners-subtype check is gone — context uses the same intersection rule as state. +- **Single inferred overload only.** With discrete signals, the distributive-intersection inference issue from `044263f2` doesn't recur — the explicit-typed overload was removed. The HLS engine compiles cleanly with just the inferred form. +- **All behaviors uniform.** Every behavior is now `Behavior` (single-arg BehaviorDeps shape — `{ state, context, config }`). The engine wrappers all read `({ config, ...deps }: Deps) => behavior({ ...deps, config: { type, ...config } })`. +- **Engine config defaults `resolveTextTrackSegment`.** Removed the `setupTextTrackActors` engine wrapper that existed solely to inject the resolver. +- **Latent bug fix:** `update-duration` referenced `videoSourceBuffer`/`audioSourceBuffer` keys the engine never wrote to; renamed to `videoBuffer`/`audioBuffer`. + +### Stage A side cleanups (not in original plan) + +- `selectVideoTrack` / `selectAudioTrack` collapsed into a single `selectMediaTrack` (bodies were byte-identical). **Note:** Stages B/C re-specialized them in commit `f6a7aecc` once narrow keys were unlocked; this Stage-A consolidation was the right move at the time but is no longer the final shape. +- `QualitySwitchingConfig.defaultBandwidth` renamed to `initialBandwidth` for naming consistency with the engine config. +- `switchQuality` reshaped to `({ state, config })` so the engine no longer needs a wrapper. +- Conditional config-spreads in engine wrappers replaced with direct `{ type, ...config }` spreads (`exactOptionalPropertyTypes: false` on the SPF tsconfig made the conditional guards unnecessary). + +## Stage B — what landed (commits `5a39d4ef` → `550b7d6d`) + +Steps 1, 2a from the user's plan. Behaviors became objects with `defineBehavior(...)` factory enforcing single-behavior key/param consistency. + +- **Behaviors are now `{ stateKeys, contextKeys, setup }` objects** (`5a39d4ef`). Every behavior in `playback/behaviors/` was converted; engine wrappers were updated to wrap `.setup` calls. +- **`defineBehavior` factory** with phantom-tag exhaustiveness check: declared `stateKeys` must equal `keyof S` (where S is inferred from the setup's `state` param type), same for `contextKeys` / `C`. Uses `const SK extends readonly (keyof S)[]` for literal-tuple capture (no `as const` needed at the call site). +- **`DepsForCfg` conditional shape** — when a slice (state/context/config) has no keys, the corresponding deps field becomes optional. Lets test calls like `behavior.setup({state, context})` work without per-call `config: {}` boilerplate. +- **`R` generic** preserves narrow setup return types (e.g. `() => void` instead of widening to the `BehaviorCleanup` union). Tests calling the cleanup directly without union-narrowing still typecheck. +- **Tests added in `deda6ae1`** cover defineBehavior surface: 9 `@ts-expect-error` exhaustiveness cases, 10 `expectTypeOf` inference cases, 4 runtime-identity cases. Total +24 tests. +- **`550b7d6d` consolidation:** the previous `create-composition-types.test.ts` was structurally typecheck-only (every test was `@ts-expect-error`-driven) but lived in a `.test.ts` file that runs at runtime. Two cases needed `if (Math.random() < 0)` runtime guards because the test bodies dereferenced missing config. Moved everything into `create-composition-types.test-d.ts` (typecheck-only via tsgo); the `Math.random` guards disappear since the file never executes. + +## Stage C — what landed (commits `6e518795` → `c2dfd365`) + +Step 2b + 3 from the user's plan. Composition derives signal maps internally; `initialState` / `initialContext` seed values. + +- **`createComposition` derives state/context signal maps** from the union of all behaviors' declared `stateKeys` / `contextKeys` (`6e518795`). Caller-supplied signal maps are gone — `CompositionOptions` is now just `{ config?, initialState?, initialContext? }`. +- **Engine drops `createStateSignals` / `createContextSignals`** factories. `createSimpleHlsEngine` is now ~5 lines of "config + initialState" passed to `createComposition`. +- **load-segments behavior fix** (same commit) — the previous `initialBandwidth !== undefined ? bridge : undefined` conditional was effectively always-true because the engine seeded `bandwidthState`. After 2b that became always-false, silently disabling ABR. Dropped the conditional — video tracks always bridge throughput updates back to engine state. +- **`initialState` / `initialContext`** added in `c2dfd365` (`Partial` / `Partial`). Engine restores its `bandwidthState` seed via `initialState` so `switchQuality` fires on initial subscribe with the configured `initialBandwidth` fallback. +- **`buildSignalMap` extracted, exported, tested** (`13046a01`, `4c66d394`): + - Functional pipeline: `flatMap → Set → Object.fromEntries`. + - Signature simplified from `(behaviors, keysOf, initial)` to `(keys: Iterable, initial: Partial)`. + - Module-level export only (not in package's `index.ts` public API). + - 8 runtime tests + 7 type tests. +- **Type cleanup in `21f43473`** — collapsed the two-overload pattern in `createComposition` into a single signature so the body has access to `Behaviors` directly. State/context inside the body are now typed as `StateSignals>` rather than the wide `Record>`. The runtime build still uses the wide shape (since TS can't follow imperative iteration), but the wide-to-narrow bridge is localized inside `buildSignalMap`. Two intentional casts remain in `createComposition` itself (the `behaviors as readonly AnyBehavior[]` for ValidateComposition, and the `config ?? {}` fallback when Cfg has no keys). + +## Engine-wrapper revisit (commits `f6a7aecc` → `e4ce10ae`) + +Originally flagged as "save for the end" of Stage B. The engine had 8 wrappers (`loadVideoSegments`, `selectAudioTrack`, `resolveTextTrack`, etc.) that each forwarded keys + reshaped config to inject a `type:` discriminant. We moved that specialization into the behavior modules, exporting per-type behaviors directly. + +- **`select-tracks.ts`** (`f6a7aecc`) — `selectMediaTrack` (one generic) split into `selectVideoTrack`, `selectAudioTrack`, `selectTextTrack`. Each declares narrow `stateKeys` (e.g. `selectVideoTrack` declares only `['presentation', 'selectedVideoTrackId']`). Bodies inlined; shared `pickFirstTrackId` helper for presentation traversal. +- **`resolve-track.ts`** (`5c19b54a`) — `resolveTrack` split into `resolveVideoTrack`, `resolveAudioTrack`, `resolveTextTrack`. Body shared via `setupTrackResolution(state, type, selectedKey)` helper using a typed `K` generic over the selected-track key. State narrowed via `Pick`. +- **`load-segments.ts`** (`e4ce10ae`) — `loadSegments` split into `loadVideoSegments`, `loadAudioSegments`. Body shared via `setupSegmentLoading(state, context, type)` helper. **State/context keys still broad** — narrowing per specialization is a follow-up (see below). +- **Engine** drops all 8 wrappers, the `Deps` shorthand, and the `StateSignals`/`ContextSignals` imports (no longer needed locally). + +## Stage D — pivoted: per-slot annotations are the contract + +The user's plan #4: read/write enforcement. **Shipped in scoped-down form (commit `b7866e6a`).** The original plan included a compose-time **writer-count invariant** ("0-or-1 writer behaviors per signal") on top of per-slot read/write annotations. The annotation work shipped; the count invariant was **dropped** after the writer audit (commit `c29fea1e`) confirmed multi-writer slots are legitimate patterns, not violations. + +### What shipped + +Behaviors declare per-slot access by typing the setup param's `state` / `context` slots as either `Signal` (read+write) or `ReadonlySignal` (read-only): + +```ts +defineBehavior({ + stateKeys: ['presentation', 'selectedVideoTrackId'], + setup: ({ state }: { + state: { + presentation: ReadonlySignal; + selectedVideoTrackId: Signal; + }; + }) => effect(() => { ... }), +}); +``` + +Why this works: + +- `ReadonlySignal` already exists (`core/signals/primitives.ts`) as `Omit, 'set'>`. The structural difference (no `.set`) drives body-level enforcement directly — TS rejects `.set()` on a slot typed as `ReadonlySignal`. **No brands needed.** +- `Behavior<>` / `BehaviorDeps<>` / `defineBehavior` are now parameterized over **slot maps** (`StateMap`, `ContextMap`) rather than data shapes (`S`, `C`). Slot maps are bounded as `Record>` so heterogeneous slot maps (some writable, some read-only) typecheck cleanly. `StateSignals` / `ContextSignals` remain as helpers for "everything writable" cases (`Composition`'s public surface still uses them — no read/write split externally). +- Cross-behavior intersection (`InferBehaviorState` / `IntersectBehaviors`) is unchanged — `UnwrapSignals` matches structurally on `{ get(): infer V }`, so both `Signal` and `ReadonlySignal` unwrap to `T`. + +All 17 playback behaviors were updated in commit `b7866e6a`. See `### Per-behavior writer audit (post-propagation)` for the full picture. + +### Why we dropped the writer-count invariant + +The original plan was: **0-or-1 writer behaviors per signal**, enforced at compose time via the type machinery. 0-writer slots would be required in `initialState`; 2+ writers would be a compile error. + +The writer audit revealed three legitimate multi-writer patterns we'd be ruling out: + +1. **Pipeline / patch** — `presentation` is written by the adapter (initial `{ url }` seed), then `resolvePresentation` (parses manifest), then `resolve{Video,Audio,Text}Track` (per-track segments), then `calculatePresentationDuration` (duration field). Each writer owns a different aspect of the same logical object. Forcing decomposition into separate `url` / `selectionSets` / `tracks.*.segments` / `duration` slots breaks the "presentation is one thing" model and pushes complexity to consumers (query 4 slots instead of 1). Forcing colocation breaks composition. + +2. **Intent + reactive default** — `selectedVideoTrackId` is written by `selectVideoTrack` (default-pick on presentation load), `switchQuality` (ABR), and external code (manual override, currently in the sandbox harness). Disambiguated today via the `abrDisabled` flag. A clean factoring exists (separate `abrSelectedVideoTrackId` + `manualSelectedVideoTrackId` slots, derive `selectedVideoTrackId` as `manual ?? abr` — see `quality-switching.ts:18`'s TODO), but we don't want to *force* this decomposition for every multi-writer case. The "two writers + mode-flag disambiguator" shape is sometimes the right answer. + +3. **Two-way DOM sync** — `preload` is written by `syncPreloadAttribute` (DOM → state mirror) and external code via `shareSignals` (state → drives downstream DOM behavior). The slot is a coordination point between observer and controller; both writers are legitimate. + +The 0-or-1 invariant would force decomposition for all three cases. That's too aggressive — it imposes a uniform shape on patterns that are genuinely different. Per-slot annotations alone (which we shipped) give us body-level write enforcement and self-documenting intent at the call site; the count invariant was layered on top but adds churn without proportional value. + +**Decision:** drop the count invariant. Multi-writer slots are legitimate. The writer audit (`### Per-behavior writer audit`) documents who writes what; review serves the same purpose the count invariant would have. + +### Composition surface stays writable (for now) + +The original plan made `composition.state` / `composition.context` **uniformly read-only externally** — all writes had to go through behaviors or the `shareSignals` callback. With the count invariant dropped, this enforcement isn't strictly necessary: the composition surface stays as `StateSignals` / `ContextSignals` (writable). External code can still write directly via `composition.state.X.set(...)` (the harness's deferred `selectedVideoTrackId` / `selectedTextTrackId` writes do this). + +Per-slot annotations are a **behavior-side** contract — they describe what each behavior reads and writes within its setup body. They're not a composition-surface contract today. + +### Follow-up: read-only public composition surface + +A future change could narrow `Composition`'s public `state` and `context` to read-only views — `{ [K in keyof S]-?: ReadonlySignal }` — making `shareSignals` + `config.onSignalsReady` the *only* external write path. TypeScript-only narrowing is enough; the runtime signals stay writable, behaviors get the writable refs through deps, and `Signal` is structurally assignable to `ReadonlySignal` so no runtime change or cast is required at the boundary. + +**Why we'd want it:** + +- **One canonical write path.** Discoverability goes from "two ways, both legal" to "one way, with the type system pointing at it." +- **Eliminates the harness's `TODO(stage-d)` direct writes** — they currently bypass `shareSignals` because they can. A read-only public surface forces them through the boundary. +- **Per-slot annotations land naturally** at the consumer side too — the `onSignalsReady` callback can declare per-slot intent the same way behaviors do. + +**Why we deferred it:** + +- **Pedagogical cost.** `composition.state.x.set(...)` from outside is the simplest first thing to show in a tutorial. Forcing every external write through `shareSignals` makes the simplest examples one indirection deeper, which complicates the learning arc in `fundamentals.md`. A NOTE callout there flags the future direction without re-pivoting the doc's structure. +- **Minor downstream churn.** Two harness writes need migration; tests that exercise behaviors via `composition.state.X.set()` would need to capture refs via `shareSignals` in their fixtures or call `behavior.setup` directly with hand-built slot maps. ~30–60 min of focused work, but not zero. + +**Preconditions when we revisit:** + +- The `selectedVideoTrackId` decomposition cleanup (`abrDisabled` → `manualSelectedVideoTrackId` + `abrSelectedVideoTrackId`, derived `selected = manual ?? abr`) is independent and can land before. Either order works. +- Decide whether `fundamentals.md`'s early "drive from outside" pedagogy moves the writes inside a tiny `driveCount` behavior, or shifts to using `shareSignals` from the first example. The tradeoff is "introduce shareSignals before defineBehavior" vs. "show direct writes first, then converge on shareSignals later." + +Not in scope for the current branch. Worth a follow-up issue. + +### Follow-up: writer-audit lint rule + +A future custom linter rule could capture the original Stage D intent (visibility into multi-writer cases) without forcing decomposition through types: + +- **Detection.** Walk all `defineBehavior(...)` calls and `shareSignals.onSignalsReady` consumer callbacks; tally writers per slot. +- **Warn on multi-writer slots.** Surface the writer list at each violating site so reviewers see the broader picture. +- **Ignore mechanism.** A comment marker (e.g. `// writer-audit-allow: `) on the writing line opts the slot out of the warning. Forces the author to articulate intent ("this is the pipeline pattern" / "this is intent + default with `abrDisabled` mode flag" / "this is DOM ↔ state sync"). +- **Separate concerns.** Lives in tooling, not in types — keeps the type system simple and lets reviewers see the multi-writer landscape without compile-time churn. + +Not in scope for the current branch. Worth opening as a follow-up issue once the branch ships. + +### Stage D-pre — what landed (input mechanism) + +Stage D-pre routed external writes through a new generic behavior (`shareSignals`) so the call site for "external writes" became uniform. Originally framed as a precondition for Stage D's count invariant; with the invariant dropped, this work stands on its own as the canonical pattern for external writes. Audit (2026-05-04) showed the external writers to composition signals were: + +| Key | Slot | Trigger | Nature | Status | +|---|---|---|---|---| +| `mediaElement` | context | `attach()` / `detach()` | DOM lifecycle | ✅ migrated to `onSignalsReady` capture in adapter | +| `preload` | state | `preload =` IDL setter | HTML attribute input | ✅ same | +| `presentation` | state | `src =` IDL setter (writes `{ url }`) | HTML attribute input | ✅ same | +| `playbackInitiated` | state | `play()` method | Imperative input | ✅ same | +| `abrDisabled` | state | sandbox harness UI toggle | pure input, no overlap | ✅ same | +| `selectedVideoTrackId` | state | sandbox harness rendition picker + `selectVideoTrack` (default-pick) + `switchQuality` (ABR) | **Multi-writer (intent + reactive default)** — accepted pattern; cleanup proposed at `quality-switching.ts:18` (decompose into `manualSelectedVideoTrackId` + `abrSelectedVideoTrackId`) | +| `selectedTextTrackId` | state | sandbox harness auto-select + `selectTextTrack` (default-pick) + `syncTextTracks` (DOM-driven) | **Multi-writer** — accepted pattern; harness still writes directly with `TODO(stage-d)` markers as cleanup hints | + +**The harness was also on the pre-Stage-A monolithic API** (`engine.state.set({...engine.state.get(),...})` + `engine.owners`) since Stage A landed — broken at runtime, not just type-stale. Stage D-pre rebuilt it on the discrete-signals shape and routed all five non-overlap inputs through `onSignalsReady`. The two multi-writer cases stayed as direct writes via `engine.state` (no longer needing a reconciler — see "Reconciler shape — optional pattern"). + +### Input mechanism — actual implementation (`shareSignals`) + +**The original sketch was more elaborate than what shipped.** The plan was to have the engine create a *separate* set of input signals, with `mirrorInputState` / `mirrorInputContext` behaviors copying each input → composition state, and `reconcileSelectedVideoTrackId` / `reconcileSelectedTextTrackId` replacing `selectVideoTrack` / `selectTextTrack`. Implementation collapsed that down: the "input signals" *are* the composition state signals — there's no parallel set, no mirror layer, and the two reconciler-overlap cases got deferred. + +What actually landed: + +1. **`shareSignals` is a generic behavior factory** in `core/composition/share-signals.ts`: + ```ts + export interface ShareSignalsConfig { + onSignalsReady?: (signals: { state: StateSignals; context: ContextSignals }) => void; + } + + export function makeShareSignals(): Behavior> { + return { + stateKeys: [], + contextKeys: [], + setup: ({ state, context, config }) => { + config.onSignalsReady?.({ state, context }); + }, + }; + } + ``` + Uses a `Behavior` literal (not `defineBehavior`) so empty key arrays don't trip the exhaustiveness check. Its setup-param S/C describe what the consumer's callback receives, not keys this behavior needs created — the composition's signal map comes from other behaviors' `stateKeys` / `contextKeys` declarations. The factory is generic so it's not tied to a specific engine; the HLS engine instantiates it once at module load with its full state/context types. + +2. **Engine wiring** — one-liner instantiation, no separate input signals: + ```ts + // packages/spf/src/playback/engines/hls/engine.ts + const shareSignals = makeShareSignals(); + + export interface SimpleHlsEngineConfig extends ShareSignalsConfig { + initialBandwidth?: number; + // ... + } + + export function createSimpleHlsEngine(config: SimpleHlsEngineConfig = {}) { + return createComposition([shareSignals, /* other behaviors */], { config: finalConfig, initialState: { ... } }); + } + ``` + +3. **`SimpleHlsEngineSignals`** — a named alias for the callback's parameter type, exported from `engine.ts` / `engines/hls/index.ts` so adapters and harnesses can type their captured refs: + ```ts + export type SimpleHlsEngineSignals = { + state: StateSignals; + context: ContextSignals; + }; + ``` + +4. **Adapter capture** — straight ref-grab in the engine-creation path: + ```ts + #signals!: SimpleHlsEngineSignals; + #createEngine() { + return createSimpleHlsEngine({ + ...this.#config, + onSignalsReady: (signals) => { this.#signals = signals; }, + }); + } + ``` + +5. **Bidirectional usage** — the consumer's callback can already declare per-slot intent by typing captured refs as `Signal` (write) or `ReadonlySignal` (read-only) — `Signal` is structurally a subtype of `ReadonlySignal`, so the narrower assignment Just Works. Discipline is per-consumer; today's consumers (adapter, harness) declare everything writable since they need both reads and writes. + +**Why this is simpler than the original sketch.** The mirror-behaviors layer assumed the input signals had to be *separate* from composition state ("input signals are owned by the engine; composition state is owned by the composition"). But there's no actual benefit to that separation — the composition is owned by the engine factory, which is the same scope. The single signal set carries both roles. + +**The web-worker generalization** still works. If the engine moves into a worker, `onSignalsReady` is the IPC boundary. The composition's signals live on the worker side; the consumer captures proxies on the main-thread side. The callback shape is the same on either side — only the marshaling changes. (We haven't built that yet; this is forward-compat reasoning.) + +**The `set src` engine-recreate cost** is also unchanged. Each `set src` destroys the composition and creates a new one; the adapter's `onSignalsReady` re-fires and re-captures refs. The adapter holds adapter-level source-of-truth fields (`#preload`, etc.) and re-applies them to the new signals. Same as the original sketch. + +### Per-behavior writer audit (post-propagation) + +After the per-slot read/write annotation propagation (commit `b7866e6a`), each behavior's intent is legible from its setup signature alone — `Signal` for writable slots, `ReadonlySignal` for read-only. Body-level enforcement catches accidental writes at typecheck time. + +#### Internal writes (via `defineBehavior` setup bodies) + +| Behavior | State writes | Context writes | +|---|---|---| +| `selectVideoTrack` / `selectAudioTrack` / `selectTextTrack` | `selected{Video,Audio,Text}TrackId` (the corresponding one) | — | +| `resolveVideoTrack` / `resolveAudioTrack` / `resolveTextTrack` | `presentation` (via shared `setupTrackResolution`; merges resolved track segments into the existing presentation) | — | +| `resolvePresentation` | `presentation` (parses manifest from `{ url }` seed) | — | +| `calculatePresentationDuration` | `presentation` (sets the `duration` field on the existing presentation) | — | +| `switchQuality` | `selectedVideoTrackId` (ABR decisions) | — | +| `trackCurrentTime` | `currentTime` | — | +| `trackPlaybackRate` | `playbackRate` | — | +| `trackPlaybackInitiated` | `playbackInitiated` (from DOM `play` event + `paused` check) | — | +| `syncTextTracks` | `selectedTextTrackId` (when DOM `` mode change picks a new track) | — | +| `syncPreloadAttribute` | `preload` (mirrors the DOM attribute when no explicit value set) | — | +| `setupMediaSource` | `mediaSourceReadyState` (mirrors `MediaSource.readyState`) | `mediaSource` | +| `setupSourceBuffers` | — | `videoBuffer`, `audioBuffer`, `videoBufferActor`, `audioBufferActor` | +| `setupTextTrackActors` | — | `textTracksActor`, `segmentLoaderActor` | +| `loadVideoSegments` | `bandwidthState` (per-chunk throughput sampling for ABR) | — | +| `loadAudioSegments` | — (audio doesn't sample bandwidth) | — | +| `loadTextTrackCues` / `updateDuration` / `endOfStream` | — (read-only — drive DOM properties / actor messages, not signal writes) | — | +| `shareSignals` | — (forwards refs to consumer; see external-writes table below) | — | + +#### External writes (via `shareSignals.onSignalsReady` callback) + +`shareSignals` itself writes nothing — it forwards composition signal refs to the consumer-supplied `onSignalsReady` callback at composition setup. The consumer captures the refs and writes through them at runtime. Two consumers exist today: + +| Consumer | State writes | Context writes | +|---|---|---| +| `SimpleHlsMedia` adapter (`packages/spf/src/playback/engines/hls/adapter.ts`) | `presentation` (`set src`), `preload` (`set preload`), `playbackInitiated` (`play()`) | `mediaElement` (`attach()` / `detach()`) | +| Sandbox harness (`apps/sandbox/templates/spf-segment-loading/main.ts`) | `presentation`, `preload`, `abrDisabled` (UI toggle) | `mediaElement` | + +Plus the deferred reconciler-overlap cases — the harness still writes these **directly via `engine.state`**, *not* via the `onSignalsReady` callback: + +| Direct writer | Slot | Marker | +|---|---|---| +| Sandbox harness rendition picker | `selectedVideoTrackId` (manual override) | `// TODO(stage-d)` | +| Sandbox harness auto-select effect | `selectedTextTrackId` (initial pick) | `// TODO(stage-d)` | + +These slots are documented in the multi-writer table below as legitimate intent + default patterns; the harness's direct writes via `engine.state` are noted with `TODO(stage-d)` markers as cleanup hints (see `selectedVideoTrackId` decomposition below) but are no longer Stage D-blocking. + +#### Multi-writer slots — accepted patterns + +Slots with more than one writer once both internal and external writes are counted. With the count invariant dropped, these are **legitimate shapes**, not violations: + +| Slot | Writers | Pattern | +|---|---|---| +| `presentation` | adapter / harness (initial `{ url }`), `resolvePresentation` (parsed), `resolve{Video,Audio,Text}Track` (per-track resolution), `calculatePresentationDuration` (duration field) | **Pipeline** — each writer owns a different aspect of the same logical object; each builds on the previous via `{ ...current, fieldOwnedHere }` rather than overwriting. | +| `preload` | `syncPreloadAttribute` (DOM → state), adapter / harness (state → drives DOM via downstream) | **Two-way DOM sync** — the slot is a coordination point between observer and controller. | +| `selectedVideoTrackId` | `selectVideoTrack` (default-pick), `switchQuality` (ABR), harness direct write (manual override) | **Intent + reactive default** — disambiguated today via `abrDisabled` flag. Cleaner factoring exists (separate `manualSelectedVideoTrackId` + `abrSelectedVideoTrackId`, derive `selectedVideoTrackId` as `manual ?? abr`); see TODO at `quality-switching.ts:18`. Independent cleanup, not Stage D-mandated. | +| `selectedTextTrackId` | `selectTextTrack` (default-pick), `syncTextTracks` (DOM-driven), harness direct write (auto-select) | Same intent + reactive default pattern. | +| `playbackInitiated` | `trackPlaybackInitiated` (DOM observer), adapter `play()` write | **Imperative + observer** — adapter sets `true` on `play()` call; observer reflects DOM `paused` state on cleanup. | +| `mediaElement` | adapter / harness only | Externally-driven only — single source from outside, no internal writers. | + +The future writer-audit lint rule (see `### Follow-up: writer-audit lint rule`) will surface multi-writer slots at review time and let the author confirm intent via an ignore comment, without forcing decomposition through types. + +### Reconciler shape — optional pattern (no longer Stage D-required) + +Originally the 2-writer cases on `selectedVideoTrackId` / `selectedTextTrackId` were going to need a reconciler to satisfy the count invariant. With the invariant dropped, **the reconciler pattern is now optional** — multiple direct writers are fine. We're keeping the sketch below as a documented pattern for cases where the intent-vs-reactive-default distinction is worth making explicit. + +```ts +// Optional pattern — replaces a default-pick behavior with a reconciler that +// also reads external intent. Useful when the consumer wants the intent vs. +// default split to be visible in the type signature. +export function reconcileSelectedVideoTrackId(intent: ReadonlySignal) { + return defineBehavior({ + stateKeys: ['presentation', 'selectedVideoTrackId'], + setup: ({ state }: { + state: { + presentation: ReadonlySignal; + selectedVideoTrackId: Signal; + }; + }) => + effect(() => { + const presentation = state.presentation.get(); + if (!presentation) return; + + const userIntent = intent.get(); + if (userIntent !== undefined) { + state.selectedVideoTrackId.set(userIntent); + return; + } + + if (!state.selectedVideoTrackId.get()) { + const id = pickFirstTrackId(presentation, 'video'); + if (id) state.selectedVideoTrackId.set(id); + } + }), + }); +} +``` + +The cleaner-still factoring (decompose into `manualSelectedVideoTrackId` + `abrSelectedVideoTrackId`, derive `selectedVideoTrackId` as `manual ?? abr`) eliminates the multi-writer entirely. Either approach works; the choice is per-case, not a framework rule. + +### Open product questions (still relevant if a reconciler is adopted) + +These are real questions about behavior, not architecture. Apply equally to the harness's current direct writes or to a future reconciler: + +- Default-pick re-fire on presentation reload — should `selectedVideoTrackId` reset to track 0 of the new presentation, or persist if user-selected and still valid? +- Intent pointing at a track that doesn't exist in the current presentation — fall back to default? Reset intent? Both? +- "Reset to auto" semantics — setting intent to `undefined` clears override; reactive default takes over. + +### Resolved questions (Stage D scope-down) + +1. ~~**`writeKeys` placement.**~~ **Resolved.** No `writeKeys`. Per-slot access lives in the setup signature. +2. ~~**`ReadSignal` / `WriteSignal` shape.**~~ **Resolved.** Reuse `Signal` (writable) and `ReadonlySignal` (read-only). No brands. +3. ~~**0-writer signals + `initialState`.**~~ **Moot — count invariant dropped.** 0-writer slots are still allowed (and seeded via `initialState` if needed); they just aren't enforced as a special case. `initialState` stays `Partial`. +4. ~~**Input mechanism for the adapter's writes.**~~ **Resolved (and shipped).** Generic `shareSignals` behavior factory hands writable signal refs to a consumer-supplied `config.onSignalsReady` callback at composition setup. +5. ~~**Single-writer enforcement scope.**~~ **Moot — count invariant dropped.** +6. ~~**Migration path for read/write annotations.**~~ **Resolved (and shipped).** All 17 behaviors migrated in commit `b7866e6a`. +7. **Code-reuse follow-up convergence.** Still open. Three behavior modules (select-tracks, resolve-track, load-segments) lost code reuse during the engine-wrapper revisit. The per-slot annotation work didn't surface a clean factory shape; this is independent grooming. + +8. **The destroy reset loop.** Currently `for (const sig of Object.values(state)) sig.set(undefined)`. Stage D moves this into per-signal cleanup owned by the writer behavior. Open: 0-writer (seeded) signals — skip the reset (preserve seeded value, treating them as immutable constants)? The cleanest answer is "yes, skip" — once they're constants, there's nothing to reset. + +9. **Type machinery shape.** The accumulator-style walk over behaviors to count writers per key is straightforward but new. Worth a focused spike (4–6 lines of `AccumulateWriters`) before committing to it. The existing `IntersectBehaviors` is a recursive tuple-walk too, so the shape is familiar. + +## Motivation + +A coordinated shift in SPF from **"shared bag mutated by everyone"** to **"declared signals with clear ownership."** Today, `state` and `owners` are each a single signal whose value is an object; any behavior can read or write any field. The shift makes each field its own signal and asks behaviors to declare which signals they read and which they write — surfacing the contract in types and making intent visible at the call site. (Originally the plan also included a compose-time writer-count invariant; that part was dropped after the writer audit confirmed multiple legitimate multi-writer patterns exist. See `### Why we dropped the writer-count invariant`.) + +## The six changes + +1. **State and `owners` become objects of discrete signals.** ✅ Stage A. +2. **Rename `owners` → `context`.** ✅ Stage A. +3. **Networking → shared singleton in `context`.** ❌ Deferred (Stage E or follow-up). +4. **Behaviors: function → object with `stateKeys` / `contextKeys`.** ✅ Stage B. +5. **No external signal setting from the composition.** ✅ Stage C — `createComposition` no longer accepts caller-built signal maps; behaviors own all writes (with `initialState` / `initialContext` for seed values). (The Stage-D follow-up here would have made `composition.state` read-only externally; that part was dropped — composition surface stays writable. External writes go through the `shareSignals.onSignalsReady` callback by convention rather than by enforcement.) +6. **Read vs. write enforcement on signals.** ✅ Stage D, scope reduced — per-slot `Signal` / `ReadonlySignal` annotations on behavior setup signatures (body-level enforcement). The originally-planned compose-time writer-count invariant ("0-or-1 writer per slot") was **dropped** — multi-writer patterns are accepted as legitimate. Future custom linter rule will warn on multi-writer slots with an ignore mechanism. + +## Proposed staging (historical reference) + +| Stage | Change | Status | +|---|---|---| +| **A** | (1) discrete signals; (2) `owners` → `context` | ✅ Complete | +| **B** | (4) behavior-as-object with `stateKeys` / `contextKeys` / `setup` | ✅ Complete | +| **C** | (5) no external setting from composition + `initialState` | ✅ Complete | +| **D-pre** | route adapter + harness writes through `shareSignals` / `onSignalsReady` callback | ✅ Shipped | +| **D (scope-down)** | (6) per-slot `Signal` / `ReadonlySignal` annotations on all behaviors. Count invariant **dropped** — multi-writer slots are legitimate patterns. | ✅ Shipped (commit `b7866e6a`); writer-audit lint rule = future follow-up | +| **E or parallel** | (3) networking singleton | ❌ Deferred | + +## Follow-ups to revisit + +### Type-specialized behaviors traded code reuse for narrow types + +The engine-wrapper consolidation moved type-specifying configs (`type: +'video'`, `type: 'audio'`, etc.) from engine wrappers into per-type +specialized behaviors exported from each behavior module. `select-tracks` +went from one `selectMediaTrack` (dynamic key access via +`state[SelectedTrackIdKeyByType[config.type]]`) plus engine wrappers, to +three direct exports: `selectVideoTrack`, `selectAudioTrack`, +`selectTextTrack` — each with narrow `stateKeys` and an inlined body. + +What we won: narrow per-behavior keys (e.g. `selectVideoTrack` only +declares `['presentation', 'selectedVideoTrackId']`); no engine wrappers; +no `config.type` discriminant carried at runtime; type-honest direct +signal access (`state.selectedVideoTrackId.set(...)` instead of +`state[selectedKey].set(...)`). + +What we lost: shared body code. Each specialization repeats the +"read presentation, check if selected, pick by type, set if found" +pattern with only the type literal and signal name varying. + +**Revisit each affected module after the engine-wrapper migration is +complete.** Look for shareable abstractions that preserve the wins — +e.g. a factory function `makeFirstTrackSelector(type, selectedKey)` that +takes the variants as parameters and produces a `defineBehavior` result. +The factory keeps narrow types (the selectedKey generic threads through +`Pick` in the setup param) but reuses the body. + +The trap to avoid: don't reintroduce a `config.type` discriminant or +type-erased `state[dynamicKey]` access. The factory binds the type at +definition time, not call time. + +Modules to revisit when time permits: +- `select-tracks.ts` (3 specializations: video/audio/text) — bodies fully inlined; ripest for a factory pattern. +- `resolve-track.ts` (3 specializations) — already shares `setupTrackResolution` via a typed K generic; body is shared but state shape is parameterized. +- `load-segments.ts` (2 specializations: video/audio) — body shared via `setupSegmentLoading`, **but state/context keys still broad**. Per-specialization narrowing (e.g. `loadAudioSegments` dropping `bandwidthState` and the video buffer keys) is a follow-up; the body is dense (~120 lines) and a state-shape parameterization wants its own pass. + +### Doc updates pending + +- `packages/spf/docs/hls-engine.md` (parent branch `docs/spf-hls-engine-composition`) — uses `update`-style state writes and single-`owners` signal. Refresh once branch ships. +- `internal/design/spf/fundamentals.md` — recommends external state writes from outside the composition; that's now disallowed (Stage C). Update post-merge. + +## Open questions to resolve before locking the plan + +1. ~~**Read vs. write split — Stage B or D?**~~ **Resolved (and shipped).** Single `stateKeys` shipped in Stage B; per-slot read/write annotations on setup signatures shipped in Stage D (commit `b7866e6a`). + +2. ~~**`setup` signature.**~~ **Resolved.** Receives `BehaviorDeps` = `{ state, context, config }`, with state/context/config optional via `DepsForCfg` when their slice has no keys. Returns `BehaviorCleanup` (narrowed via the `R` generic in `defineBehavior` so concrete returns survive). + +3. ~~**`createComposition` explicit-typed overload.**~~ **Resolved in Stage A** (and re-confirmed in `21f43473`'s single-signature collapse) — explicit overload removed; the inferred form alone handles the HLS engine's behaviors with no inference issues. + +4. ~~**Composition's read-only API surface.**~~ **Resolved (stays writable).** `Composition` continues to expose `state: StateSignals` / `context: ContextSignals` (writable everywhere). The originally-planned readonly external surface was a Stage D enforcement promise that's no longer being made — per-slot read/write annotations live on **behavior** setup signatures, not the composition's external API. + +5. **Fundamentals doc updates.** Pending — see Doc updates above. + +6. **Networking singleton design (3).** Deferred to a separate plan. + +7. **Doc-driven cleanup loop continues.** The `hls-engine.md` walkthrough is the canary for whether the new shape reads well. Same friction-list pattern. + +## Risk and scope notes + +- **Stages A through D (scoped down) have landed.** Net: behaviors are objects with declared keys + per-slot read/write annotations; composition derives the signal map; type-specialized exports replace engine wrappers; `defineBehavior` / `buildSignalMap` / `makeShareSignals` are tested primitives. Multi-writer slots are accepted as legitimate patterns (see writer audit). +- **Stage D's count invariant was dropped after the audit.** Per-slot `Signal` / `ReadonlySignal` annotations alone give us body-level write enforcement and self-documenting intent at the call site. The originally-planned compose-time writer-count invariant added churn without proportional value — see `### Why we dropped the writer-count invariant`. +- **Backwards compatibility** within the SPF package is not a concern — there are no external consumers of the behavior shape yet. Move freely. + +## See also + +- `internal/design/spf/primitives.md` — original architecture spec, especially §5 on observable state. +- `packages/spf/docs/hls-engine.md` — in-flight walkthrough that needs updates post-merge. +- `internal/design/spf/fundamentals.md` — needs updating (Stage C's "no external writes" rule). +- `.claude/plans/spf/signals-poc.md` — earlier signals spike that informed this direction. diff --git a/apps/sandbox/templates/spf-segment-loading/main.ts b/apps/sandbox/templates/spf-segment-loading/main.ts index 938d6b5e..80cd2483 100644 --- a/apps/sandbox/templates/spf-segment-loading/main.ts +++ b/apps/sandbox/templates/spf-segment-loading/main.ts @@ -8,8 +8,8 @@ import '@app/styles.css'; // autoplay=true Start with autoplay enabled // preload=auto|metadata|none Initial preload mode -import { effect } from '@videojs/spf'; -import type { SimpleHlsEngineState } from '@videojs/spf/hls'; +import { effect, snapshot } from '@videojs/spf'; +import type { SimpleHlsEngineSignals, SimpleHlsEngineState } from '@videojs/spf/hls'; import { createSimpleHlsEngine } from '@videojs/spf/hls'; // ── DOM refs ────────────────────────────────────────────────────────────────── @@ -58,8 +58,8 @@ function formatBandwidth(bps: number): string { return `${Math.round(bps / 1000)} Kbps`; } -function getVideoTracks(state: SimpleHlsEngineState) { - return state.presentation?.selectionSets?.find((s) => s.type === 'video')?.switchingSets[0]?.tracks ?? []; +function getVideoTracks(presentation: SimpleHlsEngineState['presentation']) { + return presentation?.selectionSets?.find((s) => s.type === 'video')?.switchingSets[0]?.tracks ?? []; } // ── Display functions ───────────────────────────────────────────────────────── @@ -77,7 +77,7 @@ function updateShareUrl() { function updateNowPlayingQuality() { if (!engine) return; - const segments = engine.owners.get().videoBufferActor?.snapshot.get().context.segments ?? []; + const segments = engine.context.videoBufferActor.get()?.snapshot.get().context.segments ?? []; const t = video.currentTime; const current = segments.find((s) => t >= s.startTime && t < s.startTime + s.duration); if (current?.trackBandwidth) { @@ -100,7 +100,7 @@ function correctedEstimate(estimate: number, totalWeight: number, halfLife: numb function updateThroughputDisplay() { if (!engine) return; - const bs = engine.state.get().bandwidthState; + const bs = engine.state.bandwidthState.get(); if (!bs || bs.bytesSampled === 0) { throughputDiv.textContent = '📶 Throughput: no samples yet'; throughputDiv.className = ''; @@ -120,13 +120,14 @@ function updateThroughputDisplay() { } function renderRenditionPicker() { - if (!engine) return; - const state = engine.state.get(); - const tracks = getVideoTracks(state); - const isManual = state.abrDisabled === true; + if (!engine || !signals) return; + const presentation = engine.state.presentation.get(); + const selectedVideoTrackId = engine.state.selectedVideoTrackId.get(); + const abrDisabled = engine.state.abrDisabled.get() === true; + const tracks = getVideoTracks(presentation); if (tracks.length === 0) { - renditionButtonsDiv.textContent = state.presentation ? 'No video tracks found' : 'Waiting for presentation…'; + renditionButtonsDiv.textContent = presentation ? 'No video tracks found' : 'Waiting for presentation…'; return; } @@ -135,34 +136,37 @@ function renderRenditionPicker() { const statusRow = document.createElement('div'); statusRow.className = 'abr-status'; const modeLabel = document.createElement('span'); - modeLabel.className = isManual ? 'mode-manual' : 'mode-abr'; - modeLabel.textContent = isManual ? '🔒 Manual' : '⟳ ABR'; + modeLabel.className = abrDisabled ? 'mode-manual' : 'mode-abr'; + modeLabel.textContent = abrDisabled ? '🔒 Manual' : '⟳ ABR'; statusRow.appendChild(modeLabel); - if (isManual) { + if (abrDisabled) { const enableBtn = document.createElement('button'); enableBtn.type = 'button'; enableBtn.className = 'enable-abr-btn'; enableBtn.textContent = 'Enable ABR'; enableBtn.addEventListener('click', () => { log('ABR re-enabled', 'success'); - engine.state.set({ ...engine.state.get(), abrDisabled: false }); + signals.state.abrDisabled.set(false); }); statusRow.appendChild(enableBtn); } renditionButtonsDiv.appendChild(statusRow); for (const track of tracks) { - const isSelected = track.id === state.selectedVideoTrackId; + const isSelected = track.id === selectedVideoTrackId; const btn = document.createElement('button'); btn.type = 'button'; - btn.className = `rendition-btn${isSelected ? (isManual ? ' selected-manual' : ' selected-abr') : ''}`; + btn.className = `rendition-btn${isSelected ? (abrDisabled ? ' selected-manual' : ' selected-abr') : ''}`; const res = 'width' in track && track.width && track.height ? `${track.width}×${track.height} @ ` : ''; - const badge = isSelected ? (isManual ? ' 🔒' : ' ⟳') : ''; + const badge = isSelected ? (abrDisabled ? ' 🔒' : ' ⟳') : ''; btn.textContent = `${res}${formatBandwidth(track.bandwidth)}${badge}`; btn.title = track.id; btn.addEventListener('click', () => { log(`Manual rendition select: ${formatBandwidth(track.bandwidth)} (ABR disabled)`, 'warning'); - engine.state.set({ ...engine.state.get(), selectedVideoTrackId: track.id, abrDisabled: true }); + // TODO(stage-d): selectedVideoTrackId is the deferred reconciler case — + // direct write into composition state until intent/state split lands. + engine.state.selectedVideoTrackId.set(track.id); + signals.state.abrDisabled.set(true); }); renditionButtonsDiv.appendChild(btn); } @@ -170,11 +174,11 @@ function renderRenditionPicker() { function renderResolutionStatus() { if (!engine) return; - const state = engine.state.get(); - const tracks = getVideoTracks(state); + const presentation = engine.state.presentation.get(); + const tracks = getVideoTracks(presentation); if (tracks.length === 0) { - resolutionListDiv.textContent = state.presentation ? 'No video tracks found' : 'Waiting for presentation…'; + resolutionListDiv.textContent = presentation ? 'No video tracks found' : 'Waiting for presentation…'; return; } @@ -195,22 +199,22 @@ function inspectState() { stateDiv.innerHTML = '

State Inspector

Engine not initialized
'; return; } - const state = engine.state.get(); - const owners = engine.owners.get(); + const state = snapshot(engine.state); + const ctx = snapshot(engine.context); - const videoBufferRanges = owners.videoBuffer + const videoBufferRanges = ctx.videoBuffer ? Array.from( - { length: owners.videoBuffer.buffered.length }, + { length: ctx.videoBuffer.buffered.length }, (_, i) => - `Range ${i}: ${owners.videoBuffer!.buffered.start(i).toFixed(2)}s - ${owners.videoBuffer!.buffered.end(i).toFixed(2)}s` + `Range ${i}: ${ctx.videoBuffer!.buffered.start(i).toFixed(2)}s - ${ctx.videoBuffer!.buffered.end(i).toFixed(2)}s` ).join('\n ') : 'N/A'; - const audioBufferRanges = owners.audioBuffer + const audioBufferRanges = ctx.audioBuffer ? Array.from( - { length: owners.audioBuffer.buffered.length }, + { length: ctx.audioBuffer.buffered.length }, (_, i) => - `Range ${i}: ${owners.audioBuffer!.buffered.start(i).toFixed(2)}s - ${owners.audioBuffer!.buffered.end(i).toFixed(2)}s` + `Range ${i}: ${ctx.audioBuffer!.buffered.start(i).toFixed(2)}s - ${ctx.audioBuffer!.buffered.end(i).toFixed(2)}s` ).join('\n ') : 'N/A'; @@ -220,36 +224,36 @@ function inspectState() {

Playback State

${JSON.stringify(state, null, 2)}
-

Owners (SourceBuffers)

-
Video Buffer: ${owners.videoBuffer ? '✓ Created' : '✗ Not created'}
-
Audio Buffer: ${owners.audioBuffer ? '✓ Created' : '✗ Not created'}
+

Context (SourceBuffers)

+
Video Buffer: ${ctx.videoBuffer ? '✓ Created' : '✗ Not created'}
+
Audio Buffer: ${ctx.audioBuffer ? '✓ Created' : '✗ Not created'}
${ - owners.videoBuffer + ctx.videoBuffer ? `

Video Buffer State

-
Buffered ranges: ${owners.videoBuffer.buffered.length}
+
Buffered ranges: ${ctx.videoBuffer.buffered.length}
${videoBufferRanges}
` : '' } ${ - owners.audioBuffer + ctx.audioBuffer ? `

Audio Buffer State

-
Buffered ranges: ${owners.audioBuffer.buffered.length}
+
Buffered ranges: ${ctx.audioBuffer.buffered.length}
${audioBufferRanges}
` : '' }

MediaSource State

-
readyState: ${owners.mediaSource?.readyState ?? 'N/A'}
+
readyState: ${ctx.mediaSource?.readyState ?? 'N/A'}

Buffer Model (actor context)

-
Video segments loaded: ${owners.videoBufferActor?.snapshot.get().context.segments.length ?? 0}
-
Audio segments loaded: ${owners.audioBufferActor?.snapshot.get().context.segments.length ?? 0}
+
Video segments loaded: ${ctx.videoBufferActor?.snapshot.get().context.segments.length ?? 0}
+
Audio segments loaded: ${ctx.audioBufferActor?.snapshot.get().context.segments.length ?? 0}

Video Element State

readyState: ${video.readyState}
@@ -266,20 +270,27 @@ log('=== SPF Segment Loading POC Test ==='); log(`Stream: ${INITIAL_SRC}`); let engine: ReturnType; +let signals: SimpleHlsEngineSignals; let cleanupEffects: () => void = () => {}; function startEngine(src: string) { cleanupEffects(); if (engine) engine.destroy(); - engine = createSimpleHlsEngine({ initialBandwidth: 1_000_000 }); + engine = createSimpleHlsEngine({ + initialBandwidth: 1_000_000, + onSignalsReady: (refs) => { + signals = refs; + }, + }); (window as any).engine = engine; - (window as any).state = () => engine.state.get(); - (window as any).owners = () => engine.owners.get(); + (window as any).signals = signals; + (window as any).state = () => snapshot(engine.state); + (window as any).context = () => snapshot(engine.context); // ── Reactive effects ─────────────────────────────────────────────────────── - // prev/prevOwners track one-time transitions for logging purposes. + // prev/prevContext track one-time transitions for logging purposes. // They are reset on each startEngine call so a new source logs correctly. const prev = { hasPresentation: false, @@ -287,11 +298,11 @@ function startEngine(src: string) { selectedAudioTrackId: undefined as string | undefined, selectedTextTrackId: undefined as string | undefined, }; - const prevOwners = { hasMediaSource: false, hasVideoBuffer: false, hasAudioBuffer: false }; + const prevContext = { hasMediaSource: false, hasVideoBuffer: false, hasAudioBuffer: false }; // State logger + auto-select first text track const stopStateLogger = effect(() => { - const state = engine.state.get(); + const state = snapshot(engine.state); if (state.presentation && !prev.hasPresentation) { log('Presentation resolved'); @@ -304,7 +315,9 @@ function startEngine(src: string) { const firstText = textSet?.switchingSets?.[0]?.tracks?.[0]; if (firstText) { log(`Auto-selecting text track: ${firstText.id}`); - engine.state.set({ ...engine.state.get(), selectedTextTrackId: firstText.id }); + // TODO(stage-d): selectedTextTrackId is the deferred reconciler case — + // direct write into composition state until intent/state split lands. + engine.state.selectedTextTrackId.set(firstText.id); } } @@ -325,26 +338,26 @@ function startEngine(src: string) { // Throughput + rendition picker + resolution status — re-render on any state change const stopStateUI = effect(() => { - engine.state.get(); // track all state changes + snapshot(engine.state); // track all state changes updateThroughputDisplay(); renderRenditionPicker(); renderResolutionStatus(); }); - // Owners logger - const stopOwnersLogger = effect(() => { - const owners = engine.owners.get(); + // Context logger + const stopContextLogger = effect(() => { + const ctx = snapshot(engine.context); - if (owners.mediaSource && !prevOwners.hasMediaSource) { - log(`MediaSource created: ${owners.mediaSource.readyState}`, 'success'); - prevOwners.hasMediaSource = true; + if (ctx.mediaSource && !prevContext.hasMediaSource) { + log(`MediaSource created: ${ctx.mediaSource.readyState}`, 'success'); + prevContext.hasMediaSource = true; } - if (owners.videoBuffer && !prevOwners.hasVideoBuffer) { + if (ctx.videoBuffer && !prevContext.hasVideoBuffer) { log('Video SourceBuffer created', 'success'); - prevOwners.hasVideoBuffer = true; + prevContext.hasVideoBuffer = true; - const origRemove = owners.videoBuffer.remove.bind(owners.videoBuffer); - owners.videoBuffer.remove = (start: number, end: number) => { + const origRemove = ctx.videoBuffer.remove.bind(ctx.videoBuffer); + ctx.videoBuffer.remove = (start: number, end: number) => { log( `📹 Video SourceBuffer.remove(${start.toFixed(2)}s → ${end === Infinity ? '∞' : end.toFixed(2)}s)`, 'warning' @@ -352,8 +365,8 @@ function startEngine(src: string) { return origRemove(start, end); }; - owners.videoBuffer.addEventListener('updateend', () => { - const buf = engine.owners.get().videoBuffer; + ctx.videoBuffer.addEventListener('updateend', () => { + const buf = engine.context.videoBuffer.get(); if (!buf) return; const ranges: string[] = []; for (let i = 0; i < buf.buffered.length; i++) { @@ -362,12 +375,12 @@ function startEngine(src: string) { log(`📹 Video buffered: ${ranges.join(' ') || '(empty)'}`, 'info'); }); } - if (owners.audioBuffer && !prevOwners.hasAudioBuffer) { + if (ctx.audioBuffer && !prevContext.hasAudioBuffer) { log('Audio SourceBuffer created', 'success'); - prevOwners.hasAudioBuffer = true; + prevContext.hasAudioBuffer = true; - const origRemove = owners.audioBuffer.remove.bind(owners.audioBuffer); - owners.audioBuffer.remove = (start: number, end: number) => { + const origRemove = ctx.audioBuffer.remove.bind(ctx.audioBuffer); + ctx.audioBuffer.remove = (start: number, end: number) => { log( `🔊 Audio SourceBuffer.remove(${start.toFixed(2)}s → ${end === Infinity ? '∞' : end.toFixed(2)}s)`, 'warning' @@ -375,8 +388,8 @@ function startEngine(src: string) { return origRemove(start, end); }; - owners.audioBuffer.addEventListener('updateend', () => { - const buf = engine.owners.get().audioBuffer; + ctx.audioBuffer.addEventListener('updateend', () => { + const buf = engine.context.audioBuffer.get(); if (!buf) return; const ranges: string[] = []; for (let i = 0; i < buf.buffered.length; i++) { @@ -390,19 +403,19 @@ function startEngine(src: string) { cleanupEffects = () => { stopStateLogger(); stopStateUI(); - stopOwnersLogger(); + stopContextLogger(); }; log('✓ Engine created', 'success'); - log('Exposed as window.engine / window.state() / window.owners()'); + log('Exposed as window.engine / window.signals / window.state() / window.context()'); log('✓ Reactive effects active', 'success'); // ── Wire media element ────────────────────────────────────────────────────── - // Set preload on the element BEFORE wiring owners so syncPreloadAttribute + // Set preload on the element BEFORE wiring context so syncPreloadAttribute // reads the correct value rather than the hardcoded "none" from the HTML. video.preload = preloadSelect.value as 'auto' | 'metadata' | 'none'; - engine.owners.set({ mediaElement: video }); - engine.state.set({ ...engine.state.get(), presentation: { url: src } }); + signals.context.mediaElement.set(video); + signals.state.presentation.set({ url: src }); log('✓ Orchestration started', 'success'); @@ -459,7 +472,7 @@ autoplayToggle.addEventListener('change', () => { preloadSelect.addEventListener('change', () => { const value = preloadSelect.value as 'auto' | 'metadata' | 'none'; - engine.state.set({ ...engine.state.get(), preload: value }); + signals.state.preload.set(value); log(`Preload: ${value}`); updateShareUrl(); }); diff --git a/packages/spf/docs/fundamentals.md b/packages/spf/docs/fundamentals.md index 04aeadc2..d5a49594 100644 --- a/packages/spf/docs/fundamentals.md +++ b/packages/spf/docs/fundamentals.md @@ -8,35 +8,40 @@ The framework doesn't know about your domain. It provides the composition model; ## Compositions -A composition is SPF's unit of assembly. `createComposition` takes a list of **behaviors** — functions that each handle one concern — wires them to shared reactive channels, and returns a small API for reading state and tearing everything down. +A composition is SPF's unit of assembly. `createComposition` takes a list of **behaviors** — declarative units that each handle one concern and declare which slots they read and write — wires them to shared per-slot signals, and returns a small API for reading state and tearing everything down. **What it is** — a factory that wires independent behaviors to shared reactive channels and returns a handle for reading state and tearing everything down. -**When to use it** — when a problem has multiple concerns that share data and lifecycle. Each concern stays a standalone function; shared values flow through signals; cleanup happens together. +**When to use it** — when a problem has multiple concerns that share data and lifecycle. Each concern stays a standalone unit; shared values flow through signals; cleanup happens together. ### A composition in action A composition with one stand-in behavior, driven entirely from outside: ```ts -import { createComposition, effect, update, computed, type Signal } from '@videojs/spf'; +import { createComposition, defineBehavior, effect, computed } from '@videojs/spf'; +import type { Signal } from '@videojs/spf'; -function defineCount({ state }: { state: Signal<{ count?: number }> }) { - // no logic yet — this behavior exists to carry the type -} +const defineCount = defineBehavior({ + stateKeys: ['count'], + contextKeys: [], + setup: ({ state }: { state: { count: Signal } }) => { + // no logic yet — this behavior exists to declare the count slot + }, +}); const composition = createComposition([defineCount]); -composition.state.get(); // { count?: number } +composition.state.count.get(); // undefined const stopLogging = effect(() => { - console.log(composition.state.get().count); + console.log(composition.state.count.get()); }); -const doubled = computed(() => (composition.state.get().count ?? 0) * 2); +const doubled = computed(() => (composition.state.count.get() ?? 0) * 2); const id = setInterval(() => { - update(composition.state, { count: (composition.state.get().count ?? 0) + 1 }); + composition.state.count.set((composition.state.count.get() ?? 0) + 1); }, 250); await composition.destroy(); @@ -51,75 +56,92 @@ clearInterval(id); ```ts const composition = createComposition([defineCount]); -composition.state; // Signal<{ count?: number }> -composition.owners; // Signal<{}> +composition.state; // { count: Signal } +composition.context; // {} composition.destroy(); // Promise ``` -Those three properties — `state`, `owners`, `destroy` — are the composition's entire public API. +Those three properties — `state`, `context`, `destroy` — are the composition's entire public API. > [!NOTE] -> The set of reactive channels a composition exposes may grow. Additional channels — for example an event stream, either as another TC39 signal or an `EventTarget` — are under consideration. Treat `state` and `owners` as the current primitives, not a closed set. +> The set of reactive channels a composition exposes may grow. Additional channels — for example an event stream, either as another TC39 signal or an `EventTarget` — are under consideration. Treat `state` and `context` as the current primitives, not a closed set. -`state` and `owners` are [TC39 Signals](https://github.com/tc39/proposal-signals): reactive values that you read with `.get()` and write with `.set()`. SPF adds one convenience — `update(signal, partial)` shallow-merges a partial object into the current value, so behaviors can write one field without spreading the whole object. +`state` and `context` are **maps of signals** — one [TC39 Signal](https://github.com/tc39/proposal-signals) per declared key. You read each with `.get()` and write each with `.set()`. Behaviors only see the slots they asked for in their `stateKeys` / `contextKeys` declarations. ### Giving state a shape -`defineCount` is the smallest useful behavior: a function that does nothing except declare the shape of state it expects. +`defineCount` is the smallest useful behavior: a `defineBehavior` call that does nothing except declare a state slot. ```ts -function defineCount({ state }: { state: Signal<{ count?: number }> }) { - // no logic yet — this behavior exists to carry the type -} +const defineCount = defineBehavior({ + stateKeys: ['count'], + contextKeys: [], + setup: ({ state }: { state: { count: Signal } }) => { + // no logic yet + }, +}); ``` -A behavior's parameter type is its contract with the composition. Because `defineCount` annotates its state as `Signal<{ count?: number }>`, the composition inherits that shape — and anything that tries to misuse it is caught at compile time: +The setup parameter type is the contract. `state.count: Signal` says: "this composition has a `count` slot that holds a number-or-undefined; this behavior can read and write it." `defineBehavior` enforces that the runtime `stateKeys` array matches exactly what the setup type declares — drift between the two is a compile error at the call site. + +When the composition runs, the `count` slot exists, every behavior that asks for it gets the same `Signal` reference, and: ```ts -composition.state.get(); // { count?: number } +composition.state.count.set(3); +composition.state.count.get(); // 3 -// @ts-expect-error — count must be a number -composition.state.set({ count: 'not a number' }); +// @ts-expect-error — count must be a number-or-undefined +composition.state.count.set('not a number'); ``` -Without a behavior, none of that shape exists. `createComposition([])` resolves `state` and `owners` to `Signal` — permissive on writes, useless on reads: +Without behaviors, no slots exist. `createComposition([])` returns a composition with empty `state` and `context`: ```ts const empty = createComposition([]); - -empty.state.set({ anythingAtAll: true }); // accepted -empty.state.get().count; // ❌ Property 'count' does not exist on type 'object' +empty.state; // {} ``` -Types come from behaviors. +Slots come from behaviors. -> [!NOTE] -> The exact error messages and inference rules are still evolving. The guarantee that conflicts are caught at compile time is stable; how they surface in your editor is not. +When you pass multiple behaviors, their declarations are unioned — incompatible ones (two behaviors declaring the same slot with different value types) fail at compile time. That story is demonstrated in [Context](#context), where multiple behaviors first appear organically. -When you pass multiple behaviors, their declarations are combined — incompatible ones fail at compile time. That story is demonstrated in [Owners](#owners), where multiple behaviors first appear organically. +### Per-slot read/write intent -`defineCount` is a placeholder. Real behaviors do work — run timers, wire up listeners, manage resources, return cleanup. +Behaviors declare per-slot intent by typing each setup-param slot as `Signal` (writable) or `ReadonlySignal` (read-only): + +```ts +const renderCount = defineBehavior({ + stateKeys: ['count'], + contextKeys: [], + setup: ({ state }: { state: { count: ReadonlySignal } }) => + effect(() => console.log(state.count.get())), +}); +``` + +This behavior reads `count` but cannot write it — TS rejects `.set()` on `ReadonlySignal` (which is `Omit, 'set'>`). Body-level enforcement falls out structurally; nothing extra to remember. + +A behavior that writes a slot types it as `Signal`. A behavior that only reads types it as `ReadonlySignal`. The setup signature is self-documenting. ### Using the composition from outside From outside the composition — wherever your code called `createComposition` — you interact with its signals directly. Reading is synchronous: ```ts -composition.state.get(); // { count?: number } +composition.state.count.get(); // current value ``` Observation uses `effect`: it runs its callback immediately, tracks every signal the callback reads, and re-runs the callback whenever any of those signals change. It returns a cleanup function. ```ts const stopLogging = effect(() => { - console.log(composition.state.get().count); + console.log(composition.state.count.get()); }); ``` Derived values use `computed`: a read-only signal whose value is a function of other signals. It recomputes lazily — only when something reads it after a dependency has changed. ```ts -const doubled = computed(() => (composition.state.get().count ?? 0) * 2); +const doubled = computed(() => (composition.state.count.get() ?? 0) * 2); doubled.get(); // whatever `(count ?? 0) * 2` is ``` @@ -128,10 +150,15 @@ Writes from outside are uncommon — ongoing work almost always belongs in a beh ```ts const id = setInterval(() => { - update(composition.state, { count: (composition.state.get().count ?? 0) + 1 }); + composition.state.count.set((composition.state.count.get() ?? 0) + 1); }, 250); ``` +For external code that needs to drive a composition over its lifetime — set state on user input, swap a media element, etc. — there's a generic pattern using the `shareSignals` behavior; see [shareSignals](#sharesignals). + +> [!NOTE] +> Direct writes from outside via `composition.state.x.set(...)` work today but are mostly a pedagogical convenience. For code with a longer lifecycle — a wrapper class, an adapter, anything that drives the composition over time — the canonical pattern is [`shareSignals`](#sharesignals) + `config.onSignalsReady`. A future change may narrow `composition.state` / `composition.context` to read-only views on the public surface, making [`shareSignals`](#sharesignals) the only external write path. Examples below continue to use direct writes where it keeps the focus on the concept being introduced. + Destroying the composition runs each behavior's cleanup and awaits any async work — but anything you started out here is on you: ```ts @@ -144,11 +171,11 @@ clearInterval(id); // the interval is on you ## State -The `setInterval` and the logger that drove `count` from outside both work, but their lifecycles sit apart from the composition's. Moved inside as behaviors, each gets typed access to state, cleanup ties into `destroy()`, and they coordinate with each other through the shared signal. +The `setInterval` and the logger that drove `count` from outside both work, but their lifecycles sit apart from the composition's. Moved inside as behaviors, each gets typed access to the slots it needs, cleanup ties into `destroy()`, and they coordinate with each other through the shared signals. -State is the surface those behaviors share. A single reactive signal holding a plain object, shared across every behavior in a composition and visible from the outside through `composition.state`. Behaviors write to it when something happens; behaviors read it to know what's going on; the outside world subscribes when it needs to react. Because it's a signal, changes flow automatically — nobody coordinates, nobody wires things up. +State is the surface those behaviors share. A map of discrete signals — one per declared key — visible from the outside through `composition.state`. Behaviors write to a slot when something happens; behaviors read a slot to know what's going on; the outside world subscribes when it needs to react. Because each slot is a signal, changes flow automatically — nobody coordinates, nobody wires things up. -**What it is** — a reactive signal holding an object, shared across every behavior in a composition. +**What it is** — a map of per-slot signals derived from each behavior's declared `stateKeys`. **When to use it** — for any value two or more behaviors (or the outside world) need to observe or drive. Counts, selections, flags, timestamps — anything that flows *through* the composition over time. @@ -157,29 +184,37 @@ State is the surface those behaviors share. A single reactive signal holding a p A counter that ticks on an interval paired with a logger that reads the count as it changes — two behaviors coordinating entirely through shared state: ```ts -import { createComposition, effect, update, type Signal } from '@videojs/spf'; +import { createComposition, defineBehavior, effect } from '@videojs/spf'; +import type { ReadonlySignal, Signal } from '@videojs/spf'; -function counter({ - state, - config, -}: { - state: Signal<{ count?: number }>; - config: { interval?: number }; -}) { - const id = setInterval(() => { - update(state, { count: (state.get().count ?? 0) + 1 }); - }, config.interval ?? 1000); +const counter = defineBehavior({ + stateKeys: ['count'], + contextKeys: [], + setup: ({ + state, + config, + }: { + state: { count: Signal }; + config: { interval?: number }; + }) => { + const id = setInterval(() => { + state.count.set((state.count.get() ?? 0) + 1); + }, config.interval ?? 1000); - return () => clearInterval(id); -} + return () => clearInterval(id); + }, +}); -function logCount({ state }: { state: Signal<{ count?: number }> }) { - // effect() returns its own cleanup; handing it back here ties - // the effect's lifecycle to composition.destroy() - return effect(() => { - console.log(state.get().count); - }); -} +const logCount = defineBehavior({ + stateKeys: ['count'], + contextKeys: [], + setup: ({ state }: { state: { count: ReadonlySignal } }) => + // effect() returns its own cleanup; handing it back here ties + // the effect's lifecycle to composition.destroy() + effect(() => { + console.log(state.count.get()); + }), +}); const composition = createComposition([counter, logCount], { initialState: { count: 0 }, @@ -190,28 +225,30 @@ const composition = createComposition([counter, logCount], { await composition.destroy(); // clears the interval and stops the effect ``` -Neither behavior knows the other exists. `counter` writes to state; `logCount` reads it. They coordinate through the shared signal, and each hands back the cleanup that belongs to its own lifecycle — a `clearInterval` closure for `counter`, the function `effect()` returned for `logCount`. One call to `composition.destroy()` unwinds both. +Neither behavior knows the other exists. `counter` writes to `state.count`; `logCount` reads it. They coordinate through the shared signal, and each hands back the cleanup that belongs to its own lifecycle — a `clearInterval` closure for `counter`, the function `effect()` returned for `logCount`. One call to `composition.destroy()` unwinds both. + +Note the read/write split: `counter` types `state.count` as `Signal` (it writes), `logCount` types it as `ReadonlySignal` (it only reads). At compose time, the union allows any behavior to read; only behaviors typed `Signal` can write. If you accidentally `.set()` on a slot you typed `ReadonlySignal`, TS rejects the call. ### `initialState` -`initialState` sets the starting value of the state signal: +`initialState` sets the starting value of each state slot: ```ts createComposition([counter], { initialState: { count: 0 } }); -composition.state.get(); // { count: 0 } +composition.state.count.get(); // 0 ``` -Its type is derived from the behaviors. Because `counter` annotates `state: Signal<{ count?: number }>`, TypeScript requires `initialState` to be assignable to `{ count?: number }`: +Its type is derived from the behaviors. Because `counter` annotates `state.count` as `Signal`, TypeScript requires `initialState.count` to be assignable to `number | undefined`: ```ts // ✅ matches the behavior's declared state createComposition([counter], { initialState: { count: 0 } }); -// @ts-expect-error — count must be a number +// @ts-expect-error — count must be a number-or-undefined createComposition([counter], { initialState: { count: 'zero' } }); ``` -If you omit `initialState`, the signal starts as `{}` — which is why `counter` falls back with `state.get().count ?? 0` on its first tick. +If you omit a key from `initialState`, that slot starts as `undefined` — which is why `counter` falls back with `state.count.get() ?? 0` on its first tick. ### `config` @@ -232,129 +269,126 @@ Behaviors read config directly (`config.interval`), usually with a fallback. Use --- -## Owners +## Context -A ticking counter and a console log aren't much of an application. What you'd actually want is to render the count somewhere — say, into an element on the page. That needs access to the element itself: a `
`, a buffer, an open socket. These are **resources** — platform objects with imperative interfaces that don't fit cleanly into plain application data, and the **owners** channel is where they live. +A ticking counter and a console log aren't much of an application. What you'd actually want is to render the count somewhere — say, into an element on the page. That needs access to the element itself: a `
`, a buffer, an open socket. These are **resources** — platform objects with imperative interfaces that don't fit cleanly into plain application data, and the **context** channel is where they live. -The composition holds a signal whose value is a plain object mapping keys to resources. Behaviors read the keys they care about and act on the resources directly; `effect()` re-runs when a key appears, is replaced, or is cleared. The element itself is still the element — owners don't wrap or proxy it, they just make its lifecycle reactive. +The composition holds a map of signals whose values are resources. Behaviors read the keys they care about and act on the resources directly; `effect()` re-runs when a key appears, is replaced, or is cleared. The element itself is still the element — context doesn't wrap or proxy it, it just makes its lifecycle reactive. -**What it is** — a reactive signal holding a map of named resources, shared across every behavior in a composition. +**What it is** — a map of per-slot signals for resources, parallel to state but holding values with identity rather than data. -**When to use it** — for values that have identity and behavior, not just data — DOM elements, buffers, long-lived connections. If you'd pass the thing around by reference, it probably belongs in owners. +**When to use it** — for values that have identity and behavior, not just data — DOM elements, buffers, long-lived connections. If you'd pass the thing around by reference, it probably belongs in context. > [!NOTE] -> The name "owners" is provisional. The concept — a channel for mutable resources that behaviors observe and act on — is stable; the label itself may change, and "resources" is one candidate under consideration. +> The split between `state` and `context` is structural at the framework level — both are signal maps with the same API — but conventional in practice. State is for plain data; context is for resources. The composition treats them identically; the split exists for code clarity. ### A DOM-renderer behavior A behavior that renders the counter to a DOM element, joining the counter and logger from the previous section: ```ts -import { createComposition, effect, type Signal } from '@videojs/spf'; +import { createComposition, defineBehavior, effect } from '@videojs/spf'; +import type { ReadonlySignal } from '@videojs/spf'; // counter, logCount — unchanged from the previous section -function renderCount({ - state, - owners, - config, -}: { - state: Signal<{ count?: number }>; - owners: Signal<{ renderElement?: HTMLElement }>; - config: { defaultText?: string }; -}) { - return effect(() => { - const { renderElement } = owners.get(); - if (!renderElement) return; - renderElement.textContent = String(state.get().count ?? config.defaultText ?? 'N/A'); - }); -} +const renderCount = defineBehavior({ + stateKeys: ['count'], + contextKeys: ['renderElement'], + setup: ({ + state, + context, + config, + }: { + state: { count: ReadonlySignal }; + context: { renderElement: ReadonlySignal }; + config: { defaultText?: string }; + }) => + effect(() => { + const renderElement = context.renderElement.get(); + if (!renderElement) return; + renderElement.textContent = String(state.count.get() ?? config.defaultText ?? 'N/A'); + }), +}); const composition = createComposition([counter, logCount, renderCount], { initialState: { count: 0 }, config: { interval: 250, defaultText: '--' }, - initialOwners: { renderElement: document.getElementById('counter') }, + initialContext: { renderElement: document.getElementById('counter') ?? undefined }, }); await composition.destroy(); ``` -`renderCount` reads from both channels. `effect()` tracks every signal the callback reads and re-runs when any of them change — so when `count` ticks up, or when `renderElement` is swapped out or cleared, the renderCount function runs again. The guard `if (!renderElement) return` handles the case where the element isn't in owners yet (for example, if `initialOwners` was omitted or the DOM wasn't ready). +`renderCount` reads from both channels. Both slots are typed `ReadonlySignal` — the behavior only reads, never writes. `effect()` tracks every signal the callback reads and re-runs when any of them change, so when `count` ticks up, or when `renderElement` is swapped out or cleared, the renderCount callback runs again. The guard `if (!renderElement) return` handles the case where the element isn't in context yet (for example, if `initialContext` was omitted or the DOM wasn't ready). -### `initialOwners` +### `initialContext` -`initialOwners` seeds the owners signal, the same way `initialState` seeds state: +`initialContext` seeds the context signals, the same way `initialState` seeds state: ```ts createComposition([counter, logCount, renderCount], { - initialOwners: { renderElement: document.getElementById('counter') }, + initialContext: { renderElement: document.getElementById('counter') ?? undefined }, }); ``` -Its type is derived from the behaviors. Because `renderCount` annotates `owners: Signal<{ renderElement?: HTMLElement }>`, TypeScript requires `initialOwners` to be assignable to that shape: +Its type is derived from the behaviors. Because `renderCount` annotates `context.renderElement` as a signal of `HTMLElement | undefined`, TypeScript requires `initialContext.renderElement` to be assignable to that: ```ts // @ts-expect-error — renderElement expects an HTMLElement, not a number -createComposition([renderCount], { initialOwners: { renderElement: 42 } }); +createComposition([renderCount], { initialContext: { renderElement: 42 } }); ``` -If you omit `initialOwners`, the signal starts as `{}` — which is why `renderCount` uses the optional annotation `renderElement?: HTMLElement` and guards the read. +If you omit a context key, that slot starts as `undefined` — which is why `renderCount` guards with `if (!renderElement) return` before using it. -### Updating owners from outside +### Updating context from outside -Owners is just a signal, so you can write to it the same way you write to state — usually from inside a behavior, occasionally from outside when orchestrating resources that live beyond the composition's scope. +Context slots are signals like state — you can write to them the same way, usually from inside a behavior, occasionally from outside when orchestrating resources that live beyond the composition's scope. -Swapping the element mid-composition updates what `renderCount` is rendering into. Because `effect()` tracks `owners`, the swap re-runs the callback and the new element starts receiving updates immediately: +Swapping the element mid-composition updates what `renderCount` is rendering into. Because `effect()` tracks `context.renderElement`, the swap re-runs the callback and the new element starts receiving updates immediately: ```ts const anotherDiv = document.getElementById('other-counter'); -update(composition.owners, { renderElement: anotherDiv }); +composition.context.renderElement.set(anotherDiv ?? undefined); ``` -Unsetting back to `undefined` is also fine — the guard (`if (!renderElement) return`) turns the absence into a no-op: +Unsetting back to `undefined` is also fine — the guard turns the absence into a no-op: ```ts -update(composition.owners, { renderElement: undefined }); // renderCount stops writing to the DOM -update(composition.owners, { renderElement: anotherDiv }); // and picks back up +composition.context.renderElement.set(undefined); // renderCount stops writing to the DOM +composition.context.renderElement.set(anotherDiv ?? undefined); // and picks back up ``` -The same pattern covers creation time: if you omit `initialOwners`, the signal starts as `{}`, the first effect run bails on the guard, and `renderCount` comes alive the moment a behavior (or outside code) attaches the element. +The same pattern covers creation time: if you omit `initialContext`, the slot starts as `undefined`, the first effect run bails on the guard, and `renderCount` comes alive the moment a behavior (or outside code) attaches the element. This is the loose-coupling payoff. `renderCount` doesn't need to know *when* `renderElement` will exist, only what to do when it does. Resources can arrive late, be swapped, or disappear — the behavior adjusts. +For a more structured way to do external writes — useful when a wrapper class or adapter needs to drive the composition over its lifetime — see [shareSignals](#sharesignals). + ### Composing behaviors -When you pass more than one behavior to `createComposition`, their declarations are combined and the compiler catches conflicts. The rule differs by channel. - -**State and config** use intersection: if two behaviors declare the same key with incompatible types, the intersection collapses and the composition is rejected. +When you pass more than one behavior to `createComposition`, their declarations are unioned and the compiler catches conflicts. State and context are treated identically: each behavior contributes the per-slot types it declares, and slots with conflicting types across behaviors fail at compile time. ```ts -const expectsNumber = (_deps: { state: Signal<{ value: number }> }) => {}; -const expectsString = (_deps: { state: Signal<{ value: string }> }) => {}; +const expectsNumber = defineBehavior({ + stateKeys: ['value'], + contextKeys: [], + setup: ({ state }: { state: { value: Signal } }) => {}, +}); + +const expectsString = defineBehavior({ + stateKeys: ['value'], + contextKeys: [], + setup: ({ state }: { state: { value: Signal } }) => {}, +}); // @ts-expect-error — behaviors have conflicting state types createComposition([expectsNumber, expectsString]); ``` -**Owners** use subtype compatibility, because owner values are concrete platform objects whose class hierarchy matters. Two behaviors can share an owner key if one type extends the other — the composition picks the more specific one: +Two behaviors can share a slot if their declared types are compatible — typically the same type, or one being a subtype of the other (e.g. `HTMLVideoElement` vs `HTMLElement`). The compose-time validator picks the most specific type from the intersection. -```ts -const wantsElement = (_deps: { owners: Signal<{ el?: HTMLElement }> }) => {}; -const wantsVideo = (_deps: { owners: Signal<{ el?: HTMLVideoElement }> }) => {}; - -// ✅ HTMLVideoElement extends HTMLElement — fine -createComposition([wantsElement, wantsVideo]); -``` - -Sibling types with no `extends` relationship are rejected: - -```ts -const wantsCanvas = (_deps: { owners: Signal<{ el?: HTMLCanvasElement }> }) => {}; -const wantsVideo = (_deps: { owners: Signal<{ el?: HTMLVideoElement }> }) => {}; - -// @ts-expect-error — neither HTMLCanvasElement nor HTMLVideoElement extends the other -createComposition([wantsCanvas, wantsVideo]); -``` +The same rule applies to context. The framework doesn't distinguish "data slots" from "resource slots" structurally — it just unions the declared shapes per channel. --- @@ -373,96 +407,112 @@ An `effect()` can't cleanly model that. It re-runs on every signal change with n A counter that can be paused and reset from DOM buttons. The counter is now a reactor; the rest are ordinary effect-based behaviors: ```ts -import { createComposition, effect, createMachineReactor, update, type Signal } from '@videojs/spf'; +import { createComposition, defineBehavior, effect, createMachineReactor } from '@videojs/spf'; +import type { ReadonlySignal, Signal } from '@videojs/spf'; import { listen } from '@videojs/utils/dom'; // logCount, renderCount — unchanged from the previous section -function counter({ - state, - config, -}: { - state: Signal<{ count?: number; paused?: boolean }>; - config: { interval?: number }; -}) { - return createMachineReactor({ - initial: 'paused', - monitor: () => (state.get().paused ? 'paused' : 'running'), - states: { - paused: {}, - running: { - // entry runs once on transition; its return value is cleanup, - // called on exit (pause or destroy) - entry: () => { - const id = setInterval(() => { - update(state, { count: (state.get().count ?? 0) + 1 }); - }, config.interval ?? 1000); - return () => clearInterval(id); +const counter = defineBehavior({ + stateKeys: ['count', 'paused'], + contextKeys: [], + setup: ({ + state, + config, + }: { + state: { + count: Signal; + paused: ReadonlySignal; + }; + config: { interval?: number }; + }) => + createMachineReactor({ + initial: 'paused', + monitor: () => (state.paused.get() ? 'paused' : 'running'), + states: { + paused: {}, + running: { + // entry runs once on transition; its return value is cleanup, + // called on exit (pause or destroy) + entry: () => { + const id = setInterval(() => { + state.count.set((state.count.get() ?? 0) + 1); + }, config.interval ?? 1000); + return () => clearInterval(id); + }, }, }, - }, - }); -} + }), +}); -function pauseButton({ - state, - owners, -}: { - state: Signal<{ paused?: boolean }>; - owners: Signal<{ pauseBtn?: HTMLElement }>; -}) { - // Keep the button label in sync with paused - const stopLabel = effect(() => { - const { pauseBtn } = owners.get(); - if (!pauseBtn) return; - pauseBtn.textContent = state.get().paused ? 'Start' : 'Pause'; - }); - - // listen() attaches a click handler and returns a cleanup that removes it - const stopClick = effect(() => { - const { pauseBtn } = owners.get(); - if (!pauseBtn) return; - return listen(pauseBtn, 'click', () => { - update(state, { paused: !state.get().paused }); +const pauseButton = defineBehavior({ + stateKeys: ['paused'], + contextKeys: ['pauseBtn'], + setup: ({ + state, + context, + }: { + state: { paused: Signal }; + context: { pauseBtn: ReadonlySignal }; + }) => { + // Keep the button label in sync with paused + const stopLabel = effect(() => { + const pauseBtn = context.pauseBtn.get(); + if (!pauseBtn) return; + pauseBtn.textContent = state.paused.get() ? 'Start' : 'Pause'; }); - }); - return () => { - stopLabel(); - stopClick(); - }; -} + // listen() attaches a click handler and returns a cleanup that removes it + const stopClick = effect(() => { + const pauseBtn = context.pauseBtn.get(); + if (!pauseBtn) return; + return listen(pauseBtn, 'click', () => { + state.paused.set(!state.paused.get()); + }); + }); -function resetButton({ - state, - owners, -}: { - state: Signal<{ count?: number }>; - owners: Signal<{ resetBtn?: HTMLElement }>; -}) { - return effect(() => { - const { resetBtn } = owners.get(); - if (!resetBtn) return; - return listen(resetBtn, 'click', () => update(state, { count: 0 })); - }); -} + return () => { + stopLabel(); + stopClick(); + }; + }, +}); + +const resetButton = defineBehavior({ + stateKeys: ['count'], + contextKeys: ['resetBtn'], + setup: ({ + state, + context, + }: { + state: { count: Signal }; + context: { resetBtn: ReadonlySignal }; + }) => + effect(() => { + const resetBtn = context.resetBtn.get(); + if (!resetBtn) return; + return listen(resetBtn, 'click', () => state.count.set(0)); + }), +}); const composition = createComposition([counter, logCount, renderCount, pauseButton, resetButton], { initialState: { count: 0, paused: true }, config: { interval: 250, defaultText: '--' }, - initialOwners: { - renderElement: document.getElementById('counter'), - pauseBtn: document.getElementById('pause'), - resetBtn: document.getElementById('reset'), + initialContext: { + renderElement: document.getElementById('counter') ?? undefined, + pauseBtn: document.getElementById('pause') ?? undefined, + resetBtn: document.getElementById('reset') ?? undefined, }, }); await composition.destroy(); ``` -`counter` is now a reactor with two states, `paused` and `running`. Its `monitor` reads `state.get().paused` and returns the target. When the user clicks the pause button, `pauseButton` writes to state; `monitor` re-derives, and the reactor transitions. `entry` on `running` starts the interval; the cleanup it returns runs on the way back to `paused`. The framework handles the transition — `counter` never calls `transition()` itself. +`counter` is now a reactor with two states, `paused` and `running`. Its `monitor` reads `state.paused.get()` and returns the target. When the user clicks the pause button, `pauseButton` writes to state; `monitor` re-derives, and the reactor transitions. `entry` on `running` starts the interval; the cleanup it returns runs on the way back to `paused`. The framework handles the transition — `counter` never calls `transition()` itself. -`pauseButton`, `resetButton`, and `renderCount` are ordinary effect-based behaviors reacting to the same state the reactor derives from. None of them knows a reactor exists. Everything coordinates through the shared signal. +`pauseButton`, `resetButton`, and `renderCount` are ordinary effect-based behaviors reacting to the same state the reactor derives from. None of them knows a reactor exists. Everything coordinates through the shared signals. + +The read/write split is visible across the behaviors: `counter` writes `count` (Signal) and only reads `paused` (ReadonlySignal); `pauseButton` writes `paused` (Signal); `resetButton` writes `count` (Signal). `count` and `paused` each have multiple readers but a clear set of writers — visible at each behavior's setup signature without needing to read bodies. ### Monitor, entry, and effects @@ -491,60 +541,65 @@ A plain Promise can't express any of that. It starts running the moment you crea Saving the count to a server every five ticks, with one final save on destroy: ```ts -import { createComposition, effect, Task, SerialRunner, computed, type Signal } from '@videojs/spf'; +import { createComposition, defineBehavior, effect, computed, Task, SerialRunner } from '@videojs/spf'; +import type { ReadonlySignal } from '@videojs/spf'; // counter, logCount, renderCount, pauseButton, resetButton — unchanged from previous sections -function persist({ - state, - config, -}: { - state: Signal<{ count?: number }>; - config: { saveEvery?: number }; -}) { - const runner = new SerialRunner(); +const persist = defineBehavior({ + stateKeys: ['count'], + contextKeys: [], + setup: ({ + state, + config, + }: { + state: { count: ReadonlySignal }; + config: { saveEvery?: number }; + }) => { + const runner = new SerialRunner(); - function save(count: number) { - runner.schedule( - new Task((signal) => - fetch('/api/count', { - method: 'POST', - body: JSON.stringify({ count }), - signal, - }), - ), - ); - } - - // Isolate count so unrelated state changes don't trigger a save - const count = computed(() => state.get().count ?? 0); - - // Watch count and save at every Nth tick - const stopEffect = effect(() => { - const c = count.get(); - if (c > 0 && c % (config.saveEvery ?? 5) === 0) { - save(c); + function save(count: number) { + runner.schedule( + new Task((signal) => + fetch('/api/count', { + method: 'POST', + body: JSON.stringify({ count }), + signal, + }), + ), + ); } - }); - // Async cleanup: save the final count, let the runner drain, then tear down - return async () => { - stopEffect(); - save(count.get()); - await runner.settled; - runner.destroy(); - }; -} + // Isolate count so unrelated re-runs don't fire spurious saves + const count = computed(() => state.count.get() ?? 0); + + // Watch count and save at every Nth tick + const stopEffect = effect(() => { + const c = count.get(); + if (c > 0 && c % (config.saveEvery ?? 5) === 0) { + save(c); + } + }); + + // Async cleanup: save the final count, let the runner drain, then tear down + return async () => { + stopEffect(); + save(count.get()); + await runner.settled; + runner.destroy(); + }; + }, +}); const composition = createComposition( [counter, logCount, renderCount, pauseButton, resetButton, persist], { initialState: { count: 0, paused: true }, config: { interval: 250, defaultText: '--', saveEvery: 5 }, - initialOwners: { - renderElement: document.getElementById('counter'), - pauseBtn: document.getElementById('pause'), - resetBtn: document.getElementById('reset'), + initialContext: { + renderElement: document.getElementById('counter') ?? undefined, + pauseBtn: document.getElementById('pause') ?? undefined, + resetBtn: document.getElementById('reset') ?? undefined, }, }, ); @@ -555,17 +610,17 @@ await composition.destroy(); Three things are new. The `save()` closure wraps each network request in `new Task(...)`; the task's body receives an `AbortSignal` that `fetch` understands natively. The `SerialRunner` collects scheduled tasks and runs them one at a time — scheduling a second task while the first is still running queues it behind, with no overlap. And `persist`'s cleanup is `async`: it stops the effect, schedules one last save, `await`s `runner.settled` to let pending work finish, then destroys the runner. `composition.destroy()` awaits this cleanup like any other. -One more detail is worth unpacking: why `count` is wrapped in a `computed` before the effect reads it. +`persist` types `count` as `ReadonlySignal` — it observes but doesn't write the slot. The actual save state (in flight, last error, etc.) is internal to the closure. Making *that* observable is what actors are for; see the next section. ### Narrowing what an effect re-runs on -Previous effects read `count` straight from state — `state.get().count`. Here it's wrapped in a `computed`: +The save effect wraps `count` in a `computed`: ```ts -const count = computed(() => state.get().count ?? 0); +const count = computed(() => state.count.get() ?? 0); ``` -The reason is the shape of the effect that reads it. `save()` is a non-idempotent side effect: it schedules a network request. A state signal re-notifies on every write, so reading `state.get().count` directly would re-run the effect whenever `paused` toggled (or any unrelated field changed) and fire a save whenever the current count happened to be divisible by `saveEvery`. `computed` caches by value, so reading its `.get()` inside an effect only triggers a re-run when that value actually changed. +The reason is the shape of the effect that reads it. `save()` is a non-idempotent side effect: it schedules a network request. A signal re-notifies on every write, even when the new value equals the old; reading `state.count.get()` directly inside an effect would re-fire on every write, even no-ops. `computed` caches by value: reading its `.get()` inside an effect only triggers a re-run when that value actually changed. For the DOM-update effects earlier in the doc — `renderCount` setting `textContent`, `pauseButton`'s label — spurious re-runs are harmless: the assignment just writes the same value already there. The `computed` guardrail matters when the effect's side effect isn't free to repeat. @@ -599,7 +654,16 @@ Moving that work into an actor makes the save lifecycle observable. An actor is Refactor `persist`: the runner and save state move into an actor; `persist` sends messages to it; a new `renderSaving` behavior reads the actor's snapshot to surface in-flight status to a dedicated element; a new `cancelOnReset` behavior sends `cancel` when count returns to zero while a save is in flight. ```ts -import { createComposition, effect, createMachineActor, Task, SerialRunner, computed, update, type Signal } from '@videojs/spf'; +import { + createComposition, + defineBehavior, + effect, + computed, + createMachineActor, + Task, + SerialRunner, +} from '@videojs/spf'; +import type { ReadonlySignal, Signal } from '@videojs/spf'; // counter, logCount, renderCount, pauseButton, resetButton — unchanged from previous sections @@ -648,74 +712,89 @@ function createSaveActor() { type SaveActor = ReturnType; -function persist({ - state, - owners, - config, -}: { - state: Signal<{ count?: number }>; - owners: Signal<{ saveActor?: SaveActor }>; - config: { saveEvery?: number }; -}) { - const actor = createSaveActor(); - update(owners, { saveActor: actor }); +const persist = defineBehavior({ + stateKeys: ['count'], + contextKeys: ['saveActor'], + setup: ({ + state, + context, + config, + }: { + state: { count: ReadonlySignal }; + context: { saveActor: Signal }; + config: { saveEvery?: number }; + }) => { + const actor = createSaveActor(); + context.saveActor.set(actor); - // Isolate count so unrelated state changes don't trigger a save - const count = computed(() => state.get().count ?? 0); + // Isolate count so unrelated re-runs don't fire spurious saves + const count = computed(() => state.count.get() ?? 0); - const stopEffect = effect(() => { - const c = count.get(); - if (c > 0 && c % (config.saveEvery ?? 5) === 0) { - actor.send({ type: 'save', count: c }); - } - }); + const stopEffect = effect(() => { + const c = count.get(); + if (c > 0 && c % (config.saveEvery ?? 5) === 0) { + actor.send({ type: 'save', count: c }); + } + }); - return () => { - stopEffect(); - actor.destroy(); - }; -} + return () => { + stopEffect(); + actor.destroy(); + }; + }, +}); -function renderSaving({ - owners, -}: { - owners: Signal<{ savingElement?: HTMLElement; saveActor?: SaveActor }>; -}) { - return effect(() => { - const { savingElement, saveActor } = owners.get(); - if (!savingElement) return; - savingElement.textContent = saveActor?.snapshot.get().value === 'saving' ? 'saving...' : ''; - }); -} +const renderSaving = defineBehavior({ + stateKeys: [], + contextKeys: ['savingElement', 'saveActor'], + setup: ({ + context, + }: { + context: { + savingElement: ReadonlySignal; + saveActor: ReadonlySignal; + }; + }) => + effect(() => { + const savingElement = context.savingElement.get(); + const saveActor = context.saveActor.get(); + if (!savingElement) return; + savingElement.textContent = saveActor?.snapshot.get().value === 'saving' ? 'saving...' : ''; + }), +}); -function cancelOnReset({ - state, - owners, -}: { - state: Signal<{ count?: number }>; - owners: Signal<{ saveActor?: SaveActor }>; -}) { - // Isolate count so unrelated state changes don't fire spurious cancels - const count = computed(() => state.get().count); +const cancelOnReset = defineBehavior({ + stateKeys: ['count'], + contextKeys: ['saveActor'], + setup: ({ + state, + context, + }: { + state: { count: ReadonlySignal }; + context: { saveActor: ReadonlySignal }; + }) => { + // Isolate count so unrelated state changes don't fire spurious cancels + const count = computed(() => state.count.get()); - return effect(() => { - const { saveActor } = owners.get(); - if (count.get() === 0 && saveActor?.snapshot.get().value === 'saving') { - saveActor.send({ type: 'cancel' }); - } - }); -} + return effect(() => { + const saveActor = context.saveActor.get(); + if (count.get() === 0 && saveActor?.snapshot.get().value === 'saving') { + saveActor.send({ type: 'cancel' }); + } + }); + }, +}); const composition = createComposition( [counter, logCount, renderCount, pauseButton, resetButton, persist, renderSaving, cancelOnReset], { initialState: { count: 0, paused: true }, config: { interval: 250, defaultText: '--', saveEvery: 5 }, - initialOwners: { - renderElement: document.getElementById('counter'), - savingElement: document.getElementById('saving'), - pauseBtn: document.getElementById('pause'), - resetBtn: document.getElementById('reset'), + initialContext: { + renderElement: document.getElementById('counter') ?? undefined, + savingElement: document.getElementById('saving') ?? undefined, + pauseBtn: document.getElementById('pause') ?? undefined, + resetBtn: document.getElementById('reset') ?? undefined, }, }, ); @@ -723,10 +802,9 @@ const composition = createComposition( await composition.destroy(); ``` -The actor makes the save lifecycle observable. `persist` publishes the actor through owners and forwards save triggers as messages; `renderSaving` reads `saveActor.snapshot.get().value` and writes "saving..." to its own dedicated element when the actor is in the `'saving'` state; `cancelOnReset` reads the same snapshot and sends a `cancel` message when count returns to zero during an in-flight save, aborting the work and transitioning the actor back to `idle`. None of these behaviors knows how a save is performed — they interact with the actor as a black box that happens to expose its current state. And `resetButton` no longer has to know anything about saving: it just writes `{ count: 0 }` to state; `cancelOnReset` handles the rest. +The actor makes the save lifecycle observable. `persist` publishes the actor through `context.saveActor` and forwards save triggers as messages; `renderSaving` reads `saveActor.snapshot.get().value` and writes "saving..." to its own dedicated element when the actor is in the `'saving'` state; `cancelOnReset` reads the same snapshot and sends a `cancel` message when count returns to zero during an in-flight save, aborting the work and transitioning the actor back to `idle`. None of these behaviors knows how a save is performed — they interact with the actor as a black box that happens to expose its current state. And `resetButton` no longer has to know anything about saving: it just writes `state.count.set(0)`; `cancelOnReset` handles the rest. -> [!NOTE] -> Actors live in `owners` here because they fit the shape — imperative resources with identity that behaviors observe and act on. Whether they warrant a dedicated channel is an open question. Treat actors-in-owners as a working convention, not a fixed design. +`saveActor` lives in `context` — actors fit the shape (imperative resources with identity that behaviors observe and act on). `persist` types it `Signal` because it writes the actor in once at setup; `renderSaving` and `cancelOnReset` type it `ReadonlySignal` because they only read. ### Messages, transitions, and snapshot @@ -745,64 +823,143 @@ A reactor might send a message to an actor when a condition goes true; the actor --- -## Advanced: Creating owners within behaviors +## shareSignals -Up through Actors, the caller had to hand every element the composition uses directly into `initialOwners` — `renderElement`, `savingElement`, `pauseBtn`, `resetBtn`, each looked up from the DOM before `createComposition` runs. Adding or renaming any of them means touching the caller too. Behaviors can close that loop: given a single `rootElement`, a behavior creates the descendants itself, registers them in owners, and cleans them up on teardown. That keeps resource creation inside the composition, which matters when a single composition needs several related resources that share a lifecycle. +Most behaviors above keep their lifecycle inside the composition. But sometimes external code needs to drive a composition over its lifetime — a wrapper class setting `count` from a method call, an adapter forwarding DOM events into state. Reaching directly into `composition.state.count.set(...)` works, but it spreads composition knowledge into the caller and leaves no clear boundary for "the engine's input surface." + +`shareSignals` is a generic passthrough behavior that hands the composition's writable signal refs to a consumer-supplied `config.onSignalsReady` callback at composition setup. The consumer captures the refs and writes through them at runtime. The composition itself doesn't change — `shareSignals` just forwards what's already there. + +**What it is** — a behavior factory (`makeShareSignals()`) that produces a passthrough behavior. The behavior declares no slots of its own; at setup time it invokes `config.onSignalsReady({ state, context })`. + +**When to use it** — when external code needs structured access to a composition's slots over its lifetime, especially when the composition will be re-created (e.g. on source change). The pattern is "create composition → capture refs in callback → drive from outside." + +### A counter wrapped via `shareSignals` + +```ts +import { + createComposition, + defineBehavior, + makeShareSignals, + type ShareSignalsConfig, + type StateSignals, + type ContextSignals, +} from '@videojs/spf'; + +interface CounterState { + count?: number; + paused?: boolean; +} +interface CounterContext { + renderElement?: HTMLElement; +} + +interface CounterEngineConfig extends ShareSignalsConfig { + interval?: number; + defaultText?: string; +} + +const shareSignals = makeShareSignals(); + +function createCounterEngine(config: CounterEngineConfig) { + return createComposition( + [counter, logCount, renderCount, shareSignals], + { + config, + initialState: { count: 0, paused: true }, + }, + ); +} + +// Usage from outside +let signals: { state: StateSignals; context: ContextSignals }; + +const engine = createCounterEngine({ + interval: 250, + onSignalsReady: (refs) => { + signals = refs; + }, +}); + +// Drive the engine through the captured refs +signals.context.renderElement.set(document.getElementById('counter') ?? undefined); +signals.state.paused.set(false); // start ticking + +await engine.destroy(); +``` + +The engine factory exposes a single config option (`onSignalsReady`); the consumer captures the signal refs and drives the engine through them. `shareSignals` is generic — `makeShareSignals()` works for any composition, parameterized over the composition's state and context shapes. + +For real-world use, place `shareSignals` last in the behaviors array so every other behavior's setup has run by the time the callback fires. Initial state writes will be visible to the consumer immediately. + +--- + +## Advanced: Creating context within behaviors + +Up through Actors, the caller had to hand every element the composition uses directly into `initialContext` — `renderElement`, `savingElement`, `pauseBtn`, `resetBtn`, each looked up from the DOM before `createComposition` runs. Adding or renaming any of them means touching the caller too. Behaviors can close that loop: given a single `rootElement`, a behavior creates the descendants itself, registers them in context, and cleans them up on teardown. That keeps resource creation inside the composition, which matters when a single composition needs several related resources that share a lifecycle. A `mount` behavior takes a single parent — `rootElement` — and creates the rest: ```ts -import { effect, update, type Signal } from '@videojs/spf'; +import { defineBehavior, effect } from '@videojs/spf'; +import type { ReadonlySignal, Signal } from '@videojs/spf'; // counter, logCount, renderCount, pauseButton, resetButton, persist, renderSaving, cancelOnReset — unchanged from previous sections -function mount({ - owners, -}: { - owners: Signal<{ - rootElement?: HTMLElement; - renderElement?: HTMLElement; - savingElement?: HTMLElement; - pauseBtn?: HTMLElement; - resetBtn?: HTMLElement; - }>; -}) { - return effect(() => { - const { rootElement } = owners.get(); - if (!rootElement) return; +const mount = defineBehavior({ + stateKeys: [], + contextKeys: ['rootElement', 'renderElement', 'savingElement', 'pauseBtn', 'resetBtn'], + setup: ({ + context, + }: { + context: { + rootElement: ReadonlySignal; + renderElement: Signal; + savingElement: Signal; + pauseBtn: Signal; + resetBtn: Signal; + }; + }) => + effect(() => { + const rootElement = context.rootElement.get(); + if (!rootElement) return; - const renderElement = document.createElement('div'); - const savingElement = document.createElement('div'); - const pauseBtn = document.createElement('button'); - const resetBtn = document.createElement('button'); - resetBtn.textContent = 'Reset'; + const renderElement = document.createElement('div'); + const savingElement = document.createElement('div'); + const pauseBtn = document.createElement('button'); + const resetBtn = document.createElement('button'); + resetBtn.textContent = 'Reset'; - rootElement.append(renderElement, savingElement, pauseBtn, resetBtn); - update(owners, { renderElement, savingElement, pauseBtn, resetBtn }); + rootElement.append(renderElement, savingElement, pauseBtn, resetBtn); + context.renderElement.set(renderElement); + context.savingElement.set(savingElement); + context.pauseBtn.set(pauseBtn); + context.resetBtn.set(resetBtn); - // If this behavior needed cleanup when rootElement is cleared — to - // .remove() the descendants, or close a socket, observer, or - // MediaSource — we'd return a cleanup function from the effect here. - }); -} + // If this behavior needed cleanup when rootElement is cleared — to + // .remove() the descendants, or close a socket, observer, or + // MediaSource — we'd return a cleanup function from the effect here. + }), +}); const composition = createComposition( [counter, logCount, renderCount, pauseButton, resetButton, persist, renderSaving, cancelOnReset, mount], { initialState: { count: 0, paused: true }, config: { interval: 250, defaultText: '--', saveEvery: 5 }, - initialOwners: { rootElement: document.getElementById('counter') }, + initialContext: { rootElement: document.getElementById('counter') ?? undefined }, }, ); ``` -`mount` reads `rootElement`, creates four descendant elements, attaches them to the DOM, and writes them back into owners. The other behaviors — `renderCount`, `renderSaving`, `pauseButton`, `resetButton` — pick them up through the guards we've already written; none of them knows a mount step happened. On destroy, the descendants are discarded along with `rootElement`, and the composition clears every key in owners after all behavior cleanups have run — no manual bookkeeping on either side. +`mount` reads `rootElement`, creates four descendant elements, attaches them to the DOM, and writes them back into context. The other behaviors — `renderCount`, `renderSaving`, `pauseButton`, `resetButton` — pick them up through the guards we've already written; none of them knows a mount step happened. On destroy, the descendants are discarded along with `rootElement`, and the composition clears every signal in context after all behavior cleanups have run — no manual bookkeeping on either side. -Each descendant is registered under its own key in owners rather than left for other behaviors to pick out of `rootElement` themselves. The alternative — watch the subtree with a `MutationObserver` and identify elements by selector or data attribute — works mechanically, but trades away most of what owners gives you. You lose typed identity (`renderElement: HTMLElement` is not the same contract as "some `
` inside `rootElement`"), you swap synchronous guards for coalesced microtask callbacks, and every downstream behavior becomes coupled to whatever DOM layout `mount` happens to produce. Owners is a signal of named resources; behaviors reading it never have to know where those resources came from, only that they appeared. +The slot map at `mount`'s setup site reads as documentation. `rootElement` is `ReadonlySignal` — it's an input only. The four descendants are `Signal` — `mount` writes them. Per-slot intent is visible at the boundary; reviewers don't have to spelunk the body to learn which way data flows. -This is also where behaviors-as-units pays off. The contract every downstream behavior relies on is typed owners plus guards for missing keys — nothing about *how* the owners got populated. The same composition supports several equally valid shapes: +Each descendant is registered under its own key in context rather than left for other behaviors to pick out of `rootElement` themselves. The alternative — watch the subtree with a `MutationObserver` and identify elements by selector or data attribute — works mechanically, but trades away most of what context gives you. You lose typed identity (`renderElement: HTMLElement` is not the same contract as "some `
` inside `rootElement`"), you swap synchronous guards for coalesced microtask callbacks, and every downstream behavior becomes coupled to whatever DOM layout `mount` happens to produce. Context is a map of named resources; behaviors reading it never have to know where those resources came from, only that they appeared. -- **Omit `mount`.** Pass `renderElement`, `savingElement`, and the buttons through `initialOwners` directly, or write them later via `composition.owners.set(...)`. This is the shape the Actors example uses. +This is also where behaviors-as-units pays off. The contract every downstream behavior relies on is typed context plus guards for missing keys — nothing about *how* the context got populated. The same composition supports several equally valid shapes: + +- **Omit `mount`.** Pass `renderElement`, `savingElement`, and the buttons through `initialContext` directly, or write them later via `composition.context.renderElement.set(...)`. This is the shape the Actors example uses. - **Replace `mount` with a different implementation.** A shadow-DOM variant, a React-rendered variant, one that clones a `