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

22 KiB
Raw Blame History

status, date
status date
draft 2026-04-02

Text Track Architecture

The text track implementation is the reference implementation for the createMachineActor / createMachineReactor factories in SPF. It was built as part of a deliberate spike (videojs/v10#1158) to prove out the Actor/Reactor primitives described in primitives.md and actor-reactor-factories.md.

This document records:

  1. The architecture of the implementation itself
  2. An assessment of the spike against its stated goals
  3. Points of friction encountered during the spike
  4. Possible future improvements the spike surfaces
  5. Still-open questions
  6. Implications for migrating the video/audio segment loading path

Architecture Overview

Four components, two layers:

  ┌─────────────────────────────────────────────────────┐
  │  Reactors (dom/features/)                           │
  │                                                     │
  │  syncTextTracks           — DOM <track> lifecycle   │
  │      │                      + bidirectional sync    │
  │      │ shares state/owners signal                   │
  │  loadTextTrackCues        — cue loading FSM         │
  │      │                      + actor lifecycle       │
  └──────┼──────────────────────────────────────────────┘
         │ send()
  ┌──────▼──────────────────────────────────────────────┐
  │  Actors (dom/features/)                             │
  │                                                     │
  │  TextTracksActor          — cue deduplication       │
  │      ▲                      + context snapshot      │
  │      │ send('add-cues')                             │
  │  TextTrackSegmentLoaderActor — VTT fetch            │
  │                               + serial execution    │
  └─────────────────────────────────────────────────────┘

The two reactors share a state signal (playback state) and an owners signal (platform dependencies including the actors). Both are passed in at construction time — neither reactor has any global state.


State Machines

syncTextTracks

Manages <track> element lifecycle and bridges DOM TextTrackList changes back to selectedTextTrackId in state.

'preconditions-unmet' ──── mediaElement + tracks available ────→ 'set-up'
       ↑                                                              │
       └──────────────── preconditions lost (exit cleanup) ───────────┘

any state ──── destroy() ────→ 'destroying' ────→ 'destroyed'

'set-up' owns two independent effects:

  • Effect 1 — creates <track> elements on entry; exit cleanup removes them and clears selectedTextTrackId
  • Effect 2 — syncs mode on entry (reactive: re-runs when selectedTextTrackId changes) + attaches 'change' listener to bridge DOM back to state

'preconditions-unmet' has no effects — the monitor handles the exit transition.


loadTextTrackCues

Orchestrates cue loading. Owns actor lifecycle across states.

'preconditions-unmet' ──── mediaElement + presentation ────→ 'setting-up'
       ↑               + text tracks present                       │
       │                                                     actors created
       │                                                     in owners
       │                                                           ↓
       ├────────── preconditions lost ──────────────────── 'pending'
       │                                                           │
       │                                   selectedTrack resolved + in DOM
       │                                                           ↓
       └────────── preconditions lost ──────── 'monitoring-for-loads'

any state ──── destroy() ────→ 'destroying' ────→ 'destroyed'

State effects:

  • 'preconditions-unmet' — entry effect: destroy any stale actors in owners (no-op if already undefined)
  • 'setting-up' — entry effect: destroy stale actors, create fresh TextTracksActor + TextTrackSegmentLoaderActor, write to owners
  • 'pending' — no effects (neutral waiting state)
  • 'monitoring-for-loads' — reactive effect: re-runs on currentTime / selectedTrack changes, sends load to segmentLoaderActor

All transitions are driven by a single monitor that evaluates a deriveState() computed signal.


TextTrackSegmentLoaderActor

Fetches VTT segments and delegates cue management to TextTracksActor.

A lightweight CallbackActor — no FSM states, no createMachineActor. Receives load messages, plans which segments to fetch (skipping those already recorded in TextTracksActor's context), and schedules fetches on a SerialRunner. Each new load preempts in-flight work via abortAll() before scheduling fresh tasks.

Uses a SerialRunner — segments are fetched one at a time.


TextTracksActor

Wraps a HTMLMediaElement's textTracks, owns cue deduplication and the cue record snapshot.

'active' ──── add-cues ────→ 'active'  (reducer; context updated per message)

Uses createTransitionActor — a reducer-style factory with no FSM states. snapshot.value is 'active' | 'destroyed'; the interesting state is entirely in the context (loaded cues and segments records). No runner — all message handling is synchronous.


Key Patterns

1. deriveState + monitor

Complex multi-condition transition logic lives in a pure function that is memoized into a computed() signal outside any effect body. The monitor field reads the signal — the framework compares to the current state and drives the transition:

// Hoist outside the reactor — computed() inside an effect creates a new node
// on every re-run with no memoization.
const derivedStateSignal = computed(() => deriveState(state.get(), owners.get()));

createMachineReactor({
  monitor: () => derivedStateSignal.get(),
  states: { ... },
});

deriveState is a plain function, independently testable. The monitor returns the target state — the framework handles the comparison and transition call.


2. Entry-reset pattern

States that are "reset points" (entered when preconditions are lost) explicitly destroy any stale actors on every entry. teardownActors is a guarded no-op when actors are already undefined, preventing spurious signal writes on initial startup:

function teardownActors(owners: Signal<TextTrackCueLoadingOwners>) {
  const { textTracksActor, segmentLoaderActor } = untrack(() => owners.get());
  if (!textTracksActor && !segmentLoaderActor) return; // no-op guard
  textTracksActor?.destroy();
  segmentLoaderActor?.destroy();
  update(owners, { textTracksActor: undefined, segmentLoaderActor: undefined });
}

// Called in BOTH reset states:
'preconditions-unmet': { entry: () => { teardownActors(owners); } },
'setting-up': {
  entry: () => {
    teardownActors(owners);  // defensive — same guard
    // ... create fresh actors
  },
},

The duplication is intentional: both states are entry points from which actors might have been left in owners, and both must be safe to enter from any predecessor.


3. Actors in owners

Actors created by loadTextTrackCues are written to the shared owners signal. The engine's destroy() loops over owners and destroys any value that has a destroy() method:

// engine.ts destroy():
for (const value of Object.values(owners.get())) {
  if (typeof (value as { destroy?: unknown }).destroy === 'function') {
    (value as { destroy(): void }).destroy();
  }
}

Implication: Actors in owners are destroyed by the engine, not by the reactor's own destroy(). Callers using loadTextTrackCues outside the engine must destroy actors explicitly before destroying the reactor:

const { textTracksActor, segmentLoaderActor } = owners.get();
textTracksActor?.destroy();
segmentLoaderActor?.destroy();
reactor.destroy();

4. untrack() for non-reactive reads

When an effect must read a signal value without creating a reactive dependency, wrap the read with untrack(). The two main cases:

Entry effects are automatically untracked — reading owners or state in an entry effect does not create reactive dependencies. No untrack() wrapper needed:

'setting-up': {
  // entry is automatically untracked — no need for untrack() here.
  entry: () => {
    const mediaElement = owners.get().mediaElement!;
    const textTracksActor = createTextTracksActor(mediaElement);
    // ...
  },
},

Preventing feedback loops in effects — reading actor snapshot in a reactive effect. segmentLoaderActor.snapshot changes every time the actor processes a message. Without untrack(), the reactive effect would re-run on every snapshot change, creating a tight feedback loop:

'monitoring-for-loads': {
  // effects: re-runs whenever currentTime or selectedTrack changes.
  // owners is read with untrack() — actor presence is guaranteed by
  // deriveState when in this state; actor snapshot changes must not
  // re-trigger this effect.
  effects: () => {
    const currentTime = currentTimeSignal.get();  // tracked
    const track = selectedTrackSignal.get()!;     // tracked
    const { segmentLoaderActor } = untrack(() => owners.get());
    segmentLoaderActor!.send({ type: 'load', track, currentTime });
  },
},

5. Multiple effects per state — independent tracking and cleanup

Each entry in a state's effect array becomes one independent effect() call with its own dependency tracking and cleanup. syncTextTracks's 'set-up' uses two effects so that:

  • Effect 1's cleanup (removing <track> elements) is not entangled with Effect 2's cleanup (removing the DOM listener)
  • Effect 2 can re-run reactively when selectedTextTrackId changes without triggering Effect 1 (which uses untrack() for its reads)

If both behaviors were merged into one effect, either the <track> elements would be recreated on every mode change, or mode sync would stop reacting after the first run.


6. Bidirectional sync — the change event bridge

syncTextTracks bridges DOM → state by listening to TextTrackList's 'change' event. The browser fires this event when track modes change, including when SPF itself sets modes via syncModes().

A setTimeout(..., 0) guard distinguishes SPF-initiated mode changes from user/browser-initiated ones. During the settling window (immediately after initial mode sync), 'change' events re-apply the intended modes rather than writing back to state. After the window closes, a 'change' event is treated as external selection and updates selectedTextTrackId:

let syncTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
  syncTimeout = undefined;
}, 0);

