Files
v10/internal/design/spf/signals.md
T
2026-04-08 13:38:24 -07:00

518 lines
24 KiB
Markdown

---
status: draft
date: 2026-04-02
---
# Reactive State: Signals
Signals are the reactive substrate of SPF — the primitive on which Actors, Reactors, and
the shared state layer are all built. This document records the decision to adopt signals,
the reasoning behind it, where signals fit in the broader architecture, and an honest
accounting of the tradeoffs and friction encountered so far.
---
## The Decision
SPF uses the TC39 `signal-polyfill` as its reactive primitive, wrapped by a thin layer
in `core/signals/` (`signal()`, `computed()`, `effect()`, `untrack()`, `update()`).
This is a **committed architectural direction**, not a provisional experiment. The text
track spike (videojs/v10#1158) specifically de-risked the question of whether Actors and
Reactors can be cleanly built on top of signals — the answer is yes. There is nothing in
scope or foreseeable on the horizon that would suggest revisiting this choice.
See [primitives.md §5](primitives.md) for the conceptual framing and comparison with
observables. This document focuses on the decision, the tradeoffs, and the friction.
---
## Why Signals for SPF
### All SPF state is state-over-time
The right comparison for SPF's use of reactive state is not "signals vs. cold observables"
— it is "signals vs. `BehaviorSubject`." Every piece of state in SPF is a value that has
a current reading at any moment: selected track, buffer contents, bandwidth estimate,
current time, MediaSource ready state. None of these are naturally modeled as cold,
lazy streams.
Once you commit to `BehaviorSubject` everywhere, you've given up the main ergonomic
advantages of observables (cold, lazy, composable pipelines) while keeping the main costs:
explicit `shareReplay(1)` for caching derived state, `distinctUntilChanged()` for
filtering, `tap()` as the side-effect idiom, and manual subscription management.
Signals give you the same "always has a current value" guarantee as `BehaviorSubject`,
with automatic dependency tracking for derived state and a cleaner model for side effects.
### Automatic dependency tracking
`computed()` derives new state with automatic dependency tracking and built-in caching —
no explicit wiring of dependencies, no `shareReplay(1)`. Two `computed()` calls that read
the same signals share their cache when they share the reference; defining the computed
once and passing the reference around is the natural pattern.
With `BehaviorSubject`, a derived observable requires explicit `.pipe(shareReplay(1))` to
cache, and a duplicated `.pipe()` chain creates multiple upstream subscriptions — a
correctness concern, not just an efficiency one. The signals equivalent of the same mistake
(a `computed()` defined inside an effect body) is only an efficiency concern (redundant
recomputation, no shared cache). Both mistakes should be avoided; only one of them silently
creates extra subscriptions.
### Scheduling control
The TC39 Signals proposal separates "a signal became dirty" from "an effect re-runs"
via the `Signal.subtle.Watcher` API, leaving scheduling entirely to the caller. SPF's
`effect()` uses `queueMicrotask` as its scheduler — effects are deferred to the next
microtask checkpoint, batching all synchronous writes made in a single turn.
This scheduling control is what makes the monitor-before-state ordering guarantee in
`createMachineReactor` possible. The effect scheduler drains pending computeds in an
insertion-ordered `Set`, so registration order determines execution order.
### Lower barrier to entry
Signals have a shallower learning curve than observables, even for contributors with
little or no prior reactive programming experience. The core mental model is immediately
graspable: a signal is a value that changes over time; reading it inside a `computed()`
or `effect()` makes that computation react when the value changes.
The progression from basic to advanced is natural:
1. `signal(value)` / `signal.get()` / `signal.set(value)` — read and write a value
2. `computed(() => derive(signal.get()))` — derive new state automatically
3. `effect(() => { sideEffect(signal.get()); return cleanup; })` — react to changes
4. `untrack(() => signal.get())` — read without creating a dependency
Each step adds one concept. No prior knowledge of cold vs. hot streams, subscription
lifetime management, operator composition, or the subject/observable distinction is
required to be productive.
Observables demand more upfront. Even basic usage — a `BehaviorSubject` with a
derived observable — requires understanding `.pipe()`, `shareReplay(1)`,
`distinctUntilChanged()`, subscription lifecycle, and the difference between multicasting
and unicasting. Contributors unfamiliar with RxJS will need to internalize these idioms
before they can write or review reactive code confidently.
This is not a knock on observables — the power they provide is real. But for a framework
that needs to be readable and maintainable by a broad community of contributors, including
those coming from non-reactive backgrounds, the lower entry cost of signals is meaningful.
### Flexibility: no paradigm overhead
Signals support both reactive and imperative usage, and the two compose freely. A signal
can be written from anywhere — a DOM event handler, a Promise callback, an actor message
handler, an async Task — and read reactively inside `computed()` or `effect()`, or
imperatively via `signal.get()` outside those contexts. There is no boundary between the
two modes; they are the same API used in different contexts.
This matters in three directions:
**Authoring new behaviors**: With observables, every reactive behavior — including one-off
cases — must be expressed within the operator/pipeline paradigm. When standard operators
cover the scenario, the pipeline is elegant; when they don't, the options are creative
composition or a custom operator. Either way, the pipeline shape is non-negotiable. With
signals, a new behavior can be expressed ad-hoc in an effect body — reactive reads and
imperative writes mixed as needed — without conforming to any operator shape. Reusable
patterns can be extracted into utilities when they prove recurring (`update()` and
`teardownActors` both emerged this way), but that extraction is a choice, not a
requirement. The code without the abstraction works and reads reasonably.
**Internal actor/reactor integration**: SPF's Actors are inherently imperative —
message handlers call `setContext()`, `transition()`, and `runner.schedule()`, not
observable operators. Imperative writes to signals immediately update the reactive graph;
Reactors pick them up on the next microtask. There is no impedance mismatch at the
boundary between the actor layer and the reactive layer.
**External integration surface**: Third-party integrators and higher-level abstractions
built on SPF can interact with engine state without adopting the full reactive model.
`state.get()` reads current values imperatively; `owners.patch()` writes imperatively;
wrapping a read in `effect()` opts into reactivity. The signal is a value container first;
the reactive graph is opt-in. With observables, integration requires working within the
observable paradigm or bridging out explicitly at every boundary.
### Community validation
Discussions with engineers from adjacent projects in the streaming and playback space
reinforced this direction. Specifically:
- **Luke Curley** (Media over QUIC / MoQ) — reviewed the approach and preferred signals
over observables, particularly after seeing the Actor/Reactor layering built on top.
- **Casey Occhialini** (Common Media Library maintainer; principal player engineer,
Paramount) — independently preferred signals, citing readability and the lower mental
overhead compared to RxJS-style observable pipelines.
Neither of these is a proof of correctness, but they represent relevant signal (no pun
intended) from engineers who work on similar problems and have considered both options.
---
## Three Roles in SPF's Architecture
Signals do three distinct jobs in SPF simultaneously. This is a deliberate design choice
rather than an accident — it means contributors learn one primitive and apply it at every
layer.
```
signal-polyfill (TC39 proposal implementation)
↓ wrapped by
core/signals/ signal(), computed(), effect(), untrack(), update()
↓ used as
1. Shared state substrate
PlaybackEngineState — signal<S> (presentation, tracks, bandwidth, ...)
PlaybackEngineOwners — signal<O> (mediaElement, actors, buffers, ...)
2. Reactor execution model
createMachineReactor() — monitor, entry, and effects each become effect() calls
Transitions fire when a monitor fn returns a new state from observed signals
3. Actor observability
createMachineActor() — snapshot is a signal<{ value, context }>
Reactors and the engine observe Actor state without polling or callbacks
```
The tight coupling across all three roles is also the source of the reactive context
tension described below. When signals are doing everything, the signal context is
everywhere — and so is the possibility of accidental tracking.
---
## The Reactive Context Tension
This is the most fundamental tradeoff of the signals model, and the one most worth
understanding clearly.
With **observables**, reactive context is **syntactic**. You explicitly construct a
pipeline. Reading a value inside a `.pipe()` chain is visibly different — in the code,
structurally — from reading a value imperatively. There is no ambiguity about whether
you are subscribing.
With **signals**, reactive context is **ambient**. Whether a read creates a reactive
dependency depends on what is wrapping the call at runtime — not on the call itself.
`signal.get()` inside an `effect()` or `computed()` creates a tracked dependency.
The same `signal.get()` outside those contexts, or inside `untrack()`, does not.
**The call site looks identical either way.**
This has two compounding consequences:
**For authors:** At every signal read inside an effect, you must actively decide: "do I
want this read to be tracked?" Enter-once effects need `untrack()` for reads that are
setup-only. Reactive-within-state effects should track intentionally. Nothing in the API
surface distinguishes these intentions — `untrack()` is a manual opt-out from the ambient
reactive context. Forgetting it produces unexpected re-runs; misusing it produces effects
that stop reacting when they should.
**For readers:** Understanding an effect's actual reactive dependencies requires reading
it carefully and identifying which signal accesses are inside or outside `untrack()`.
The structure gives no syntactic signal of intent. A read that drives re-runs looks
the same as a read that merely samples the current value.
This is a **fundamental tradeoff** of the ambient reactive context model, not a tooling
gap. The observable model makes reactive participation explicit at the cost of pipeline
ceremony. The signal model makes reactive participation implicit at the cost of requiring
discipline (and footgun risk) at every read site.
One approach is to accept the ambient context and compensate through conventions:
`untrack()` at every non-reactive read site, comments naming effect intent, code review
attention to accidental tracking. This keeps the API surface minimal but relies on
discipline.
Another approach — more relevant to abstractions built on signals than to direct signal
usage — is to make reactive participation explicit at the API surface. An abstraction that
distinguishes "run once on entry" from "re-run reactively" in its definition shape
encodes the intent structurally, removing the need for `untrack()` in the common case.
The tradeoff is more API surface in exchange for fewer footguns. See
[actor-reactor-factories.md](actor-reactor-factories.md) for how this applies to
`createMachineReactor` specifically.
---
## Points of Friction
### Inline computed anti-pattern
`computed()` inside an effect body creates a new `Computed` node on every re-run — no
memoization, no shared cache. This is a general signals footgun that applies anywhere
`computed()` and `effect()` are combined directly:
```typescript
// Wrong — new Computed on every effect re-run, no memoization
effect(() => {
const trackSignal = computed(() => findSelectedTrack(state.get())); // new each time
const track = trackSignal.get();
// ...
});
// Correct — hoist computed to the same scope as the effect
const trackSignal = computed(() => findSelectedTrack(state.get()));
effect(() => {
const track = trackSignal.get();
// ...
});
```
The mistake produces incorrect behavior (no memoization, no shared cache) rather than an
error, and the incorrect code looks structurally reasonable. With observable pipelines,
the equivalent mistake (constructing a new `.pipe()` chain inside a subscription callback)
creates extra upstream subscriptions — a more visible correctness failure, easier to catch
in review.
Abstractions built on signals compound this subtly. When an abstraction hides the effect
boundary — as `createMachineReactor` does, where each array entry in `states[S]` becomes an
`effect()` — the author does not see the `effect(() => { ... })` wrapper at the call site.
The anti-pattern is easier to miss when the reactive boundary is implicit in a definition
shape rather than explicit at the point of use.
---
### No native `distinctUntilChanged`
`computed()` propagates updates whenever its dependencies change, unless you provide a
custom `equals` comparator. For derived values that return objects or arrays — where
reference equality is too strict — you need to define `equals` explicitly:
```typescript
const modelTextTracksSignal = computed(() => getModelTextTracks(state.get().presentation), {
equals(prev, next) {
if (prev === next) return true;
if (prev?.length !== next?.length) return false;
return !!next?.every(t => prev?.some(p => p.id === t.id));
}
});
```
Forgetting `equals` produces spurious effect re-runs whenever the presentation changes,
even when the track set is logically unchanged. There is no warning — just unexpected
behavior. The custom equality function is also a recurring pattern that should be
factored into a generic utility.
---
### `update()` and deep object spreading
The `update()` helper does shallow merging via `Partial<T>`. For deeply nested context
objects — like `TextTracksActor.context`, which holds `Record<string, CueRecord[]>` per
track — updating a single entry requires nested spreading:
```typescript
setContext({
...context,
loaded: {
...context.loaded,
[trackId]: [...existingCues, ...prunedCues],
},
});
```
This is verbose, error-prone for deeply nested structures, and feels inelegant. No
general solution has been identified yet. The directions worth exploring: structural
sharing utilities (`updateIn(context, path, updater)`), finer-grained signal decomposition
(one signal per field instead of one signal for a whole object), or an Immer-style
`produce()` pattern for immutable updates. For now, this remains a known cost.
---
### Event-sequence patterns
Signals are a weaker fit when the logic is "react to events in sequence" rather than
"react to the current value of state." The distinction is subtle but real: state-over-time
has a current value that you can read and derive from; an event sequence is about what
happened, in what order, relative to other events.
`trackPlaybackInitiated` is the clearest current example. Its logic is:
> *When not initiated:* listen for the next `play` event on the current element.
> *When initiated:* listen for the next qualifying reset — url changes, or element swaps
> while paused.
This is fundamentally temporal / sequential reasoning. An RxJS-style observable version
expresses it directly:
```typescript
function trackPlaybackInitiated({ state$, owners$, events }) {
const url$ = state$.pipe(map(s => s.presentation?.url), distinctUntilChanged());
const element$ = owners$.pipe(map(o => o.mediaElement ?? null), distinctUntilChanged());
return state$.pipe(
map(s => !!s.playbackInitiated),
distinctUntilChanged(),
switchMap(initiated =>
initiated
? // true → watch for the next qualifying reset
merge(url$, element$).pipe(
withLatestFrom(element$),
filter(([, el]) => !el || el.paused),
take(1),
mapTo(false as const)
)
: // false → watch for the next play on the current element
element$.pipe(
switchMap(el => el ? fromEvent(el, 'play').pipe(take(1)) : EMPTY),
tap(() => events.next({ type: 'play' })),
mapTo(true as const)
)
),
tap(playbackInitiated => state$.next({ playbackInitiated }))
);
}
```
`switchMap` alternates between the two subscription modes declaratively. `distinctUntilChanged`
handles change detection implicitly. `withLatestFrom` samples the current element at reset
time. `take(1)` expresses "wait for the next qualifying event" naturally. The structure of
the logic maps directly onto the structure of the code.
The current signals version must simulate the same structure with more moving parts: a
local intermediate signal as a scratch pad, closure variables (`lastPresentationUrl`,
`lastMediaElement`) that manually implement what `distinctUntilChanged` does implicitly,
and three coordinated effects whose relationships are harder to follow than a single
pipeline:
```typescript
// Local signal: written by the URL effect (false) and the play listener (true).
// undefined = not yet initialized (suppresses the merge effect on startup).
const playbackInitiated = signal<boolean | undefined>(undefined);
let lastPresentationUrl: string | undefined;
let lastMediaElement: HTMLMediaElement | undefined;
// False stream: reset on URL change or element swap.
effect(() => {
const url = presentationUrl.get();
const el = mediaElement.get();
const urlChanged = url !== lastPresentationUrl;
const elChanged = el !== lastMediaElement;
if ((urlChanged && lastPresentationUrl !== undefined) ||
(elChanged && lastMediaElement !== undefined)) {
playbackInitiated.set(false);
}
lastPresentationUrl = url;
lastMediaElement = el;
});
// True stream: set on play event.
effect(() => {
const el = mediaElement.get();
if (!el) return;
return listen(el, 'play', () => playbackInitiated.set(true));
});
// Merge effect: bridge local signal → state.
effect(() => {
const pi = playbackInitiated.get();
if (pi === undefined) return;
if (state.get().playbackInitiated !== pi) update(state, { playbackInitiated: pi });
});
```
Migrating `trackPlaybackInitiated` to a Reactor would improve legibility — Reactor
states name the two modes explicitly, Reactor context formalizes the "previous value"
storage, and the entry-reset pattern handles cleanup clearly. But the fundamental gap
remains: **signals give you the current value of a thing; they do not give you "did this
value just change since the last time I asked."** Temporal comparison requires storing
the previous value yourself — in context, closure, or otherwise. `distinctUntilChanged`
handles this implicitly; signals require explicit housekeeping.
SPF's surface area for event-sequence patterns is small (nearly everything is
state-over-time). Where these patterns appear, the Reactor abstraction is the recommended
home — it provides the right structure, even if it can't fully match the ergonomics of
a composed observable pipeline.
---
## TC39 and Polyfill Risks
### API stability
The TC39 Signals proposal is at Stage 1 and may evolve. This is acknowledged but is not
a major risk in practice. SPF references the polyfill only through the `core/signals/`
module boundary — a breaking API change in the polyfill would require updating fewer than
50 lines of wrapper code. Options remain open: freeze the polyfill version, diverge from
it, or replace it with a minimal hand-rolled implementation if needed.
Major breaking changes to the core `Signal.State` / `Signal.Computed` / `Watcher` API
surface are unlikely given the proposal's direction, but even if they occurred, the module
boundary contains the blast radius.
### Bundle size and complexity
The more significant risk is if the polyfill grows substantially in size or complexity —
either because the proposal grows in scope, or because conformance requirements expand.
This is harder to hedge against architecturally. It would need to be addressed by
evaluating alternatives (minimal hand-roll, alternative polyfill) at the time.
---
## Effect Execution Order: Behavioral, Not Guaranteed
The TC39 Signals proposal specifies *that* effects re-run when their dependencies change;
it does not specify the *order* in which multiple effects re-run relative to one another.
Execution order among concurrent effects is an implementation detail of the scheduler, not
a formal commitment of the spec.
This matters whenever an abstraction relies on one effect running before another. The
current `core/signals/effect.ts` scheduler drains pending effects via `queueMicrotask`,
collecting dirty effects into an insertion-ordered `Set` before draining. This produces
a predictable execution order: effects registered first run first. But that is a property
of the polyfill's `Watcher` implementation and the scheduler's use of `Set` — not
something the TC39 proposal guarantees.
`createMachineReactor` takes a load-bearing dependency on this property. The monitor-before-state
ordering guarantee — that `monitor` effects always run before per-state effects — is an
explicit guarantee of `createMachineReactor`'s own API, documented in source and tested in
practice. But it depends on the underlying scheduler preserving insertion order. A future
polyfill version or native implementation that reordered effects for optimization would
silently break every Reactor FSM that relies on this ordering.
This is worth naming as a general principle: **any abstraction that requires ordered effect
execution takes an implicit dependency on the scheduler's behavioral properties, not on a
spec guarantee.** For SPF this is currently manageable — the polyfill is pinned and the
dependency is documented — but it is a constraint that would need attention if the TC39
proposal's scheduling semantics changed substantially.
---
## Future Directions
### Making reactive participation explicit at abstraction boundaries
The ambient reactive context concern — and the `untrack()` discipline it requires — is a
property of direct signal usage. Abstractions built on signals have an additional option:
encode reactive intent in their API surface, removing the burden from callers.
`createMachineReactor` implements this principle via `entry` and `effects` per-state
effect keys:
```typescript
states: {
'set-up': {
entry: [/* automatically untracked — run once on state entry */],
effects: [/* tracked — re-run when dependencies change */],
}
}
```
`entry` effects are automatically untracked — no `untrack()` needed for reads that are
setup-only. `effects` re-run when tracked signals change; `untrack()` is only needed
for reads within `effects` that should not create dependencies.
The principle generalizes: any abstraction built on signals can choose to make reactive
context explicit at its API boundary, trading more surface area for fewer footguns and more
readable intent. The tradeoff favors explicitness as abstraction complexity grows — the
ambient context concern that is manageable with a small number of direct `effect()` calls
becomes harder to track when effects are hidden inside a factory definition.
### Structured update utilities
A `updateIn(signal, path, updater)` or Immer-style `produce()` utility would address the
deep-spreading verbosity for nested context objects. This is a well-understood problem
with a clear solution space; the right answer depends on how frequently deeply nested
mutations appear as more Actors are written.
### Finer-grained signal decomposition
Where context objects have independent fields that update at different rates, splitting
one large signal into multiple per-field signals would reduce the blast radius of each
write — fewer effects would re-run on any given change. The tradeoff is more signals to
manage and coordinate. Worth evaluating once more Actor context shapes are established.