const onChange = () => {
  if (syncTimeout) {
    // Inside settling window: browser auto-selection overriding modes.
    // Re-apply without touching state.
    syncModes(mediaElement.textTracks, untrack(() => selectedTextTrackIdSignal.get()));
    return;
  }
  // Outside settling window: treat as user selection, write back to state.
  const showingTrack = Array.from(mediaElement.textTracks)
    .find(t => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions'));
  const newId = showingTrack?.id;
  if (newId === untrack(() => selectedTextTrackIdSignal.get())) return;
  update(state, { selectedTextTrackId: newId });
};

Spike Goal Assessment

Evaluated against the goals from videojs/v10#1158:

Goal Result Notes
Finite state machine Both createMachineReactor and createMachineActor produce explicit FSMs with named states
Non-finite context TextTracksActor.context holds unbounded loaded + segments maps; reactive via snapshot
Teardown / abort propagation destroy() fires effect cleanups; SerialRunner.abortAll() aborts in-flight Tasks; actors in owners destroyed by engine
Message → task IoC createMachineActor decouples message dispatch from task execution; SerialRunner handles scheduling
Reactive snapshots Both factories expose snapshot: ReadonlySignal<{ status, context }>
Bidirectional sync syncTextTracks Effect 2 bridges TextTrackList 'change' events back to state

What was harder than expected:

  • Reactor actor lifecycle is implicit, not self-contained. Actors live in owners, and destruction depends on the engine's generic loop. Callers using these reactors outside the engine must manage actor destruction explicitly.
  • The monitor-before-state ordering guarantee requires care — it's an implementation guarantee of createMachineReactor, not a formal TC39 Signals guarantee. It cannot be assumed outside createMachineReactor.
  • Entry vs. reactive effect intent was initially invisible in the definition shape — addressed by the entry / effects split adopted after the spike.

Points of Friction

Inline computed anti-pattern

computed() inside an effect body creates a new Computed node on every re-run — no memoization, no deduplication. Computeds that gate re-runs must be hoisted outside the effect body. This is easy to miss because the code looks correct:

// WRONG — new Computed on every re-run, no memoization
states: {
  'monitoring-for-loads': {
    effects: () => {
      const trackSignal = computed(() => findSelectedTrack(state.get())); // new node each time!
      const track = trackSignal.get();
      segmentLoaderActor.send({ type: 'load', track, currentTime });
    },
  }
}

// CORRECT — hoist outside createMachineReactor()
const trackSignal = computed(() => findSelectedTrack(state.get()));
createMachineReactor({ states: { 'monitoring-for-loads': {
  effects: () => {
    const track = trackSignal.get();
    segmentLoaderActor.send({ type: 'load', track, currentTime });
  },
} } });

untrack() in effects

The entry / effects split eliminated the most common footgun (accidental tracking in enter-once effects). However, effects still require untrack() for reads that should not create reactive dependencies. Missing it produces unexpected re-runs when the read signal changes. The discipline is narrower now — only needed in effects, not in all effects — but it remains a convention rather than API enforcement.

Actor lifecycle ownership split

The reactor creates actors but does not destroy them — the engine (or caller) does. This is a deliberate design choice (see actors-in-owners pattern), but it creates an implicit contract: callers using loadTextTrackCues outside the engine must remember to destroy the actors before destroying the reactor. There is no API enforcement.

Entry-reset required in both reset states

teardownActors() must be called in both 'preconditions-unmet' and 'setting-up' because both are entry points that could be reached after actors were created. Missing the defensive call in either state creates a window where actors leak on rapid precondition cycling. This is a footgun that is easy to overlook when adding new states.

Bidirectional sync timing depends on a setTimeout guard

The setTimeout(..., 0) window in syncTextTracks is a Chromium workaround for browser auto-selection behavior. It is a best-effort heuristic, not a robust solution. The 'change' event is dispatched as a task (async, after the current script), so the guard fires before the event under normal conditions — but this is not formally guaranteed. Alternative approaches (e.g., tracking which modes SPF set, comparing before/after) were not explored during the spike.


Possible Future Improvements

entry vs. reactive distinction in the definition shape (Implemented)

Adopted as entry / effects in the createMachineReactor definition shape. entry effects are automatically untracked; effects re-run when tracked signals change. See actor-reactor-factories.md for the decided design.

Self-contained actor lifecycle in Reactor

Rather than writing actors to owners and relying on the engine's generic destroy loop, a Reactor could own actor lifecycle directly — creating actors on state entry and destroying them on state exit as part of the definition. The entry-reset pattern is already approximating this behavior imperatively; formalizing it would eliminate the split ownership contract.

One way to express this: state exit callbacks alongside effect cleanup:

'setting-up': {
  entry: () => {
    const textTracksActor = createTextTracksActor(mediaElement);
    return { actors: { textTracksActor } };  // framework manages lifecycle
  }
}

This is speculative — the entry-reset pattern works today and the cost of the split ownership is manageable. Revisit if the pattern spreads to video/audio.

Formal context field on Reactor

Reactors do not have a context field — non-finite state is held in closures and the owners signal. owners is externally visible (other features can observe actor state); closure variables are not inspectable from outside. Whether a formal Reactor context (reactive via snapshot) would be worthwhile depends on what debugging and testing patterns emerge as more Reactors are written.

Cue deduplication: open design question in TextTracksActor

TextTracksActor currently silently gates on a missing or disabled TextTrack (early return if textTrack is not found). A comment in the source (text-tracks-actor.ts:5257) identifies four possible approaches but does not resolve the choice:

  • Silent gating (current behavior)
  • Console warning + early return
  • Domain-specific error
  • Assume it can't happen and let it throw

The right answer likely depends on whether this case is expected in practice (i.e., can the segment loader send add-cues for a track that isn't yet in the DOM?) and whether that constitutes a recoverable error or a programming bug.


Still Open Questions

Monitor-before-state ordering: guarantee or implementation detail?

The ordering relies on Signal.subtle.Watcher's getPending() returning computeds in insertion order. This is the behavior of the TC39 signal-polyfill, but it is not a formal guarantee of the TC39 Signals proposal specification. If a future implementation changes this ordering (e.g., for optimization), FSMs built on the monitor-before-state pattern would silently break.

Options: (a) document it as a polyfill-specific implementation guarantee and accept the risk, (b) add an explicit mechanism to enforce ordering (e.g., monitor effects check state and no-op if already transitioning), or (c) redesign to not rely on ordering (e.g., per-state effects always re-check conditions themselves).

Effect scheduling: what happens under compound state changes?

When multiple signals change in the same microtask batch (e.g., state.patch() touches both selectedTextTrackId and currentTime), do effects see them as one update or two?

The current implementation defers effects via queueMicrotask, batching at the microtask checkpoint — so compound changes in the same synchronous turn should produce one flush. But the exact semantics under owners.patch() calls interleaved with state.patch() calls have not been formally characterized or tested.

Error handling in Actors

If a Task inside an Actor throws an unaborted error, what should happen? TextTrackSegmentLoaderActor catches fetch errors and logs them (graceful degradation per segment). TextTracksActor silently gates on missing track IDs. Neither has a formal error state.

The right answer differs by Actor and error type — some errors are recoverable (missing segment, transient network failure), others are not (SourceBuffer in error state, MSE closed). No general policy has been established.


Implications for Video/Audio Migration

The text track spike establishes patterns that apply directly to the video/audio path:

loadSegments → reactor migration: loadSegments currently uses a loadingInputsEq equality function to gate re-runs — the deriveState + monitor pattern is the direct equivalent. The equality function's conditions map to the FSM's state conditions.

prevState tracking: loadSegments detects track switches by comparing prevState.track.id !== curState.track.id. In the reactor model, the reactor re-entering a state IS the "previous state" signal — state entry is the transition event.

SourceBufferActor: Now uses createMachineActor with idle/updating states, onSettled, and a cancel message. SegmentLoaderActor also uses createMachineActor with the continue/preempt pattern proved out by the text track spike.

Actors in owners: The video/audio actors should follow the same actors-in-owners pattern — reactors create them, engine destroys them generically. videoBufferActor and audioBufferActor already follow this (manually); the text track pattern formalizes it.

Bandwidth bridge: loadSegments currently writes bandwidthState back to shared state via an onSample callback (a temporary migration artifact). The reactor model should absorb this — the reactor observes bandwidth signals directly rather than writing back through state.