From f177024b8664f5f8d69173fbe3c1c03d78cd0b35 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 31 Mar 2026 08:58:40 -0700 Subject: [PATCH 01/79] refactor(spf): consolidate text track sync into FSM-based syncTextTracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces setupTextTracks, syncTextTrackModes, and syncSelectedTextTrackFromDom with a single syncTextTracks function implementing a 5-state FSM (preconditions-unmet → setting-up → set-up → destroying → destroyed). Each FSM state is managed by a dedicated effect; entry/exit actions map to the effect body and its returned cleanup function respectively. Co-Authored-By: Claude Sonnet 4.6 --- .../spf/src/dom/features/sync-text-tracks.ts | 241 ++++++++++++++ .../features/tests/sync-text-tracks.test.ts | 301 ++++++++++++++++++ .../spf/src/dom/playback-engine/engine.ts | 18 +- .../spf/src/dom/tests/playback-engine.test.ts | 16 +- 4 files changed, 554 insertions(+), 22 deletions(-) create mode 100644 packages/spf/src/dom/features/sync-text-tracks.ts create mode 100644 packages/spf/src/dom/features/tests/sync-text-tracks.test.ts diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts new file mode 100644 index 000000000..f86f233d2 --- /dev/null +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -0,0 +1,241 @@ +import { listen } from '@videojs/utils/dom'; +import { effect } from '../../core/signals/effect'; +import { computed, type Signal, signal, untrack, update } from '../../core/signals/primitives'; +import type { PartiallyResolvedTextTrack, Presentation, TextTrack } from '../../core/types'; +import type { TextTrackBufferState } from './load-text-track-cues'; + +/** + * FSM states for text track sync. + * + * ``` + * 'preconditions-unmet' ──── mediaElement + tracks available ────→ 'setting-up' + * ↑ | + * | setup complete + * preconditions lost | + * (exit cleanup) ↓ + * └──────────────────────────────────────────────────────── 'set-up' + * | + * presentation changed │ + * (exit cleanup tears down │ + * old tracks, re-entry │ + * creates new ones) │ + * ↓ │ + * 'setting-up' ←──────────┘ + * + * any non-final state ──── destroy() ────→ 'destroying' ────→ 'destroyed' + * ``` + */ +export type TextTrackSyncStatus = 'preconditions-unmet' | 'setting-up' | 'set-up' | 'destroying' | 'destroyed'; + +/** + * State shape for text track sync. + */ +export interface TextTrackSyncState { + presentation?: Presentation | undefined; + selectedTextTrackId?: string | undefined; + /** @TODO(Phase 1 Step 2) Remove coupling to loadTextTrackCues — Reactor should only write selectedTextTrackId. */ + textBufferState?: TextTrackBufferState | undefined; +} + +/** + * Owners shape for text track sync. + */ +export interface TextTrackSyncOwners { + mediaElement?: HTMLMediaElement | undefined; + /** Written by syncTextTracks as a side effect for loadTextTrackCues. Will be removed in Phase 3. */ + textTracks?: Map | undefined; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function createTrackElement(track: PartiallyResolvedTextTrack | TextTrack): HTMLTrackElement { + const el = document.createElement('track'); + el.id = track.id; + el.kind = track.kind; + el.label = track.label; + el.toggleAttribute('data-src-track', true); + if (track.language) el.srclang = track.language; + if (track.default) el.default = true; + el.src = track.url; + return el; +} + +function getModelTextTracks( + presentation: Presentation | undefined +): (PartiallyResolvedTextTrack | TextTrack)[] | undefined { + return presentation?.selectionSets?.find((s) => s.type === 'text')?.switchingSets[0]?.tracks; +} + +function syncModes(textTracks: TextTrackList, selectedId: string | undefined): void { + for (let i = 0; i < textTracks.length; i++) { + const track = textTracks[i]!; + if (track.kind !== 'subtitles' && track.kind !== 'captions') continue; + track.mode = track.id === selectedId ? 'showing' : 'disabled'; + } +} + +// ============================================================================ +// Main export +// ============================================================================ + +/** + * Text track sync orchestration. + * + * Implements the TextTrackSync FSM using one effect per state: + * + * - **`cleanupPreconditionsUnmet`** — waits for preconditions, then transitions + * to `'setting-up'`. + * - **`cleanupSettingUp`** — creates `` elements, then transitions to + * `'set-up'`. + * - **`cleanupSetUp`** — guards the `'set-up'` state; exit cleanup removes + * `` elements on any outbound transition. + * - **`cleanupModes`** — active in `'set-up'`; owns mode sync, the Chromium + * settling-window guard, and the `'change'` listener that bridges DOM state + * back to `selectedTextTrackId`. + * + * @example + * const cleanup = syncTextTracks({ state, owners }); + */ +export function syncTextTracks({ + state, + owners, +}: { + state: Signal; + owners: Signal; +}): () => void { + const statusSignal = signal('preconditions-unmet'); + + const mediaElementSignal = computed(() => owners.get().mediaElement); + const modelTextTracksSignal = computed(() => getModelTextTracks(state.get().presentation), { + /** @TODO Make generic and abstract away for Array | undefined (CJP) */ + equals(prevTextTracks, nextTextTracks) { + if (prevTextTracks === nextTextTracks) return true; + if (typeof prevTextTracks !== typeof nextTextTracks) return false; + if (prevTextTracks?.length !== nextTextTracks?.length) return false; + // NOTE: This could probably be optimized, but the set should generally be small (CJP) + return ( + !!nextTextTracks && + nextTextTracks.every((nextTextTrack) => + prevTextTracks?.some((prevTextTrack) => prevTextTrack.id === nextTextTrack.id) + ) + ); + }, + }); + + const selectedTextTrackIdSignal = computed(() => state.get().selectedTextTrackId); + + const preconditionsMetSignal = computed(() => !!mediaElementSignal.get() && !!modelTextTracksSignal.get()?.length); + + const teardownTextTracks = (mediaElement: HTMLMediaElement) => { + mediaElement.querySelectorAll('track[data-src-track]:is([kind="subtitles"],[kind="captions"').forEach((trackEl) => { + trackEl.remove(); + }); + }; + + const setupTextTracks = (mediaElement: HTMLMediaElement, modelTextTracks: PartiallyResolvedTextTrack[]) => { + modelTextTracks.forEach((modelTextTrack) => { + const trackElement = createTrackElement(modelTextTrack); + mediaElement.appendChild(trackElement); + }); + }; + + const cleanupPreconditionsUnmet = effect(() => { + if (statusSignal.get() !== 'preconditions-unmet') return; + if (preconditionsMetSignal.get()) { + statusSignal.set('setting-up'); + } + }); + + const cleanupSettingUp = effect(() => { + if (statusSignal.get() !== 'setting-up') return; + setupTextTracks( + mediaElementSignal.get() as HTMLMediaElement, + modelTextTracksSignal.get() as PartiallyResolvedTextTrack[] + ); + statusSignal.set('set-up'); + }); + + const cleanupSetUp = effect(() => { + if (statusSignal.get() !== 'set-up') return; + // Preconditions have changed back to unmet, so transition back to that state (which will cause a teardown/"exit") + if (!preconditionsMetSignal.get()) { + statusSignal.set('preconditions-unmet'); + return; + } + + const currentMediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); + + /** @TODO DELETE AFTER MIGRATION (CJP) */ + const trackMap = new Map(); + currentMediaElement + .querySelectorAll('track[data-src-track]:is([kind="subtitles"],[kind="captions"') + .forEach((trackEl) => { + trackMap.set(trackEl.id, trackEl); + }); + update(owners, { textTracks: trackMap } as Partial); + + return () => { + /** @TODO DELETE AFTER MIGRATION (CJP) */ + update(owners, { textTracks: undefined } as Partial); + teardownTextTracks(currentMediaElement); + }; + }); + + const cleanupModes = effect(() => { + if (statusSignal.get() !== 'set-up') return; + + const mediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); + const selectedId = selectedTextTrackIdSignal.get(); + + syncModes(mediaElement.textTracks, selectedId); + let syncTimeout: ReturnType | undefined = setTimeout(() => { + syncTimeout = undefined; + }, 0); + + let currentUnlisten: (() => void) | undefined; + + const onChange = () => { + if (syncTimeout) { + // Inside the settling window: browser auto-selection is overriding our + // modes. Re-apply to restore the intended state without touching state. + currentUnlisten?.(); + syncModes( + mediaElement.textTracks, + untrack(() => selectedTextTrackIdSignal.get()) + ); + currentUnlisten = listen(mediaElement.textTracks, 'change', onChange); + return; + } + + const showingTrack = Array.from(mediaElement.textTracks).find( + (t) => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions') + ); + + // showingTrack.id matches the SPF track ID set by createTrackElement above. + // Fall back to undefined for empty-string IDs (non-SPF-managed tracks). + const newId = showingTrack?.id; + const currentModelId = untrack(() => selectedTextTrackIdSignal.get()); + if (newId === currentModelId) return; + update(state, { selectedTextTrackId: newId } as Partial); + }; + + currentUnlisten = listen(mediaElement.textTracks, 'change', onChange); + + return () => { + clearTimeout(syncTimeout ?? undefined); + currentUnlisten?.(); + if (untrack(() => statusSignal.get()) !== 'set-up') { + update(state, { selectedTextTrackId: undefined } as Partial); + } + }; + }); + + return () => { + cleanupPreconditionsUnmet(); + cleanupSettingUp(); + cleanupSetUp(); + cleanupModes(); + }; +} diff --git a/packages/spf/src/dom/features/tests/sync-text-tracks.test.ts b/packages/spf/src/dom/features/tests/sync-text-tracks.test.ts new file mode 100644 index 000000000..328f73165 --- /dev/null +++ b/packages/spf/src/dom/features/tests/sync-text-tracks.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from 'vitest'; +import { signal } from '../../../core/signals/primitives'; +import { syncTextTracks, type TextTrackSyncOwners, type TextTrackSyncState } from '../sync-text-tracks'; + +function makePresentation(tracks: Array<{ id: string; kind?: string; language?: string }>) { + return { + id: 'pres-1', + url: 'http://example.com/playlist.m3u8', + selectionSets: [ + { + id: 'set-1', + type: 'text' as const, + switchingSets: [ + { + id: 'sw-1', + tracks: tracks.map((t) => ({ + id: t.id, + type: 'text' as const, + kind: (t.kind ?? 'subtitles') as 'subtitles', + label: t.id, + language: t.language ?? '', + url: 'data:text/vtt,', + mimeType: 'text/vtt', + bandwidth: 0, + groupId: 'subs', + })), + }, + ], + }, + ], + } as any; +} + +function setup(initialState: TextTrackSyncState = {}, initialOwners: TextTrackSyncOwners = {}) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = syncTextTracks({ state, owners }); + return { state, owners, cleanup }; +} + +describe('syncTextTracks', () => { + it('creates track elements when mediaElement and presentation are available', async () => { + const mediaElement = document.createElement('video'); + const presentation = makePresentation([ + { id: 'track-en', language: 'en' }, + { id: 'track-es', language: 'es' }, + ]); + + const { state, owners, cleanup } = setup(); + + owners.set({ ...owners.get(), mediaElement }); + state.set({ ...state.get(), presentation }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mediaElement.children.length).toBe(2); + expect((mediaElement.children[0] as HTMLTrackElement).id).toBe('track-en'); + expect((mediaElement.children[1] as HTMLTrackElement).id).toBe('track-es'); + + cleanup(); + }); + + it('writes owners.textTracks Map for loadTextTrackCues', async () => { + const mediaElement = document.createElement('video'); + const presentation = makePresentation([ + { id: 'track-en', language: 'en' }, + { id: 'track-es', language: 'es' }, + ]); + + const { state, owners, cleanup } = setup(); + owners.set({ ...owners.get(), mediaElement }); + state.set({ ...state.get(), presentation }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const textTracks = owners.get().textTracks; + expect(textTracks).toBeDefined(); + expect(textTracks?.size).toBe(2); + expect(textTracks?.get('track-en')).toBe(mediaElement.children[0]); + expect(textTracks?.get('track-es')).toBe(mediaElement.children[1]); + + cleanup(); + }); + + it('does not create tracks when no mediaElement', async () => { + const presentation = makePresentation([{ id: 'track-en', language: 'en' }]); + const { state, owners, cleanup } = setup(); + + state.set({ ...state.get(), presentation }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(owners.get().textTracks).toBeUndefined(); + cleanup(); + }); + + it('does not create tracks when presentation has no text tracks', async () => { + const mediaElement = document.createElement('video'); + const { state, owners, cleanup } = setup(); + + owners.set({ ...owners.get(), mediaElement }); + state.set({ + ...state.get(), + presentation: { + id: 'pres-1', + url: 'http://example.com/playlist.m3u8', + selectionSets: [], + } as any, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mediaElement.children.length).toBe(0); + cleanup(); + }); + + it('sets selected track to "showing" and others to "disabled"', async () => { + const mediaElement = document.createElement('video'); + const presentation = makePresentation([ + { id: 'track-en', language: 'en' }, + { id: 'track-es', language: 'es' }, + ]); + + const { state, owners, cleanup } = setup({ presentation }); + owners.set({ ...owners.get(), mediaElement }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + state.set({ ...state.get(), selectedTextTrackId: 'track-en' }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const [enEl, esEl] = Array.from(mediaElement.children) as HTMLTrackElement[]; + expect(enEl!.track.mode).toBe('showing'); + expect(esEl!.track.mode).toBe('disabled'); + + cleanup(); + }); + + it('switches active track when selection changes', async () => { + const mediaElement = document.createElement('video'); + const presentation = makePresentation([ + { id: 'track-en', language: 'en' }, + { id: 'track-es', language: 'es' }, + ]); + + const { state, owners, cleanup } = setup({ presentation, selectedTextTrackId: 'track-en' }); + owners.set({ ...owners.get(), mediaElement }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const [enEl, esEl] = Array.from(mediaElement.children) as HTMLTrackElement[]; + expect(enEl!.track.mode).toBe('showing'); + expect(esEl!.track.mode).toBe('disabled'); + + state.set({ ...state.get(), selectedTextTrackId: 'track-es' }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(enEl!.track.mode).toBe('disabled'); + expect(esEl!.track.mode).toBe('showing'); + + cleanup(); + }); + + it('disables all tracks when selection is cleared', async () => { + const mediaElement = document.createElement('video'); + const presentation = makePresentation([ + { id: 'track-en', language: 'en' }, + { id: 'track-es', language: 'es' }, + ]); + + const { state, owners, cleanup } = setup({ presentation, selectedTextTrackId: 'track-en' }); + owners.set({ ...owners.get(), mediaElement }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + state.set({ ...state.get(), selectedTextTrackId: undefined }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const [enEl, esEl] = Array.from(mediaElement.children) as HTMLTrackElement[]; + expect(enEl!.track.mode).toBe('disabled'); + expect(esEl!.track.mode).toBe('disabled'); + + cleanup(); + }); + + it('does not touch non-subtitle/caption tracks', async () => { + const mediaElement = document.createElement('video'); + const presentation = makePresentation([{ id: 'track-en', language: 'en' }]); + + // Add a chapters track directly (not via presentation) + const chaptersEl = document.createElement('track'); + chaptersEl.kind = 'chapters'; + chaptersEl.id = 'chapters-en'; + chaptersEl.src = 'data:text/vtt,'; + mediaElement.appendChild(chaptersEl); + chaptersEl.track.mode = 'hidden'; + + const { owners, cleanup } = setup({ presentation, selectedTextTrackId: 'track-en' }); + owners.set({ ...owners.get(), mediaElement }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(chaptersEl.track.mode).toBe('hidden'); + + cleanup(); + }); + + it('bridges external mode change → selectedTextTrackId', async () => { + const mediaElement = document.createElement('video'); + const presentation = makePresentation([ + { id: 'track-en', language: 'en' }, + { id: 'track-es', language: 'es' }, + ]); + + const { state, owners, cleanup } = setup({ presentation }); + owners.set({ ...owners.get(), mediaElement }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Simulate external code (e.g. captions button) showing Spanish + const esEl = Array.from(mediaElement.children).find( + (el) => (el as HTMLTrackElement).id === 'track-es' + ) as HTMLTrackElement; + esEl.track.mode = 'showing'; + mediaElement.textTracks.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(state.get().selectedTextTrackId).toBe('track-es'); + + cleanup(); + }); + + it('clears selectedTextTrackId when external code disables all tracks', async () => { + const mediaElement = document.createElement('video'); + const presentation = makePresentation([ + { id: 'track-en', language: 'en' }, + { id: 'track-es', language: 'es' }, + ]); + + const { state, owners, cleanup } = setup({ presentation, selectedTextTrackId: 'track-en' }); + owners.set({ ...owners.get(), mediaElement }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Simulate external code disabling all tracks + const enEl = Array.from(mediaElement.children).find( + (el) => (el as HTMLTrackElement).id === 'track-en' + ) as HTMLTrackElement; + enEl.track.mode = 'disabled'; + mediaElement.textTracks.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(state.get().selectedTextTrackId).toBeUndefined(); + + cleanup(); + }); + + it('removes track elements on cleanup', async () => { + const mediaElement = document.createElement('video'); + const presentation = makePresentation([ + { id: 'track-en', language: 'en' }, + { id: 'track-es', language: 'es' }, + ]); + + const { owners, cleanup } = setup({ presentation }); + owners.set({ ...owners.get(), mediaElement }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mediaElement.children.length).toBe(2); + + cleanup(); + expect(mediaElement.children.length).toBe(0); + }); + + it('clears owners.textTracks on cleanup', async () => { + const mediaElement = document.createElement('video'); + const presentation = makePresentation([{ id: 'track-en', language: 'en' }]); + + const { owners, cleanup } = setup({ presentation }); + owners.set({ ...owners.get(), mediaElement }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(owners.get().textTracks?.size).toBe(1); + + cleanup(); + expect(owners.get().textTracks).toBeUndefined(); + }); + + it('creates tracks only once (idempotent on re-runs)', async () => { + const mediaElement = document.createElement('video'); + const presentation = makePresentation([{ id: 'track-en', language: 'en' }]); + + const { state, owners, cleanup } = setup({ presentation }); + owners.set({ ...owners.get(), mediaElement }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const firstChild = mediaElement.children[0]; + expect(mediaElement.children.length).toBe(1); + + // Trigger a re-run by changing selectedTextTrackId + state.set({ ...state.get(), selectedTextTrackId: 'track-en' }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mediaElement.children.length).toBe(1); + expect(mediaElement.children[0]).toBe(firstChild); + + cleanup(); + }); +}); diff --git a/packages/spf/src/dom/playback-engine/engine.ts b/packages/spf/src/dom/playback-engine/engine.ts index 0928d081e..498ad7d88 100644 --- a/packages/spf/src/dom/playback-engine/engine.ts +++ b/packages/spf/src/dom/playback-engine/engine.ts @@ -13,9 +13,7 @@ import type { TextTrackBufferState } from '../features/load-text-track-cues'; import { loadTextTrackCues } from '../features/load-text-track-cues'; import { setupMediaSource } from '../features/setup-mediasource'; import { setupSourceBuffers } from '../features/setup-sourcebuffer'; -import { setupTextTracks } from '../features/setup-text-tracks'; -import { syncSelectedTextTrackFromDom } from '../features/sync-selected-text-track-from-dom'; -import { syncTextTrackModes } from '../features/sync-text-track-modes'; +import { syncTextTracks } from '../features/sync-text-tracks'; import { trackCurrentTime } from '../features/track-current-time'; import { trackPlaybackInitiated } from '../features/track-playback-initiated'; import { updateDuration } from '../features/update-duration'; @@ -276,17 +274,9 @@ export function createPlaybackEngine(config: PlaybackEngineConfig = {}): Playbac // 6.5. Signal end of stream when all segments loaded endOfStream({ state, owners }), - // 7. Setup text tracks (when mediaElement and presentation ready) - setupTextTracks({ state, owners }), - - // 8. Sync text track modes (when track selected and track elements created) - syncTextTrackModes({ state, owners }), - - // 8.5. Bridge DOM text track mode changes → selectedTextTrackId - // Detects when external code (e.g. captions button via toggleSubtitles()) - // sets a subtitle/caption track to 'showing' and reflects that into SPF - // state, which in turn drives loadTextTrackCues. - syncSelectedTextTrackFromDom({ state, owners }), + // 7-8.5. Text track sync: setup, mode sync, and DOM bridge in one reactive function. + // Consolidates setupTextTracks, syncTextTrackModes, syncSelectedTextTrackFromDom. + syncTextTracks({ state, owners }), // 9. Load text track cues (when track resolved and mode set) loadTextTrackCues({ state, owners }), diff --git a/packages/spf/src/dom/tests/playback-engine.test.ts b/packages/spf/src/dom/tests/playback-engine.test.ts index 574d5b9c6..bcacc8428 100644 --- a/packages/spf/src/dom/tests/playback-engine.test.ts +++ b/packages/spf/src/dom/tests/playback-engine.test.ts @@ -1314,9 +1314,9 @@ http://example.com/text-es-seg1.vtt const textTracks = engine.owners.get().textTracks!; const tracks = Array.from(mediaElement.children) as HTMLTrackElement[]; - // Initially all tracks should be hidden (no selection, managed by activateTextTrack) - expect(tracks[0]!.track.mode).toBe('hidden'); - expect(tracks[1]!.track.mode).toBe('hidden'); + // Initially all tracks should be disabled (no selection) + expect(tracks[0]!.track.mode).toBe('disabled'); + expect(tracks[1]!.track.mode).toBe('disabled'); // Get track IDs from the map const englishTrackId = Array.from(textTracks.entries()).find(([, el]) => el.srclang === 'en')?.[0]; @@ -1337,7 +1337,7 @@ http://example.com/text-es-seg1.vtt const spanishTrack = textTracks.get(spanishTrackId!)!; expect(englishTrack.track.mode).toBe('showing'); - expect(spanishTrack.track.mode).toBe('hidden'); + expect(spanishTrack.track.mode).toBe('disabled'); }, { timeout: 2000 } ); @@ -1353,13 +1353,13 @@ http://example.com/text-es-seg1.vtt const englishTrack = textTracks.get(englishTrackId!)!; const spanishTrack = textTracks.get(spanishTrackId!)!; - expect(englishTrack.track.mode).toBe('hidden'); + expect(englishTrack.track.mode).toBe('disabled'); expect(spanishTrack.track.mode).toBe('showing'); }, { timeout: 2000 } ); - // Deselect (hide all) — omit selectedTextTrackId to satisfy exactOptionalPropertyTypes + // Deselect (disable all) — omit selectedTextTrackId to satisfy exactOptionalPropertyTypes const { selectedTextTrackId: _removed, ...deselected } = engine.state.get(); engine.state.set(deselected as ReturnType); @@ -1368,8 +1368,8 @@ http://example.com/text-es-seg1.vtt const englishTrack = textTracks.get(englishTrackId!)!; const spanishTrack = textTracks.get(spanishTrackId!)!; - expect(englishTrack.track.mode).toBe('hidden'); - expect(spanishTrack.track.mode).toBe('hidden'); + expect(englishTrack.track.mode).toBe('disabled'); + expect(spanishTrack.track.mode).toBe('disabled'); }, { timeout: 2000 } ); From 580dee9f00fb03aa364d136e056b966f4e545c97 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 31 Mar 2026 09:42:25 -0700 Subject: [PATCH 02/79] refactor(spf): remove textBufferState coupling from TextTrackSyncState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical testing confirmed cues added via addCue() survive a disabled → showing mode transition when no src is set on the element. The textBufferState clearing on deselect in the old syncSelectedTextTrackFromDom was therefore unnecessary. Removes the textBufferState field and TextTrackBufferState import from sync-text-tracks.ts, and adds a browser test documenting the verified cue preservation behavior. Co-Authored-By: Claude Sonnet 4.6 --- .../spf/src/dom/features/sync-text-tracks.ts | 5 -- .../text-track-mode-cue-preservation.test.ts | 88 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 packages/spf/src/dom/features/tests/text-track-mode-cue-preservation.test.ts diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index f86f233d2..68c57b486 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -2,8 +2,6 @@ import { listen } from '@videojs/utils/dom'; import { effect } from '../../core/signals/effect'; import { computed, type Signal, signal, untrack, update } from '../../core/signals/primitives'; import type { PartiallyResolvedTextTrack, Presentation, TextTrack } from '../../core/types'; -import type { TextTrackBufferState } from './load-text-track-cues'; - /** * FSM states for text track sync. * @@ -33,8 +31,6 @@ export type TextTrackSyncStatus = 'preconditions-unmet' | 'setting-up' | 'set-up export interface TextTrackSyncState { presentation?: Presentation | undefined; selectedTextTrackId?: string | undefined; - /** @TODO(Phase 1 Step 2) Remove coupling to loadTextTrackCues — Reactor should only write selectedTextTrackId. */ - textBufferState?: TextTrackBufferState | undefined; } /** @@ -58,7 +54,6 @@ function createTrackElement(track: PartiallyResolvedTextTrack | TextTrack): HTML el.toggleAttribute('data-src-track', true); if (track.language) el.srclang = track.language; if (track.default) el.default = true; - el.src = track.url; return el; } diff --git a/packages/spf/src/dom/features/tests/text-track-mode-cue-preservation.test.ts b/packages/spf/src/dom/features/tests/text-track-mode-cue-preservation.test.ts new file mode 100644 index 000000000..0f29e215e --- /dev/null +++ b/packages/spf/src/dom/features/tests/text-track-mode-cue-preservation.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +/** + * Empirical tests for TextTrack cue preservation across mode transitions. + * + * Key question: does setting mode="disabled" permanently discard cues added + * via addCue(), or are they preserved and accessible again after re-enabling? + * + * Tests run in real Chromium via @vitest/browser-playwright. + */ +describe('TextTrack cue preservation across mode transitions', () => { + function makeTrack(): { video: HTMLVideoElement; trackEl: HTMLTrackElement; track: TextTrack } { + const video = document.createElement('video'); + const trackEl = document.createElement('track'); + trackEl.kind = 'subtitles'; + // No src — SPF manages cues via addCue() only + video.appendChild(trackEl); + return { video, trackEl, track: trackEl.track }; + } + + it('track.cues is null when mode="disabled"', () => { + const { track } = makeTrack(); + track.mode = 'disabled'; + expect(track.cues).toBeNull(); + }); + + it('track.cues is a list when mode="showing"', () => { + const { track } = makeTrack(); + track.mode = 'showing'; + expect(track.cues).not.toBeNull(); + }); + + it('track.cues is a list when mode="hidden"', () => { + const { track } = makeTrack(); + track.mode = 'hidden'; + expect(track.cues).not.toBeNull(); + }); + + it('cues added via addCue() survive disabled → showing transition (no src)', () => { + const { track } = makeTrack(); + + track.mode = 'showing'; + track.addCue(new VTTCue(0, 5, 'Hello')); + expect(track.cues?.length).toBe(1); + + track.mode = 'disabled'; + expect(track.cues).toBeNull(); + + track.mode = 'showing'; + expect(track.cues?.length).toBe(1); + }); + + it('cues added via addCue() survive disabled → hidden transition (no src)', () => { + const { track } = makeTrack(); + + track.mode = 'showing'; + track.addCue(new VTTCue(0, 5, 'Hello')); + + track.mode = 'disabled'; + track.mode = 'hidden'; + expect(track.cues?.length).toBe(1); + }); + + it('cues added via addCue() survive hidden → disabled → showing transition (no src)', () => { + const { track } = makeTrack(); + + track.mode = 'hidden'; + track.addCue(new VTTCue(0, 5, 'Hello')); + + track.mode = 'disabled'; + track.mode = 'showing'; + expect(track.cues?.length).toBe(1); + }); + + it('multiple cues survive disabled → showing transition (no src)', () => { + const { track } = makeTrack(); + + track.mode = 'showing'; + track.addCue(new VTTCue(0, 2, 'First')); + track.addCue(new VTTCue(2, 4, 'Second')); + track.addCue(new VTTCue(4, 6, 'Third')); + expect(track.cues?.length).toBe(3); + + track.mode = 'disabled'; + track.mode = 'showing'; + expect(track.cues?.length).toBe(3); + }); +}); From 9001a16d1a7aacbc42b3e0a74523a7a0448fc9a9 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 31 Mar 2026 09:44:13 -0700 Subject: [PATCH 03/79] refactor(spf): delete superseded text track reactors and their tests setupTextTracks, syncTextTrackModes, and syncSelectedTextTrackFromDom are fully replaced by syncTextTracks. Removes the six source and test files, and drops the now-incorrect src assertion from the playback-engine test (track elements no longer have src set). Co-Authored-By: Claude Sonnet 4.6 --- .../spf/src/dom/features/setup-text-tracks.ts | 120 -------- .../sync-selected-text-track-from-dom.ts | 91 ------ .../src/dom/features/sync-text-track-modes.ts | 54 ---- .../features/tests/setup-text-tracks.test.ts | 169 ----------- .../sync-selected-text-track-from-dom.test.ts | 284 ------------------ .../tests/sync-text-track-modes.test.ts | 155 ---------- .../spf/src/dom/tests/playback-engine.test.ts | 3 - 7 files changed, 876 deletions(-) delete mode 100644 packages/spf/src/dom/features/setup-text-tracks.ts delete mode 100644 packages/spf/src/dom/features/sync-selected-text-track-from-dom.ts delete mode 100644 packages/spf/src/dom/features/sync-text-track-modes.ts delete mode 100644 packages/spf/src/dom/features/tests/setup-text-tracks.test.ts delete mode 100644 packages/spf/src/dom/features/tests/sync-selected-text-track-from-dom.test.ts delete mode 100644 packages/spf/src/dom/features/tests/sync-text-track-modes.test.ts diff --git a/packages/spf/src/dom/features/setup-text-tracks.ts b/packages/spf/src/dom/features/setup-text-tracks.ts deleted file mode 100644 index 9c6c71fa2..000000000 --- a/packages/spf/src/dom/features/setup-text-tracks.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { effect } from '../../core/signals/effect'; -import { computed, type Signal, update } from '../../core/signals/primitives'; -import type { PartiallyResolvedTextTrack, Presentation, TextTrack } from '../../core/types'; - -/** - * State shape for text track setup. - */ -export interface TextTrackState { - presentation?: Presentation | undefined; - selectedTextTrackId?: string | undefined; -} - -/** - * Owners shape for text track setup. - */ -export interface TextTrackOwners { - mediaElement?: HTMLMediaElement | undefined; - textTracks?: Map; -} - -/** - * Create a track element for a text track. - * - * Note: We use DOM elements instead of the TextTrack JS API - * because there's no way to remove TextTracks added via addTextTrack(). - */ -function createTrackElement(track: PartiallyResolvedTextTrack | TextTrack): HTMLTrackElement { - const trackElement = document.createElement('track'); - - trackElement.id = track.id; - trackElement.kind = track.kind; - trackElement.label = track.label; - - if (track.language) { - trackElement.srclang = track.language; - } - - if (track.default) { - trackElement.default = true; - } - - // Set src to track URL - trackElement.src = track.url; - - return trackElement; -} - -/** - * Setup text tracks orchestration. - * - * Triggers when: - * - mediaElement exists - * - presentation is resolved (has text tracks) - * - * Creates elements for all text tracks and adds them as children - * to the media element. This allows the browser's native text track rendering. - * - * Note: Uses DOM track elements instead of TextTrack API because tracks - * added via addTextTrack() cannot be removed. - * - * @example - * const cleanup = setupTextTracks({ state, owners }); - */ -export function setupTextTracks({ - state, - owners, -}: { - state: Signal; - owners: Signal; -}): () => void { - const modelTextTracksSignal = computed( - // NOTE: This assumes exactly one selection set and switching set for TextTracks (CJP) - () => - state.get().presentation?.selectionSets?.find((selectionSet) => selectionSet.type === 'text')?.switchingSets[0] - ?.tracks, - { - /** @TODO Make generic and abstract away for Array | undefined (CJP) */ - equals(prevTextTracks, nextTextTracks) { - if (prevTextTracks === nextTextTracks) return true; - if (typeof prevTextTracks !== typeof nextTextTracks) return false; - if (prevTextTracks?.length !== nextTextTracks?.length) return false; - // NOTE: This could probably be optimized, but the set should generally be small (CJP) - return ( - !!nextTextTracks && - nextTextTracks.every((nextTextTrack) => - prevTextTracks?.some((prevTextTrack) => prevTextTrack.id === nextTextTrack.id) - ) - ); - }, - } - ); - const ownerTextTracksSignal = computed(() => owners.get().textTracks); - const mediaElementSignal = computed(() => owners.get().mediaElement); - - const canSetupTextTracksSignal = computed(() => !!mediaElementSignal.get() && modelTextTracksSignal.get()?.length); - const shouldSetupTextTracksSignal = computed(() => !ownerTextTracksSignal.get()); - - const cleanupEffect = effect(() => { - if (!canSetupTextTracksSignal.get() || !shouldSetupTextTracksSignal.get()) return; - const mediaElement = mediaElementSignal.get(); - const modelTextTracks = modelTextTracksSignal.get() as TextTrack[]; - - const trackMap = new Map(); - modelTextTracks.forEach((modelTextTrack) => { - const trackElement = createTrackElement(modelTextTrack); - mediaElement!.appendChild(trackElement); - trackMap.set(modelTextTrack.id, trackElement); - }); - - if (trackMap.size) { - const patch: Partial = { textTracks: trackMap }; - update(owners, patch); - } - }); - - return () => { - owners.get().textTracks?.forEach((trackElement) => trackElement.remove()); - cleanupEffect(); - }; -} diff --git a/packages/spf/src/dom/features/sync-selected-text-track-from-dom.ts b/packages/spf/src/dom/features/sync-selected-text-track-from-dom.ts deleted file mode 100644 index e651aed22..000000000 --- a/packages/spf/src/dom/features/sync-selected-text-track-from-dom.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { listen } from '@videojs/utils/dom'; -import { effect } from '../../core/signals/effect'; -import { computed, type Signal, update } from '../../core/signals/primitives'; -import type { TextTrackBufferState } from './load-text-track-cues'; - -/** - * State shape for DOM-driven text track selection. - */ -export interface SelectedTextTrackFromDomState { - selectedTextTrackId?: string | undefined; - textBufferState?: TextTrackBufferState | undefined; -} - -/** - * Owners shape for DOM-driven text track selection. - */ -export interface SelectedTextTrackFromDomOwners { - mediaElement?: HTMLMediaElement | undefined; -} - -/** - * Sync selectedTextTrackId from DOM text track mode changes. - * - * Listens to the `change` event on `media.textTracks` and updates - * `selectedTextTrackId` when external code (e.g. the captions button via - * `toggleSubtitles()`) changes a subtitle/caption track mode to 'showing'. - * - * This bridges the core store's `toggleSubtitles()` with SPF's reactive text - * track pipeline (`syncTextTrackModes`, `loadTextTrackCues`). Without this - * bridge, direct DOM mode changes would be immediately overridden by - * `syncTextTrackModes` on the next SPF state update. - * - * When a subtitle/caption track's mode is 'showing', its DOM `id` — which - * matches the SPF track ID set by `setupTextTracks` — is written to - * `selectedTextTrackId`. When no subtitle/caption track is 'showing', - * `selectedTextTrackId` is cleared along with the deselected track's - * `textBufferState` entry — setting mode to 'disabled' clears native cues from - * the track element, so the buffer must be reset to re-fetch cues on re-enable. - * - * @example - * const cleanup = syncSelectedTextTrackFromDom({ state, owners }); - */ -export function syncSelectedTextTrackFromDom< - S extends SelectedTextTrackFromDomState, - O extends SelectedTextTrackFromDomOwners, ->({ state, owners }: { state: Signal; owners: Signal }): () => void { - const mediaElement = computed(() => owners.get().mediaElement); - - return effect(() => { - const el = mediaElement.get(); - if (!el) return; - - return listen(el.textTracks, 'change', () => { - const showingTrack = Array.from(el.textTracks).find( - (t) => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions') - ); - - // showingTrack.id is set from the SPF presentation track ID by setupTextTracks. - // Fall back to undefined for empty-string IDs (non-SPF-managed tracks). - const newId = showingTrack?.id || undefined; - const current = state.get(); - - // Guard against redundant writes — e.g. syncTextTrackModes confirming the - // current selection, which would otherwise create a feedback loop. - if (current.selectedTextTrackId === newId) return; - - if (newId) { - const patch: Partial = { selectedTextTrackId: newId }; - update(state, patch); - } else { - // When deselecting, clear the textBufferState entry for the previous track. - // Setting mode to 'disabled' (as toggleSubtitles() does) clears native cues - // from the track element, so the buffer must be reset to allow re-fetching - // on re-enable. - const prevId = current.selectedTextTrackId; - if (prevId && current.textBufferState?.[prevId]) { - const next = { ...current.textBufferState }; - delete next[prevId]; - const patch: Partial = { - selectedTextTrackId: undefined, - textBufferState: next, - }; - update(state, patch); - } else { - const patch: Partial = { selectedTextTrackId: undefined }; - update(state, patch); - } - } - }); - }); -} diff --git a/packages/spf/src/dom/features/sync-text-track-modes.ts b/packages/spf/src/dom/features/sync-text-track-modes.ts deleted file mode 100644 index 97b7481b9..000000000 --- a/packages/spf/src/dom/features/sync-text-track-modes.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { effect } from '../../core/signals/effect'; -import { computed, type Signal } from '../../core/signals/primitives'; - -/** - * State shape for text track mode synchronization. - */ -export interface TextTrackModeState { - selectedTextTrackId?: string | undefined; -} - -/** - * Owners shape for text track mode synchronization. - */ -export interface TextTrackModeOwners { - textTracks?: Map; -} - -/** - * Sync text track modes orchestration. - * - * Manages track element modes based on selectedTextTrackId: - * - Selected track: mode = "showing" - * - Other tracks: mode = "hidden" - * - No selection: all tracks mode = "hidden" - * - * Note: Uses "hidden" instead of "disabled" for non-selected tracks - * so they remain available in the browser's track menu. - * - * @example - * const cleanup = syncTextTrackModes({ state, owners }); - */ -export function syncTextTrackModes({ - state, - owners, -}: { - state: Signal; - owners: Signal; -}): () => void { - const textTracksSignal = computed(() => owners.get().textTracks); - const selectedTextTrackIdSignal = computed(() => state.get().selectedTextTrackId); - - const canSyncTextTrackModes = computed(() => !!textTracksSignal.get()?.size); - - return effect(() => { - if (!canSyncTextTrackModes.get()) return; - /** @TODO refactor TextTracks owners model. Should simply use id. Also should use corresponding TextTrack (JS) element if possible (CJP) */ - const textTracks = textTracksSignal.get() as Map; - const selectedTextTrackId = selectedTextTrackIdSignal.get() as string; - - for (const [trackId, trackElement] of textTracks) { - trackElement.track.mode = trackId === selectedTextTrackId ? 'showing' : 'hidden'; - } - }); -} diff --git a/packages/spf/src/dom/features/tests/setup-text-tracks.test.ts b/packages/spf/src/dom/features/tests/setup-text-tracks.test.ts deleted file mode 100644 index 85b03dbbd..000000000 --- a/packages/spf/src/dom/features/tests/setup-text-tracks.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { signal } from '../../../core/signals/primitives'; -import type { Presentation, TextSelectionSet } from '../../../core/types'; -import { setupTextTracks, type TextTrackOwners, type TextTrackState } from '../setup-text-tracks'; - -function setupSetupTextTracks(initialState: TextTrackState = {}, initialOwners: TextTrackOwners = {}) { - const state = signal(initialState); - const owners = signal(initialOwners); - const cleanup = setupTextTracks({ state, owners }); - return { state, owners, cleanup }; -} - -const textPresentation: Presentation = { - id: 'pres-1', - url: 'http://example.com/playlist.m3u8', - selectionSets: [ - { - id: 'text-set', - type: 'text', - switchingSets: [ - { - id: 'text-switching', - type: 'text', - tracks: [ - { - type: 'text', - id: 'text-en', - url: 'http://example.com/text-en.m3u8', - bandwidth: 256, - mimeType: 'text/vtt', - codecs: [], - groupId: 'subs', - label: 'English', - kind: 'subtitles', - language: 'en', - }, - { - type: 'text', - id: 'text-es', - url: 'http://example.com/text-es.m3u8', - bandwidth: 256, - mimeType: 'text/vtt', - codecs: [], - groupId: 'subs', - label: 'Spanish', - kind: 'subtitles', - language: 'es', - default: true, - }, - ], - }, - ], - } as TextSelectionSet, - ], -}; - -describe('setupTextTracks', () => { - it('creates track elements when mediaElement and presentation ready', async () => { - const mediaElement = document.createElement('video'); - - const { owners, cleanup } = setupSetupTextTracks({ presentation: textPresentation }, { mediaElement }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(owners.get().textTracks?.size).toBe(2); - expect(mediaElement.children.length).toBe(2); - - const track1 = mediaElement.children[0] as HTMLTrackElement; - expect(track1.tagName).toBe('TRACK'); - expect(track1.id).toBe('text-en'); - expect(track1.kind).toBe('subtitles'); - expect(track1.label).toBe('English'); - expect(track1.srclang).toBe('en'); - expect(track1.src).toBe('http://example.com/text-en.m3u8'); - expect(track1.default).toBe(false); - - const track2 = mediaElement.children[1] as HTMLTrackElement; - expect(track2.tagName).toBe('TRACK'); - expect(track2.id).toBe('text-es'); - expect(track2.kind).toBe('subtitles'); - expect(track2.label).toBe('Spanish'); - expect(track2.srclang).toBe('es'); - expect(track2.src).toBe('http://example.com/text-es.m3u8'); - expect(track2.default).toBe(true); - - cleanup(); - }); - - it('waits for mediaElement before creating tracks', async () => { - const { owners, cleanup } = setupSetupTextTracks({ presentation: textPresentation }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(owners.get().textTracks).toBeUndefined(); - - const mediaElement = document.createElement('video'); - owners.set({ ...owners.get(), mediaElement }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(owners.get().textTracks?.size).toBe(2); - - cleanup(); - }); - - it('waits for presentation before creating tracks', async () => { - const mediaElement = document.createElement('video'); - const { state, owners, cleanup } = setupSetupTextTracks({}, { mediaElement }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(owners.get().textTracks).toBeUndefined(); - - state.set({ ...state.get(), presentation: textPresentation }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(owners.get().textTracks?.size).toBe(2); - - cleanup(); - }); - - it('does not create track elements when no text tracks in presentation', async () => { - const presentation: Presentation = { - id: 'pres-1', - url: 'http://example.com/playlist.m3u8', - selectionSets: [], - }; - - const mediaElement = document.createElement('video'); - const { owners, cleanup } = setupSetupTextTracks({ presentation }, { mediaElement }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(owners.get().textTracks).toBeUndefined(); - expect(mediaElement.children.length).toBe(0); - - cleanup(); - }); - - it('only runs once (idempotent)', async () => { - const mediaElement = document.createElement('video'); - - const { state, owners, cleanup } = setupSetupTextTracks({ presentation: textPresentation }, { mediaElement }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const firstTrackMap = owners.get().textTracks; - expect(firstTrackMap?.size).toBe(2); - expect(mediaElement.children.length).toBe(2); - - state.set({ ...state.get(), selectedTextTrackId: 'text-en' }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(owners.get().textTracks).toBe(firstTrackMap); - expect(mediaElement.children.length).toBe(2); - - cleanup(); - }); - - it('removes track elements on cleanup', async () => { - const mediaElement = document.createElement('video'); - - const { cleanup } = setupSetupTextTracks({ presentation: textPresentation }, { mediaElement }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(mediaElement.children.length).toBe(2); - - cleanup(); - expect(mediaElement.children.length).toBe(0); - }); -}); diff --git a/packages/spf/src/dom/features/tests/sync-selected-text-track-from-dom.test.ts b/packages/spf/src/dom/features/tests/sync-selected-text-track-from-dom.test.ts deleted file mode 100644 index b32c45d84..000000000 --- a/packages/spf/src/dom/features/tests/sync-selected-text-track-from-dom.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { signal } from '../../../core/signals/primitives'; -import { - type SelectedTextTrackFromDomOwners, - type SelectedTextTrackFromDomState, - syncSelectedTextTrackFromDom, -} from '../sync-selected-text-track-from-dom'; - -function setup(initialState: SelectedTextTrackFromDomState = {}, initialOwners: SelectedTextTrackFromDomOwners = {}) { - const state = signal(initialState); - const owners = signal(initialOwners); - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); - return { state, owners, cleanup }; -} - -function createSubtitleTrack(mediaElement: HTMLMediaElement, id: string): HTMLTrackElement { - const trackEl = document.createElement('track'); - trackEl.kind = 'subtitles'; - trackEl.label = id; - trackEl.id = id; - trackEl.src = 'data:text/vtt,'; - mediaElement.appendChild(trackEl); - return trackEl; -} - -describe('syncSelectedTextTrackFromDom', () => { - it('does nothing when no mediaElement', async () => { - const { state, cleanup } = setup(); - - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(state.get().selectedTextTrackId).toBeUndefined(); - - cleanup(); - }); - - it('patches selectedTextTrackId when a subtitle track mode changes to "showing"', async () => { - const mediaElement = document.createElement('video'); - const trackEl = createSubtitleTrack(mediaElement, 'track-en'); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const { state, cleanup } = setup({}, { mediaElement }); - - trackEl.track.mode = 'showing'; - mediaElement.textTracks.dispatchEvent(new Event('change')); - - await vi.waitFor(() => { - expect(state.get().selectedTextTrackId).toBe('track-en'); - }); - - cleanup(); - }); - - it('patches selectedTextTrackId when a captions track mode changes to "showing"', async () => { - const mediaElement = document.createElement('video'); - const trackEl = document.createElement('track'); - trackEl.kind = 'captions'; - trackEl.id = 'track-cc'; - trackEl.src = 'data:text/vtt,'; - mediaElement.appendChild(trackEl); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const { state, cleanup } = setup({}, { mediaElement }); - - trackEl.track.mode = 'showing'; - mediaElement.textTracks.dispatchEvent(new Event('change')); - - await vi.waitFor(() => { - expect(state.get().selectedTextTrackId).toBe('track-cc'); - }); - - cleanup(); - }); - - it('clears selectedTextTrackId when no subtitle/caption track is showing', async () => { - const mediaElement = document.createElement('video'); - const trackEl = createSubtitleTrack(mediaElement, 'track-en'); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const { state, cleanup } = setup({ selectedTextTrackId: 'track-en' }, { mediaElement }); - - trackEl.track.mode = 'disabled'; - mediaElement.textTracks.dispatchEvent(new Event('change')); - - await vi.waitFor(() => { - expect(state.get().selectedTextTrackId).toBeUndefined(); - }); - - cleanup(); - }); - - it('clears textBufferState for the deselected track when disabling', async () => { - const mediaElement = document.createElement('video'); - const trackEl = createSubtitleTrack(mediaElement, 'track-en'); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const { state, cleanup } = setup( - { - selectedTextTrackId: 'track-en', - textBufferState: { - 'track-en': { segments: [{ id: 'seg-0' }, { id: 'seg-1' }] }, - }, - }, - { mediaElement } - ); - - trackEl.track.mode = 'disabled'; - mediaElement.textTracks.dispatchEvent(new Event('change')); - - await vi.waitFor(() => { - expect(state.get().selectedTextTrackId).toBeUndefined(); - expect(state.get().textBufferState?.['track-en']).toBeUndefined(); - }); - - cleanup(); - }); - - it('does not modify textBufferState for other tracks when disabling one', async () => { - const mediaElement = document.createElement('video'); - const trackEl = createSubtitleTrack(mediaElement, 'track-en'); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const { state, cleanup } = setup( - { - selectedTextTrackId: 'track-en', - textBufferState: { - 'track-en': { segments: [{ id: 'seg-0' }] }, - 'track-fr': { segments: [{ id: 'seg-0' }] }, - }, - }, - { mediaElement } - ); - - trackEl.track.mode = 'disabled'; - mediaElement.textTracks.dispatchEvent(new Event('change')); - - await vi.waitFor(() => { - expect(state.get().textBufferState?.['track-en']).toBeUndefined(); - }); - - expect(state.get().textBufferState?.['track-fr']).toEqual({ segments: [{ id: 'seg-0' }] }); - - cleanup(); - }); - - it('ignores non-subtitle/caption track kinds', async () => { - const mediaElement = document.createElement('video'); - const chapterEl = document.createElement('track'); - chapterEl.kind = 'chapters'; - chapterEl.id = 'chapters-track'; - chapterEl.src = 'data:text/vtt,'; - mediaElement.appendChild(chapterEl); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const { state, cleanup } = setup({}, { mediaElement }); - - chapterEl.track.mode = 'showing'; - mediaElement.textTracks.dispatchEvent(new Event('change')); - - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(state.get().selectedTextTrackId).toBeUndefined(); - - cleanup(); - }); - - it('does not patch when selectedTextTrackId already matches the showing track', async () => { - const mediaElement = document.createElement('video'); - const trackEl = createSubtitleTrack(mediaElement, 'track-en'); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const stateSignal = signal({ selectedTextTrackId: 'track-en' }); - const ownersSignal = signal({ mediaElement }); - - const setSpy = vi.spyOn(stateSignal, 'set'); - - const cleanupEffect = syncSelectedTextTrackFromDom({ state: stateSignal, owners: ownersSignal }); - - trackEl.track.mode = 'showing'; - mediaElement.textTracks.dispatchEvent(new Event('change')); - - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(setSpy).not.toHaveBeenCalled(); - - cleanupEffect(); - }); - - it('does not re-register listener when owners updates but mediaElement is unchanged', async () => { - const mediaElement = document.createElement('video'); - - const stateSignal = signal({}); - const ownersSignal = signal({ mediaElement }); - - const addEventListenerSpy = vi.spyOn(mediaElement.textTracks, 'addEventListener'); - - const cleanupEffect = syncSelectedTextTrackFromDom({ state: stateSignal, owners: ownersSignal }); - - await new Promise((resolve) => setTimeout(resolve, 20)); - const callsBefore = addEventListenerSpy.mock.calls.length; - - ownersSignal.set({ ...ownersSignal.get(), videoBuffer: {} as any }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(addEventListenerSpy.mock.calls.length).toBe(callsBefore); - - cleanupEffect(); - }); - - it('starts listening when mediaElement is added later', async () => { - const { state, owners, cleanup } = setup(); - - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(state.get().selectedTextTrackId).toBeUndefined(); - - const mediaElement = document.createElement('video'); - const trackEl = createSubtitleTrack(mediaElement, 'track-en'); - await new Promise((resolve) => setTimeout(resolve, 50)); - - owners.set({ ...owners.get(), mediaElement }); - - trackEl.track.mode = 'showing'; - mediaElement.textTracks.dispatchEvent(new Event('change')); - - await vi.waitFor(() => { - expect(state.get().selectedTextTrackId).toBe('track-en'); - }); - - cleanup(); - }); - - it('stops listening to old mediaElement when replaced', async () => { - const element1 = document.createElement('video'); - const trackEl1 = createSubtitleTrack(element1, 'track-en'); - - const element2 = document.createElement('video'); - const trackEl2 = createSubtitleTrack(element2, 'track-fr'); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const { state, owners, cleanup } = setup({}, { mediaElement: element1 }); - - owners.set({ ...owners.get(), mediaElement: element2 }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - trackEl1.track.mode = 'showing'; - element1.textTracks.dispatchEvent(new Event('change')); - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(state.get().selectedTextTrackId).toBeUndefined(); - - trackEl2.track.mode = 'showing'; - element2.textTracks.dispatchEvent(new Event('change')); - - await vi.waitFor(() => { - expect(state.get().selectedTextTrackId).toBe('track-fr'); - }); - - cleanup(); - }); - - it('removes listener on cleanup', async () => { - const mediaElement = document.createElement('video'); - const trackEl = createSubtitleTrack(mediaElement, 'track-en'); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const { state, cleanup } = setup({}, { mediaElement }); - cleanup(); - - trackEl.track.mode = 'showing'; - mediaElement.textTracks.dispatchEvent(new Event('change')); - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(state.get().selectedTextTrackId).toBeUndefined(); - }); -}); diff --git a/packages/spf/src/dom/features/tests/sync-text-track-modes.test.ts b/packages/spf/src/dom/features/tests/sync-text-track-modes.test.ts deleted file mode 100644 index 4ae6ef827..000000000 --- a/packages/spf/src/dom/features/tests/sync-text-track-modes.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { signal } from '../../../core/signals/primitives'; -import { syncTextTrackModes, type TextTrackModeOwners, type TextTrackModeState } from '../sync-text-track-modes'; - -function setupSyncTextTrackModes(initialState: TextTrackModeState = {}, initialOwners: TextTrackModeOwners = {}) { - const state = signal(initialState); - const owners = signal(initialOwners); - const cleanup = syncTextTrackModes({ state, owners }); - return { state, owners, cleanup }; -} - -describe('syncTextTrackModes', () => { - it('sets selected track mode to "showing"', async () => { - const mediaElement = document.createElement('video'); - - const track1 = document.createElement('track'); - track1.kind = 'subtitles'; - track1.label = 'English'; - track1.src = 'data:text/vtt,'; - mediaElement.appendChild(track1); - - const track2 = document.createElement('track'); - track2.kind = 'subtitles'; - track2.label = 'Spanish'; - track2.src = 'data:text/vtt,'; - mediaElement.appendChild(track2); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const { state, cleanup } = setupSyncTextTrackModes( - {}, - { - textTracks: new Map([ - ['track-en', track1], - ['track-es', track2], - ]), - } - ); - - state.set({ ...state.get(), selectedTextTrackId: 'track-en' }); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(track1.track.mode).toBe('showing'); - expect(track2.track.mode).toBe('hidden'); - - cleanup(); - }); - - it('switches active track when selection changes', async () => { - const mediaElement = document.createElement('video'); - - const track1 = document.createElement('track'); - track1.kind = 'subtitles'; - track1.src = 'data:text/vtt,'; - mediaElement.appendChild(track1); - - const track2 = document.createElement('track'); - track2.kind = 'subtitles'; - track2.src = 'data:text/vtt,'; - mediaElement.appendChild(track2); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const { state, cleanup } = setupSyncTextTrackModes( - {}, - { - textTracks: new Map([ - ['track-en', track1], - ['track-es', track2], - ]), - } - ); - - state.set({ ...state.get(), selectedTextTrackId: 'track-en' }); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(track1.track.mode).toBe('showing'); - expect(track2.track.mode).toBe('hidden'); - - state.set({ ...state.get(), selectedTextTrackId: 'track-es' }); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(track1.track.mode).toBe('hidden'); - expect(track2.track.mode).toBe('showing'); - - cleanup(); - }); - - it('hides all tracks when no selection', async () => { - const mediaElement = document.createElement('video'); - - const track1 = document.createElement('track'); - track1.kind = 'subtitles'; - track1.src = 'data:text/vtt,'; - mediaElement.appendChild(track1); - - const track2 = document.createElement('track'); - track2.kind = 'subtitles'; - track2.src = 'data:text/vtt,'; - mediaElement.appendChild(track2); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const { state, cleanup } = setupSyncTextTrackModes( - {}, - { - textTracks: new Map([ - ['track-en', track1], - ['track-es', track2], - ]), - } - ); - - state.set({ ...state.get(), selectedTextTrackId: 'track-en' }); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(track1.track.mode).toBe('showing'); - - state.set({ ...state.get(), selectedTextTrackId: undefined }); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(track1.track.mode).toBe('hidden'); - expect(track2.track.mode).toBe('hidden'); - - cleanup(); - }); - - it('does nothing when textTracks not available', async () => { - const { cleanup } = setupSyncTextTrackModes({ selectedTextTrackId: 'track-en' }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - cleanup(); - }); - - it('handles track selection before track elements created', async () => { - const mediaElement = document.createElement('video'); - - const track1 = document.createElement('track'); - track1.kind = 'subtitles'; - track1.src = 'data:text/vtt,'; - mediaElement.appendChild(track1); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const { owners, cleanup } = setupSyncTextTrackModes({ selectedTextTrackId: 'track-en' }); - - owners.set({ ...owners.get(), textTracks: new Map([['track-en', track1]]) }); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(track1.track.mode).toBe('showing'); - - cleanup(); - }); -}); diff --git a/packages/spf/src/dom/tests/playback-engine.test.ts b/packages/spf/src/dom/tests/playback-engine.test.ts index bcacc8428..7e25255a3 100644 --- a/packages/spf/src/dom/tests/playback-engine.test.ts +++ b/packages/spf/src/dom/tests/playback-engine.test.ts @@ -1216,21 +1216,18 @@ http://example.com/video-seg1.m4s expect(tracks[0]!.kind).toBe('subtitles'); expect(tracks[0]!.label).toBe('English'); expect(tracks[0]!.srclang).toBe('en'); - expect(tracks[0]!.src).toBe('http://example.com/text-en.m3u8'); expect(tracks[0]!.default).toBe(false); // Spanish track (DEFAULT) expect(tracks[1]!.kind).toBe('subtitles'); expect(tracks[1]!.label).toBe('Spanish'); expect(tracks[1]!.srclang).toBe('es'); - expect(tracks[1]!.src).toBe('http://example.com/text-es.m3u8'); expect(tracks[1]!.default).toBe(true); // French track expect(tracks[2]!.kind).toBe('subtitles'); expect(tracks[2]!.label).toBe('French'); expect(tracks[2]!.srclang).toBe('fr'); - expect(tracks[2]!.src).toBe('http://example.com/text-fr.m3u8'); expect(tracks[2]!.default).toBe(false); }, { timeout: 2000 } From 5fd5dfbafa61b66b56e554922a87eac7eb3a0f93 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 31 Mar 2026 09:55:01 -0700 Subject: [PATCH 04/79] refactor(spf): remove owners.textTracks map in favour of mediaElement.textTracks lookup loadTextTrackCues no longer requires a pre-built Map in owners. It now holds a mediaElement reference and looks up the native TextTrack by id via Array.from(mediaElement.textTracks).find(t => t.id === trackId). syncTextTracks no longer builds or writes the map. PlaybackEngineOwners no longer carries the textTracks field. Co-Authored-By: Claude Sonnet 4.6 --- .../src/dom/features/load-text-track-cues.ts | 13 +++-- .../spf/src/dom/features/sync-text-tracks.ts | 13 ----- .../tests/load-text-track-cues.test.ts | 57 ++++++++++--------- .../features/tests/sync-text-tracks.test.ts | 36 ------------ .../spf/src/dom/playback-engine/engine.ts | 3 - .../spf/src/dom/tests/playback-engine.test.ts | 38 ++++--------- 6 files changed, 49 insertions(+), 111 deletions(-) diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index a569efe36..37635fefc 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -132,7 +132,7 @@ export interface TextTrackCueLoadingState { * Owners shape for text track cue loading. */ export interface TextTrackCueLoadingOwners { - textTracks?: Map; + mediaElement?: HTMLMediaElement; } /** @@ -171,12 +171,11 @@ function getSelectedTextTrackFromOwners( owners: TextTrackCueLoadingOwners ): globalThis.TextTrack | undefined { const trackId = state.selectedTextTrackId; - if (!trackId || !owners.textTracks) { + if (!trackId || !owners.mediaElement) { return undefined; } - const trackElement = owners.textTracks.get(trackId); - return trackElement?.track; + return Array.from(owners.mediaElement.textTracks).find((t) => t.id === trackId); } /** @@ -188,7 +187,11 @@ function getSelectedTextTrackFromOwners( * - Track element exists for selected track */ export function canLoadTextTrackCues(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): boolean { - return !!state.selectedTextTrackId && !!owners.textTracks && owners.textTracks.has(state.selectedTextTrackId); + return ( + !!state.selectedTextTrackId && + !!owners.mediaElement && + Array.from(owners.mediaElement.textTracks).some((t) => t.id === state.selectedTextTrackId) + ); } /** diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index 68c57b486..27cfe6261 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -38,8 +38,6 @@ export interface TextTrackSyncState { */ export interface TextTrackSyncOwners { mediaElement?: HTMLMediaElement | undefined; - /** Written by syncTextTracks as a side effect for loadTextTrackCues. Will be removed in Phase 3. */ - textTracks?: Map | undefined; } // ============================================================================ @@ -162,18 +160,7 @@ export function syncTextTracks mediaElementSignal.get() as HTMLMediaElement); - /** @TODO DELETE AFTER MIGRATION (CJP) */ - const trackMap = new Map(); - currentMediaElement - .querySelectorAll('track[data-src-track]:is([kind="subtitles"],[kind="captions"') - .forEach((trackEl) => { - trackMap.set(trackEl.id, trackEl); - }); - update(owners, { textTracks: trackMap } as Partial); - return () => { - /** @TODO DELETE AFTER MIGRATION (CJP) */ - update(owners, { textTracks: undefined } as Partial); teardownTextTracks(currentMediaElement); }; }); diff --git a/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts b/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts index d10c14951..9b4c95dbf 100644 --- a/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts +++ b/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts @@ -69,9 +69,7 @@ describe('canLoadTextTrackCues', () => { const state: TextTrackCueLoadingState = { presentation: createMockPresentation([]), }; - const owners: TextTrackCueLoadingOwners = { - textTracks: new Map(), - }; + const owners: TextTrackCueLoadingOwners = {}; expect(canLoadTextTrackCues(state, owners)).toBe(false); }); @@ -92,7 +90,7 @@ describe('canLoadTextTrackCues', () => { presentation: createMockPresentation([]), }; const owners: TextTrackCueLoadingOwners = { - textTracks: new Map(), + mediaElement: document.createElement('video'), }; expect(canLoadTextTrackCues(state, owners)).toBe(false); @@ -103,9 +101,12 @@ describe('canLoadTextTrackCues', () => { selectedTextTrackId: 'text-1', presentation: createMockPresentation([]), }; + const video = document.createElement('video'); const trackElement = document.createElement('track'); + trackElement.id = 'text-1'; + video.appendChild(trackElement); const owners: TextTrackCueLoadingOwners = { - textTracks: new Map([['text-1', trackElement]]), + mediaElement: video, }; expect(canLoadTextTrackCues(state, owners)).toBe(true); @@ -124,11 +125,10 @@ describe('shouldLoadTextTrackCues', () => { ]), }; const trackElement = document.createElement('track'); + trackElement.id = 'text-1'; const video = document.createElement('video'); video.appendChild(trackElement); - const owners: TextTrackCueLoadingOwners = { - textTracks: new Map([['text-1', trackElement]]), - }; + const owners: TextTrackCueLoadingOwners = { mediaElement: video }; expect(shouldLoadTextTrackCues(state, owners)).toBe(false); }); @@ -147,12 +147,11 @@ describe('shouldLoadTextTrackCues', () => { ]), }; const trackElement = document.createElement('track'); + trackElement.id = 'text-1'; const video = document.createElement('video'); video.appendChild(trackElement); trackElement.track.mode = 'hidden'; // Enable cue access - const owners: TextTrackCueLoadingOwners = { - textTracks: new Map([['text-1', trackElement]]), - }; + const owners: TextTrackCueLoadingOwners = { mediaElement: video }; expect(shouldLoadTextTrackCues(state, owners)).toBe(true); }); @@ -173,6 +172,7 @@ describe('loadTextTrackCues', () => { // cues getter to maintain a persistent list across awaits for these tests. function makeTrackWithPersistentCues() { const trackElement = document.createElement('track'); + trackElement.id = 'text-1'; const video = document.createElement('video'); video.appendChild(trackElement); trackElement.track.mode = 'hidden'; @@ -191,7 +191,7 @@ describe('loadTextTrackCues', () => { configurable: true, }); - return { trackElement, addCueSpy }; + return { trackElement, video, addCueSpy }; } it('adds all cues when there are no duplicates', async () => { @@ -201,14 +201,14 @@ describe('loadTextTrackCues', () => { .mockResolvedValueOnce([new VTTCue(5, 10, 'Cue B')]) .mockResolvedValueOnce([new VTTCue(10, 15, 'Cue C')]); - const { trackElement, addCueSpy } = makeTrackWithPersistentCues(); + const { video, addCueSpy } = makeTrackWithPersistentCues(); const { cleanup } = setupLoadTextTrackCues( { selectedTextTrackId: 'text-1', presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(3) }]), }, - { textTracks: new Map([['text-1', trackElement]]) } + { mediaElement: video } ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -223,14 +223,14 @@ describe('loadTextTrackCues', () => { .mockResolvedValueOnce([new VTTCue(8, 12, 'Boundary cue')]) .mockResolvedValueOnce([new VTTCue(8, 12, 'Boundary cue')]); - const { trackElement, addCueSpy } = makeTrackWithPersistentCues(); + const { video, addCueSpy } = makeTrackWithPersistentCues(); const { cleanup } = setupLoadTextTrackCues( { selectedTextTrackId: 'text-1', presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(2) }]), }, - { textTracks: new Map([['text-1', trackElement]]) } + { mediaElement: video } ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -244,14 +244,14 @@ describe('loadTextTrackCues', () => { .mockResolvedValueOnce([new VTTCue(0, 5, 'Hello')]) .mockResolvedValueOnce([new VTTCue(0, 5, 'World')]); // same timing, different text — not a duplicate - const { trackElement, addCueSpy } = makeTrackWithPersistentCues(); + const { video, addCueSpy } = makeTrackWithPersistentCues(); const { cleanup } = setupLoadTextTrackCues( { selectedTextTrackId: 'text-1', presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(2) }]), }, - { textTracks: new Map([['text-1', trackElement]]) } + { mediaElement: video } ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -265,14 +265,14 @@ describe('loadTextTrackCues', () => { .mockResolvedValueOnce([new VTTCue(0, 8, 'Unique to seg 0'), new VTTCue(8, 12, 'Boundary cue')]) .mockResolvedValueOnce([new VTTCue(8, 12, 'Boundary cue'), new VTTCue(12, 20, 'Unique to seg 1')]); - const { trackElement, addCueSpy } = makeTrackWithPersistentCues(); + const { video, addCueSpy } = makeTrackWithPersistentCues(); const { cleanup } = setupLoadTextTrackCues( { selectedTextTrackId: 'text-1', presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(2) }]), }, - { textTracks: new Map([['text-1', trackElement]]) } + { mediaElement: video } ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -283,7 +283,7 @@ describe('loadTextTrackCues', () => { }); it('does nothing when track not selected', async () => { - const { cleanup } = setupLoadTextTrackCues({ presentation: createMockPresentation([]) }, { textTracks: new Map() }); + const { cleanup } = setupLoadTextTrackCues({ presentation: createMockPresentation([]) }, {}); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -295,6 +295,7 @@ describe('loadTextTrackCues', () => { it('triggers loading for single segment', async () => { const trackElement = document.createElement('track'); + trackElement.id = 'text-1'; const video = document.createElement('video'); video.appendChild(trackElement); trackElement.track.mode = 'hidden'; // Enable cue access @@ -304,7 +305,7 @@ describe('loadTextTrackCues', () => { selectedTextTrackId: 'text-1', presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(1) }]), }, - { textTracks: new Map([['text-1', trackElement]]) } + { mediaElement: video } ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -318,6 +319,7 @@ describe('loadTextTrackCues', () => { it('triggers loading for multiple segments', async () => { const trackElement = document.createElement('track'); + trackElement.id = 'text-1'; const video = document.createElement('video'); video.appendChild(trackElement); trackElement.track.mode = 'hidden'; // Enable cue access @@ -327,7 +329,7 @@ describe('loadTextTrackCues', () => { selectedTextTrackId: 'text-1', presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(3) }]), }, - { textTracks: new Map([['text-1', trackElement]]) } + { mediaElement: video } ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -347,6 +349,7 @@ describe('loadTextTrackCues', () => { it('continues on segment error (partial loading)', async () => { const trackElement = document.createElement('track'); + trackElement.id = 'text-1'; const video = document.createElement('video'); video.appendChild(trackElement); trackElement.track.mode = 'hidden'; // Enable cue access @@ -367,7 +370,7 @@ describe('loadTextTrackCues', () => { }, ]), }, - { textTracks: new Map([['text-1', trackElement]]) } + { mediaElement: video } ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -391,6 +394,7 @@ describe('loadTextTrackCues', () => { it('does nothing when track not in presentation', async () => { const trackElement = document.createElement('track'); + trackElement.id = 'text-999'; const video = document.createElement('video'); video.appendChild(trackElement); @@ -399,7 +403,7 @@ describe('loadTextTrackCues', () => { selectedTextTrackId: 'text-999', presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(1) }]), }, - { textTracks: new Map([['text-999', trackElement]]) } + { mediaElement: video } ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -416,6 +420,7 @@ describe('loadTextTrackCues', () => { // At t=15: window [15, 45) adds seg-3 (start=30) and seg-4 (start=40). function makeWindowingSetup(currentTime = 0) { const trackElement = document.createElement('track'); + trackElement.id = 'text-1'; const video = document.createElement('video'); video.appendChild(trackElement); trackElement.track.mode = 'hidden'; @@ -426,7 +431,7 @@ describe('loadTextTrackCues', () => { currentTime, presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(5) }]), }, - { textTracks: new Map([['text-1', trackElement]]) } + { mediaElement: video } ); return { state, owners, cleanup, trackElement }; diff --git a/packages/spf/src/dom/features/tests/sync-text-tracks.test.ts b/packages/spf/src/dom/features/tests/sync-text-tracks.test.ts index 328f73165..31e968861 100644 --- a/packages/spf/src/dom/features/tests/sync-text-tracks.test.ts +++ b/packages/spf/src/dom/features/tests/sync-text-tracks.test.ts @@ -59,27 +59,6 @@ describe('syncTextTracks', () => { cleanup(); }); - it('writes owners.textTracks Map for loadTextTrackCues', async () => { - const mediaElement = document.createElement('video'); - const presentation = makePresentation([ - { id: 'track-en', language: 'en' }, - { id: 'track-es', language: 'es' }, - ]); - - const { state, owners, cleanup } = setup(); - owners.set({ ...owners.get(), mediaElement }); - state.set({ ...state.get(), presentation }); - await new Promise((resolve) => setTimeout(resolve, 50)); - - const textTracks = owners.get().textTracks; - expect(textTracks).toBeDefined(); - expect(textTracks?.size).toBe(2); - expect(textTracks?.get('track-en')).toBe(mediaElement.children[0]); - expect(textTracks?.get('track-es')).toBe(mediaElement.children[1]); - - cleanup(); - }); - it('does not create tracks when no mediaElement', async () => { const presentation = makePresentation([{ id: 'track-en', language: 'en' }]); const { state, owners, cleanup } = setup(); @@ -87,7 +66,6 @@ describe('syncTextTracks', () => { state.set({ ...state.get(), presentation }); await new Promise((resolve) => setTimeout(resolve, 50)); - expect(owners.get().textTracks).toBeUndefined(); cleanup(); }); @@ -264,20 +242,6 @@ describe('syncTextTracks', () => { expect(mediaElement.children.length).toBe(0); }); - it('clears owners.textTracks on cleanup', async () => { - const mediaElement = document.createElement('video'); - const presentation = makePresentation([{ id: 'track-en', language: 'en' }]); - - const { owners, cleanup } = setup({ presentation }); - owners.set({ ...owners.get(), mediaElement }); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(owners.get().textTracks?.size).toBe(1); - - cleanup(); - expect(owners.get().textTracks).toBeUndefined(); - }); - it('creates tracks only once (idempotent on re-runs)', async () => { const mediaElement = document.createElement('video'); const presentation = makePresentation([{ id: 'track-en', language: 'en' }]); diff --git a/packages/spf/src/dom/playback-engine/engine.ts b/packages/spf/src/dom/playback-engine/engine.ts index 498ad7d88..198a1af60 100644 --- a/packages/spf/src/dom/playback-engine/engine.ts +++ b/packages/spf/src/dom/playback-engine/engine.ts @@ -106,9 +106,6 @@ export interface PlaybackEngineOwners { audioBuffer?: SourceBuffer; videoBufferActor?: SourceBufferActor; audioBufferActor?: SourceBufferActor; - - // Text tracks (track elements by ID) - textTracks?: Map; } /** diff --git a/packages/spf/src/dom/tests/playback-engine.test.ts b/packages/spf/src/dom/tests/playback-engine.test.ts index 7e25255a3..649500656 100644 --- a/packages/spf/src/dom/tests/playback-engine.test.ts +++ b/packages/spf/src/dom/tests/playback-engine.test.ts @@ -1200,12 +1200,6 @@ http://example.com/video-seg1.m4s // Wait for text tracks to be set up await vi.waitFor( () => { - const owners = engine.owners.get(); - - // Text tracks should be created - expect(owners.textTracks).toBeDefined(); - expect(owners.textTracks?.size).toBe(3); - // Track elements should be in DOM expect(mediaElement.children.length).toBe(3); @@ -1303,36 +1297,30 @@ http://example.com/text-es-seg1.vtt // Wait for text tracks to be set up await vi.waitFor( () => { - expect(engine.owners.get().textTracks?.size).toBe(2); + expect(mediaElement.children.length).toBe(2); }, { timeout: 2000 } ); - const textTracks = engine.owners.get().textTracks!; const tracks = Array.from(mediaElement.children) as HTMLTrackElement[]; + const englishTrack = tracks.find((el) => el.srclang === 'en')!; + const spanishTrack = tracks.find((el) => el.srclang === 'es')!; - // Initially all tracks should be disabled (no selection) - expect(tracks[0]!.track.mode).toBe('disabled'); - expect(tracks[1]!.track.mode).toBe('disabled'); - - // Get track IDs from the map - const englishTrackId = Array.from(textTracks.entries()).find(([, el]) => el.srclang === 'en')?.[0]; - const spanishTrackId = Array.from(textTracks.entries()).find(([, el]) => el.srclang === 'es')?.[0]; + expect(englishTrack).toBeDefined(); + expect(spanishTrack).toBeDefined(); - expect(englishTrackId).toBeDefined(); - expect(spanishTrackId).toBeDefined(); + // Initially all tracks should be disabled (no selection) + expect(englishTrack.track.mode).toBe('disabled'); + expect(spanishTrack.track.mode).toBe('disabled'); // Select English track engine.state.set({ ...engine.state.get(), - selectedTextTrackId: englishTrackId!, + selectedTextTrackId: englishTrack.id, }); await vi.waitFor( () => { - const englishTrack = textTracks.get(englishTrackId!)!; - const spanishTrack = textTracks.get(spanishTrackId!)!; - expect(englishTrack.track.mode).toBe('showing'); expect(spanishTrack.track.mode).toBe('disabled'); }, @@ -1342,14 +1330,11 @@ http://example.com/text-es-seg1.vtt // Switch to Spanish track engine.state.set({ ...engine.state.get(), - selectedTextTrackId: spanishTrackId!, + selectedTextTrackId: spanishTrack.id, }); await vi.waitFor( () => { - const englishTrack = textTracks.get(englishTrackId!)!; - const spanishTrack = textTracks.get(spanishTrackId!)!; - expect(englishTrack.track.mode).toBe('disabled'); expect(spanishTrack.track.mode).toBe('showing'); }, @@ -1362,9 +1347,6 @@ http://example.com/text-es-seg1.vtt await vi.waitFor( () => { - const englishTrack = textTracks.get(englishTrackId!)!; - const spanishTrack = textTracks.get(spanishTrackId!)!; - expect(englishTrack.track.mode).toBe('disabled'); expect(spanishTrack.track.mode).toBe('disabled'); }, From 1381df182e7c246b369b676c90c841f1d0053d5f Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 31 Mar 2026 10:15:42 -0700 Subject: [PATCH 05/79] =?UTF-8?q?refactor(spf):=20simplify=20cleanupModes?= =?UTF-8?q?=20listener=20=E2=80=94=20drop=20redundant=20remove/re-add?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit change events on TextTrackList are queued as tasks (async per spec), so syncModes inside onChange cannot cause re-entrant calls. The syncTimeout guard is sufficient; removing and re-adding the listener on each settling- window change event was unnecessary complexity. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/dom/features/sync-text-tracks.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index 27cfe6261..34ab064eb 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -176,18 +176,15 @@ export function syncTextTracks void) | undefined; - const onChange = () => { if (syncTimeout) { // Inside the settling window: browser auto-selection is overriding our // modes. Re-apply to restore the intended state without touching state. - currentUnlisten?.(); + // change events are queued as tasks (async), so no re-entrancy risk. syncModes( mediaElement.textTracks, untrack(() => selectedTextTrackIdSignal.get()) ); - currentUnlisten = listen(mediaElement.textTracks, 'change', onChange); return; } @@ -203,11 +200,11 @@ export function syncTextTracks); }; - currentUnlisten = listen(mediaElement.textTracks, 'change', onChange); + const unlisten = listen(mediaElement.textTracks, 'change', onChange); return () => { clearTimeout(syncTimeout ?? undefined); - currentUnlisten?.(); + unlisten(); if (untrack(() => statusSignal.get()) !== 'set-up') { update(state, { selectedTextTrackId: undefined } as Partial); } From e0d84119863711c72ad022d4c5cf1b92f599c5b2 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 31 Mar 2026 10:51:38 -0700 Subject: [PATCH 06/79] =?UTF-8?q?feat(spf):=20add=20TextTracksActor=20?= =?UTF-8?q?=E2=80=94=20wraps=20HTMLMediaElement.textTracks,=20owns=20cue?= =?UTF-8?q?=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../features/tests/text-tracks-actor.test.ts | 147 ++++++++++++++++++ .../spf/src/dom/features/text-tracks-actor.ts | 91 +++++++++++ 2 files changed, 238 insertions(+) create mode 100644 packages/spf/src/dom/features/tests/text-tracks-actor.test.ts create mode 100644 packages/spf/src/dom/features/text-tracks-actor.ts diff --git a/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts b/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts new file mode 100644 index 000000000..4bd760ec1 --- /dev/null +++ b/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { TextTracksActor } from '../text-tracks-actor'; + +function makeMediaElement(trackIds: string[]): HTMLMediaElement { + const video = document.createElement('video'); + for (const id of trackIds) { + const el = document.createElement('track'); + el.id = id; + el.kind = 'subtitles'; + video.appendChild(el); + } + return video; +} + +describe('TextTracksActor', () => { + it('starts with idle status and empty loaded context', () => { + const video = makeMediaElement(['track-en']); + const actor = new TextTracksActor(video); + + expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().context.loaded).toEqual({}); + }); + + it('adds cues to the correct TextTrack', () => { + const video = makeMediaElement(['track-en']); + const actor = new TextTracksActor(video); + const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; + textTrack.mode = 'hidden'; + + actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + + expect(textTrack.cues?.length).toBe(1); + }); + + it('records added cues in snapshot context', () => { + const video = makeMediaElement(['track-en']); + const actor = new TextTracksActor(video); + const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; + textTrack.mode = 'hidden'; + + actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello'), new VTTCue(2, 4, 'World')] }); + + const loaded = actor.snapshot.get().context.loaded['track-en']; + expect(loaded).toHaveLength(2); + expect(loaded![0]).toMatchObject({ startTime: 0, endTime: 2, text: 'Hello' }); + expect(loaded![1]).toMatchObject({ startTime: 2, endTime: 4, text: 'World' }); + }); + + it('deduplicates cues by startTime + endTime + text', () => { + const video = makeMediaElement(['track-en']); + const actor = new TextTracksActor(video); + const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; + textTrack.mode = 'hidden'; + + actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + + expect(textTrack.cues?.length).toBe(1); + expect(actor.snapshot.get().context.loaded['track-en']).toHaveLength(1); + }); + + it('does not deduplicate cues with different text at the same time range', () => { + const video = makeMediaElement(['track-en']); + const actor = new TextTracksActor(video); + const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; + textTrack.mode = 'hidden'; + + actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hola')] }); + + expect(textTrack.cues?.length).toBe(2); + }); + + it('does not update snapshot when all cues are duplicates', () => { + const video = makeMediaElement(['track-en']); + const actor = new TextTracksActor(video); + const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; + textTrack.mode = 'hidden'; + + actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + const snapshotAfterFirst = actor.snapshot.get(); + + actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + + expect(actor.snapshot.get()).toBe(snapshotAfterFirst); + }); + + it('tracks cues independently per track ID', () => { + const video = makeMediaElement(['track-en', 'track-es']); + const actor = new TextTracksActor(video); + for (const t of Array.from(video.textTracks)) t.mode = 'hidden'; + + actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-es', cues: [new VTTCue(0, 2, 'Hola'), new VTTCue(2, 4, 'Mundo')] }); + + expect(actor.snapshot.get().context.loaded['track-en']).toHaveLength(1); + expect(actor.snapshot.get().context.loaded['track-es']).toHaveLength(2); + }); + + it('is a no-op when trackId is not found in textTracks', () => { + const video = makeMediaElement(['track-en']); + const actor = new TextTracksActor(video); + + actor.send({ type: 'add-cues', trackId: 'nonexistent', cues: [new VTTCue(0, 2, 'Hello')] }); + + expect(actor.snapshot.get().context.loaded).toEqual({}); + }); + + it('transitions to destroyed on destroy()', () => { + const video = makeMediaElement(['track-en']); + const actor = new TextTracksActor(video); + + actor.destroy(); + + expect(actor.snapshot.get().status).toBe('destroyed'); + }); + + it('ignores send() after destroy()', () => { + const video = makeMediaElement(['track-en']); + const actor = new TextTracksActor(video); + const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; + textTrack.mode = 'hidden'; + + actor.destroy(); + actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + + expect(textTrack.cues?.length ?? 0).toBe(0); + expect(actor.snapshot.get().context.loaded).toEqual({}); + }); + + it('snapshot is reactive — updates are observable via signal', () => { + const video = makeMediaElement(['track-en']); + const actor = new TextTracksActor(video); + const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; + textTrack.mode = 'hidden'; + + const snapshots: ReturnType[] = []; + // Read initial value + snapshots.push(actor.snapshot.get()); + + actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + snapshots.push(actor.snapshot.get()); + + expect(snapshots[0]!.context.loaded['track-en']).toBeUndefined(); + expect(snapshots[1]!.context.loaded['track-en']).toHaveLength(1); + }); +}); diff --git a/packages/spf/src/dom/features/text-tracks-actor.ts b/packages/spf/src/dom/features/text-tracks-actor.ts new file mode 100644 index 000000000..53d4cfebe --- /dev/null +++ b/packages/spf/src/dom/features/text-tracks-actor.ts @@ -0,0 +1,91 @@ +import type { ActorSnapshot, SignalActor } from '../../core/actor'; +import { type ReadonlySignal, signal, update } from '../../core/signals/primitives'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Finite (bounded) operational modes of the actor. */ +export type TextTracksActorStatus = 'idle' | 'destroyed'; + +/** Minimal cue record — enough for deduplication and snapshot observability. */ +export interface CueRecord { + startTime: number; + endTime: number; + text: string; +} + +/** Non-finite (extended) data managed by the actor — the XState "context". */ +export interface TextTracksActorContext { + /** Cues added per track ID. Used for duplicate detection and snapshot observability. */ + loaded: Record; +} + +/** Complete snapshot of a TextTracksActor. */ +export type TextTracksActorSnapshot = ActorSnapshot; + +export type AddCuesMessage = { type: 'add-cues'; trackId: string; cues: VTTCue[] }; +export type TextTracksActorMessage = AddCuesMessage; + +// ============================================================================= +// Helpers +// ============================================================================= + +function isDuplicateCue(cue: VTTCue, existing: CueRecord[]): boolean { + return existing.some((r) => r.startTime === cue.startTime && r.endTime === cue.endTime && r.text === cue.text); +} + +// ============================================================================= +// Implementation +// ============================================================================= + +/** TextTrack actor: wraps all text tracks on a media element, owns cue operations. */ +export class TextTracksActor implements SignalActor { + readonly #mediaElement: HTMLMediaElement; + readonly #snapshotSignal = signal({ + status: 'idle', + context: { loaded: {} }, + }); + + constructor(mediaElement: HTMLMediaElement) { + this.#mediaElement = mediaElement; + } + + get snapshot(): ReadonlySignal { + return this.#snapshotSignal; + } + + send(message: TextTracksActorMessage): void { + if (this.#snapshotSignal.get().status === 'destroyed') return; + + // NOTE: Currently assumes cues are applied to a non-disabled TextTrack. Discuss different approaches here, including: + // - Making the message responsible for auto-selection of the textTrack (changes logic in sync-text-tracks) + // - Silent gating/console warning + early bail + // - throwing a domain-specific error + // - accepting as is (which would result in errors, but also "shouldn't ever happen" unless a bug is introduced) + // (CJP) + const { trackId, cues } = message; + const textTrack = Array.from(this.#mediaElement.textTracks).find((t) => t.id === trackId); + if (!textTrack) return; + + const ctx = this.#snapshotSignal.get().context; + const existing = ctx.loaded[trackId] ?? []; + const prunedCues = cues.filter((cue) => !isDuplicateCue(cue, existing)); + if (!prunedCues.length) return; + + prunedCues.forEach((cue) => textTrack.addCue(cue)); + update(this.#snapshotSignal, { + context: { + ...ctx, + loaded: { + ...ctx.loaded, + [trackId]: [...existing, ...prunedCues], + }, + }, + }); + } + + destroy(): void { + update(this.#snapshotSignal, { status: 'destroyed' }); + } +} From 8b067512afdd65b53933d91a976a541795f81970 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 31 Mar 2026 10:58:18 -0700 Subject: [PATCH 07/79] refactor(spf): add segment tracking to TextTracksActor context Co-Authored-By: Claude Sonnet 4.6 --- .../features/tests/text-tracks-actor.test.ts | 78 ++++++++++++++----- .../spf/src/dom/features/text-tracks-actor.ts | 22 ++++-- 2 files changed, 74 insertions(+), 26 deletions(-) diff --git a/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts b/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts index 4bd760ec1..edc7fc55a 100644 --- a/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts +++ b/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts @@ -13,12 +13,13 @@ function makeMediaElement(trackIds: string[]): HTMLMediaElement { } describe('TextTracksActor', () => { - it('starts with idle status and empty loaded context', () => { + it('starts with idle status and empty context', () => { const video = makeMediaElement(['track-en']); const actor = new TextTracksActor(video); expect(actor.snapshot.get().status).toBe('idle'); expect(actor.snapshot.get().context.loaded).toEqual({}); + expect(actor.snapshot.get().context.segments).toEqual({}); }); it('adds cues to the correct TextTrack', () => { @@ -27,7 +28,7 @@ describe('TextTracksActor', () => { const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); expect(textTrack.cues?.length).toBe(1); }); @@ -38,7 +39,12 @@ describe('TextTracksActor', () => { const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello'), new VTTCue(2, 4, 'World')] }); + actor.send({ + type: 'add-cues', + trackId: 'track-en', + segmentId: 'seg-0', + cues: [new VTTCue(0, 2, 'Hello'), new VTTCue(2, 4, 'World')], + }); const loaded = actor.snapshot.get().context.loaded['track-en']; expect(loaded).toHaveLength(2); @@ -46,64 +52,96 @@ describe('TextTracksActor', () => { expect(loaded![1]).toMatchObject({ startTime: 2, endTime: 4, text: 'World' }); }); + it('records segment in snapshot context', () => { + const video = makeMediaElement(['track-en']); + const actor = new TextTracksActor(video); + const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; + textTrack.mode = 'hidden'; + + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-1', cues: [new VTTCue(2, 4, 'World')] }); + + expect(actor.snapshot.get().context.segments['track-en']).toEqual([{ id: 'seg-0' }, { id: 'seg-1' }]); + }); + it('deduplicates cues by startTime + endTime + text', () => { const video = makeMediaElement(['track-en']); const actor = new TextTracksActor(video); const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); - actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-1', cues: [new VTTCue(0, 2, 'Hello')] }); expect(textTrack.cues?.length).toBe(1); expect(actor.snapshot.get().context.loaded['track-en']).toHaveLength(1); }); - it('does not deduplicate cues with different text at the same time range', () => { + it('deduplicates segments by id', () => { const video = makeMediaElement(['track-en']); const actor = new TextTracksActor(video); const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); - actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hola')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); - expect(textTrack.cues?.length).toBe(2); + expect(actor.snapshot.get().context.segments['track-en']).toHaveLength(1); }); - it('does not update snapshot when all cues are duplicates', () => { + it('does not update snapshot when both cues and segment are already recorded', () => { const video = makeMediaElement(['track-en']); const actor = new TextTracksActor(video); const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); const snapshotAfterFirst = actor.snapshot.get(); - actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); expect(actor.snapshot.get()).toBe(snapshotAfterFirst); }); - it('tracks cues independently per track ID', () => { + it('does not deduplicate cues with different text at the same time range', () => { + const video = makeMediaElement(['track-en']); + const actor = new TextTracksActor(video); + const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; + textTrack.mode = 'hidden'; + + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-1', cues: [new VTTCue(0, 2, 'Hola')] }); + + expect(textTrack.cues?.length).toBe(2); + }); + + it('tracks cues and segments independently per track ID', () => { const video = makeMediaElement(['track-en', 'track-es']); const actor = new TextTracksActor(video); for (const t of Array.from(video.textTracks)) t.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); - actor.send({ type: 'add-cues', trackId: 'track-es', cues: [new VTTCue(0, 2, 'Hola'), new VTTCue(2, 4, 'Mundo')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ + type: 'add-cues', + trackId: 'track-es', + segmentId: 'seg-0', + cues: [new VTTCue(0, 2, 'Hola'), new VTTCue(2, 4, 'Mundo')], + }); expect(actor.snapshot.get().context.loaded['track-en']).toHaveLength(1); expect(actor.snapshot.get().context.loaded['track-es']).toHaveLength(2); + expect(actor.snapshot.get().context.segments['track-en']).toEqual([{ id: 'seg-0' }]); + expect(actor.snapshot.get().context.segments['track-es']).toEqual([{ id: 'seg-0' }]); }); it('is a no-op when trackId is not found in textTracks', () => { const video = makeMediaElement(['track-en']); const actor = new TextTracksActor(video); - actor.send({ type: 'add-cues', trackId: 'nonexistent', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'nonexistent', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); expect(actor.snapshot.get().context.loaded).toEqual({}); + expect(actor.snapshot.get().context.segments).toEqual({}); }); it('transitions to destroyed on destroy()', () => { @@ -122,10 +160,11 @@ describe('TextTracksActor', () => { textTrack.mode = 'hidden'; actor.destroy(); - actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); expect(textTrack.cues?.length ?? 0).toBe(0); expect(actor.snapshot.get().context.loaded).toEqual({}); + expect(actor.snapshot.get().context.segments).toEqual({}); }); it('snapshot is reactive — updates are observable via signal', () => { @@ -135,13 +174,14 @@ describe('TextTracksActor', () => { textTrack.mode = 'hidden'; const snapshots: ReturnType[] = []; - // Read initial value snapshots.push(actor.snapshot.get()); - actor.send({ type: 'add-cues', trackId: 'track-en', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); snapshots.push(actor.snapshot.get()); expect(snapshots[0]!.context.loaded['track-en']).toBeUndefined(); expect(snapshots[1]!.context.loaded['track-en']).toHaveLength(1); + expect(snapshots[0]!.context.segments['track-en']).toBeUndefined(); + expect(snapshots[1]!.context.segments['track-en']).toEqual([{ id: 'seg-0' }]); }); }); diff --git a/packages/spf/src/dom/features/text-tracks-actor.ts b/packages/spf/src/dom/features/text-tracks-actor.ts index 53d4cfebe..75ee7cc35 100644 --- a/packages/spf/src/dom/features/text-tracks-actor.ts +++ b/packages/spf/src/dom/features/text-tracks-actor.ts @@ -19,12 +19,14 @@ export interface CueRecord { export interface TextTracksActorContext { /** Cues added per track ID. Used for duplicate detection and snapshot observability. */ loaded: Record; + /** Segments whose cues have been fully added, keyed by track ID. Used for load planning. */ + segments: Record>; } /** Complete snapshot of a TextTracksActor. */ export type TextTracksActorSnapshot = ActorSnapshot; -export type AddCuesMessage = { type: 'add-cues'; trackId: string; cues: VTTCue[] }; +export type AddCuesMessage = { type: 'add-cues'; trackId: string; segmentId: string; cues: VTTCue[] }; export type TextTracksActorMessage = AddCuesMessage; // ============================================================================= @@ -44,7 +46,7 @@ export class TextTracksActor implements SignalActor({ status: 'idle', - context: { loaded: {} }, + context: { loaded: {}, segments: {} }, }); constructor(mediaElement: HTMLMediaElement) { @@ -64,14 +66,17 @@ export class TextTracksActor implements SignalActor t.id === trackId); if (!textTrack) return; const ctx = this.#snapshotSignal.get().context; - const existing = ctx.loaded[trackId] ?? []; - const prunedCues = cues.filter((cue) => !isDuplicateCue(cue, existing)); - if (!prunedCues.length) return; + const existingCues = ctx.loaded[trackId] ?? []; + const existingSegments = ctx.segments[trackId] ?? []; + const prunedCues = cues.filter((cue) => !isDuplicateCue(cue, existingCues)); + const segmentAlreadyLoaded = existingSegments.some((s) => s.id === segmentId); + + if (prunedCues.length === 0 && segmentAlreadyLoaded) return; prunedCues.forEach((cue) => textTrack.addCue(cue)); update(this.#snapshotSignal, { @@ -79,8 +84,11 @@ export class TextTracksActor implements SignalActor Date: Tue, 31 Mar 2026 11:05:04 -0700 Subject: [PATCH 08/79] refactor(spf): integrate TextTracksActor into loadTextTrackCues; remove textBufferState from state Co-Authored-By: Claude Sonnet 4.6 --- .../src/dom/features/load-text-track-cues.ts | 107 ++++-------------- .../spf/src/dom/playback-engine/engine.ts | 4 - 2 files changed, 24 insertions(+), 87 deletions(-) diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 37635fefc..498767469 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -4,23 +4,14 @@ import { computed, type Signal } from '../../core/signals/primitives'; import type { Presentation, Segment, TextTrack } from '../../core/types'; import { isResolvedTrack } from '../../core/types'; import { parseVttSegment } from '../text/parse-vtt-segment'; - -const CueKeys = ['startTime', 'endTime', 'text'] as const; -function isDuplicateCue(cue: VTTCue, existingCues: globalThis.TextTrack['cues']): boolean { - return Array.prototype.some.call(existingCues ?? [], (existingCue) => { - return CueKeys.every((k) => existingCue[k] === cue[k]); - }); -} +import { TextTracksActor } from './text-tracks-actor'; const loadVttSegmentTask = async ( - { segment }: { segment: Segment }, - { textTrack }: { textTrack: globalThis.TextTrack } + { segment, trackId }: { segment: Segment; trackId: string }, + { textTrack, actor }: { textTrack: globalThis.TextTrack; actor: TextTracksActor } ): Promise => { const cues = await parseVttSegment(segment.url); - cues.forEach((cue) => { - if (isDuplicateCue(cue, textTrack.cues)) return; - textTrack.addCue(cue); - }); + actor.send({ type: 'add-cues', trackId, segmentId: segment.id, cues }); }; // ============================================================================ @@ -35,7 +26,7 @@ const loadTextTrackCuesTask = async ( context: { signal: AbortSignal; textTrack: globalThis.TextTrack; - state: Signal; + actor: TextTracksActor; } ): Promise => { const track = findSelectedTextTrack(currentState); @@ -46,36 +37,19 @@ const loadTextTrackCuesTask = async ( const trackId = track.id; - // Resolve segments already recorded in the state model for this track. - // Keyed by track ID so multiple text tracks don't interfere with each other. - const loadedIds = new Set((currentState.textBufferState?.[trackId]?.segments ?? []).map((s) => s.id)); + const loadedIds = new Set((context.actor.snapshot.get().context.segments[trackId] ?? []).map((s) => s.id)); const alreadyLoaded = segments.filter((s) => loadedIds.has(s.id)); - // Apply the same forward buffer window as audio/video segment loading. const currentTime = currentState.currentTime ?? 0; const segmentsToLoad = getSegmentsToLoad(segments, alreadyLoaded, currentTime).filter((s) => !loadedIds.has(s.id)); if (segmentsToLoad.length === 0) return; - // Execute subtasks sequentially, recording each loaded segment in state. for (const segment of segmentsToLoad) { if (context.signal.aborted) break; try { - await loadVttSegmentTask({ segment }, { textTrack: context.textTrack }); - - // Record the loaded segment in shared state — mirrors bufferState for - // audio/video and supports N text tracks keyed by track ID. - const latestState = context.state.get(); - const latest = latestState.textBufferState ?? {}; - const trackState = latest[trackId] ?? { segments: [] }; - context.state.set({ - ...latestState, - textBufferState: { - ...latest, - [trackId]: { segments: [...trackState.segments, { id: segment.id }] }, - }, - } as S); + await loadVttSegmentTask({ segment, trackId }, { textTrack: context.textTrack, actor: context.actor }); } catch (error) { if (error instanceof Error && error.name === 'AbortError') break; console.error('Failed to load VTT segment:', error); @@ -101,21 +75,6 @@ const loadTextTrackCuesTask = async ( // STATE & OWNERS // ============================================================================ -/** - * Loaded-segment record for a single text track. - */ -export interface TextTrackSegmentState { - segments: Array<{ id: string }>; -} - -/** - * Buffer model for text track cues — keyed by track ID. - * - * Using a per-track-ID map (rather than fixed 'video'/'audio' keys) because - * there can be N text tracks — one per language/subtitle variant. - */ -export type TextTrackBufferState = Record; - /** * State shape for text track cue loading. */ @@ -124,8 +83,6 @@ export interface TextTrackCueLoadingState { presentation?: Presentation; /** Current playback position — used to gate VTT segment fetching to the forward buffer window. */ currentTime?: number; - /** Loaded-segment model for text tracks, keyed by track ID. */ - textBufferState?: TextTrackBufferState; } /** @@ -155,16 +112,6 @@ function findSelectedTextTrack(state: TextTrackCueLoadingState): TextTrack | und /** * Get the browser's TextTrack object for the selected text track. - * - * Retrieves the live TextTrack interface from the track element in owners, - * which is used for adding cues, checking mode, and managing track state. - * - * Note: Returns the DOM TextTrack interface (HTMLTrackElement.track), - * not the presentation Track metadata type. - * - * @param state - Current playback state (track selection) - * @param owners - DOM owners containing track elements map - * @returns DOM TextTrack interface or undefined if not found */ function getSelectedTextTrackFromOwners( state: TextTrackCueLoadingState, @@ -180,11 +127,6 @@ function getSelectedTextTrackFromOwners( /** * Check if we can load text track cues. - * - * Requires: - * - Selected text track ID exists - * - Track elements map exists - * - Track element exists for selected track */ export function canLoadTextTrackCues(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): boolean { return ( @@ -196,11 +138,6 @@ export function canLoadTextTrackCues(state: TextTrackCueLoadingState, owners: Te /** * Check if we should load text track cues. - * - * Only load if: - * - Track is resolved (has segments) - * - Track has at least one segment - * - Track element exists */ export function shouldLoadTextTrackCues(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): boolean { if (!canLoadTextTrackCues(state, owners)) { @@ -223,15 +160,6 @@ export function shouldLoadTextTrackCues(state: TextTrackCueLoadingState, owners: /** * Load text track cues orchestration. * - * Triggers when: - * - Text track is selected - * - Track is resolved (has segments) - * - Track element exists - * - * Fetches and parses VTT segments within the forward buffer window, then adds - * cues to the track incrementally. Continues on segment errors to provide - * partial subtitles. - * * @example * const cleanup = loadTextTrackCues({ state, owners }); */ @@ -246,15 +174,25 @@ export function loadTextTrackCues state.get().selectedTextTrackId); + const mediaElement = computed(() => owners.get().mediaElement); const cleanupEffect = effect(() => { const s = state.get(); const o = owners.get(); - // Abort any in-progress task when the selected track changes. - // The new track's textBufferState entry will be empty, so the task - // naturally starts fresh without needing any explicit reset. + // Manage actor lifecycle when mediaElement changes. + const currentMediaElement = mediaElement.get(); + if (currentMediaElement !== actorMediaElement) { + actor?.destroy(); + actor = currentMediaElement ? new TextTracksActor(currentMediaElement) : undefined; + actorMediaElement = currentMediaElement; + } + if (selectedTrackId.get() !== lastTrackId) { lastTrackId = selectedTrackId.get(); abortController?.abort(); @@ -262,15 +200,17 @@ export function loadTextTrackCues { currentTask = null; }); @@ -278,6 +218,7 @@ export function loadTextTrackCues { abortController?.abort(); + actor?.destroy(); cleanupEffect(); }; } diff --git a/packages/spf/src/dom/playback-engine/engine.ts b/packages/spf/src/dom/playback-engine/engine.ts index 198a1af60..8b1a9de7d 100644 --- a/packages/spf/src/dom/playback-engine/engine.ts +++ b/packages/spf/src/dom/playback-engine/engine.ts @@ -9,7 +9,6 @@ import type { ReadonlySignal, Signal } from '../../core/signals/primitives'; import { signal } from '../../core/signals/primitives'; import { endOfStream } from '../features/end-of-stream'; import { loadSegments } from '../features/load-segments'; -import type { TextTrackBufferState } from '../features/load-text-track-cues'; import { loadTextTrackCues } from '../features/load-text-track-cues'; import { setupMediaSource } from '../features/setup-mediasource'; import { setupSourceBuffers } from '../features/setup-sourcebuffer'; @@ -78,9 +77,6 @@ export interface PlaybackEngineState { // concerns don't share a field; see quality-switching.ts for the full design note. abrDisabled?: boolean; - // Text track buffer state (tracks loaded VTT segments per text track ID) - textBufferState?: TextTrackBufferState; - // Current playback position (mirrored from mediaElement via trackCurrentTime) currentTime?: number; From 9214fd7b5c547455396cee33bafcc3d2a6135deb Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 31 Mar 2026 12:05:17 -0700 Subject: [PATCH 09/79] feat(spf): add TextTrackSegmentLoaderActor; migrate loadTextTrackCues to Actor/Reactor pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements TextTrackSegmentLoaderActor as a proper SignalActor using Task/SerialRunner primitives, and simplifies loadTextTrackCues to a thin Reactor that delegates all async work to the Actor layer. Key decisions: - SerialRunner chosen over ConcurrentRunner — VTT segments are small, serial execution keeps the generation-counter pattern simple - Empty object context — all loaded-segment bookkeeping lives in TextTracksActor, not the loader - untrack() wraps textTracksActor.snapshot.get() inside send() to prevent the Reactor's effect from subscribing to the TextTracksActor snapshot, which would cause spurious re-triggers on every cue add Co-Authored-By: Claude Sonnet 4.6 --- .../src/dom/features/load-text-track-cues.ts | 122 ++------ .../tests/load-text-track-cues.test.ts | 25 +- .../text-track-segment-loader-actor.test.ts | 264 ++++++++++++++++++ .../text-track-segment-loader-actor.ts | 120 ++++++++ 4 files changed, 426 insertions(+), 105 deletions(-) create mode 100644 packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts create mode 100644 packages/spf/src/dom/features/text-track-segment-loader-actor.ts diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 498767469..784a88d89 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -1,76 +1,10 @@ -import { getSegmentsToLoad } from '../../core/buffer/forward-buffer'; import { effect } from '../../core/signals/effect'; import { computed, type Signal } from '../../core/signals/primitives'; -import type { Presentation, Segment, TextTrack } from '../../core/types'; +import type { Presentation, TextTrack } from '../../core/types'; import { isResolvedTrack } from '../../core/types'; -import { parseVttSegment } from '../text/parse-vtt-segment'; +import { TextTrackSegmentLoaderActor } from './text-track-segment-loader-actor'; import { TextTracksActor } from './text-tracks-actor'; -const loadVttSegmentTask = async ( - { segment, trackId }: { segment: Segment; trackId: string }, - { textTrack, actor }: { textTrack: globalThis.TextTrack; actor: TextTracksActor } -): Promise => { - const cues = await parseVttSegment(segment.url); - actor.send({ type: 'add-cues', trackId, segmentId: segment.id, cues }); -}; - -// ============================================================================ -// MAIN TASK (composite - orchestrates subtasks) -// ============================================================================ - -/** - * Load text track cues task (composite - orchestrates VTT segment subtasks). - */ -const loadTextTrackCuesTask = async ( - { currentState }: { currentState: S }, - context: { - signal: AbortSignal; - textTrack: globalThis.TextTrack; - actor: TextTracksActor; - } -): Promise => { - const track = findSelectedTextTrack(currentState); - if (!track || !isResolvedTrack(track)) return; - - const { segments } = track; - if (segments.length === 0) return; - - const trackId = track.id; - - const loadedIds = new Set((context.actor.snapshot.get().context.segments[trackId] ?? []).map((s) => s.id)); - const alreadyLoaded = segments.filter((s) => loadedIds.has(s.id)); - - const currentTime = currentState.currentTime ?? 0; - const segmentsToLoad = getSegmentsToLoad(segments, alreadyLoaded, currentTime).filter((s) => !loadedIds.has(s.id)); - - if (segmentsToLoad.length === 0) return; - - for (const segment of segmentsToLoad) { - if (context.signal.aborted) break; - - try { - await loadVttSegmentTask({ segment, trackId }, { textTrack: context.textTrack, actor: context.actor }); - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') break; - console.error('Failed to load VTT segment:', error); - // Continue to next segment (graceful degradation) - } - } - - // Chrome bug: after a track goes through mode='disabled' (which clears cues) and back - // to 'showing', cues added to the track aren't activated. Re-adding all cues forces - // Chrome to re-process them. Safe no-op in other browsers. - // Mirrors the workaround in HlsMediaTextTracksMixin (packages/core/src/dom/media/hls/text-tracks.ts). - if (context.textTrack.mode === 'showing' && context.textTrack.cues) { - Array.from(context.textTrack.cues).forEach((cue) => { - context.textTrack.addCue(cue); - }); - } - - // Wait a frame before completing to allow state updates to flush - await new Promise((resolve) => requestAnimationFrame(resolve)); -}; - // ============================================================================ // STATE & OWNERS // ============================================================================ @@ -89,7 +23,7 @@ export interface TextTrackCueLoadingState { * Owners shape for text track cue loading. */ export interface TextTrackCueLoadingOwners { - mediaElement?: HTMLMediaElement; + mediaElement?: HTMLMediaElement | undefined; } /** @@ -170,15 +104,12 @@ export function loadTextTrackCues; owners: Signal; }): () => void { - let currentTask: Promise | null = null; - let abortController: AbortController | null = null; - let lastTrackId: string | undefined; - - // Actor lifecycle: tied to the mediaElement. Recreated when mediaElement changes. - let actor: TextTracksActor | undefined; + // Actor lifecycle: both actors are tied to the mediaElement. + // Recreated together when mediaElement changes. + let textTracksActor: TextTracksActor | undefined; + let segmentLoaderActor: TextTrackSegmentLoaderActor | undefined; let actorMediaElement: HTMLMediaElement | undefined; - const selectedTrackId = computed(() => state.get().selectedTextTrackId); const mediaElement = computed(() => owners.get().mediaElement); const cleanupEffect = effect(() => { @@ -188,37 +119,30 @@ export function loadTextTrackCues { - currentTask = null; - }); + const track = findSelectedTextTrack(s); + if (!track || !isResolvedTrack(track)) return; + + segmentLoaderActor.send({ type: 'load', track, currentTime: s.currentTime ?? 0 }); }); return () => { - abortController?.abort(); - actor?.destroy(); + textTracksActor?.destroy(); + segmentLoaderActor?.destroy(); cleanupEffect(); }; } diff --git a/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts b/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts index 9b4c95dbf..1b7e81307 100644 --- a/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts +++ b/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts @@ -500,17 +500,30 @@ describe('loadTextTrackCues', () => { }); it('fetches all segments immediately when the track fits in one window', async () => { - const { state, cleanup } = makeWindowingSetup(0); - // Override to a 3-segment track (0,10,20) — all fit in [0,30) - state.set({ - ...state.get(), - presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(3) }]), - }); + // 3 segments × 10s = 30s total — all fit in the default [0, 30) window. + // Set up directly with 3 segments so no preemption from a prior 5-segment load. + const trackElement = document.createElement('track'); + trackElement.id = 'text-1'; + const video = document.createElement('video'); + video.appendChild(trackElement); + trackElement.track.mode = 'hidden'; + + const { cleanup } = setupLoadTextTrackCues( + { + selectedTextTrackId: 'text-1', + currentTime: 0, + presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(3) }]), + }, + { mediaElement: video } + ); await new Promise((resolve) => setTimeout(resolve, 50)); const { parseVttSegment } = await import('../../text/parse-vtt-segment'); expect(parseVttSegment).toHaveBeenCalledTimes(3); + expect(parseVttSegment).toHaveBeenCalledWith('https://example.com/segment-0.vtt'); + expect(parseVttSegment).toHaveBeenCalledWith('https://example.com/segment-1.vtt'); + expect(parseVttSegment).toHaveBeenCalledWith('https://example.com/segment-2.vtt'); cleanup(); }); diff --git a/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts b/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts new file mode 100644 index 000000000..5d4d956bd --- /dev/null +++ b/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TextTrack } from '../../../core/types'; +import { TextTrackSegmentLoaderActor } from '../text-track-segment-loader-actor'; +import { TextTracksActor } from '../text-tracks-actor'; + +vi.mock('../../text/parse-vtt-segment', () => ({ + parseVttSegment: vi.fn((url: string) => { + if (url.includes('fail')) { + return Promise.reject(new Error('Network error')); + } + return Promise.resolve([new VTTCue(0, 5, `Cue from ${url}`)]); + }), + destroyVttParser: vi.fn(), +})); + +function makeMediaElement(trackIds: string[]): HTMLMediaElement { + const video = document.createElement('video'); + for (const id of trackIds) { + const el = document.createElement('track'); + el.id = id; + el.kind = 'subtitles'; + video.appendChild(el); + el.track.mode = 'hidden'; + } + return video; +} + +function makeResolvedTextTrack(id: string, segmentUrls: string[]): TextTrack { + return { + type: 'text', + id, + url: 'https://example.com/text.m3u8', + mimeType: 'text/vtt', + bandwidth: 0, + groupId: 'subs', + label: 'English', + kind: 'subtitles', + language: 'en', + startTime: 0, + duration: segmentUrls.length * 10, + segments: segmentUrls.map((url, i) => ({ + id: `seg-${i}`, + url, + duration: 10, + startTime: i * 10, + })), + }; +} + +describe('TextTrackSegmentLoaderActor', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('starts with idle status and empty context', () => { + const video = makeMediaElement(['track-en']); + const textTracksActor = new TextTracksActor(video); + const actor = new TextTrackSegmentLoaderActor(textTracksActor); + + expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().context).toEqual({}); + + actor.destroy(); + textTracksActor.destroy(); + }); + + it('transitions to done immediately when no segments need loading', () => { + const video = makeMediaElement(['track-en']); + const textTracksActor = new TextTracksActor(video); + const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const track = makeResolvedTextTrack('track-en', []); + + actor.send({ type: 'load', track, currentTime: 0 }); + + expect(actor.snapshot.get().status).toBe('done'); + + actor.destroy(); + textTracksActor.destroy(); + }); + + it('transitions loading → done after all segments are fetched', async () => { + const video = makeMediaElement(['track-en']); + const textTracksActor = new TextTracksActor(video); + const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); + + actor.send({ type: 'load', track, currentTime: 0 }); + expect(actor.snapshot.get().status).toBe('loading'); + + await vi.waitFor(() => { + expect(actor.snapshot.get().status).toBe('done'); + }); + + actor.destroy(); + textTracksActor.destroy(); + }); + + it('delegates cue loading to TextTracksActor', async () => { + const video = makeMediaElement(['track-en']); + const textTracksActor = new TextTracksActor(video); + const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt', 'https://example.com/seg-1.vtt']); + + actor.send({ type: 'load', track, currentTime: 0 }); + + await vi.waitFor(() => { + expect(actor.snapshot.get().status).toBe('done'); + }); + + expect(textTracksActor.snapshot.get().context.segments['track-en']).toHaveLength(2); + expect(textTracksActor.snapshot.get().context.loaded['track-en']).toHaveLength(2); + + actor.destroy(); + textTracksActor.destroy(); + }); + + it('skips already-loaded segments on repeat send()', async () => { + const { parseVttSegment } = await import('../../text/parse-vtt-segment'); + + const video = makeMediaElement(['track-en']); + const textTracksActor = new TextTracksActor(video); + const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt', 'https://example.com/seg-1.vtt']); + + actor.send({ type: 'load', track, currentTime: 0 }); + await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('done')); + expect(parseVttSegment).toHaveBeenCalledTimes(2); + + // Repeat send — all segments already in TextTracksActor context + actor.send({ type: 'load', track, currentTime: 0 }); + expect(actor.snapshot.get().status).toBe('done'); + expect(parseVttSegment).toHaveBeenCalledTimes(2); + + actor.destroy(); + textTracksActor.destroy(); + }); + + it('continues loading remaining segments after a fetch error', async () => { + const { parseVttSegment } = await import('../../text/parse-vtt-segment'); + vi.mocked(parseVttSegment) + .mockResolvedValueOnce([new VTTCue(0, 5, 'Good')]) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce([new VTTCue(20, 25, 'Also good')]); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const video = makeMediaElement(['track-en']); + const textTracksActor = new TextTracksActor(video); + const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const track = makeResolvedTextTrack('track-en', [ + 'https://example.com/seg-0.vtt', + 'https://example.com/fail.vtt', + 'https://example.com/seg-2.vtt', + ]); + + actor.send({ type: 'load', track, currentTime: 0 }); + + await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('done')); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load VTT segment:', expect.any(Error)); + // Segments 0 and 2 succeeded; the failed segment is not recorded + expect(textTracksActor.snapshot.get().context.segments['track-en']).toHaveLength(2); + + consoleErrorSpy.mockRestore(); + actor.destroy(); + textTracksActor.destroy(); + }); + + it('preempts in-flight work when a new send() arrives', async () => { + const { parseVttSegment } = await import('../../text/parse-vtt-segment'); + + let resolveSeg0!: (cues: VTTCue[]) => void; + vi.mocked(parseVttSegment) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSeg0 = resolve; + }) + ) + .mockResolvedValue([new VTTCue(0, 5, 'Cue')]); + + const video = makeMediaElement(['track-en', 'track-es']); + const textTracksActor = new TextTracksActor(video); + const actor = new TextTrackSegmentLoaderActor(textTracksActor); + + const track1 = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); + const track2 = makeResolvedTextTrack('track-es', ['https://example.com/seg-1.vtt']); + + // Start loading track1 — paused waiting for seg0 + actor.send({ type: 'load', track: track1, currentTime: 0 }); + expect(actor.snapshot.get().status).toBe('loading'); + + // Wait for the Task to actually start running — resolveSeg0 is assigned inside + // the Promise constructor, which executes when parseVttSegment is called async. + await vi.waitFor(() => expect(parseVttSegment).toHaveBeenCalledTimes(1)); + + // Switch to track2 — preempts track1 + actor.send({ type: 'load', track: track2, currentTime: 0 }); + + // Unblock seg0 — signal is already aborted, so the cue is discarded + resolveSeg0([]); + + await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('done')); + + // track-en was preempted — no cues recorded + expect(textTracksActor.snapshot.get().context.segments['track-en']).toBeUndefined(); + // track-es completed successfully + expect(textTracksActor.snapshot.get().context.segments['track-es']).toHaveLength(1); + + actor.destroy(); + textTracksActor.destroy(); + }); + + it('transitions to destroyed on destroy()', () => { + const video = makeMediaElement(['track-en']); + const textTracksActor = new TextTracksActor(video); + const actor = new TextTrackSegmentLoaderActor(textTracksActor); + + actor.destroy(); + + expect(actor.snapshot.get().status).toBe('destroyed'); + + textTracksActor.destroy(); + }); + + it('ignores send() after destroy()', async () => { + const { parseVttSegment } = await import('../../text/parse-vtt-segment'); + + const video = makeMediaElement(['track-en']); + const textTracksActor = new TextTracksActor(video); + const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); + + actor.destroy(); + actor.send({ type: 'load', track, currentTime: 0 }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(parseVttSegment).not.toHaveBeenCalled(); + expect(actor.snapshot.get().status).toBe('destroyed'); + + textTracksActor.destroy(); + }); + + it('snapshot is reactive — status transitions are observable via signal', async () => { + const video = makeMediaElement(['track-en']); + const textTracksActor = new TextTracksActor(video); + const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); + + const observed = [actor.snapshot.get().status]; + + actor.send({ type: 'load', track, currentTime: 0 }); + observed.push(actor.snapshot.get().status); + + await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('done')); + observed.push(actor.snapshot.get().status); + + expect(observed).toEqual(['idle', 'loading', 'done']); + + actor.destroy(); + textTracksActor.destroy(); + }); +}); diff --git a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts new file mode 100644 index 000000000..37ba387d6 --- /dev/null +++ b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts @@ -0,0 +1,120 @@ +import type { ActorSnapshot, SignalActor } from '../../core/actor'; +import { getSegmentsToLoad } from '../../core/buffer/forward-buffer'; +import type { ReadonlySignal } from '../../core/signals/primitives'; +import { signal, untrack, update } from '../../core/signals/primitives'; +import { SerialRunner, Task } from '../../core/task'; +import type { TextTrack } from '../../core/types'; +import { parseVttSegment } from '../text/parse-vtt-segment'; +import type { TextTracksActor } from './text-tracks-actor'; + +// ============================================================================= +// Types +// ============================================================================= + +export type TextTrackSegmentLoaderStatus = 'idle' | 'loading' | 'done' | 'destroyed'; + +export type TextTrackSegmentLoaderSnapshot = ActorSnapshot; + +export type TextTrackSegmentLoaderMessage = { + type: 'load'; + track: TextTrack; + currentTime: number; +}; + +// ============================================================================= +// Implementation +// ============================================================================= + +/** + * Loads VTT segments for a text track and delegates cue management to a + * TextTracksActor. Mirrors the SegmentLoaderActor/SourceBufferActor pattern + * for the text track equivalent. + * + * Planning is done in send(): segments already recorded in TextTracksActor's + * context are skipped. Each new send() preempts in-flight work via abortAll() + * before scheduling fresh tasks. + */ +export class TextTrackSegmentLoaderActor implements SignalActor { + readonly #textTracksActor: TextTracksActor; + readonly #snapshotSignal = signal({ + status: 'idle', + context: {}, + }); + readonly #runner = new SerialRunner(); + #destroyed = false; + #loadGeneration = 0; + + constructor(textTracksActor: TextTracksActor) { + this.#textTracksActor = textTracksActor; + } + + get snapshot(): ReadonlySignal { + return this.#snapshotSignal; + } + + send(message: TextTrackSegmentLoaderMessage): void { + if (this.#destroyed) return; + + const { track, currentTime } = message; + const trackId = track.id; + + // Plan: determine which segments still need loading. + // TextTracksActor owns the authoritative record of loaded segments. + const loadedIds = new Set( + untrack(() => (this.#textTracksActor.snapshot.get().context.segments[trackId] ?? []).map((s) => s.id)) + ); + const alreadyLoaded = track.segments.filter((s) => loadedIds.has(s.id)); + const segmentsToLoad = getSegmentsToLoad(track.segments, alreadyLoaded, currentTime).filter( + (s) => !loadedIds.has(s.id) + ); + + // Preempt any in-flight work before scheduling the new plan. + this.#runner.abortAll(); + + if (segmentsToLoad.length === 0) { + update(this.#snapshotSignal, { status: 'done' }); + return; + } + + const generation = ++this.#loadGeneration; + update(this.#snapshotSignal, { status: 'loading' }); + + // Capture actor reference so Tasks close over it, not `this`. + const textTracksActor = this.#textTracksActor; + + const promises = segmentsToLoad.map((segment) => + this.#runner.schedule( + new Task(async (signal) => { + if (signal.aborted) return; + try { + const cues = await parseVttSegment(segment.url); + if (signal.aborted) return; + textTracksActor.send({ type: 'add-cues', trackId, segmentId: segment.id, cues }); + } catch (error) { + // Graceful degradation: log and continue to the next segment. + console.error('Failed to load VTT segment:', error); + } + }) + ) + ); + + // Transition to 'done' once all tasks in this generation complete. + // The generation check prevents a stale transition if a new send() arrived. + Promise.all(promises) + .then(() => { + if (this.#destroyed || this.#loadGeneration !== generation) return; + update(this.#snapshotSignal, { status: 'done' }); + }) + .catch(() => { + // Tasks handle their own errors; this catch prevents unhandled rejection + // in the unlikely event a Task throws despite the internal try/catch. + }); + } + + destroy(): void { + if (this.#destroyed) return; + this.#destroyed = true; + this.#runner.destroy(); + update(this.#snapshotSignal, { status: 'destroyed' }); + } +} From f7f6521c49958d9872e0d776cce406a26a4c4fda Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 31 Mar 2026 12:52:05 -0700 Subject: [PATCH 10/79] refactor(spf): align text track actor message/context shapes with SourceBufferActor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces CueSegmentMeta (id + startTime + duration + trackId) on AddCuesMessage, mirroring AppendSegmentMeta on AppendSegmentMessage. TextTracksActorContext.segments now stores full segment timing instead of bare { id } records. This lets TextTrackSegmentLoaderActor pass context.segments[trackId] directly to getSegmentsToLoad, eliminating the id→object reconstruction and the redundant id-based double-filter that worked around the mismatch. Also removes the distinct 'done' status (collapses back to 'idle') and the redundant #destroyed flag (status signal is the source of truth). Co-Authored-By: Claude Sonnet 4.6 --- .../text-track-segment-loader-actor.test.ts | 22 ++++---- .../features/tests/text-tracks-actor.test.ts | 52 +++++++++++-------- .../text-track-segment-loader-actor.ts | 38 +++++++------- .../spf/src/dom/features/text-tracks-actor.ts | 13 +++-- 4 files changed, 69 insertions(+), 56 deletions(-) diff --git a/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts b/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts index 5d4d956bd..f385bd439 100644 --- a/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts +++ b/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts @@ -64,7 +64,7 @@ describe('TextTrackSegmentLoaderActor', () => { textTracksActor.destroy(); }); - it('transitions to done immediately when no segments need loading', () => { + it('stays idle when no segments need loading', () => { const video = makeMediaElement(['track-en']); const textTracksActor = new TextTracksActor(video); const actor = new TextTrackSegmentLoaderActor(textTracksActor); @@ -72,13 +72,13 @@ describe('TextTrackSegmentLoaderActor', () => { actor.send({ type: 'load', track, currentTime: 0 }); - expect(actor.snapshot.get().status).toBe('done'); + expect(actor.snapshot.get().status).toBe('idle'); actor.destroy(); textTracksActor.destroy(); }); - it('transitions loading → done after all segments are fetched', async () => { + it('transitions loading → idle after all segments are fetched', async () => { const video = makeMediaElement(['track-en']); const textTracksActor = new TextTracksActor(video); const actor = new TextTrackSegmentLoaderActor(textTracksActor); @@ -88,7 +88,7 @@ describe('TextTrackSegmentLoaderActor', () => { expect(actor.snapshot.get().status).toBe('loading'); await vi.waitFor(() => { - expect(actor.snapshot.get().status).toBe('done'); + expect(actor.snapshot.get().status).toBe('idle'); }); actor.destroy(); @@ -104,7 +104,7 @@ describe('TextTrackSegmentLoaderActor', () => { actor.send({ type: 'load', track, currentTime: 0 }); await vi.waitFor(() => { - expect(actor.snapshot.get().status).toBe('done'); + expect(actor.snapshot.get().status).toBe('idle'); }); expect(textTracksActor.snapshot.get().context.segments['track-en']).toHaveLength(2); @@ -123,12 +123,12 @@ describe('TextTrackSegmentLoaderActor', () => { const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt', 'https://example.com/seg-1.vtt']); actor.send({ type: 'load', track, currentTime: 0 }); - await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('done')); + await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('idle')); expect(parseVttSegment).toHaveBeenCalledTimes(2); // Repeat send — all segments already in TextTracksActor context actor.send({ type: 'load', track, currentTime: 0 }); - expect(actor.snapshot.get().status).toBe('done'); + expect(actor.snapshot.get().status).toBe('idle'); expect(parseVttSegment).toHaveBeenCalledTimes(2); actor.destroy(); @@ -155,7 +155,7 @@ describe('TextTrackSegmentLoaderActor', () => { actor.send({ type: 'load', track, currentTime: 0 }); - await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('done')); + await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('idle')); expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load VTT segment:', expect.any(Error)); // Segments 0 and 2 succeeded; the failed segment is not recorded @@ -200,7 +200,7 @@ describe('TextTrackSegmentLoaderActor', () => { // Unblock seg0 — signal is already aborted, so the cue is discarded resolveSeg0([]); - await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('done')); + await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('idle')); // track-en was preempted — no cues recorded expect(textTracksActor.snapshot.get().context.segments['track-en']).toBeUndefined(); @@ -253,10 +253,10 @@ describe('TextTrackSegmentLoaderActor', () => { actor.send({ type: 'load', track, currentTime: 0 }); observed.push(actor.snapshot.get().status); - await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('done')); + await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('idle')); observed.push(actor.snapshot.get().status); - expect(observed).toEqual(['idle', 'loading', 'done']); + expect(observed).toEqual(['idle', 'loading', 'idle']); actor.destroy(); textTracksActor.destroy(); diff --git a/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts b/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts index edc7fc55a..b500b965f 100644 --- a/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts +++ b/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import type { CueSegmentMeta } from '../text-tracks-actor'; import { TextTracksActor } from '../text-tracks-actor'; function makeMediaElement(trackIds: string[]): HTMLMediaElement { @@ -12,6 +13,10 @@ function makeMediaElement(trackIds: string[]): HTMLMediaElement { return video; } +function meta(trackId: string, id: string, startTime = 0, duration = 10): CueSegmentMeta { + return { trackId, id, startTime, duration }; +} + describe('TextTracksActor', () => { it('starts with idle status and empty context', () => { const video = makeMediaElement(['track-en']); @@ -28,7 +33,7 @@ describe('TextTracksActor', () => { const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-0'), cues: [new VTTCue(0, 2, 'Hello')] }); expect(textTrack.cues?.length).toBe(1); }); @@ -41,8 +46,7 @@ describe('TextTracksActor', () => { actor.send({ type: 'add-cues', - trackId: 'track-en', - segmentId: 'seg-0', + meta: meta('track-en', 'seg-0'), cues: [new VTTCue(0, 2, 'Hello'), new VTTCue(2, 4, 'World')], }); @@ -58,10 +62,13 @@ describe('TextTracksActor', () => { const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-1', cues: [new VTTCue(2, 4, 'World')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-0', 0, 10), cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-1', 10, 10), cues: [new VTTCue(2, 4, 'World')] }); - expect(actor.snapshot.get().context.segments['track-en']).toEqual([{ id: 'seg-0' }, { id: 'seg-1' }]); + expect(actor.snapshot.get().context.segments['track-en']).toEqual([ + { id: 'seg-0', startTime: 0, duration: 10 }, + { id: 'seg-1', startTime: 10, duration: 10 }, + ]); }); it('deduplicates cues by startTime + endTime + text', () => { @@ -70,8 +77,8 @@ describe('TextTracksActor', () => { const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-1', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-0', 0, 10), cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-1', 10, 10), cues: [new VTTCue(0, 2, 'Hello')] }); expect(textTrack.cues?.length).toBe(1); expect(actor.snapshot.get().context.loaded['track-en']).toHaveLength(1); @@ -83,8 +90,8 @@ describe('TextTracksActor', () => { const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-0'), cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-0'), cues: [new VTTCue(0, 2, 'Hello')] }); expect(actor.snapshot.get().context.segments['track-en']).toHaveLength(1); }); @@ -95,10 +102,10 @@ describe('TextTracksActor', () => { const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-0'), cues: [new VTTCue(0, 2, 'Hello')] }); const snapshotAfterFirst = actor.snapshot.get(); - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-0'), cues: [new VTTCue(0, 2, 'Hello')] }); expect(actor.snapshot.get()).toBe(snapshotAfterFirst); }); @@ -109,8 +116,8 @@ describe('TextTracksActor', () => { const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-1', cues: [new VTTCue(0, 2, 'Hola')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-0', 0, 10), cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-1', 10, 10), cues: [new VTTCue(0, 2, 'Hola')] }); expect(textTrack.cues?.length).toBe(2); }); @@ -120,25 +127,24 @@ describe('TextTracksActor', () => { const actor = new TextTracksActor(video); for (const t of Array.from(video.textTracks)) t.mode = 'hidden'; - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-0'), cues: [new VTTCue(0, 2, 'Hello')] }); actor.send({ type: 'add-cues', - trackId: 'track-es', - segmentId: 'seg-0', + meta: meta('track-es', 'seg-0'), cues: [new VTTCue(0, 2, 'Hola'), new VTTCue(2, 4, 'Mundo')], }); expect(actor.snapshot.get().context.loaded['track-en']).toHaveLength(1); expect(actor.snapshot.get().context.loaded['track-es']).toHaveLength(2); - expect(actor.snapshot.get().context.segments['track-en']).toEqual([{ id: 'seg-0' }]); - expect(actor.snapshot.get().context.segments['track-es']).toEqual([{ id: 'seg-0' }]); + expect(actor.snapshot.get().context.segments['track-en']).toEqual([{ id: 'seg-0', startTime: 0, duration: 10 }]); + expect(actor.snapshot.get().context.segments['track-es']).toEqual([{ id: 'seg-0', startTime: 0, duration: 10 }]); }); it('is a no-op when trackId is not found in textTracks', () => { const video = makeMediaElement(['track-en']); const actor = new TextTracksActor(video); - actor.send({ type: 'add-cues', trackId: 'nonexistent', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('nonexistent', 'seg-0'), cues: [new VTTCue(0, 2, 'Hello')] }); expect(actor.snapshot.get().context.loaded).toEqual({}); expect(actor.snapshot.get().context.segments).toEqual({}); @@ -160,7 +166,7 @@ describe('TextTracksActor', () => { textTrack.mode = 'hidden'; actor.destroy(); - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-0'), cues: [new VTTCue(0, 2, 'Hello')] }); expect(textTrack.cues?.length ?? 0).toBe(0); expect(actor.snapshot.get().context.loaded).toEqual({}); @@ -176,12 +182,12 @@ describe('TextTracksActor', () => { const snapshots: ReturnType[] = []; snapshots.push(actor.snapshot.get()); - actor.send({ type: 'add-cues', trackId: 'track-en', segmentId: 'seg-0', cues: [new VTTCue(0, 2, 'Hello')] }); + actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-0', 0, 10), cues: [new VTTCue(0, 2, 'Hello')] }); snapshots.push(actor.snapshot.get()); expect(snapshots[0]!.context.loaded['track-en']).toBeUndefined(); expect(snapshots[1]!.context.loaded['track-en']).toHaveLength(1); expect(snapshots[0]!.context.segments['track-en']).toBeUndefined(); - expect(snapshots[1]!.context.segments['track-en']).toEqual([{ id: 'seg-0' }]); + expect(snapshots[1]!.context.segments['track-en']).toEqual([{ id: 'seg-0', startTime: 0, duration: 10 }]); }); }); diff --git a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts index 37ba387d6..244d97b04 100644 --- a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts +++ b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts @@ -11,7 +11,7 @@ import type { TextTracksActor } from './text-tracks-actor'; // Types // ============================================================================= -export type TextTrackSegmentLoaderStatus = 'idle' | 'loading' | 'done' | 'destroyed'; +export type TextTrackSegmentLoaderStatus = 'idle' | 'loading' | 'destroyed'; export type TextTrackSegmentLoaderSnapshot = ActorSnapshot; @@ -41,7 +41,6 @@ export class TextTrackSegmentLoaderActor implements SignalActor (this.#textTracksActor.snapshot.get().context.segments[trackId] ?? []).map((s) => s.id)) - ); - const alreadyLoaded = track.segments.filter((s) => loadedIds.has(s.id)); - const segmentsToLoad = getSegmentsToLoad(track.segments, alreadyLoaded, currentTime).filter( - (s) => !loadedIds.has(s.id) - ); + // untrack() covers both reads: the Actor's own status and the TextTracksActor's + // snapshot — neither should subscribe the calling Reactor's effect. + const [isDestroyed, bufferedSegments] = untrack(() => { + const status = this.#snapshotSignal.get().status; + const segments = this.#textTracksActor.snapshot.get().context.segments[trackId] ?? []; + return [status === 'destroyed', segments] as const; + }); + if (isDestroyed) return; + const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime); // Preempt any in-flight work before scheduling the new plan. this.#runner.abortAll(); if (segmentsToLoad.length === 0) { - update(this.#snapshotSignal, { status: 'done' }); + update(this.#snapshotSignal, { status: 'idle' }); return; } @@ -89,7 +88,11 @@ export class TextTrackSegmentLoaderActor implements SignalActor { - if (this.#destroyed || this.#loadGeneration !== generation) return; - update(this.#snapshotSignal, { status: 'done' }); + if (this.#snapshotSignal.get().status === 'destroyed' || this.#loadGeneration !== generation) return; + update(this.#snapshotSignal, { status: 'idle' }); }) .catch(() => { // Tasks handle their own errors; this catch prevents unhandled rejection @@ -112,8 +115,7 @@ export class TextTrackSegmentLoaderActor implements SignalActor & { trackId: string }; + /** Non-finite (extended) data managed by the actor — the XState "context". */ export interface TextTracksActorContext { /** Cues added per track ID. Used for duplicate detection and snapshot observability. */ loaded: Record; /** Segments whose cues have been fully added, keyed by track ID. Used for load planning. */ - segments: Record>; + segments: Record>>; } /** Complete snapshot of a TextTracksActor. */ export type TextTracksActorSnapshot = ActorSnapshot; -export type AddCuesMessage = { type: 'add-cues'; trackId: string; segmentId: string; cues: VTTCue[] }; +export type AddCuesMessage = { type: 'add-cues'; meta: CueSegmentMeta; cues: VTTCue[] }; export type TextTracksActorMessage = AddCuesMessage; // ============================================================================= @@ -66,7 +70,8 @@ export class TextTracksActor implements SignalActor t.id === trackId); if (!textTrack) return; @@ -88,7 +93,7 @@ export class TextTracksActor implements SignalActor Date: Tue, 31 Mar 2026 13:21:34 -0700 Subject: [PATCH 11/79] refactor(spf): add SerialRunner.settled; simplify TextTrackSegmentLoaderActor Exposes the runner's current chain tail as a read-only `settled` getter. Capturing it after scheduling a batch and comparing identity in the resolution callback replaces the #loadGeneration counter, Promise.all collection, and .catch() in TextTrackSegmentLoaderActor. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/task.ts | 10 +++++ .../text-track-segment-loader-actor.ts | 45 +++++++------------ 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/packages/spf/src/core/task.ts b/packages/spf/src/core/task.ts index 307048765..746450ad5 100644 --- a/packages/spf/src/core/task.ts +++ b/packages/spf/src/core/task.ts @@ -192,6 +192,16 @@ export class SerialRunner { return result as Promise; } + /** + * A promise that resolves when all currently-scheduled tasks have settled. + * Use the reference as a generation token: capture it after scheduling a + * batch, then check identity in the resolution callback to detect whether + * a subsequent abortAll() + new batch has superseded this one. + */ + get settled(): Promise { + return this.#chain as Promise; + } + abortAll(): void { for (const task of this.#pending) task.abort(); this.#pending.clear(); diff --git a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts index 244d97b04..bdf98edeb 100644 --- a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts +++ b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts @@ -41,7 +41,6 @@ export class TextTrackSegmentLoaderActor implements SignalActor this.#snapshotSignal.get().status); + if (status === 'destroyed') return; const { track, currentTime } = message; const trackId = track.id; - - // Plan: determine which segments still need loading. - // TextTracksActor owns the authoritative record of loaded segments. - // untrack() covers both reads: the Actor's own status and the TextTracksActor's - // snapshot — neither should subscribe the calling Reactor's effect. - const [isDestroyed, bufferedSegments] = untrack(() => { - const status = this.#snapshotSignal.get().status; - const segments = this.#textTracksActor.snapshot.get().context.segments[trackId] ?? []; - return [status === 'destroyed', segments] as const; - }); - if (isDestroyed) return; + const bufferedSegments = untrack(() => this.#textTracksActor.snapshot.get().context.segments[trackId] ?? []); const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime); // Preempt any in-flight work before scheduling the new plan. this.#runner.abortAll(); - - if (segmentsToLoad.length === 0) { + if (!segmentsToLoad.length) { update(this.#snapshotSignal, { status: 'idle' }); return; } - const generation = ++this.#loadGeneration; update(this.#snapshotSignal, { status: 'loading' }); // Capture actor reference so Tasks close over it, not `this`. const textTracksActor = this.#textTracksActor; - const promises = segmentsToLoad.map((segment) => + for (const segment of segmentsToLoad) { this.#runner.schedule( new Task(async (signal) => { if (signal.aborted) return; @@ -98,20 +87,20 @@ export class TextTrackSegmentLoaderActor implements SignalActor { - if (this.#snapshotSignal.get().status === 'destroyed' || this.#loadGeneration !== generation) return; + // Transition to idle when this generation's tasks all settle. + // runner.settled is the chain tail after the tasks above; if a new send() + // arrives and abortAll() + new scheduling advances the chain, the reference + // will differ and the stale callback is a no-op. + const settled = this.#runner.settled; + settled.then(() => { + if (this.#runner.settled !== settled) return; + if (this.#snapshotSignal.get().status !== 'destroyed') { update(this.#snapshotSignal, { status: 'idle' }); - }) - .catch(() => { - // Tasks handle their own errors; this catch prevents unhandled rejection - // in the unlikely event a Task throws despite the internal try/catch. - }); + } + }); } destroy(): void { From 656909c8f6b60534c8f3a9c66d8e93b1d98776d3 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 31 Mar 2026 13:27:06 -0700 Subject: [PATCH 12/79] refactor(spf): tighten getSegmentsToLoad param type; minor actor cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrows bufferedSegments to Pick — the only fields the function actually uses. Removes the assumption that callers must hold full Segment objects. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/buffer/forward-buffer.ts | 2 +- .../spf/src/dom/features/text-track-segment-loader-actor.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/spf/src/core/buffer/forward-buffer.ts b/packages/spf/src/core/buffer/forward-buffer.ts index 3b770e243..90dd85810 100644 --- a/packages/spf/src/core/buffer/forward-buffer.ts +++ b/packages/spf/src/core/buffer/forward-buffer.ts @@ -91,7 +91,7 @@ export function calculateForwardFlushPoint( export function getSegmentsToLoad( segments: readonly Segment[], - bufferedSegments: readonly Segment[], + bufferedSegments: readonly Pick[], currentTime: number, config: ForwardBufferConfig = DEFAULT_FORWARD_BUFFER_CONFIG ): Segment[] { diff --git a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts index bdf98edeb..1b8a75466 100644 --- a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts +++ b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts @@ -69,8 +69,7 @@ export class TextTrackSegmentLoaderActor implements SignalActor { this.#runner.schedule( new Task(async (signal) => { if (signal.aborted) return; @@ -88,7 +87,7 @@ export class TextTrackSegmentLoaderActor implements SignalActor Date: Tue, 31 Mar 2026 18:37:31 -0700 Subject: [PATCH 13/79] feat(spf): add createActor factory with SerialRunner.whenSettled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `createActor` — a declarative factory for building message-driven actors with finite state, per-state message handlers, an optional task runner, and automatic `onSettled` transitions. `SerialRunner.whenSettled(callback)` owns the generation-token logic internally, eliminating the need for a runner wrapper or scheduling-tracking flag in the actor. The raw runner is passed directly to handlers. Design documented in `internal/design/spf/actor-reactor-factories.md`. Co-Authored-By: Claude Sonnet 4.6 --- .../design/spf/actor-reactor-factories.md | 345 +++++++++++++++ internal/design/spf/index.md | 13 +- internal/design/spf/primitives.md | 27 +- packages/spf/src/core/create-actor.ts | 223 ++++++++++ packages/spf/src/core/task.ts | 19 + .../spf/src/core/tests/create-actor.test.ts | 415 ++++++++++++++++++ 6 files changed, 1028 insertions(+), 14 deletions(-) create mode 100644 internal/design/spf/actor-reactor-factories.md create mode 100644 packages/spf/src/core/create-actor.ts create mode 100644 packages/spf/src/core/tests/create-actor.test.ts diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md new file mode 100644 index 000000000..3a98c1256 --- /dev/null +++ b/internal/design/spf/actor-reactor-factories.md @@ -0,0 +1,345 @@ +--- +status: decided +date: 2026-03-31 +--- + +# Actor and Reactor Factories + +Design for `createActor` and `createReactor` — the declarative factory functions that replace +bespoke Actor classes and function-based Reactors in SPF. + +Motivated by the text track architecture spike (see `.claude/plans/foamy-finding-quasar.md`), +which produced the first proper `SignalActor` class implementations and surfaced the need for +shared, principled primitives. + +--- + +## Decision + +Actors and Reactors are defined via a **declarative definition object** passed to a factory +function. The factory constructs the live instance — managing the status signal, runner +lifecycle, and `'destroyed'` terminal state. Consumers define behavior; the framework handles +mechanics. + +Two separate factories: + +```typescript +const actor = createActor(actorDefinition); +const reactor = createReactor(reactorDefinition); +``` + +Both return instances that implement `SignalActor` and expose `snapshot` and `destroy()`. + +--- + +## Actor Definition + +### Shape + +```typescript +type ActorDefinition = { + runner?: () => RunnerLike; // factory — called once at createActor() time + initial: UserStatus; + context: Context; + states: { + [S in UserStatus]: { + onSettled?: UserStatus; // when runner settles in this state → transition here + on?: { + [M in Message as M['type']]?: ( + message: M, + ctx: { + transition: (to: UserStatus) => void; + runner: RunnerLike; + context: Context; + setContext: (next: Context) => void; + } + ) => void; + }; + }; + }; +}; +``` + +### Example — `TextTrackSegmentLoaderActor` + +```typescript +import { SerialRunner, Task } from '../../core/task'; +import { parseVttSegment } from '../text/parse-vtt-segment'; + +const textTrackSegmentLoaderDef = { + runner: () => new SerialRunner(), + initial: 'idle' as const, + context: {} as Record, + states: { + idle: { + on: { + load: (msg, { transition, runner }) => { + const segments = plan(msg); + if (!segments.length) return; + segments.forEach(s => runner.schedule(new Task(async (signal) => { + const cues = await parseVttSegment(s.url); + if (!signal.aborted) textTracksActor.send({ type: 'add-cues', ... }); + }))); + transition('loading'); + } + } + }, + loading: { + onSettled: 'idle', + on: { + load: (msg, { runner }) => { + runner.abortAll(); + plan(msg).forEach(s => runner.schedule(new Task(...))); + // stays 'loading' — onSettled handles → 'idle' + } + } + } + } +}; +``` + +### Example — `TextTracksActor` (no runner, synchronous) + +```typescript +const textTracksActorDef = { + // runner: omitted — no async work + initial: 'idle' as const, + context: { loaded: {}, segments: {} } as TextTracksActorContext, + states: { + idle: { + on: { + 'add-cues': (msg, { context, setContext }) => { + setContext(applyAddCues(context, msg)); + } + } + } + } +}; +``` + +--- + +## Reactor Definition + +### Shape + +```typescript +type ReactorDefinition = { + initial: UserStatus; + states: { + [S in UserStatus]: Array< + (ctx: { transition: (to: UserStatus) => void }) => (() => void) | void + >; + }; +}; +``` + +Each array element becomes one independent `effect()` call gated on that state. Multiple entries +for the same state produce multiple effects — each with independent dependency tracking and +cleanup. This is the mechanism that replaces multiple named `cleanupX` variables in the current +function-based reactors. + +### Example — `syncTextTracks` + +```typescript +const syncTextTracksDef = { + initial: 'preconditions-unmet' as const, + states: { + 'preconditions-unmet': [ + ({ transition }) => { + if (preconditionsMet.get()) transition('setting-up'); + } + ], + 'setting-up': [ + ({ transition }) => { + setupTextTracks(mediaElement.get()!, modelTextTracks.get()!); + transition('set-up'); + } + ], + 'set-up': [ + // Effect #1 — guards state; exit cleanup tears down track elements + ({ transition }) => { + if (!preconditionsMet.get()) { transition('preconditions-unmet'); return; } + const el = untrack(() => mediaElement.get()!); + return () => teardownTextTracks(el); + }, + // Effect #2 — mode sync + DOM change listener (independent tracking/cleanup) + () => { + syncModes(mediaElement.textTracks, selectedId.get()); + const unlisten = listen(mediaElement.textTracks, 'change', onChange); + return () => unlisten(); + } + ] + } +}; +``` + +--- + +## Key Design Decisions + +### Factory functions, not base classes + +**Decision:** `createActor(def)` and `createReactor(def)` rather than `extends BaseActor` / +`extends Reactor`. + +**Alternatives considered:** +- **Base class + subclass** — `class TextTracksActor extends BaseActor<...>`. Familiar OO pattern, + explicit contract. But inheritance couples the consumer to the framework's class hierarchy, + limits composition, and makes the definition implicit (spread across the constructor body). +- **Interface only** — each Actor/Reactor implements `SignalActor` directly. No boilerplate + reduction; every implementation reimplements the same snapshot/signal/destroy mechanics. + +**Rationale:** A definition object is pure data — inspectable, serializable, testable in isolation +without instantiation. The factory owns all mechanics (snapshot signal, runner lifecycle, +`'destroyed'` guard); the definition owns behavior. Aligns with the XState model and keeps the +door open for a future definition-vs-implementation separation (see below). + +--- + +### Separate `createActor` and `createReactor` + +**Decision:** Two distinct factories with distinct definition shapes. + +**Alternatives considered:** +- **Unified `createMachine`** — one factory for both, distinguishing by definition shape (Actors + have `on`/`runner`; Reactors have effect arrays). XState does this. + +**Rationale:** Actors and Reactors have genuinely different input shapes and internal mechanics. +A unified factory would produce a definition type with optional properties for both cases, +losing type-level guarantees (e.g., a Reactor definition shouldn't have `runner` or `on`). +The shared core — status signal, `'destroyed'` terminal, `destroy()` — is thin enough to +extract as an internal `createMachineCore` without a unified public API. XState unifies because +its actors ARE the reactive graph; in SPF, the separation between reactive observation (Reactor) +and message dispatch (Actor) is intentional and worth preserving in the API surface. + +--- + +### `'destroyed'` is implicit and always enforced + +**Decision:** User-defined status types never include `'destroyed'`. The framework always adds it +as the terminal state. `destroy()` on any Actor or Reactor always transitions to `'destroyed'` +and calls exit cleanup for the currently active state. + +```typescript +// User defines: +type LoaderUserStatus = 'idle' | 'loading'; +// Framework produces: +type LoaderStatus = 'idle' | 'loading' | 'destroyed'; +``` + +**Rationale:** `'destroyed'` is universal — every Actor and Reactor has it. Making it implicit +ensures it can't be accidentally omitted or given a custom behavior that breaks framework +guarantees (e.g., `send()` being a no-op in the destroyed state). Users only define their +domain-meaningful states. + +--- + +### Runner as a factory function, actor-lifetime scope + +**Decision:** `runner: () => new SerialRunner()` — a factory function called once when +`createActor()` is called. The runner lives for the actor's full lifetime and is destroyed +when the actor is destroyed. + +**Alternatives considered:** +- **Magic strings** (`runner: 'serial'`) — requires a string-to-class registry and introduces an + extra import layer. Deferred to a possible future XState-style definition-vs-implementation + split. +- **Constructor reference** (`runner: SerialRunner`) — `new def.runner()`. Slightly less explicit + than a factory; doesn't compose as naturally when construction needs configuration. +- **State-lifetime runners** — runner created on state entry, destroyed on state exit. Naturally + eliminates the generation-token problem (`onSettled` always refers to the fresh chain). + Rejected because it prevents runner state from persisting across state transitions — the + current `TextTrackSegmentLoaderActor` intentionally keeps its runner across idle/loading cycles. + +**Rationale:** Actor-lifetime scope matches the current pattern and is the most flexible default. +A factory function (`() => new X(options)`) handles configured runners without changing the +framework. The generation-token problem (`onSettled` must refer to the latest chain, not a +stale one) is handled by the framework internally rather than by runner scope. + +--- + +### Per-state `on` handlers + +**Decision:** Message handlers are declared per state. The same message type can appear in +multiple states with different behavior. + +```typescript +states: { + idle: { on: { load: (msg, ctx) => { /* plan + schedule; transition → loading */ } } }, + loading: { on: { load: (msg, ctx) => { /* abort + replan; stay loading */ } } } +} +``` + +**Alternatives considered:** +- **Top-level `on`** with internal state guard — one handler per message type, branches on + `context.status` internally. More compact for simple cases, but hides state-dependent + behavior in imperative branches rather than making it explicit in the definition. + +**Rationale:** Matches XState's model. State-scoped handlers make valid message/state combinations +explicit and inspectable from the definition alone — no need to trace imperative branches. + +--- + +### `onSettled` at the state level + +**Decision:** Each state can declare `onSettled: 'targetStatus'`. When the actor's runner settles +(all scheduled tasks have completed) while the actor is in that state, the framework automatically +transitions to `targetStatus`. + +**Rationale:** This replaces the manual `runner.settled` reference-equality pattern in +`TextTrackSegmentLoaderActor`. The framework owns the generation-token logic — re-subscribing to +`runner.settled` each time tasks are scheduled so that `abortAll()` + reschedule correctly +cancels the previous settled callback. + +--- + +## XState-style Definition vs. Implementation + +The current design uses a single definition object that contains both structure (states, runner +type, initial status) and behavior (handler functions). XState v5 separates these: + +```typescript +// Definition — pure structure, no runtime dependencies +const def = setup({ actors: { fetcher: fetchActor } }).createMachine({ ... }); + +// Implementation — runtime wiring +const actor = createActor(def, { input: { ... } }); +``` + +This separation enables serialization, visualization, and testing the definition without +instantiation. SPF's current factory approach is compatible with this future direction: +`runner: () => new SerialRunner()` today becomes a named reference resolved against a provided +implementation map later. The migration path is additive — no existing definitions need to change. + +--- + +## Open Questions + +### `settled` on `ConcurrentRunner` + +`SerialRunner` exposes `.settled` (the current promise chain tail). `ConcurrentRunner` does not. +`onSettled` at the state level implies the runner has a way to signal completion. + +Options: +- Add `settled` to `ConcurrentRunner` (resolves when `#pending` map empties — same concept) +- Define a `SettledRunner` interface and make `onSettled` only valid for runners that implement it + +Leaning toward the former: `settled` is a generally useful concept for any runner. + +### Reactor `context` + +The current design gives Reactors no context (no non-finite state). This matches the current +function-based reactors. If a Reactor needs to track something across effect re-runs (e.g., +`prevInputs` in `loadSegments`), it currently uses a closure variable. + +Open: should `createReactor` support an optional `context` field, or should Reactor context +always be held via closure? Closure is simpler; a formal context field would make Reactor +snapshots richer and more inspectable. + +### Handler context API stability + +The second argument to message handlers is currently sketched as +`{ transition, runner, context, setContext }`. The exact shape — including whether `runner` is +always present (or `undefined` when no runner is declared) and whether `context` is the full +snapshot context or a subset — is to be finalized during implementation. diff --git a/internal/design/spf/index.md b/internal/design/spf/index.md index 19122bb57..a918c3c15 100644 --- a/internal/design/spf/index.md +++ b/internal/design/spf/index.md @@ -11,12 +11,13 @@ A lean, actor-based framework for HLS playback over MSE. Handles manifest parsin ## Contents -| Document | Purpose | -| ---------------------------------- | ------------------------------------------------------------- | -| [index.md](index.md) | Overview, problem, quick start, surface API | -| [primitives.md](primitives.md) | Foundational building blocks (Tasks, Actors, Reactors, State) | -| [architecture.md](architecture.md) | Current implementation: layers, components, data flow | -| [decisions.md](decisions.md) | Decided and open design decisions | +| Document | Purpose | +| ---------------------------------------------------------- | ------------------------------------------------------------- | +| [index.md](index.md) | Overview, problem, quick start, surface API | +| [primitives.md](primitives.md) | Foundational building blocks (Tasks, Actors, Reactors, State) | +| [actor-reactor-factories.md](actor-reactor-factories.md) | Decided design for `createActor` / `createReactor` factories | +| [architecture.md](architecture.md) | Current implementation: layers, components, data flow | +| [decisions.md](decisions.md) | Decided and open design decisions | ## Problem diff --git a/internal/design/spf/primitives.md b/internal/design/spf/primitives.md index 55f64ca2a..c66666085 100644 --- a/internal/design/spf/primitives.md +++ b/internal/design/spf/primitives.md @@ -45,6 +45,8 @@ A Task is the unit of work *inside* an Actor or Reactor. Actors plan and execute `core/task.ts` — thin wrapper around a function with an `AbortController`. The shape is approximately right; the question is how much structure to add. +> **See also:** [actor-reactor-factories.md](actor-reactor-factories.md) — decided design for `createActor` / `createReactor`, including how runners are declared and lifecycle-managed. + ### Open questions - **`aborted` as a distinct terminal state** — currently a Task that is aborted throws and lands in `error`, losing the distinction between "cancelled" and "failed". Tentatively, `aborted` should be a first-class terminal state: `pending → running → done | error | aborted`. The mechanism is straightforward — when `run()` catches a rejection, it checks whether the Task's abort signal is already aborted; if so, it transitions to `aborted` rather than `error`. This keeps the abort/error distinction out of the error value and makes it inspectable via `status` alone. @@ -107,13 +109,17 @@ An Actor does not know about state outside itself. It receives messages and prod The concept is approximately right in the current codebase, but implementations are bespoke closures rather than classes. They will need to be refactored into classes with a formal interface. Beyond that structural change, additional structure is likely to emerge — for example, an Actor may define an explicit message map from message type to Task, making the relationship between inputs and work more declarative and inspectable. +### Decided + +- **Snapshot as signal** — Actors expose `snapshot` as a `ReadonlySignal`, making current state synchronously readable and tracked in reactive contexts without polling. +- **Message validity per state** — Actors define valid messages per state via a per-state `on` map in the definition. Messages sent in a state with no handler for that type are silently dropped. `'destroyed'` always drops all messages. +- **Factory function, not base class** — `createActor(definition)` rather than `extends BaseActor`. See [actor-reactor-factories.md](actor-reactor-factories.md). +- **`'destroyed'` is always implicit** — the framework adds it as the terminal state; user status types never include it. +- **Actor dependencies are explicit** — Actors receive dependencies at construction time (via the factory call site) and interact with peer Actors via `send()`. No global state access. + ### Open questions -- **Snapshot as signal vs subscribable** — does the Actor expose `snapshot` as a **signal** (synchronously readable, tracked in reactive contexts) or as a **subscribable** (push-based, no current value without explicit storage)? This is tightly coupled to the Observable State decision (§5). The synchronous-inspection use case (e.g., `endOfStream` reading idle status without subscribing) slightly favors signals. -- **Message validity and handling** — whether a message is valid depends on the Actor's current status. Some messages may be invalid in certain states and should be rejected or ignored rather than queued. How each Actor defines valid messages per state, and what happens when an invalid message arrives (silent drop, error, warning), is left to the Actor's own finite state machine definition. - **Error handling** — if a Task inside an Actor throws an unaborted error, does the Actor die, recover to an error state, or retry? No answer yet; depends on which Actors exist and what errors are recoverable. -- **Base class vs interface** — if Actors are classes, is there a base class (`BaseActor`) with common snapshot/status machinery, or just an interface that each Actor implements independently? -- **Scope of Actor dependencies** — should Actors be definitionally constrained to their own state plus explicitly passed-in dependencies (including other Actors, platform resources like a `SourceBuffer`, etc.), or should they be permitted to read from or write to shared global state (e.g., global owners, global events)? The current pattern has Actors receiving everything they need at construction time and interacting with other Actors via `send()` — one Actor's output becoming another's input. Allowing global state access would blur the boundary between Actor and Reactor (which exists precisely to mediate between global state and Actors). Tentatively: no — keep Actors self-contained; Reactors are the right place for global state coordination. --- @@ -142,12 +148,17 @@ A Reactor is typically the bridge between observable state and one or more Actor The current codebase has top-level functions in `dom/features/` that gesture at the Reactor concept — they observe state and produce side effects — but lack the formal structure entirely: no class, no status, no snapshot, no defined lifecycle. These will need significant rework to become first-class Reactors. +### Decided + +- **Snapshot as signal** — same decision as Actors. `snapshot` is a `ReadonlySignal<{ status: ReactorStatus }>`. +- **Factory function, not base class** — `createReactor(definition)`. Per-state effects arrays; each element becomes one independent `effect()` call. See [actor-reactor-factories.md](actor-reactor-factories.md). +- **Reactors do not send to other Reactors** — coordination flows through state or via `actor.send()`. + ### Open questions -- **Snapshot as signal vs subscribable** — same question as Actors (§3). Tightly coupled to §5. -- **Effect scheduling** — when observed state changes, does a Reactor's response fire synchronously within the same update batch, or always deferred? Synchronous firing is simpler but risks re-entrancy; deferral is safer but adds latency. This is closely tied to how the Observable State primitive handles scheduling. -- **Lifecycle ownership** — who creates and destroys Reactors? Currently the engine owns all of this explicitly. With a signal-based state primitive, Reactors could self-scope to a signal context and auto-dispose. Worth defining regardless. -- **Can a Reactor send to another Reactor?** — Probably not directly (that would make it an Actor). If cross-Reactor coordination is needed, it likely flows through state. +- **Effect scheduling** — when observed state changes, does a Reactor's response fire synchronously within the same update batch, or always deferred? Tightly coupled to §5. +- **Lifecycle ownership** — who creates and destroys Reactors? Currently the engine owns all of this explicitly. With a signal-based state primitive, Reactors could self-scope to a signal context and auto-dispose. +- **Reactor context** — should `createReactor` support an optional `context` field for non-finite state, or should Reactor context always be held via closure? See [actor-reactor-factories.md](actor-reactor-factories.md). --- diff --git a/packages/spf/src/core/create-actor.ts b/packages/spf/src/core/create-actor.ts new file mode 100644 index 000000000..641eca08e --- /dev/null +++ b/packages/spf/src/core/create-actor.ts @@ -0,0 +1,223 @@ +import type { ActorSnapshot, SignalActor } from './actor'; +import { signal, untrack, update } from './signals/primitives'; +import type { TaskLike } from './task'; + +// ============================================================================= +// Runner interfaces +// ============================================================================= + +/** + * Minimal interface for any runner that can be used with createActor. + */ +export interface RunnerLike { + schedule(task: TaskLike): Promise; + abortAll(): void; + destroy(): void; +} + +/** + * Extended runner interface for runners that support `onSettled` state declarations. + */ +export interface SettledRunnerLike extends RunnerLike { + whenSettled(callback: () => void): void; +} + +function hasWhenSettled(runner: RunnerLike): runner is SettledRunnerLike { + return 'whenSettled' in runner; +} + +// ============================================================================= +// Definition types +// ============================================================================= + +/** + * Context passed to message handlers. + * `runner` is present and typed as the exact runner instance only when the + * definition includes a runner factory. + */ +export type HandlerContext< + UserStatus extends string, + Context extends object, + RunnerFactory extends (() => RunnerLike) | undefined, +> = { + transition: (to: UserStatus) => void; + context: Context; + setContext: (next: Context) => void; +} & (RunnerFactory extends () => infer R ? { runner: R } : object); + +/** + * Definition for a single user-defined state. + */ +export type ActorStateDefinition< + UserStatus extends string, + Context extends object, + Message extends { type: string }, + RunnerFactory extends (() => RunnerLike) | undefined, +> = { + /** + * When the actor's runner settles while in this state, automatically + * transition to this status. The framework owns the generation-token logic — + * re-registering after each `runner.schedule()` call so that + * `abortAll()` + reschedule correctly supersedes stale callbacks. + */ + onSettled?: UserStatus; + /** Message handlers active in this state. Messages with no handler are silently dropped. */ + on?: { + [M in Message as M['type']]?: ( + message: Extract, + ctx: HandlerContext + ) => void; + }; +}; + +/** + * Full actor definition passed to `createActor`. + * + * `UserStatus` is the set of domain-meaningful states. `'destroyed'` is always + * added by the framework as the implicit terminal state — do not include it here. + */ +export type ActorDefinition< + UserStatus extends string, + Context extends object, + Message extends { type: string }, + RunnerFactory extends (() => RunnerLike) | undefined = undefined, +> = { + /** + * Runner factory — called once at `createActor()` time. + * The runner lives for the full actor lifetime and is destroyed with it. + * + * @example + * runner: () => new SerialRunner() + */ + runner?: RunnerFactory; + /** Initial status. */ + initial: UserStatus; + /** Initial context. */ + context: Context; + /** + * Per-state definitions. States with no definition silently drop all messages. + * All user-defined states must appear as keys in the `UserStatus` union. + */ + states: Partial>>; +}; + +// ============================================================================= +// Live actor interface +// ============================================================================= + +/** Live actor instance returned by `createActor`. */ +export interface MessageActor + extends SignalActor { + send(message: Message): void; +} + +// ============================================================================= +// Implementation +// ============================================================================= + +/** + * Creates a message-driven actor from a declarative definition. + * + * The actor owns a reactive snapshot signal (status + context), an optional + * runner, and dispatches incoming messages to per-state handlers. `'destroyed'` + * is always the implicit terminal state — `destroy()` transitions there + * unconditionally and all subsequent `send()` calls are no-ops. + * + * When a state declares `onSettled`, the framework calls `runner.whenSettled()` + * after the handler returns. The runner owns the generation-token logic — if + * new tasks are scheduled before the current batch settles, the callback is + * automatically superseded. + * + * @example + * const actor = createActor({ + * runner: () => new SerialRunner(), + * initial: 'idle', + * context: {}, + * states: { + * idle: { + * on: { + * load: (msg, { transition, runner }) => { + * segments.forEach(s => runner.schedule(new Task(...))); + * transition('loading'); + * } + * } + * }, + * loading: { + * onSettled: 'idle', + * on: { + * load: (msg, { runner }) => { + * runner.abortAll(); + * segments.forEach(s => runner.schedule(new Task(...))); + * } + * } + * } + * } + * }); + */ +export function createActor< + UserStatus extends string, + Context extends object, + Message extends { type: string }, + RunnerFactory extends (() => RunnerLike) | undefined = undefined, +>( + def: ActorDefinition +): MessageActor { + type FullStatus = UserStatus | 'destroyed'; + + const runner = def.runner?.() as RunnerLike | undefined; + const snapshotSignal = signal>({ + status: def.initial as FullStatus, + context: def.context, + }); + + const getStatus = (): FullStatus => untrack(() => snapshotSignal.get().status); + const getContext = (): Context => untrack(() => snapshotSignal.get().context); + + const transition = (to: FullStatus): void => { + update(snapshotSignal, { status: to }); + }; + + const setContext = (context: Context): void => { + update(snapshotSignal, { context }); + }; + + return { + get snapshot() { + return snapshotSignal; + }, + + send(message: Message): void { + const status = getStatus(); + if (status === 'destroyed') return; + const stateDef = def.states[status as UserStatus]; + const handler = stateDef?.on?.[message.type as keyof typeof stateDef.on] as + | ((msg: Message, ctx: HandlerContext) => void) + | undefined; + if (!handler) return; + handler(message, { + context: getContext(), + transition: (to: UserStatus) => transition(to as FullStatus), + setContext, + ...(runner ? { runner } : {}), + } as HandlerContext); + // Register onSettled after the handler so we read the post-transition status. + const newStatus = getStatus(); + if (newStatus !== 'destroyed') { + const newStateDef = def.states[newStatus as UserStatus]; + if (newStateDef?.onSettled && runner && hasWhenSettled(runner)) { + const targetStatus = newStateDef.onSettled as FullStatus; + runner.whenSettled(() => { + if (getStatus() !== newStatus) return; + transition(targetStatus); + }); + } + } + }, + + destroy(): void { + if (getStatus() === 'destroyed') return; + runner?.destroy(); + transition('destroyed'); + }, + }; +} diff --git a/packages/spf/src/core/task.ts b/packages/spf/src/core/task.ts index 746450ad5..aaa20e977 100644 --- a/packages/spf/src/core/task.ts +++ b/packages/spf/src/core/task.ts @@ -202,6 +202,25 @@ export class SerialRunner { return this.#chain as Promise; } + /** + * Registers a callback to fire when all currently-pending tasks settle. + * If the runner is already idle (no pending or running tasks), the callback + * is never called. If new tasks are scheduled before the current batch + * settles, the callback is superseded and silently dropped — no stale + * callbacks, no generation token required by the caller. + */ + whenSettled(callback: () => void): void { + if (this.#pending.size === 0 && this.#current === null) return; + const currentChain = this.#chain; + currentChain.then( + () => { + if (this.#chain !== currentChain) return; + callback(); + }, + () => {} + ); + } + abortAll(): void { for (const task of this.#pending) task.abort(); this.#pending.clear(); diff --git a/packages/spf/src/core/tests/create-actor.test.ts b/packages/spf/src/core/tests/create-actor.test.ts new file mode 100644 index 000000000..e00ae2076 --- /dev/null +++ b/packages/spf/src/core/tests/create-actor.test.ts @@ -0,0 +1,415 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createActor } from '../create-actor'; +import { SerialRunner, Task } from '../task'; + +// ============================================================================= +// Helpers +// ============================================================================= + +function makeCounter() { + return createActor({ + initial: 'idle' as const, + context: { count: 0 }, + states: { + idle: { + on: { + increment: (_, { context, setContext }) => setContext({ count: context.count + 1 }), + start: (_, { transition }) => transition('running'), + }, + }, + running: { + on: { + stop: (_, { transition }) => transition('idle'), + }, + }, + }, + }); +} + +// ============================================================================= +// createActor — core behavior +// ============================================================================= + +describe('createActor', () => { + it('starts with the initial status and context', () => { + const actor = makeCounter(); + + expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().context).toEqual({ count: 0 }); + + actor.destroy(); + }); + + it('dispatches messages to the correct state handler', () => { + const handler = vi.fn(); + const actor = createActor({ + initial: 'idle' as const, + context: {}, + states: { + idle: { on: { ping: handler } }, + }, + }); + + actor.send({ type: 'ping' }); + expect(handler).toHaveBeenCalledOnce(); + + actor.destroy(); + }); + + it('passes message, context, transition, and setContext to handlers', () => { + let captured: { msg: unknown; ctx: unknown } | undefined; + const actor = createActor({ + initial: 'idle' as const, + context: { value: 42 }, + states: { + idle: { + on: { + go: (msg, ctx) => { + captured = { msg, ctx }; + }, + }, + }, + }, + }); + + actor.send({ type: 'go' }); + + expect(captured).toBeDefined(); + expect((captured!.msg as { type: string }).type).toBe('go'); + expect((captured!.ctx as { context: unknown }).context).toEqual({ value: 42 }); + expect(typeof (captured!.ctx as { transition: unknown }).transition).toBe('function'); + expect(typeof (captured!.ctx as { setContext: unknown }).setContext).toBe('function'); + + actor.destroy(); + }); + + it('transitions status via transition()', () => { + const actor = makeCounter(); + + actor.send({ type: 'start' }); + + expect(actor.snapshot.get().status).toBe('running'); + + actor.destroy(); + }); + + it('updates context via setContext()', () => { + const actor = makeCounter(); + + actor.send({ type: 'increment' }); + actor.send({ type: 'increment' }); + + expect(actor.snapshot.get().context.count).toBe(2); + + actor.destroy(); + }); + + it('handler receives context value at dispatch time', () => { + const observed: number[] = []; + const actor = createActor({ + initial: 'idle' as const, + context: { count: 0 }, + states: { + idle: { + on: { + read: (_, { context }) => { + observed.push(context.count); + }, + set: (_, { setContext }) => setContext({ count: 99 }), + }, + }, + }, + }); + + actor.send({ type: 'read' }); // sees 0 + actor.send({ type: 'set' }); + actor.send({ type: 'read' }); // sees 99 + + expect(observed).toEqual([0, 99]); + + actor.destroy(); + }); + + it('drops messages with no handler in the current state', () => { + const actor = makeCounter(); + + actor.send({ type: 'start' }); // → running + // 'increment' has no handler in 'running' + expect(() => actor.send({ type: 'increment' })).not.toThrow(); + expect(actor.snapshot.get().context.count).toBe(0); + + actor.destroy(); + }); + + it('drops messages when the state has no on map', () => { + const actor = createActor({ + initial: 'idle' as const, + context: {}, + states: { + idle: {}, + }, + }); + + expect(() => actor.send({ type: 'anything' } as never)).not.toThrow(); + + actor.destroy(); + }); + + it('snapshot is reactive — status and context changes are observable', () => { + const actor = makeCounter(); + + const before = actor.snapshot.get(); + actor.send({ type: 'increment' }); + actor.send({ type: 'start' }); + const after = actor.snapshot.get(); + + expect(before.status).toBe('idle'); + expect(before.context.count).toBe(0); + expect(after.status).toBe('running'); + expect(after.context.count).toBe(1); + + actor.destroy(); + }); +}); + +// ============================================================================= +// createActor — destroy +// ============================================================================= + +describe('createActor — destroy', () => { + it('transitions to destroyed on destroy()', () => { + const actor = makeCounter(); + + actor.destroy(); + + expect(actor.snapshot.get().status).toBe('destroyed'); + }); + + it('destroy() is idempotent', () => { + const actor = makeCounter(); + + actor.destroy(); + expect(() => actor.destroy()).not.toThrow(); + expect(actor.snapshot.get().status).toBe('destroyed'); + }); + + it('drops send() after destroy()', () => { + const handler = vi.fn(); + const actor = createActor({ + initial: 'idle' as const, + context: {}, + states: { idle: { on: { ping: handler } } }, + }); + + actor.destroy(); + actor.send({ type: 'ping' }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('destroys the runner on destroy()', () => { + const runner = new SerialRunner(); + const destroySpy = vi.spyOn(runner, 'destroy'); + + const actor = createActor({ + runner: () => runner, + initial: 'idle' as const, + context: {}, + states: { idle: {} }, + }); + + actor.destroy(); + + expect(destroySpy).toHaveBeenCalledOnce(); + }); +}); + +// ============================================================================= +// createActor — runner and onSettled +// ============================================================================= + +describe('createActor — runner', () => { + it('provides the runner to handlers when a runner factory is given', () => { + let capturedRunner: unknown; + const actor = createActor({ + runner: () => new SerialRunner(), + initial: 'idle' as const, + context: {}, + states: { + idle: { + on: { + go: (_, ctx) => { + capturedRunner = ctx.runner; + }, + }, + }, + }, + }); + + actor.send({ type: 'go' }); + + expect(capturedRunner).toBeDefined(); + expect(typeof (capturedRunner as { schedule: unknown }).schedule).toBe('function'); + expect(typeof (capturedRunner as { abortAll: unknown }).abortAll).toBe('function'); + + actor.destroy(); + }); + + it('omits runner from handler context when no runner factory is given', () => { + let capturedCtx: Record | undefined; + const actor = createActor({ + initial: 'idle' as const, + context: {}, + states: { + idle: { + on: { + go: (_, ctx) => { + capturedCtx = ctx as Record; + }, + }, + }, + }, + }); + + actor.send({ type: 'go' }); + + expect('runner' in (capturedCtx ?? {})).toBe(false); + + actor.destroy(); + }); + + it('transitions to onSettled state when the runner settles', async () => { + const actor = createActor({ + runner: () => new SerialRunner(), + initial: 'idle' as const, + context: {}, + states: { + idle: { + on: { + load: (_, { transition, runner }) => { + runner.schedule(new Task(async () => {})); + transition('loading'); + }, + }, + }, + loading: { + onSettled: 'idle', + }, + }, + }); + + actor.send({ type: 'load' }); + expect(actor.snapshot.get().status).toBe('loading'); + + await vi.waitFor(() => { + expect(actor.snapshot.get().status).toBe('idle'); + }); + + actor.destroy(); + }); + + it('onSettled is a no-op when the state changes before the runner settles', async () => { + let resolveTask!: () => void; + const actor = createActor({ + runner: () => new SerialRunner(), + initial: 'idle' as const, + context: {}, + states: { + idle: { + on: { + load: (_, { transition, runner }) => { + runner.schedule( + new Task(async () => { + await new Promise((r) => { + resolveTask = r; + }); + }) + ); + transition('loading'); + }, + }, + }, + loading: { + onSettled: 'idle', + on: { + cancel: (_, { transition }) => transition('cancelled'), + }, + }, + cancelled: {}, + }, + }); + + actor.send({ type: 'load' }); + // Wait for the task to actually start running so resolveTask is assigned + await vi.waitFor(() => expect(resolveTask).toBeDefined()); + + actor.send({ type: 'cancel' }); + expect(actor.snapshot.get().status).toBe('cancelled'); + + // Unblock the task — the settled callback fires but the state check prevents transition + resolveTask(); + await new Promise((r) => setTimeout(r, 10)); + + expect(actor.snapshot.get().status).toBe('cancelled'); // not 'idle' + + actor.destroy(); + }); + + it('onSettled generation-token: rescheduling supersedes the stale callback', async () => { + let resolveFirst!: () => void; + + const actor = createActor({ + runner: () => new SerialRunner(), + initial: 'idle' as const, + context: {}, + states: { + idle: { + on: { + load: (_, { transition, runner }) => { + runner.schedule( + new Task(async () => { + await new Promise((r) => { + resolveFirst = r; + }); + }) + ); + transition('loading'); + }, + }, + }, + loading: { + onSettled: 'idle', + on: { + load: (_, { runner }) => { + runner.abortAll(); + // Schedule a fast task — re-registers onSettled with the new chain. + // SerialRunner is serial, so the fast task is chained after the slow one. + runner.schedule(new Task(async () => {})); + }, + }, + }, + }, + }); + + // First load — enters loading with a paused slow task + actor.send({ type: 'load' }); + // Wait for the task to actually start running so resolveFirst is assigned + await vi.waitFor(() => expect(resolveFirst).toBeDefined()); + + // Second load: aborts slow task, schedules fast task (chained after slow in SerialRunner) + actor.send({ type: 'load' }); + + // Unblock the slow task — it completes, then the fast task runs to completion. + // The slow task's stale settled callback fires first (runner.settled !== settled1 → no-op). + // The fast task's settled callback fires second (runner.settled === settled2 → transitions). + resolveFirst(); + + await vi.waitFor(() => { + expect(actor.snapshot.get().status).toBe('idle'); + }); + + expect(actor.snapshot.get().status).toBe('idle'); // exactly one transition, not two + + actor.destroy(); + }); +}); From 33fd5dd32d9622c2506cc6c8ab18ff7e1d58cc1a Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 31 Mar 2026 18:40:00 -0700 Subject: [PATCH 14/79] refactor(spf): migrate TextTrackSegmentLoaderActor to createActor factory Replaces the class with a `createTextTrackSegmentLoaderActor` factory function using the new `createActor` primitive. The `runner.settled` promise + manual generation-token pattern is replaced by `onSettled: 'idle'` at the state level, delegating all generation-token logic to `SerialRunner.whenSettled`. Co-Authored-By: Claude Sonnet 4.6 --- .../src/dom/features/load-text-track-cues.ts | 5 +- .../text-track-segment-loader-actor.test.ts | 22 ++--- .../text-track-segment-loader-actor.ts | 97 ++++++++----------- 3 files changed, 55 insertions(+), 69 deletions(-) diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 784a88d89..0b29c1bc2 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -2,7 +2,8 @@ import { effect } from '../../core/signals/effect'; import { computed, type Signal } from '../../core/signals/primitives'; import type { Presentation, TextTrack } from '../../core/types'; import { isResolvedTrack } from '../../core/types'; -import { TextTrackSegmentLoaderActor } from './text-track-segment-loader-actor'; +import type { TextTrackSegmentLoaderActor } from './text-track-segment-loader-actor'; +import { createTextTrackSegmentLoaderActor } from './text-track-segment-loader-actor'; import { TextTracksActor } from './text-tracks-actor'; // ============================================================================ @@ -123,7 +124,7 @@ export function loadTextTrackCues ({ @@ -55,7 +55,7 @@ describe('TextTrackSegmentLoaderActor', () => { it('starts with idle status and empty context', () => { const video = makeMediaElement(['track-en']); const textTracksActor = new TextTracksActor(video); - const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const actor = createTextTrackSegmentLoaderActor(textTracksActor); expect(actor.snapshot.get().status).toBe('idle'); expect(actor.snapshot.get().context).toEqual({}); @@ -67,7 +67,7 @@ describe('TextTrackSegmentLoaderActor', () => { it('stays idle when no segments need loading', () => { const video = makeMediaElement(['track-en']); const textTracksActor = new TextTracksActor(video); - const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', []); actor.send({ type: 'load', track, currentTime: 0 }); @@ -81,7 +81,7 @@ describe('TextTrackSegmentLoaderActor', () => { it('transitions loading → idle after all segments are fetched', async () => { const video = makeMediaElement(['track-en']); const textTracksActor = new TextTracksActor(video); - const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); actor.send({ type: 'load', track, currentTime: 0 }); @@ -98,7 +98,7 @@ describe('TextTrackSegmentLoaderActor', () => { it('delegates cue loading to TextTracksActor', async () => { const video = makeMediaElement(['track-en']); const textTracksActor = new TextTracksActor(video); - const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt', 'https://example.com/seg-1.vtt']); actor.send({ type: 'load', track, currentTime: 0 }); @@ -119,7 +119,7 @@ describe('TextTrackSegmentLoaderActor', () => { const video = makeMediaElement(['track-en']); const textTracksActor = new TextTracksActor(video); - const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt', 'https://example.com/seg-1.vtt']); actor.send({ type: 'load', track, currentTime: 0 }); @@ -146,7 +146,7 @@ describe('TextTrackSegmentLoaderActor', () => { const video = makeMediaElement(['track-en']); const textTracksActor = new TextTracksActor(video); - const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', [ 'https://example.com/seg-0.vtt', 'https://example.com/fail.vtt', @@ -181,7 +181,7 @@ describe('TextTrackSegmentLoaderActor', () => { const video = makeMediaElement(['track-en', 'track-es']); const textTracksActor = new TextTracksActor(video); - const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track1 = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); const track2 = makeResolvedTextTrack('track-es', ['https://example.com/seg-1.vtt']); @@ -214,7 +214,7 @@ describe('TextTrackSegmentLoaderActor', () => { it('transitions to destroyed on destroy()', () => { const video = makeMediaElement(['track-en']); const textTracksActor = new TextTracksActor(video); - const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const actor = createTextTrackSegmentLoaderActor(textTracksActor); actor.destroy(); @@ -228,7 +228,7 @@ describe('TextTrackSegmentLoaderActor', () => { const video = makeMediaElement(['track-en']); const textTracksActor = new TextTracksActor(video); - const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); actor.destroy(); @@ -245,7 +245,7 @@ describe('TextTrackSegmentLoaderActor', () => { it('snapshot is reactive — status transitions are observable via signal', async () => { const video = makeMediaElement(['track-en']); const textTracksActor = new TextTracksActor(video); - const actor = new TextTrackSegmentLoaderActor(textTracksActor); + const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); const observed = [actor.snapshot.get().status]; diff --git a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts index 1b8a75466..90dc8c32b 100644 --- a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts +++ b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts @@ -1,7 +1,7 @@ -import type { ActorSnapshot, SignalActor } from '../../core/actor'; import { getSegmentsToLoad } from '../../core/buffer/forward-buffer'; -import type { ReadonlySignal } from '../../core/signals/primitives'; -import { signal, untrack, update } from '../../core/signals/primitives'; +import type { MessageActor } from '../../core/create-actor'; +import { createActor } from '../../core/create-actor'; +import { untrack } from '../../core/signals/primitives'; import { SerialRunner, Task } from '../../core/task'; import type { TextTrack } from '../../core/types'; import { parseVttSegment } from '../text/parse-vtt-segment'; @@ -11,9 +11,7 @@ import type { TextTracksActor } from './text-tracks-actor'; // Types // ============================================================================= -export type TextTrackSegmentLoaderStatus = 'idle' | 'loading' | 'destroyed'; - -export type TextTrackSegmentLoaderSnapshot = ActorSnapshot; +export type TextTrackSegmentLoaderStatus = 'idle' | 'loading'; export type TextTrackSegmentLoaderMessage = { type: 'load'; @@ -21,6 +19,12 @@ export type TextTrackSegmentLoaderMessage = { currentTime: number; }; +export type TextTrackSegmentLoaderActor = MessageActor< + TextTrackSegmentLoaderStatus | 'destroyed', + object, + TextTrackSegmentLoaderMessage +>; + // ============================================================================= // Implementation // ============================================================================= @@ -30,47 +34,31 @@ export type TextTrackSegmentLoaderMessage = { * TextTracksActor. Mirrors the SegmentLoaderActor/SourceBufferActor pattern * for the text track equivalent. * - * Planning is done in send(): segments already recorded in TextTracksActor's - * context are skipped. Each new send() preempts in-flight work via abortAll() - * before scheduling fresh tasks. + * Planning is done in the load handler: segments already recorded in + * TextTracksActor's context are skipped. Each load preempts in-flight work + * via abortAll() before scheduling fresh tasks. The runner's onSettled + * callback transitions back to idle when all tasks complete. */ -export class TextTrackSegmentLoaderActor implements SignalActor { - readonly #textTracksActor: TextTracksActor; - readonly #snapshotSignal = signal({ - status: 'idle', - context: {}, - }); - readonly #runner = new SerialRunner(); - - constructor(textTracksActor: TextTracksActor) { - this.#textTracksActor = textTracksActor; - } - - get snapshot(): ReadonlySignal { - return this.#snapshotSignal; - } - - send(message: TextTrackSegmentLoaderMessage): void { - const status = untrack(() => this.#snapshotSignal.get().status); - if (status === 'destroyed') return; +export function createTextTrackSegmentLoaderActor(textTracksActor: TextTracksActor): TextTrackSegmentLoaderActor { + const loadHandler = ( + message: TextTrackSegmentLoaderMessage, + { transition, runner }: { transition: (to: TextTrackSegmentLoaderStatus) => void; runner: SerialRunner } + ): void => { const { track, currentTime } = message; const trackId = track.id; - const bufferedSegments = untrack(() => this.#textTracksActor.snapshot.get().context.segments[trackId] ?? []); + const bufferedSegments = untrack(() => textTracksActor.snapshot.get().context.segments[trackId] ?? []); const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime); // Preempt any in-flight work before scheduling the new plan. - this.#runner.abortAll(); + runner.abortAll(); if (!segmentsToLoad.length) { - update(this.#snapshotSignal, { status: 'idle' }); + transition('idle'); return; } - update(this.#snapshotSignal, { status: 'loading' }); - - // Capture actor reference so Tasks close over it, not `this`. - const textTracksActor = this.#textTracksActor; - segmentsToLoad.forEach((segment) => { - this.#runner.schedule( + transition('loading'); + for (const segment of segmentsToLoad) { + runner.schedule( new Task(async (signal) => { if (signal.aborted) return; try { @@ -87,24 +75,21 @@ export class TextTrackSegmentLoaderActor implements SignalActor { - if (this.#runner.settled !== settled) return; - if (this.#snapshotSignal.get().status !== 'destroyed') { - update(this.#snapshotSignal, { status: 'idle' }); - } - }); - } + } + }; - destroy(): void { - if (this.#snapshotSignal.get().status === 'destroyed') return; - this.#runner.destroy(); - update(this.#snapshotSignal, { status: 'destroyed' }); - } + return createActor({ + runner: () => new SerialRunner(), + initial: 'idle' as TextTrackSegmentLoaderStatus, + context: {} as object, + states: { + idle: { + on: { load: loadHandler }, + }, + loading: { + onSettled: 'idle', + on: { load: loadHandler }, + }, + }, + }); } From 35103a3f0f78cf7c5292fec6036684bbb0a4490c Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 31 Mar 2026 18:44:37 -0700 Subject: [PATCH 15/79] refactor(spf): migrate TextTracksActor to createActor factory Replaces the TextTracksActor class with a createTextTracksActor factory function using the createActor primitive. Context mutations via direct signal updates become setContext() calls; mediaElement is captured in the factory closure rather than held as a private field. Co-Authored-By: Claude Sonnet 4.6 --- .../src/dom/features/load-text-track-cues.ts | 5 +- .../text-track-segment-loader-actor.test.ts | 22 ++-- .../features/tests/text-tracks-actor.test.ts | 28 ++--- .../spf/src/dom/features/text-tracks-actor.ts | 108 ++++++++---------- 4 files changed, 75 insertions(+), 88 deletions(-) diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 0b29c1bc2..5b3599cc8 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -4,7 +4,8 @@ import type { Presentation, TextTrack } from '../../core/types'; import { isResolvedTrack } from '../../core/types'; import type { TextTrackSegmentLoaderActor } from './text-track-segment-loader-actor'; import { createTextTrackSegmentLoaderActor } from './text-track-segment-loader-actor'; -import { TextTracksActor } from './text-tracks-actor'; +import type { TextTracksActor } from './text-tracks-actor'; +import { createTextTracksActor } from './text-tracks-actor'; // ============================================================================ // STATE & OWNERS @@ -123,7 +124,7 @@ export function loadTextTrackCues ({ parseVttSegment: vi.fn((url: string) => { @@ -54,7 +54,7 @@ describe('TextTrackSegmentLoaderActor', () => { it('starts with idle status and empty context', () => { const video = makeMediaElement(['track-en']); - const textTracksActor = new TextTracksActor(video); + const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); expect(actor.snapshot.get().status).toBe('idle'); @@ -66,7 +66,7 @@ describe('TextTrackSegmentLoaderActor', () => { it('stays idle when no segments need loading', () => { const video = makeMediaElement(['track-en']); - const textTracksActor = new TextTracksActor(video); + const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', []); @@ -80,7 +80,7 @@ describe('TextTrackSegmentLoaderActor', () => { it('transitions loading → idle after all segments are fetched', async () => { const video = makeMediaElement(['track-en']); - const textTracksActor = new TextTracksActor(video); + const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); @@ -97,7 +97,7 @@ describe('TextTrackSegmentLoaderActor', () => { it('delegates cue loading to TextTracksActor', async () => { const video = makeMediaElement(['track-en']); - const textTracksActor = new TextTracksActor(video); + const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt', 'https://example.com/seg-1.vtt']); @@ -118,7 +118,7 @@ describe('TextTrackSegmentLoaderActor', () => { const { parseVttSegment } = await import('../../text/parse-vtt-segment'); const video = makeMediaElement(['track-en']); - const textTracksActor = new TextTracksActor(video); + const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt', 'https://example.com/seg-1.vtt']); @@ -145,7 +145,7 @@ describe('TextTrackSegmentLoaderActor', () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const video = makeMediaElement(['track-en']); - const textTracksActor = new TextTracksActor(video); + const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', [ 'https://example.com/seg-0.vtt', @@ -180,7 +180,7 @@ describe('TextTrackSegmentLoaderActor', () => { .mockResolvedValue([new VTTCue(0, 5, 'Cue')]); const video = makeMediaElement(['track-en', 'track-es']); - const textTracksActor = new TextTracksActor(video); + const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track1 = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); @@ -213,7 +213,7 @@ describe('TextTrackSegmentLoaderActor', () => { it('transitions to destroyed on destroy()', () => { const video = makeMediaElement(['track-en']); - const textTracksActor = new TextTracksActor(video); + const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); actor.destroy(); @@ -227,7 +227,7 @@ describe('TextTrackSegmentLoaderActor', () => { const { parseVttSegment } = await import('../../text/parse-vtt-segment'); const video = makeMediaElement(['track-en']); - const textTracksActor = new TextTracksActor(video); + const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); @@ -244,7 +244,7 @@ describe('TextTrackSegmentLoaderActor', () => { it('snapshot is reactive — status transitions are observable via signal', async () => { const video = makeMediaElement(['track-en']); - const textTracksActor = new TextTracksActor(video); + const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); diff --git a/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts b/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts index b500b965f..a2d04310d 100644 --- a/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts +++ b/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { CueSegmentMeta } from '../text-tracks-actor'; -import { TextTracksActor } from '../text-tracks-actor'; +import { createTextTracksActor } from '../text-tracks-actor'; function makeMediaElement(trackIds: string[]): HTMLMediaElement { const video = document.createElement('video'); @@ -20,7 +20,7 @@ function meta(trackId: string, id: string, startTime = 0, duration = 10): CueSeg describe('TextTracksActor', () => { it('starts with idle status and empty context', () => { const video = makeMediaElement(['track-en']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); expect(actor.snapshot.get().status).toBe('idle'); expect(actor.snapshot.get().context.loaded).toEqual({}); @@ -29,7 +29,7 @@ describe('TextTracksActor', () => { it('adds cues to the correct TextTrack', () => { const video = makeMediaElement(['track-en']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; @@ -40,7 +40,7 @@ describe('TextTracksActor', () => { it('records added cues in snapshot context', () => { const video = makeMediaElement(['track-en']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; @@ -58,7 +58,7 @@ describe('TextTracksActor', () => { it('records segment in snapshot context', () => { const video = makeMediaElement(['track-en']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; @@ -73,7 +73,7 @@ describe('TextTracksActor', () => { it('deduplicates cues by startTime + endTime + text', () => { const video = makeMediaElement(['track-en']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; @@ -86,7 +86,7 @@ describe('TextTracksActor', () => { it('deduplicates segments by id', () => { const video = makeMediaElement(['track-en']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; @@ -98,7 +98,7 @@ describe('TextTracksActor', () => { it('does not update snapshot when both cues and segment are already recorded', () => { const video = makeMediaElement(['track-en']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; @@ -112,7 +112,7 @@ describe('TextTracksActor', () => { it('does not deduplicate cues with different text at the same time range', () => { const video = makeMediaElement(['track-en']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; @@ -124,7 +124,7 @@ describe('TextTracksActor', () => { it('tracks cues and segments independently per track ID', () => { const video = makeMediaElement(['track-en', 'track-es']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); for (const t of Array.from(video.textTracks)) t.mode = 'hidden'; actor.send({ type: 'add-cues', meta: meta('track-en', 'seg-0'), cues: [new VTTCue(0, 2, 'Hello')] }); @@ -142,7 +142,7 @@ describe('TextTracksActor', () => { it('is a no-op when trackId is not found in textTracks', () => { const video = makeMediaElement(['track-en']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); actor.send({ type: 'add-cues', meta: meta('nonexistent', 'seg-0'), cues: [new VTTCue(0, 2, 'Hello')] }); @@ -152,7 +152,7 @@ describe('TextTracksActor', () => { it('transitions to destroyed on destroy()', () => { const video = makeMediaElement(['track-en']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); actor.destroy(); @@ -161,7 +161,7 @@ describe('TextTracksActor', () => { it('ignores send() after destroy()', () => { const video = makeMediaElement(['track-en']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; @@ -175,7 +175,7 @@ describe('TextTracksActor', () => { it('snapshot is reactive — updates are observable via signal', () => { const video = makeMediaElement(['track-en']); - const actor = new TextTracksActor(video); + const actor = createTextTracksActor(video); const textTrack = Array.from(video.textTracks).find((t) => t.id === 'track-en')!; textTrack.mode = 'hidden'; diff --git a/packages/spf/src/dom/features/text-tracks-actor.ts b/packages/spf/src/dom/features/text-tracks-actor.ts index a6e09746d..fed8cbed9 100644 --- a/packages/spf/src/dom/features/text-tracks-actor.ts +++ b/packages/spf/src/dom/features/text-tracks-actor.ts @@ -1,14 +1,11 @@ -import type { ActorSnapshot, SignalActor } from '../../core/actor'; -import { type ReadonlySignal, signal, update } from '../../core/signals/primitives'; +import type { MessageActor } from '../../core/create-actor'; +import { createActor } from '../../core/create-actor'; import type { Segment } from '../../core/types'; // ============================================================================= // Types // ============================================================================= -/** Finite (bounded) operational modes of the actor. */ -export type TextTracksActorStatus = 'idle' | 'destroyed'; - /** Minimal cue record — enough for deduplication and snapshot observability. */ export interface CueRecord { startTime: number; @@ -27,12 +24,11 @@ export interface TextTracksActorContext { segments: Record>>; } -/** Complete snapshot of a TextTracksActor. */ -export type TextTracksActorSnapshot = ActorSnapshot; - export type AddCuesMessage = { type: 'add-cues'; meta: CueSegmentMeta; cues: VTTCue[] }; export type TextTracksActorMessage = AddCuesMessage; +export type TextTracksActor = MessageActor<'idle' | 'destroyed', TextTracksActorContext, TextTracksActorMessage>; + // ============================================================================= // Helpers // ============================================================================= @@ -46,59 +42,49 @@ function isDuplicateCue(cue: VTTCue, existing: CueRecord[]): boolean { // ============================================================================= /** TextTrack actor: wraps all text tracks on a media element, owns cue operations. */ -export class TextTracksActor implements SignalActor { - readonly #mediaElement: HTMLMediaElement; - readonly #snapshotSignal = signal({ - status: 'idle', - context: { loaded: {}, segments: {} }, - }); - - constructor(mediaElement: HTMLMediaElement) { - this.#mediaElement = mediaElement; - } - - get snapshot(): ReadonlySignal { - return this.#snapshotSignal; - } - - send(message: TextTracksActorMessage): void { - if (this.#snapshotSignal.get().status === 'destroyed') return; - - // NOTE: Currently assumes cues are applied to a non-disabled TextTrack. Discuss different approaches here, including: - // - Making the message responsible for auto-selection of the textTrack (changes logic in sync-text-tracks) - // - Silent gating/console warning + early bail - // - throwing a domain-specific error - // - accepting as is (which would result in errors, but also "shouldn't ever happen" unless a bug is introduced) - // (CJP) - const { meta, cues } = message; - const { trackId, id: segmentId, startTime, duration } = meta; - const textTrack = Array.from(this.#mediaElement.textTracks).find((t) => t.id === trackId); - if (!textTrack) return; - - const ctx = this.#snapshotSignal.get().context; - const existingCues = ctx.loaded[trackId] ?? []; - const existingSegments = ctx.segments[trackId] ?? []; - const prunedCues = cues.filter((cue) => !isDuplicateCue(cue, existingCues)); - const segmentAlreadyLoaded = existingSegments.some((s) => s.id === segmentId); - - if (prunedCues.length === 0 && segmentAlreadyLoaded) return; - - prunedCues.forEach((cue) => textTrack.addCue(cue)); - update(this.#snapshotSignal, { - context: { - ...ctx, - loaded: { - ...ctx.loaded, - [trackId]: [...existingCues, ...prunedCues], +export function createTextTracksActor(mediaElement: HTMLMediaElement): TextTracksActor { + return createActor({ + initial: 'idle' as const, + context: { loaded: {}, segments: {} } as TextTracksActorContext, + states: { + idle: { + on: { + 'add-cues': (message, { context, setContext }) => { + // NOTE: Currently assumes cues are applied to a non-disabled TextTrack. Discuss different approaches here, including: + // - Making the message responsible for auto-selection of the textTrack (changes logic in sync-text-tracks) + // - Silent gating/console warning + early bail + // - throwing a domain-specific error + // - accepting as is (which would result in errors, but also "shouldn't ever happen" unless a bug is introduced) + // (CJP) + const { meta, cues } = message; + const { trackId, id: segmentId, startTime, duration } = meta; + const textTrack = Array.from(mediaElement.textTracks).find((t) => t.id === trackId); + if (!textTrack) return; + + const existingCues = context.loaded[trackId] ?? []; + const existingSegments = context.segments[trackId] ?? []; + const prunedCues = cues.filter((cue) => !isDuplicateCue(cue, existingCues)); + const segmentAlreadyLoaded = existingSegments.some((s) => s.id === segmentId); + + if (prunedCues.length === 0 && segmentAlreadyLoaded) return; + + for (const cue of prunedCues) textTrack.addCue(cue); + setContext({ + ...context, + loaded: { + ...context.loaded, + [trackId]: [...existingCues, ...prunedCues], + }, + segments: segmentAlreadyLoaded + ? context.segments + : { + ...context.segments, + [trackId]: [...existingSegments, { id: segmentId, startTime, duration }], + }, + }); + }, }, - segments: segmentAlreadyLoaded - ? ctx.segments - : { ...ctx.segments, [trackId]: [...existingSegments, { id: segmentId, startTime, duration }] }, }, - }); - } - - destroy(): void { - update(this.#snapshotSignal, { status: 'destroyed' }); - } + }, + }); } From e68b13314967ca9928bd588880e7cb867c760a9e Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 1 Apr 2026 06:52:08 -0700 Subject: [PATCH 16/79] refactor(spf): promote whenSettled to RunnerLike; full RunnerLike conformance for ConcurrentRunner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge SettledRunnerLike into RunnerLike — whenSettled is now required on all runners - Remove hasWhenSettled type guard; simplify onSettled check in createActor - Add whenSettled, destroy, and settled-promise tracking to ConcurrentRunner - Change ConcurrentRunner.schedule() to return Promise; return in-flight promise on dedupe - Add ConcurrentRunner tests: whenSettled, destroy, deduplication returns same promise Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-actor.ts | 12 +-- packages/spf/src/core/task.ts | 67 ++++++++++++--- packages/spf/src/core/tests/task.test.ts | 104 ++++++++++++++++++++++- 3 files changed, 158 insertions(+), 25 deletions(-) diff --git a/packages/spf/src/core/create-actor.ts b/packages/spf/src/core/create-actor.ts index 641eca08e..2dfa53490 100644 --- a/packages/spf/src/core/create-actor.ts +++ b/packages/spf/src/core/create-actor.ts @@ -13,19 +13,9 @@ export interface RunnerLike { schedule(task: TaskLike): Promise; abortAll(): void; destroy(): void; -} - -/** - * Extended runner interface for runners that support `onSettled` state declarations. - */ -export interface SettledRunnerLike extends RunnerLike { whenSettled(callback: () => void): void; } -function hasWhenSettled(runner: RunnerLike): runner is SettledRunnerLike { - return 'whenSettled' in runner; -} - // ============================================================================= // Definition types // ============================================================================= @@ -204,7 +194,7 @@ export function createActor< const newStatus = getStatus(); if (newStatus !== 'destroyed') { const newStateDef = def.states[newStatus as UserStatus]; - if (newStateDef?.onSettled && runner && hasWhenSettled(runner)) { + if (newStateDef?.onSettled && runner) { const targetStatus = newStateDef.onSettled as FullStatus; runner.whenSettled(() => { if (getStatus() !== newStatus) return; diff --git a/packages/spf/src/core/task.ts b/packages/spf/src/core/task.ts index aaa20e977..815f06f40 100644 --- a/packages/spf/src/core/task.ts +++ b/packages/spf/src/core/task.ts @@ -124,25 +124,68 @@ export class Task implements TaskLike>(); + readonly #pending = new Map; promise: Promise }>(); + #settled: Promise = Promise.resolve(); + #resolveSettled: (() => void) | null = null; - schedule(task: TaskLike): void { - if (this.#pending.has(task.id)) return; + schedule(task: TaskLike): Promise { + const existing = this.#pending.get(task.id); + if (existing) return existing.promise as Promise; - this.#pending.set(task.id, task as TaskLike); - task - .run() - .catch((error) => { - if (!(error instanceof Error && error.name === 'AbortError')) throw error; - }) - .finally(() => { - this.#pending.delete(task.id); + if (this.#pending.size === 0) { + this.#settled = new Promise((resolve) => { + this.#resolveSettled = resolve; }); + } + + const promise = task.run(); + // Suppress unhandled rejection for callers that ignore the return value. + promise.catch(() => {}); + // Cleanup: update pending and resolve settled regardless of outcome. + const cleanup = () => { + this.#pending.delete(task.id); + if (this.#pending.size === 0) { + this.#resolveSettled?.(); + this.#resolveSettled = null; + } + }; + promise.then(cleanup, cleanup); + + this.#pending.set(task.id, { task: task as TaskLike, promise: promise as Promise }); + return promise; + } + + /** + * Registers a callback to fire when all currently in-flight tasks settle. + * If the runner is already idle, the callback is never called. If abortAll() + * is called before the batch settles, the callback is superseded and silently + * dropped — no stale callbacks, no generation token required by the caller. + */ + whenSettled(callback: () => void): void { + if (this.#pending.size === 0) return; + const captured = this.#settled; + captured.then( + () => { + if (this.#settled !== captured) return; + callback(); + }, + () => {} + ); } abortAll(): void { - for (const task of this.#pending.values()) task.abort(); + for (const { task } of this.#pending.values()) task.abort(); this.#pending.clear(); + // Resolve the current settled promise so any .then() handlers are queued, + // then replace the reference — whenSettled callbacks that captured the old + // reference will see the identity mismatch and be dropped. + this.#resolveSettled?.(); + this.#resolveSettled = null; + this.#settled = Promise.resolve(); + } + + destroy(): void { + this.abortAll(); } } diff --git a/packages/spf/src/core/tests/task.test.ts b/packages/spf/src/core/tests/task.test.ts index 4c38072df..cdedbe1bd 100644 --- a/packages/spf/src/core/tests/task.test.ts +++ b/packages/spf/src/core/tests/task.test.ts @@ -207,8 +207,10 @@ describe('ConcurrentRunner', () => { { id: 'track-1' } ); - runner.schedule(first); - runner.schedule(second); // same id — should be ignored + const p1 = runner.schedule(first); + const p2 = runner.schedule(second); // same id — should return existing promise + + expect(p2).toBe(p1); // deduplicated: same promise returned resolveFirst(); await vi.waitFor(() => expect(first.status).toBe('done')); @@ -218,6 +220,104 @@ describe('ConcurrentRunner', () => { expect(second.status).toBe('pending'); }); + it('whenSettled fires after all tasks complete', async () => { + const runner = new ConcurrentRunner(); + const cb = vi.fn(); + + let resolveA!: () => void; + let resolveB!: () => void; + const a = new Task( + () => + new Promise((r) => { + resolveA = r; + }), + { id: 'a' } + ); + const b = new Task( + () => + new Promise((r) => { + resolveB = r; + }), + { id: 'b' } + ); + + runner.schedule(a); + runner.schedule(b); + await vi.waitFor(() => expect(resolveA).toBeDefined()); + await vi.waitFor(() => expect(resolveB).toBeDefined()); + + runner.whenSettled(cb); + expect(cb).not.toHaveBeenCalled(); + + resolveA(); + await vi.waitFor(() => expect(a.status).toBe('done')); + expect(cb).not.toHaveBeenCalled(); // b still in flight + + resolveB(); + await vi.waitFor(() => expect(cb).toHaveBeenCalledOnce()); + }); + + it('whenSettled does not fire when runner is already idle', async () => { + const runner = new ConcurrentRunner(); + const cb = vi.fn(); + + runner.whenSettled(cb); + await Promise.resolve(); + + expect(cb).not.toHaveBeenCalled(); + }); + + it('whenSettled is superseded by abortAll()', async () => { + const runner = new ConcurrentRunner(); + const cb = vi.fn(); + + const task = new Task( + async () => { + await new Promise(() => {}); // never resolves on its own + }, + { id: 'x' } + ); + + runner.schedule(task); + runner.whenSettled(cb); + + runner.abortAll(); + await new Promise((r) => setTimeout(r, 10)); + + expect(cb).not.toHaveBeenCalled(); + }); + + it('whenSettled fires for a new batch registered after abortAll()', async () => { + const runner = new ConcurrentRunner(); + const cb = vi.fn(); + + runner.schedule(new Task(async () => {}, { id: 'first' })); + runner.abortAll(); + + runner.schedule(new Task(async () => {}, { id: 'second' })); + runner.whenSettled(cb); + + await vi.waitFor(() => expect(cb).toHaveBeenCalledOnce()); + }); + + it('destroy() aborts all in-flight tasks', async () => { + const runner = new ConcurrentRunner(); + let signal: AbortSignal | undefined; + const task = new Task( + async (s) => { + signal = s; + await new Promise(() => {}); + }, + { id: 'x' } + ); + + runner.schedule(task); + await vi.waitFor(() => expect(signal).toBeDefined()); + + runner.destroy(); + expect(signal!.aborted).toBe(true); + }); + it('abortAll() cancels all in-flight tasks', async () => { const runner = new ConcurrentRunner(); let signal1: AbortSignal | undefined; From aa9ac910cbd2adc0e9dda60045f037936b6b174b Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 1 Apr 2026 08:16:33 -0700 Subject: [PATCH 17/79] feat(spf): add createReactor factory; migrate syncTextTracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces createReactor — the declarative counterpart to createActor for signal-driven reactive logic. Each state holds an array of effect functions (one independent effect() per element) with its own dependency tracking and cleanup lifecycle. Snapshot shape matches actors: { status, context }. 'destroying' and 'destroyed' are implicit terminal states; destroy() transitions through both synchronously, leaving room for async teardown in the future. Migrates syncTextTracks to createReactor, replacing four named effect closures with a declarative state definition. Moves selectedTextTrackId reset from the mode-sync effect to the state-guard exit cleanup (correct cohesion). Return type changes from () => void to Reactor for future status observability; engine cleanups forEach updated to dispatch by shape. Documents segment-actor migration assessment in .claude/plans/spf/. Co-Authored-By: Claude Sonnet 4.6 --- .../plans/spf/actor-migration-assessment.md | 155 ++++++++ packages/spf/src/core/create-reactor.ts | 151 ++++++++ .../spf/src/core/tests/create-reactor.test.ts | 359 ++++++++++++++++++ .../spf/src/dom/features/sync-text-tracks.ts | 209 +++++----- .../features/tests/sync-text-tracks.test.ts | 50 +-- .../spf/src/dom/playback-engine/engine.ts | 4 +- 6 files changed, 789 insertions(+), 139 deletions(-) create mode 100644 .claude/plans/spf/actor-migration-assessment.md create mode 100644 packages/spf/src/core/create-reactor.ts create mode 100644 packages/spf/src/core/tests/create-reactor.test.ts diff --git a/.claude/plans/spf/actor-migration-assessment.md b/.claude/plans/spf/actor-migration-assessment.md new file mode 100644 index 000000000..54585c198 --- /dev/null +++ b/.claude/plans/spf/actor-migration-assessment.md @@ -0,0 +1,155 @@ +# Actor Migration Assessment + +Assessment of migrating SPF's segment-related actors to the `createActor` factory. +Written after completing the text track Actor/Reactor spike. + +--- + +## Background + +The text track spike produced two reference implementations using `createActor`: + +- `TextTracksActor` — cue management, `idle` → `loading` → `idle` +- `TextTrackSegmentLoaderActor` — VTT fetch planning/execution, same FSM shape + +Both were clean fits: fire-and-forget message sending, `SerialRunner` as an internal +detail, status transitions driven by `onSettled`. + +The segment-loading layer has three actors to consider: +`SegmentLoaderActor`, `SourceBufferActor`, and `loadSegments` (Reactor). + +--- + +## SegmentLoaderActor + +**File:** `packages/spf/src/dom/features/segment-loader-actor.ts` + +### Fit with `createActor` + +Mostly a good fit. Status is effectively `idle | loading` (the `running` boolean), +messages are fire-and-forget, and the `SerialRunner` pattern is already there +conceptually. The FSM would look like: + +``` +idle → load message → loading (schedule tasks) +loading → load message → loading (continue or preempt) +loading → runner settles → idle (via onSettled) +``` + +### Key blocker: continue/preempt logic + +When a new `load` arrives mid-run, the actor decides: + +- **Preempt** — in-flight work is not needed for the new plan: `abortAll()` + reschedule. + This maps cleanly to `SerialRunner`. + +- **Continue** — in-flight work IS needed (e.g. currently fetching segment X, new plan + also needs segment X): let it finish, replace the queued remainder only. + `SerialRunner` has no concept of "replace queued tasks without aborting the running one." + +The continue case exists to avoid re-fetching partially-streamed video/audio segments — +a real bandwidth cost, not just an edge case. Simplifying to always-preempt would be a +regression. + +### Recommended path + +Add `SerialRunner.replaceQueue(tasks: TaskLike[])` — drops queued (not in-flight) tasks +and enqueues the new list. This is a small, well-scoped addition that maps directly to +the continue case and is independently useful. + +With that in place, the `loading` state handler becomes: + +```ts +load: (msg, { runner, context, setContext }) => { + const allTasks = planTasks(msg, context); + if (inFlightStillNeeded(allTasks, context)) { + runner.replaceQueue(allTasks.filter(/* exclude in-flight */)); + } else { + runner.abortAll(); + allTasks.forEach(t => runner.schedule(t)); + } +} +``` + +In-flight tracking (`inFlightInitTrackId`, `inFlightSegmentId`) would move from +closure-locals into actor context via `setContext`. + +--- + +## SourceBufferActor + +**File:** `packages/spf/src/dom/media/source-buffer-actor.ts` + +### Does not fit `createActor` + +`SourceBufferActor` is a fundamentally different kind of actor. The mismatches are +deep, not surface-level: + +1. **Awaitable send** — `send()` returns `Promise`; callers (`SegmentLoaderActor`) + await it. `createActor.send()` returns `void`. Bridging this would require either + complicating the factory or losing the awaitable API that the loader depends on. + +2. **Context as task output** — In `createActor`, context updates happen synchronously + inside handlers. In `SourceBufferActor`, context is the *return value* of async tasks: + each task computes the new `SourceBufferActorContext` from the physical SourceBuffer + state, and that value becomes the next snapshot. This is an inversion of control that + doesn't map to `createActor`'s handler model. + +3. **`batch()` method** — A distinct multi-message protocol with its own `workingCtx` + threading between tasks. No `createActor` equivalent. + +4. **`onPartialContext`** — Mid-task side-effect writing to the snapshot signal during + streaming appends (before the task resolves). No hook for this in `createActor`. + +### Two actor patterns, not one + +This reveals that the codebase has two distinct actor patterns: + +| Pattern | Example | `send()` | Context updates | Runner | +|---|---|---|---|---| +| **Command-queue actor** | `SourceBufferActor` | `Promise` (awaitable) | Derived from async task results | Exposed to callers indirectly | +| **Message actor** | `TextTracksActor`, `TextTrackSegmentLoaderActor` | `void` (fire-and-forget) | Set synchronously in handlers | Hidden internal detail | + +Both are valid. The question is whether to unify them under a single factory, or +explicitly recognize and document the two patterns. + +### Options for unification + +**Option A: `createActor` gains awaitable send** +- `send()` returns `Promise`, resolved when the triggered tasks settle. +- Complex: requires tracking which tasks a message schedules and when they complete. +- May also need a way to propagate task results back to context. + +**Option B: A separate `createCommandActor` factory** +- Factory for the command-queue pattern: tasks return next context, `send()` is awaitable. +- Keeps `createActor` clean; explicit about the two patterns. + +**Option C: Leave `SourceBufferActor` as bespoke** +- It already has a reactive snapshot, `SerialRunner`, and a sound destroy pattern. +- Unifying under a factory would be refactoring for its own sake. +- Revisit only if a second command-queue actor appears and the pattern is worth naming. + +**Recommended:** Option C for now. `SourceBufferActor` is well-structured. Revisit +when (if) a second command-queue actor emerges. + +--- + +## loadSegments (Reactor) + +**File:** `packages/spf/src/dom/features/load-segments.ts` + +Currently a function with signals/effects inline — it is the Reactor layer that +bridges state → `SegmentLoaderActor` messages. Not itself an actor. + +Could be rewritten as a class-based Reactor if a `createReactor` pattern is established +(per the primitives.md design). Lower priority than the actor migrations; natural +follow-on after `SegmentLoaderActor` is migrated. + +--- + +## Recommended order + +1. Add `SerialRunner.replaceQueue()`. +2. Migrate `SegmentLoaderActor` to `createActor`. +3. Revisit `loadSegments` as a Reactor class. +4. Decide on command-queue actor unification only if a second such actor appears. diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts new file mode 100644 index 000000000..bf08677b5 --- /dev/null +++ b/packages/spf/src/core/create-reactor.ts @@ -0,0 +1,151 @@ +import type { ActorSnapshot, SignalActor } from './actor'; +import { effect } from './signals/effect'; +import { signal, untrack, update } from './signals/primitives'; + +// ============================================================================= +// Definition types +// ============================================================================= + +/** + * A single effect function within a reactor state. + * + * Called when the reactor enters or re-evaluates the state it belongs to. + * May return a cleanup function that runs before each re-evaluation and on + * state exit (including destroy). + */ +export type ReactorEffectFn = (ctx: { + transition: (to: UserStatus) => void; + context: Context; + setContext: (next: Context) => void; +}) => (() => void) | void; + +/** + * Full reactor definition passed to `createReactor`. + * + * `UserStatus` is the set of domain-meaningful states. `'destroying'` and + * `'destroyed'` are always added by the framework as implicit terminal states — + * do not include them here. + */ +export type ReactorDefinition = { + /** Initial status. */ + initial: UserStatus; + /** Initial context. */ + context: Context; + /** + * Per-state effect arrays. Each element becomes one independent `effect()` + * call gated on that state, with its own dependency tracking and cleanup + * lifecycle. States with no entry silently run no effects. + */ + states: Partial[]>>; +}; + +// ============================================================================= +// Live reactor interface +// ============================================================================= + +/** Live reactor instance returned by `createReactor`. */ +export type Reactor = SignalActor; + +// ============================================================================= +// Implementation +// ============================================================================= + +/** + * Creates a reactive Reactor from a declarative definition. + * + * A Reactor is driven by subscriptions to external signals rather than + * imperative messages. Each state holds an array of effect functions — + * every element becomes one independent `effect()` call gated on that state, + * with its own dependency tracking and cleanup lifecycle. This replaces the + * pattern of multiple named `cleanupX = effect(...)` variables in function-based + * reactors. + * + * `'destroying'` and `'destroyed'` are always implicit terminal states. + * `destroy()` transitions through both in sequence: `'destroying'` first (for + * potential async teardown in a future extension), then immediately `'destroyed'` + * for the synchronous base case. Active effect cleanups fire via disposal. + * + * @example + * const reactor = createReactor({ + * initial: 'waiting', + * context: {}, + * states: { + * waiting: [ + * ({ transition }) => { + * if (readySignal.get()) transition('active'); + * } + * ], + * active: [ + * // Effect 1 — guard and exit cleanup + * ({ transition }) => { + * if (!readySignal.get()) { transition('waiting'); return; } + * return () => teardown(); + * }, + * // Effect 2 — independent tracking/cleanup + * () => { + * const unsub = subscribe(valueSignal.get(), handler); + * return () => unsub(); + * } + * ] + * } + * }); + */ +export function createReactor( + def: ReactorDefinition +): Reactor { + type FullStatus = UserStatus | 'destroying' | 'destroyed'; + + const snapshotSignal = signal>({ + status: def.initial as FullStatus, + context: def.context, + }); + + const getStatus = (): FullStatus => untrack(() => snapshotSignal.get().status); + + const transition = (to: FullStatus): void => { + update(snapshotSignal, { status: to }); + }; + + const setContext = (context: Context): void => { + update(snapshotSignal, { context }); + }; + + // For each user-defined state, wrap each effect fn in a status-gated effect(). + // The outer effect reads snapshotSignal (tracking both status and context), gates + // on the matching state, and delegates to the user fn whose own signal reads + // establish the inner dependency set. + const effectDisposals: Array<() => void> = []; + for (const [state, fns] of Object.entries(def.states) as Array< + [UserStatus, ReactorEffectFn[]] + >) { + for (const fn of fns) { + const dispose = effect(() => { + const snapshot = snapshotSignal.get(); + if (snapshot.status !== state) return; + return fn({ + transition: (to: UserStatus) => transition(to as FullStatus), + context: snapshot.context, + setContext, + }); + }); + effectDisposals.push(dispose); + } + } + + return { + get snapshot() { + return snapshotSignal; + }, + + destroy(): void { + const status = getStatus(); + if (status === 'destroying' || status === 'destroyed') return; + // Two-step teardown: transition through 'destroying' first to leave room + // for async teardown in a future extension, then immediately 'destroyed' + // for the synchronous base case. Active effect cleanups fire via disposal. + transition('destroying'); + transition('destroyed'); + for (const dispose of effectDisposals) dispose(); + }, + }; +} diff --git a/packages/spf/src/core/tests/create-reactor.test.ts b/packages/spf/src/core/tests/create-reactor.test.ts new file mode 100644 index 000000000..8535006b4 --- /dev/null +++ b/packages/spf/src/core/tests/create-reactor.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createReactor } from '../create-reactor'; +import { signal } from '../signals/primitives'; + +// One microtask tick — enough for the signal-polyfill watcher to flush pending effects. +const tick = () => new Promise((resolve) => queueMicrotask(resolve)); + +// ============================================================================= +// createReactor — core behavior +// ============================================================================= + +describe('createReactor', () => { + it('starts with the initial status and context', () => { + const reactor = createReactor({ + initial: 'idle' as const, + context: { value: 0 }, + states: {}, + }); + + expect(reactor.snapshot.get().status).toBe('idle'); + expect(reactor.snapshot.get().context).toEqual({ value: 0 }); + + reactor.destroy(); + }); + + it('runs the effect for the initial state on creation', () => { + const fn = vi.fn(); + createReactor({ + initial: 'idle' as const, + context: {}, + states: { idle: [fn] }, + }).destroy(); + + expect(fn).toHaveBeenCalledOnce(); + }); + + it('does not run effects for states other than the initial state', () => { + const otherFn = vi.fn(); + createReactor<'idle' | 'other', object>({ + initial: 'idle', + context: {}, + states: { + idle: [], + other: [otherFn], + }, + }).destroy(); + + expect(otherFn).not.toHaveBeenCalled(); + }); + + it('passes transition, context, and setContext to effect fns', () => { + let captured: unknown; + createReactor({ + initial: 'idle' as const, + context: { x: 1 }, + states: { + idle: [ + (ctx) => { + captured = ctx; + }, + ], + }, + }).destroy(); + + expect(typeof (captured as { transition: unknown }).transition).toBe('function'); + expect((captured as { context: unknown }).context).toEqual({ x: 1 }); + expect(typeof (captured as { setContext: unknown }).setContext).toBe('function'); + }); + + it('transitions status via transition()', async () => { + const src = signal(false); + const reactor = createReactor<'waiting' | 'active', object>({ + initial: 'waiting', + context: {}, + states: { + waiting: [ + ({ transition }) => { + if (src.get()) transition('active'); + }, + ], + active: [], + }, + }); + + expect(reactor.snapshot.get().status).toBe('waiting'); + + src.set(true); + await tick(); + + expect(reactor.snapshot.get().status).toBe('active'); + + reactor.destroy(); + }); + + it('activates the correct effects after transition', async () => { + const src = signal(false); + const activeFn = vi.fn(); + + const reactor = createReactor<'waiting' | 'active', object>({ + initial: 'waiting', + context: {}, + states: { + waiting: [ + ({ transition }) => { + if (src.get()) transition('active'); + }, + ], + active: [activeFn], + }, + }); + + expect(activeFn).not.toHaveBeenCalled(); + + src.set(true); + await tick(); + await tick(); // second tick: effects for 'active' now run + + expect(activeFn).toHaveBeenCalledOnce(); + + reactor.destroy(); + }); + + it('updates context via setContext()', () => { + let captured = 0; + const reactor = createReactor({ + initial: 'idle' as const, + context: { count: 0 }, + states: { + idle: [ + ({ context, setContext }) => { + captured = context.count; + setContext({ count: context.count + 1 }); + }, + ], + }, + }); + + // Effect ran on creation with count: 0, then setContext wrote count: 1 + expect(captured).toBe(0); + expect(reactor.snapshot.get().context.count).toBe(1); + + reactor.destroy(); + }); + + it('multiple effects in the same state run independently', () => { + const fn1 = vi.fn(); + const fn2 = vi.fn(); + const fn3 = vi.fn(); + + createReactor({ + initial: 'idle' as const, + context: {}, + states: { idle: [fn1, fn2, fn3] }, + }).destroy(); + + expect(fn1).toHaveBeenCalledOnce(); + expect(fn2).toHaveBeenCalledOnce(); + expect(fn3).toHaveBeenCalledOnce(); + }); + + it('re-runs only the effect whose dependency changed', async () => { + const src1 = signal(0); + const src2 = signal(0); + const fn1 = vi.fn(() => { + src1.get(); + }); + const fn2 = vi.fn(() => { + src2.get(); + }); + + const reactor = createReactor({ + initial: 'idle' as const, + context: {}, + states: { idle: [fn1, fn2] }, + }); + + expect(fn1).toHaveBeenCalledOnce(); + expect(fn2).toHaveBeenCalledOnce(); + + src1.set(1); + await tick(); + + expect(fn1).toHaveBeenCalledTimes(2); + expect(fn2).toHaveBeenCalledOnce(); // not re-run — no dependency on src1 + + reactor.destroy(); + }); + + it('snapshot is reactive', async () => { + const src = signal(false); + const reactor = createReactor<'waiting' | 'active', object>({ + initial: 'waiting', + context: {}, + states: { + waiting: [ + ({ transition }) => { + if (src.get()) transition('active'); + }, + ], + active: [], + }, + }); + + const before = reactor.snapshot.get(); + src.set(true); + await tick(); + const after = reactor.snapshot.get(); + + expect(before.status).toBe('waiting'); + expect(after.status).toBe('active'); + + reactor.destroy(); + }); +}); + +// ============================================================================= +// createReactor — cleanup +// ============================================================================= + +describe('createReactor — cleanup', () => { + it('calls the effect cleanup on state exit', async () => { + const src = signal(false); + const cleanup = vi.fn(); + + const reactor = createReactor<'active' | 'done', object>({ + initial: 'active', + context: {}, + states: { + active: [ + ({ transition }) => { + if (src.get()) transition('done'); + return cleanup; + }, + ], + done: [], + }, + }); + + expect(cleanup).not.toHaveBeenCalled(); + + src.set(true); + await tick(); + + expect(cleanup).toHaveBeenCalledOnce(); + + reactor.destroy(); + }); + + it('calls the effect cleanup before re-running when a dependency changes', async () => { + const src = signal(0); + const cleanup = vi.fn(); + + const reactor = createReactor({ + initial: 'idle' as const, + context: {}, + states: { + idle: [ + () => { + src.get(); + return cleanup; + }, + ], + }, + }); + + expect(cleanup).not.toHaveBeenCalled(); + + src.set(1); + await tick(); + + expect(cleanup).toHaveBeenCalledOnce(); + + reactor.destroy(); + }); + + it('calls effect cleanups on destroy()', () => { + const cleanup = vi.fn(); + + const reactor = createReactor({ + initial: 'idle' as const, + context: {}, + states: { + idle: [() => cleanup], + }, + }); + + reactor.destroy(); + + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it('does not call cleanup for inactive state effects on destroy()', () => { + const activeCleanup = vi.fn(); + const inactiveCleanup = vi.fn(); + + createReactor<'idle' | 'other', object>({ + initial: 'idle', + context: {}, + states: { + idle: [() => activeCleanup], + other: [() => inactiveCleanup], + }, + }).destroy(); + + expect(activeCleanup).toHaveBeenCalledOnce(); + expect(inactiveCleanup).not.toHaveBeenCalled(); + }); +}); + +// ============================================================================= +// createReactor — destroy +// ============================================================================= + +describe('createReactor — destroy', () => { + it('transitions to destroyed on destroy()', () => { + const reactor = createReactor({ + initial: 'idle' as const, + context: {}, + states: { idle: [] }, + }); + + reactor.destroy(); + + expect(reactor.snapshot.get().status).toBe('destroyed'); + }); + + it('destroy() is idempotent', () => { + const reactor = createReactor({ + initial: 'idle' as const, + context: {}, + states: { idle: [] }, + }); + + reactor.destroy(); + expect(() => reactor.destroy()).not.toThrow(); + expect(reactor.snapshot.get().status).toBe('destroyed'); + }); + + it('does not run effects after destroy()', async () => { + const src = signal(0); + const fn = vi.fn(() => { + src.get(); + }); + + const reactor = createReactor({ + initial: 'idle' as const, + context: {}, + states: { idle: [fn] }, + }); + + reactor.destroy(); + fn.mockClear(); + + src.set(1); + await tick(); + + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index 34ab064eb..418849ce4 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -1,7 +1,9 @@ import { listen } from '@videojs/utils/dom'; -import { effect } from '../../core/signals/effect'; -import { computed, type Signal, signal, untrack, update } from '../../core/signals/primitives'; +import type { Reactor } from '../../core/create-reactor'; +import { createReactor } from '../../core/create-reactor'; +import { computed, type Signal, untrack, update } from '../../core/signals/primitives'; import type { PartiallyResolvedTextTrack, Presentation, TextTrack } from '../../core/types'; + /** * FSM states for text track sync. * @@ -23,7 +25,7 @@ import type { PartiallyResolvedTextTrack, Presentation, TextTrack } from '../../ * any non-final state ──── destroy() ────→ 'destroying' ────→ 'destroyed' * ``` */ -export type TextTrackSyncStatus = 'preconditions-unmet' | 'setting-up' | 'set-up' | 'destroying' | 'destroyed'; +export type TextTrackSyncStatus = 'preconditions-unmet' | 'setting-up' | 'set-up'; /** * State shape for text track sync. @@ -76,20 +78,23 @@ function syncModes(textTracks: TextTrackList, selectedId: string | undefined): v /** * Text track sync orchestration. * - * Implements the TextTrackSync FSM using one effect per state: + * Implements the TextTrackSync FSM via `createReactor` with one effect per + * concern per state: * - * - **`cleanupPreconditionsUnmet`** — waits for preconditions, then transitions + * - **`'preconditions-unmet'`** — waits for preconditions, then transitions * to `'setting-up'`. - * - **`cleanupSettingUp`** — creates `` elements, then transitions to + * - **`'setting-up'`** — creates `` elements, then transitions to * `'set-up'`. - * - **`cleanupSetUp`** — guards the `'set-up'` state; exit cleanup removes - * `` elements on any outbound transition. - * - **`cleanupModes`** — active in `'set-up'`; owns mode sync, the Chromium - * settling-window guard, and the `'change'` listener that bridges DOM state - * back to `selectedTextTrackId`. + * - **`'set-up'` effect 1** — guards the state; exit cleanup removes `` + * elements and clears `selectedTextTrackId` on any outbound transition. + * - **`'set-up'` effect 2** — owns mode sync, the Chromium settling-window + * guard, and the `'change'` listener that bridges DOM state back to + * `selectedTextTrackId`. * * @example - * const cleanup = syncTextTracks({ state, owners }); + * const reactor = syncTextTracks({ state, owners }); + * // later: + * reactor.destroy(); */ export function syncTextTracks({ state, @@ -97,9 +102,7 @@ export function syncTextTracks; owners: Signal; -}): () => void { - const statusSignal = signal('preconditions-unmet'); - +}): Reactor { const mediaElementSignal = computed(() => owners.get().mediaElement); const modelTextTracksSignal = computed(() => getModelTextTracks(state.get().presentation), { /** @TODO Make generic and abstract away for Array | undefined (CJP) */ @@ -116,105 +119,87 @@ export function syncTextTracks state.get().selectedTextTrackId); - const preconditionsMetSignal = computed(() => !!mediaElementSignal.get() && !!modelTextTracksSignal.get()?.length); - const teardownTextTracks = (mediaElement: HTMLMediaElement) => { - mediaElement.querySelectorAll('track[data-src-track]:is([kind="subtitles"],[kind="captions"').forEach((trackEl) => { - trackEl.remove(); - }); - }; - - const setupTextTracks = (mediaElement: HTMLMediaElement, modelTextTracks: PartiallyResolvedTextTrack[]) => { - modelTextTracks.forEach((modelTextTrack) => { - const trackElement = createTrackElement(modelTextTrack); - mediaElement.appendChild(trackElement); - }); - }; - - const cleanupPreconditionsUnmet = effect(() => { - if (statusSignal.get() !== 'preconditions-unmet') return; - if (preconditionsMetSignal.get()) { - statusSignal.set('setting-up'); - } - }); - - const cleanupSettingUp = effect(() => { - if (statusSignal.get() !== 'setting-up') return; - setupTextTracks( - mediaElementSignal.get() as HTMLMediaElement, - modelTextTracksSignal.get() as PartiallyResolvedTextTrack[] - ); - statusSignal.set('set-up'); - }); - - const cleanupSetUp = effect(() => { - if (statusSignal.get() !== 'set-up') return; - // Preconditions have changed back to unmet, so transition back to that state (which will cause a teardown/"exit") - if (!preconditionsMetSignal.get()) { - statusSignal.set('preconditions-unmet'); - return; - } - - const currentMediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); - - return () => { - teardownTextTracks(currentMediaElement); - }; - }); - - const cleanupModes = effect(() => { - if (statusSignal.get() !== 'set-up') return; - - const mediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); - const selectedId = selectedTextTrackIdSignal.get(); - - syncModes(mediaElement.textTracks, selectedId); - let syncTimeout: ReturnType | undefined = setTimeout(() => { - syncTimeout = undefined; - }, 0); - - const onChange = () => { - if (syncTimeout) { - // Inside the settling window: browser auto-selection is overriding our - // modes. Re-apply to restore the intended state without touching state. - // change events are queued as tasks (async), so no re-entrancy risk. - syncModes( - mediaElement.textTracks, - untrack(() => selectedTextTrackIdSignal.get()) - ); - return; - } - - const showingTrack = Array.from(mediaElement.textTracks).find( - (t) => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions') - ); - - // showingTrack.id matches the SPF track ID set by createTrackElement above. - // Fall back to undefined for empty-string IDs (non-SPF-managed tracks). - const newId = showingTrack?.id; - const currentModelId = untrack(() => selectedTextTrackIdSignal.get()); - if (newId === currentModelId) return; - update(state, { selectedTextTrackId: newId } as Partial); - }; - - const unlisten = listen(mediaElement.textTracks, 'change', onChange); - - return () => { - clearTimeout(syncTimeout ?? undefined); - unlisten(); - if (untrack(() => statusSignal.get()) !== 'set-up') { - update(state, { selectedTextTrackId: undefined } as Partial); - } - }; + return createReactor({ + initial: 'preconditions-unmet', + context: {}, + states: { + 'preconditions-unmet': [ + ({ transition }) => { + if (preconditionsMetSignal.get()) transition('setting-up'); + }, + ], + + 'setting-up': [ + ({ transition }) => { + const mediaElement = mediaElementSignal.get() as HTMLMediaElement; + const modelTextTracks = modelTextTracksSignal.get() as PartiallyResolvedTextTrack[]; + modelTextTracks.forEach((track) => mediaElement.appendChild(createTrackElement(track))); + transition('set-up'); + }, + ], + + 'set-up': [ + // Effect 1 — guards state; exit cleanup tears down track elements and + // clears selectedTextTrackId on any outbound transition (including destroy). + ({ transition }) => { + if (!preconditionsMetSignal.get()) { + transition('preconditions-unmet'); + return; + } + const currentMediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); + return () => { + currentMediaElement + .querySelectorAll('track[data-src-track]:is([kind="subtitles"],[kind="captions"') + .forEach((trackEl) => trackEl.remove()); + update(state, { selectedTextTrackId: undefined } as Partial); + }; + }, + + // Effect 2 — mode sync + DOM change listener (independent tracking/cleanup). + () => { + const mediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); + const selectedId = selectedTextTrackIdSignal.get(); + + syncModes(mediaElement.textTracks, selectedId); + let syncTimeout: ReturnType | undefined = setTimeout(() => { + syncTimeout = undefined; + }, 0); + + const onChange = () => { + if (syncTimeout) { + // Inside the settling window: browser auto-selection is overriding our + // modes. Re-apply to restore the intended state without touching state. + // change events are queued as tasks (async), so no re-entrancy risk. + syncModes( + mediaElement.textTracks, + untrack(() => selectedTextTrackIdSignal.get()) + ); + return; + } + + const showingTrack = Array.from(mediaElement.textTracks).find( + (t) => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions') + ); + + // showingTrack.id matches the SPF track ID set by createTrackElement above. + // Fall back to undefined for empty-string IDs (non-SPF-managed tracks). + const newId = showingTrack?.id; + const currentModelId = untrack(() => selectedTextTrackIdSignal.get()); + if (newId === currentModelId) return; + update(state, { selectedTextTrackId: newId } as Partial); + }; + + const unlisten = listen(mediaElement.textTracks, 'change', onChange); + + return () => { + clearTimeout(syncTimeout ?? undefined); + unlisten(); + }; + }, + ], + }, }); - - return () => { - cleanupPreconditionsUnmet(); - cleanupSettingUp(); - cleanupSetUp(); - cleanupModes(); - }; } diff --git a/packages/spf/src/dom/features/tests/sync-text-tracks.test.ts b/packages/spf/src/dom/features/tests/sync-text-tracks.test.ts index 31e968861..47f7dbafb 100644 --- a/packages/spf/src/dom/features/tests/sync-text-tracks.test.ts +++ b/packages/spf/src/dom/features/tests/sync-text-tracks.test.ts @@ -34,8 +34,8 @@ function makePresentation(tracks: Array<{ id: string; kind?: string; language?: function setup(initialState: TextTrackSyncState = {}, initialOwners: TextTrackSyncOwners = {}) { const state = signal(initialState); const owners = signal(initialOwners); - const cleanup = syncTextTracks({ state, owners }); - return { state, owners, cleanup }; + const reactor = syncTextTracks({ state, owners }); + return { state, owners, reactor }; } describe('syncTextTracks', () => { @@ -46,7 +46,7 @@ describe('syncTextTracks', () => { { id: 'track-es', language: 'es' }, ]); - const { state, owners, cleanup } = setup(); + const { state, owners, reactor } = setup(); owners.set({ ...owners.get(), mediaElement }); state.set({ ...state.get(), presentation }); @@ -56,22 +56,22 @@ describe('syncTextTracks', () => { expect((mediaElement.children[0] as HTMLTrackElement).id).toBe('track-en'); expect((mediaElement.children[1] as HTMLTrackElement).id).toBe('track-es'); - cleanup(); + reactor.destroy(); }); it('does not create tracks when no mediaElement', async () => { const presentation = makePresentation([{ id: 'track-en', language: 'en' }]); - const { state, owners, cleanup } = setup(); + const { state, reactor } = setup(); state.set({ ...state.get(), presentation }); await new Promise((resolve) => setTimeout(resolve, 50)); - cleanup(); + reactor.destroy(); }); it('does not create tracks when presentation has no text tracks', async () => { const mediaElement = document.createElement('video'); - const { state, owners, cleanup } = setup(); + const { state, owners, reactor } = setup(); owners.set({ ...owners.get(), mediaElement }); state.set({ @@ -85,7 +85,7 @@ describe('syncTextTracks', () => { await new Promise((resolve) => setTimeout(resolve, 50)); expect(mediaElement.children.length).toBe(0); - cleanup(); + reactor.destroy(); }); it('sets selected track to "showing" and others to "disabled"', async () => { @@ -95,7 +95,7 @@ describe('syncTextTracks', () => { { id: 'track-es', language: 'es' }, ]); - const { state, owners, cleanup } = setup({ presentation }); + const { state, owners, reactor } = setup({ presentation }); owners.set({ ...owners.get(), mediaElement }); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -106,7 +106,7 @@ describe('syncTextTracks', () => { expect(enEl!.track.mode).toBe('showing'); expect(esEl!.track.mode).toBe('disabled'); - cleanup(); + reactor.destroy(); }); it('switches active track when selection changes', async () => { @@ -116,7 +116,7 @@ describe('syncTextTracks', () => { { id: 'track-es', language: 'es' }, ]); - const { state, owners, cleanup } = setup({ presentation, selectedTextTrackId: 'track-en' }); + const { state, owners, reactor } = setup({ presentation, selectedTextTrackId: 'track-en' }); owners.set({ ...owners.get(), mediaElement }); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -130,7 +130,7 @@ describe('syncTextTracks', () => { expect(enEl!.track.mode).toBe('disabled'); expect(esEl!.track.mode).toBe('showing'); - cleanup(); + reactor.destroy(); }); it('disables all tracks when selection is cleared', async () => { @@ -140,7 +140,7 @@ describe('syncTextTracks', () => { { id: 'track-es', language: 'es' }, ]); - const { state, owners, cleanup } = setup({ presentation, selectedTextTrackId: 'track-en' }); + const { state, owners, reactor } = setup({ presentation, selectedTextTrackId: 'track-en' }); owners.set({ ...owners.get(), mediaElement }); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -151,7 +151,7 @@ describe('syncTextTracks', () => { expect(enEl!.track.mode).toBe('disabled'); expect(esEl!.track.mode).toBe('disabled'); - cleanup(); + reactor.destroy(); }); it('does not touch non-subtitle/caption tracks', async () => { @@ -166,13 +166,13 @@ describe('syncTextTracks', () => { mediaElement.appendChild(chaptersEl); chaptersEl.track.mode = 'hidden'; - const { owners, cleanup } = setup({ presentation, selectedTextTrackId: 'track-en' }); + const { owners, reactor } = setup({ presentation, selectedTextTrackId: 'track-en' }); owners.set({ ...owners.get(), mediaElement }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(chaptersEl.track.mode).toBe('hidden'); - cleanup(); + reactor.destroy(); }); it('bridges external mode change → selectedTextTrackId', async () => { @@ -182,7 +182,7 @@ describe('syncTextTracks', () => { { id: 'track-es', language: 'es' }, ]); - const { state, owners, cleanup } = setup({ presentation }); + const { state, owners, reactor } = setup({ presentation }); owners.set({ ...owners.get(), mediaElement }); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -197,7 +197,7 @@ describe('syncTextTracks', () => { expect(state.get().selectedTextTrackId).toBe('track-es'); - cleanup(); + reactor.destroy(); }); it('clears selectedTextTrackId when external code disables all tracks', async () => { @@ -207,7 +207,7 @@ describe('syncTextTracks', () => { { id: 'track-es', language: 'es' }, ]); - const { state, owners, cleanup } = setup({ presentation, selectedTextTrackId: 'track-en' }); + const { state, owners, reactor } = setup({ presentation, selectedTextTrackId: 'track-en' }); owners.set({ ...owners.get(), mediaElement }); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -222,23 +222,23 @@ describe('syncTextTracks', () => { expect(state.get().selectedTextTrackId).toBeUndefined(); - cleanup(); + reactor.destroy(); }); - it('removes track elements on cleanup', async () => { + it('removes track elements on destroy', async () => { const mediaElement = document.createElement('video'); const presentation = makePresentation([ { id: 'track-en', language: 'en' }, { id: 'track-es', language: 'es' }, ]); - const { owners, cleanup } = setup({ presentation }); + const { owners, reactor } = setup({ presentation }); owners.set({ ...owners.get(), mediaElement }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(mediaElement.children.length).toBe(2); - cleanup(); + reactor.destroy(); expect(mediaElement.children.length).toBe(0); }); @@ -246,7 +246,7 @@ describe('syncTextTracks', () => { const mediaElement = document.createElement('video'); const presentation = makePresentation([{ id: 'track-en', language: 'en' }]); - const { state, owners, cleanup } = setup({ presentation }); + const { state, owners, reactor } = setup({ presentation }); owners.set({ ...owners.get(), mediaElement }); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -260,6 +260,6 @@ describe('syncTextTracks', () => { expect(mediaElement.children.length).toBe(1); expect(mediaElement.children[0]).toBe(firstChild); - cleanup(); + reactor.destroy(); }); }); diff --git a/packages/spf/src/dom/playback-engine/engine.ts b/packages/spf/src/dom/playback-engine/engine.ts index 8b1a9de7d..9b461d270 100644 --- a/packages/spf/src/dom/playback-engine/engine.ts +++ b/packages/spf/src/dom/playback-engine/engine.ts @@ -267,7 +267,7 @@ export function createPlaybackEngine(config: PlaybackEngineConfig = {}): Playbac // 6.5. Signal end of stream when all segments loaded endOfStream({ state, owners }), - // 7-8.5. Text track sync: setup, mode sync, and DOM bridge in one reactive function. + // 7-8.5. Text track sync: setup, mode sync, and DOM bridge. // Consolidates setupTextTracks, syncTextTrackModes, syncSelectedTextTrackFromDom. syncTextTracks({ state, owners }), @@ -280,7 +280,7 @@ export function createPlaybackEngine(config: PlaybackEngineConfig = {}): Playbac state, owners, destroy: () => { - cleanups.forEach((cleanup) => cleanup()); + cleanups.forEach((cleanup) => (typeof cleanup === 'function' ? cleanup() : cleanup.destroy())); destroyVttParser(); }, }; From e324b1d2e64e80fb569b554aeccb7045cfe36e39 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 1 Apr 2026 08:52:01 -0700 Subject: [PATCH 18/79] refactor(spf): migrate resolvePresentation to createReactor Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 10 +- .../src/core/features/resolve-presentation.ts | 92 +++++++++++-------- .../tests/resolve-presentation.test.ts | 52 +++++------ 3 files changed, 87 insertions(+), 67 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index bf08677b5..d47826ad9 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -123,9 +123,15 @@ export function createReactor const snapshot = snapshotSignal.get(); if (snapshot.status !== state) return; return fn({ - transition: (to: UserStatus) => transition(to as FullStatus), + transition: (to: UserStatus) => { + if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; + transition(to as FullStatus); + }, context: snapshot.context, - setContext, + setContext: (context: Context) => { + if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; + setContext(context); + }, }); }); effectDisposals.push(dispose); diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index 3061d97b8..8c12eff7e 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -1,6 +1,7 @@ import { fetchResolvable, getResponseText } from '../../dom/network/fetch'; +import type { Reactor } from '../create-reactor'; +import { createReactor } from '../create-reactor'; import { parseMultivariantPlaylist } from '../hls/parse-multivariant'; -import { effect } from '../signals/effect'; import { computed, type Signal, update } from '../signals/primitives'; import type { AddressableObject, Presentation } from '../types'; @@ -55,59 +56,72 @@ export function shouldResolve(state: PresentationState): boolean { ); } +export type ResolvePresentationStatus = 'idle' | 'resolving'; + /** * Resolves unresolved presentations using reactive composition. * + * FSM: `'idle'` ↔ `'resolving'` + * + * - `'idle'` — waits for `canResolve && shouldResolve`, then starts the fetch + * and transitions to `'resolving'`. + * - `'resolving'` — in-flight fetch; exit cleanup aborts it. + * * Triggers resolution when: * - State-driven: Unresolved presentation + preload allows (auto/metadata) * - Playback-driven: playbackInitiated is true * * @example - * ```ts - * const state = signal({ presentation: undefined, preload: 'auto', playbackInitiated: false }); - * - * const cleanup = resolvePresentation({ state }); - * - * // State-driven: resolves immediately when preload allows - * state.set({ ...state.get(), presentation: { url: 'http://example.com/playlist.m3u8' } }); - * - * // Playback-driven: resolves when playbackInitiated is set - * state.set({ ...state.get(), preload: 'none', presentation: { url: '...' }, playbackInitiated: true }); - * ``` + * const reactor = resolvePresentation({ state }); + * // later: + * reactor.destroy(); */ -export function resolvePresentation({ state }: { state: Signal }): () => void { +export function resolvePresentation({ + state, +}: { + state: Signal; +}): Reactor { const canResolveSignal = computed(() => canResolve(state.get())); const shouldResolveSignal = computed(() => shouldResolve(state.get())); - let resolving = false; let abortController: AbortController | null = null; - const cleanupEffect = effect(() => { - if (!canResolveSignal.get() || !shouldResolveSignal.get() || resolving) return; + return createReactor({ + initial: 'idle', + context: {}, + states: { + idle: [ + ({ transition }) => { + if (!canResolveSignal.get() || !shouldResolveSignal.get()) return; - const presentation = state.get().presentation as UnresolvedPresentation; - resolving = true; - abortController = new AbortController(); + const presentation = state.get().presentation as UnresolvedPresentation; + abortController = new AbortController(); - fetchResolvable(presentation, { signal: abortController.signal }) - .then((response) => getResponseText(response)) - .then((text) => { - const parsed = parseMultivariantPlaylist(text, presentation); - const patch: Partial = { presentation: parsed }; - update(state, patch); - }) - .catch((error) => { - if (error instanceof Error && error.name === 'AbortError') return; - throw error; - }) - .finally(() => { - resolving = false; - abortController = null; - }); - }); + fetchResolvable(presentation, { signal: abortController.signal }) + .then((response) => getResponseText(response)) + .then((text) => { + const parsed = parseMultivariantPlaylist(text, presentation); + update(state, { presentation: parsed } as Partial); + }) + .catch((error) => { + if (error instanceof Error && error.name === 'AbortError') return; + throw error; + }) + .finally(() => { + abortController = null; + transition('idle'); + }); - return () => { - abortController?.abort(); - cleanupEffect(); - }; + transition('resolving'); + }, + ], + + resolving: [ + () => () => { + abortController?.abort(); + abortController = null; + }, + ], + }, + }); } diff --git a/packages/spf/src/core/features/tests/resolve-presentation.test.ts b/packages/spf/src/core/features/tests/resolve-presentation.test.ts index da2a861cc..33e9cd1a5 100644 --- a/packages/spf/src/core/features/tests/resolve-presentation.test.ts +++ b/packages/spf/src/core/features/tests/resolve-presentation.test.ts @@ -29,7 +29,7 @@ variant2.m3u8`) ); // Act - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); // Trigger resolution by setting unresolved presentation state.set({ ...state.get(), presentation: { url: 'http://example.com/playlist.m3u8' } }); @@ -55,7 +55,7 @@ variant2.m3u8`) expect(videoSet!.switchingSets[0]?.tracks.length).toBeGreaterThan(0); // Cleanup - cleanup(); + reactor.destroy(); }); it('does not trigger resolution when other state fields change', async () => { @@ -79,7 +79,7 @@ variant1.m3u8`) ); // Act - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); // Change volume before resolution state.set({ ...state.get(), volume: 0.5 }); @@ -107,7 +107,7 @@ variant1.m3u8`) expect(fetchSpy).not.toHaveBeenCalled(); // Cleanup - cleanup(); + reactor.destroy(); }); it('resolves presentation initialized as unresolved', async () => { @@ -130,7 +130,7 @@ variant1.m3u8`) }); // Act - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); // Wait for resolution (should happen automatically) await vi.waitFor(() => { @@ -145,7 +145,7 @@ variant1.m3u8`) expect(resolved.selectionSets).toBeDefined(); // Cleanup - cleanup(); + reactor.destroy(); }); it('does not re-resolve presentation initialized as resolved', async () => { @@ -193,7 +193,7 @@ variant1.m3u8`) }); // Act - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); // Wait a bit to ensure no fetch triggered await new Promise((resolve) => setTimeout(resolve, 50)); @@ -203,7 +203,7 @@ variant1.m3u8`) expect(state.get().presentation).toBe(resolvedPresentation); // Cleanup - cleanup(); + reactor.destroy(); }); it('resolves new unresolved presentation after resolved one', async () => { @@ -233,7 +233,7 @@ variant2.m3u8`) }); // Act - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); // Replace with unresolved presentation state.set({ ...state.get(), presentation: { url: 'http://example.com/second.m3u8' } }); @@ -252,7 +252,7 @@ variant2.m3u8`) expect(resolved.selectionSets).toBeDefined(); // Cleanup - cleanup(); + reactor.destroy(); }); describe('preload policy', () => { @@ -273,13 +273,13 @@ variant1.m3u8`) preload: 'auto', }); - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); await vi.waitFor(() => { expect(state.get().presentation).toHaveProperty('id'); }); - cleanup(); + reactor.destroy(); }); it('resolves when preload is "metadata"', async () => { @@ -299,13 +299,13 @@ variant1.m3u8`) preload: 'metadata', }); - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); await vi.waitFor(() => { expect(state.get().presentation).toHaveProperty('id'); }); - cleanup(); + reactor.destroy(); }); it('does not resolve when preload is "none"', async () => { @@ -321,14 +321,14 @@ variant1.m3u8`) preload: 'none', }); - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchSpy).not.toHaveBeenCalled(); expect(state.get().presentation).toEqual({ url: 'http://example.com/playlist.m3u8' }); - cleanup(); + reactor.destroy(); }); it('does not resolve when preload is undefined', async () => { @@ -344,14 +344,14 @@ variant1.m3u8`) preload: undefined, }); - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchSpy).not.toHaveBeenCalled(); expect(state.get().presentation).toEqual({ url: 'http://example.com/playlist.m3u8' }); - cleanup(); + reactor.destroy(); }); }); @@ -374,7 +374,7 @@ variant1.m3u8`) preload: 'none', }); - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); // Initially shouldn't fetch (preload="none", not yet initiated) expect(fetchSpy).not.toHaveBeenCalled(); @@ -389,7 +389,7 @@ variant1.m3u8`) expect(fetchSpy).toHaveBeenCalledOnce(); - cleanup(); + reactor.destroy(); }); it('does not resolve when playbackInitiated is false with preload "none"', async () => { @@ -406,14 +406,14 @@ variant1.m3u8`) preload: 'none', }); - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchSpy).not.toHaveBeenCalled(); expect(state.get().presentation).toEqual({ url: 'http://example.com/playlist.m3u8' }); - cleanup(); + reactor.destroy(); }); }); @@ -435,7 +435,7 @@ variant1.m3u8`) preload: 'auto', }); - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); // Rapidly trigger additional state changes while resolution is in progress state.set({ ...state.get(), preload: 'auto' }); @@ -449,7 +449,7 @@ variant1.m3u8`) // Should only fetch once despite multiple state changes expect(fetchSpy).toHaveBeenCalledOnce(); - cleanup(); + reactor.destroy(); }); it('allows resolving different presentations sequentially', async () => { @@ -470,7 +470,7 @@ variant1.m3u8`) preload: 'auto', }); - const cleanup = resolvePresentation({ state }); + const reactor = resolvePresentation({ state }); // Wait for first resolution await vi.waitFor(() => { @@ -491,7 +491,7 @@ variant1.m3u8`) // Should have fetched twice (different URLs) expect(fetchSpy).toHaveBeenCalledTimes(2); - cleanup(); + reactor.destroy(); }); }); }); From 0a397762fb91f18f9436726fa6a51b85adae1307 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 1 Apr 2026 09:22:05 -0700 Subject: [PATCH 19/79] refactor(spf): expand resolvePresentation to 4-state FSM with deriveStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit States are now preconditions-unmet → idle → resolving → resolved, with mutually-exclusive conditions derived by deriveStatus(). Each state has a single condition monitor that reads derivedStatusSignal and transitions directly to whatever state conditions currently dictate — including mid-resolve condition changes. resolving additionally carries a fetch task that returns its AbortController so createReactor aborts it on exit. createReactor now supports { abort(): void } as an effect return type. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 7 +- .../src/core/features/resolve-presentation.ts | 81 ++++++++++++------- .../tests/resolve-presentation.test.ts | 3 +- 3 files changed, 59 insertions(+), 32 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index d47826ad9..fac7faa58 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -17,7 +17,7 @@ export type ReactorEffectFn = transition: (to: UserStatus) => void; context: Context; setContext: (next: Context) => void; -}) => (() => void) | void; +}) => (() => void) | { abort(): void } | void; /** * Full reactor definition passed to `createReactor`. @@ -122,7 +122,7 @@ export function createReactor const dispose = effect(() => { const snapshot = snapshotSignal.get(); if (snapshot.status !== state) return; - return fn({ + const result = fn({ transition: (to: UserStatus) => { if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; transition(to as FullStatus); @@ -133,6 +133,9 @@ export function createReactor setContext(context); }, }); + if (!result) return undefined; + if (typeof result === 'function') return result; + return () => result.abort(); }); effectDisposals.push(dispose); } diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index 8c12eff7e..7c1a66718 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -2,7 +2,7 @@ import { fetchResolvable, getResponseText } from '../../dom/network/fetch'; import type { Reactor } from '../create-reactor'; import { createReactor } from '../create-reactor'; import { parseMultivariantPlaylist } from '../hls/parse-multivariant'; -import { computed, type Signal, update } from '../signals/primitives'; +import { computed, type Signal, untrack, update } from '../signals/primitives'; import type { AddressableObject, Presentation } from '../types'; /** @@ -56,20 +56,30 @@ export function shouldResolve(state: PresentationState): boolean { ); } -export type ResolvePresentationStatus = 'idle' | 'resolving'; +export type ResolvePresentationStatus = 'preconditions-unmet' | 'idle' | 'resolving' | 'resolved'; /** - * Resolves unresolved presentations using reactive composition. - * - * FSM: `'idle'` ↔ `'resolving'` + * Derives the correct status from current state conditions. * - * - `'idle'` — waits for `canResolve && shouldResolve`, then starts the fetch - * and transitions to `'resolving'`. - * - `'resolving'` — in-flight fetch; exit cleanup aborts it. + * States are mutually exclusive and exhaustive: + * - `'preconditions-unmet'`: no presentation, or presentation has no URL + * - `'idle'`: URL present, unresolved (no id), shouldResolve not met + * - `'resolving'`: URL present, unresolved (no id), shouldResolve met + * - `'resolved'`: URL present, resolved (has id) + */ +function deriveStatus(state: PresentationState): ResolvePresentationStatus { + const { presentation } = state; + if (!presentation || !('url' in presentation)) return 'preconditions-unmet'; + if ('id' in presentation) return 'resolved'; + return shouldResolve(state) ? 'resolving' : 'idle'; +} + +/** + * Resolves unresolved presentations using reactive composition. * - * Triggers resolution when: - * - State-driven: Unresolved presentation + preload allows (auto/metadata) - * - Playback-driven: playbackInitiated is true + * FSM driven by `deriveStatus` — each state monitors conditions and transitions + * immediately when they change. `'resolving'` additionally runs the fetch task + * and returns an AbortController so the framework aborts it on state exit. * * @example * const reactor = resolvePresentation({ state }); @@ -81,45 +91,58 @@ export function resolvePresentation({ }: { state: Signal; }): Reactor { - const canResolveSignal = computed(() => canResolve(state.get())); - const shouldResolveSignal = computed(() => shouldResolve(state.get())); - - let abortController: AbortController | null = null; + const derivedStatusSignal = computed(() => deriveStatus(state.get())); return createReactor({ - initial: 'idle', + initial: 'preconditions-unmet', context: {}, states: { + 'preconditions-unmet': [ + ({ transition }) => { + const target = derivedStatusSignal.get(); + if (target !== 'preconditions-unmet') transition(target); + }, + ], + idle: [ ({ transition }) => { - if (!canResolveSignal.get() || !shouldResolveSignal.get()) return; + const target = derivedStatusSignal.get(); + if (target !== 'idle') transition(target); + }, + ], - const presentation = state.get().presentation as UnresolvedPresentation; - abortController = new AbortController(); + resolving: [ + // Condition monitor — exits immediately if conditions have changed. + ({ transition }) => { + const target = derivedStatusSignal.get(); + if (target !== 'resolving') transition(target); + }, - fetchResolvable(presentation, { signal: abortController.signal }) + // Fetch task — returns the AbortController so the framework aborts on exit. + ({ transition }) => { + const presentation = untrack(() => state.get().presentation) as UnresolvedPresentation; + const ac = new AbortController(); + + fetchResolvable(presentation, { signal: ac.signal }) .then((response) => getResponseText(response)) .then((text) => { const parsed = parseMultivariantPlaylist(text, presentation); update(state, { presentation: parsed } as Partial); + transition('resolved'); }) .catch((error) => { if (error instanceof Error && error.name === 'AbortError') return; throw error; - }) - .finally(() => { - abortController = null; - transition('idle'); }); - transition('resolving'); + return ac; }, ], - resolving: [ - () => () => { - abortController?.abort(); - abortController = null; + resolved: [ + ({ transition }) => { + const target = derivedStatusSignal.get(); + if (target !== 'resolved') transition(target); }, ], }, diff --git a/packages/spf/src/core/features/tests/resolve-presentation.test.ts b/packages/spf/src/core/features/tests/resolve-presentation.test.ts index 33e9cd1a5..2bd0c53c1 100644 --- a/packages/spf/src/core/features/tests/resolve-presentation.test.ts +++ b/packages/spf/src/core/features/tests/resolve-presentation.test.ts @@ -482,9 +482,10 @@ variant1.m3u8`) // Now load a different presentation state.set({ ...state.get(), presentation: { url: 'http://example.com/second.m3u8' } }); - // Wait for second resolution + // Wait for second resolution — check id to confirm the fetch actually completed await vi.waitFor(() => { const pres = state.get().presentation as Presentation; + expect(pres).toHaveProperty('id'); expect(pres.url).toBe('http://example.com/second.m3u8'); }); From d936a3d338e18aed6331ea53d709cbe18c17679e Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 1 Apr 2026 09:27:01 -0700 Subject: [PATCH 20/79] refactor(spf): remove explicit transition('resolved') from fetch task After update(state, { presentation: parsed }), derivedStatusSignal becomes 'resolved' automatically, which triggers the resolving condition monitor to transition. No need for the fetch task to call transition directly. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/features/resolve-presentation.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index 7c1a66718..52807ad11 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -119,7 +119,7 @@ export function resolvePresentation({ }, // Fetch task — returns the AbortController so the framework aborts on exit. - ({ transition }) => { + () => { const presentation = untrack(() => state.get().presentation) as UnresolvedPresentation; const ac = new AbortController(); @@ -128,7 +128,6 @@ export function resolvePresentation({ .then((text) => { const parsed = parseMultivariantPlaylist(text, presentation); update(state, { presentation: parsed } as Partial); - transition('resolved'); }) .catch((error) => { if (error instanceof Error && error.name === 'AbortError') return; From fdf2a4cb1bc547d38ba66aa774f1867a7f06a515 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 1 Apr 2026 10:18:08 -0700 Subject: [PATCH 21/79] feat(spf): add always effects to createReactor; apply to resolvePresentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit always effects run in every non-terminal state and receive the current status in ctx — removing the need to duplicate the same condition monitor in each state block. Processed before per-state effects so cross-cutting monitors fire first in the initial synchronous run. resolvePresentation collapses four identical condition monitors into one always entry; only the resolving fetch task remains state-specific. states is now Record (not Partial) — all valid states must be declared, even as empty arrays, to anticipate future type enforcement. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 81 +++++++--- .../src/core/features/resolve-presentation.ts | 42 ++--- .../spf/src/core/tests/create-reactor.test.ts | 152 +++++++++++++++++- 3 files changed, 225 insertions(+), 50 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index fac7faa58..ee5384cce 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -19,6 +19,20 @@ export type ReactorEffectFn = setContext: (next: Context) => void; }) => (() => void) | { abort(): void } | void; +/** + * A cross-cutting effect function that runs in every non-terminal state. + * + * Identical to `ReactorEffectFn` except the current `status` is also passed, + * allowing a single effect to monitor conditions and drive transitions from + * any state without duplicating the same guard in every state block. + */ +export type ReactorAlwaysEffectFn = (ctx: { + status: UserStatus; + transition: (to: UserStatus) => void; + context: Context; + setContext: (next: Context) => void; +}) => (() => void) | { abort(): void } | void; + /** * Full reactor definition passed to `createReactor`. * @@ -32,11 +46,19 @@ export type ReactorDefinition /** Initial context. */ context: Context; /** - * Per-state effect arrays. Each element becomes one independent `effect()` - * call gated on that state, with its own dependency tracking and cleanup - * lifecycle. States with no entry silently run no effects. + * Cross-cutting effects that run in every non-terminal state. + * Each element becomes one independent `effect()` call that re-runs on any + * status or context change, with the current `status` available in ctx. + * Useful for condition monitors that apply uniformly across all states. */ - states: Partial[]>>; + always?: ReactorAlwaysEffectFn[]; + /** + * Per-state effect arrays. Every valid status must be declared — use an empty + * array for states with no effects. Each element becomes one independent + * `effect()` call gated on that state, with its own dependency tracking and + * cleanup lifecycle. + */ + states: Record[]>; }; // ============================================================================= @@ -69,19 +91,16 @@ export type Reactor = SignalActor * const reactor = createReactor({ * initial: 'waiting', * context: {}, + * // Runs in every non-terminal state — drives transitions from a single place. + * always: [ + * ({ status, transition }) => { + * const target = deriveStatus(); + * if (target !== status) transition(target); + * } + * ], * states: { - * waiting: [ - * ({ transition }) => { - * if (readySignal.get()) transition('active'); - * } - * ], * active: [ - * // Effect 1 — guard and exit cleanup - * ({ transition }) => { - * if (!readySignal.get()) { transition('waiting'); return; } - * return () => teardown(); - * }, - * // Effect 2 — independent tracking/cleanup + * // State-specific work with its own cleanup lifecycle. * () => { * const unsub = subscribe(valueSignal.get(), handler); * return () => unsub(); @@ -110,11 +129,35 @@ export function createReactor update(snapshotSignal, { context }); }; - // For each user-defined state, wrap each effect fn in a status-gated effect(). - // The outer effect reads snapshotSignal (tracking both status and context), gates - // on the matching state, and delegates to the user fn whose own signal reads - // establish the inner dependency set. const effectDisposals: Array<() => void> = []; + + // 'always' effects run in every non-terminal state — processed first so that + // cross-cutting condition monitors fire before per-state effects in the + // initial synchronous run. + for (const fn of def.always ?? []) { + const dispose = effect(() => { + const snapshot = snapshotSignal.get(); + if (snapshot.status === 'destroying' || snapshot.status === 'destroyed') return; + const result = fn({ + status: snapshot.status as UserStatus, + transition: (to: UserStatus) => { + if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; + transition(to as FullStatus); + }, + context: snapshot.context, + setContext: (context: Context) => { + if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; + setContext(context); + }, + }); + if (!result) return undefined; + if (typeof result === 'function') return result; + return () => result.abort(); + }); + effectDisposals.push(dispose); + } + + // Per-state effects — each is gated on its matching status. for (const [state, fns] of Object.entries(def.states) as Array< [UserStatus, ReactorEffectFn[]] >) { diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index 52807ad11..e0ce2a045 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -77,9 +77,9 @@ function deriveStatus(state: PresentationState): ResolvePresentationStatus { /** * Resolves unresolved presentations using reactive composition. * - * FSM driven by `deriveStatus` — each state monitors conditions and transitions - * immediately when they change. `'resolving'` additionally runs the fetch task - * and returns an AbortController so the framework aborts it on state exit. + * FSM driven by `deriveStatus` — a single `always` monitor keeps the status in + * sync with conditions at all times. `'resolving'` additionally runs the fetch + * task and returns an AbortController so the framework aborts it on state exit. * * @example * const reactor = resolvePresentation({ state }); @@ -96,28 +96,16 @@ export function resolvePresentation({ return createReactor({ initial: 'preconditions-unmet', context: {}, + always: [ + ({ status, transition }) => { + const target = derivedStatusSignal.get(); + if (target !== status) transition(target); + }, + ], states: { - 'preconditions-unmet': [ - ({ transition }) => { - const target = derivedStatusSignal.get(); - if (target !== 'preconditions-unmet') transition(target); - }, - ], - - idle: [ - ({ transition }) => { - const target = derivedStatusSignal.get(); - if (target !== 'idle') transition(target); - }, - ], - + 'preconditions-unmet': [], + idle: [], resolving: [ - // Condition monitor — exits immediately if conditions have changed. - ({ transition }) => { - const target = derivedStatusSignal.get(); - if (target !== 'resolving') transition(target); - }, - // Fetch task — returns the AbortController so the framework aborts on exit. () => { const presentation = untrack(() => state.get().presentation) as UnresolvedPresentation; @@ -137,13 +125,7 @@ export function resolvePresentation({ return ac; }, ], - - resolved: [ - ({ transition }) => { - const target = derivedStatusSignal.get(); - if (target !== 'resolved') transition(target); - }, - ], + resolved: [], }, }); } diff --git a/packages/spf/src/core/tests/create-reactor.test.ts b/packages/spf/src/core/tests/create-reactor.test.ts index 8535006b4..d4a3fba34 100644 --- a/packages/spf/src/core/tests/create-reactor.test.ts +++ b/packages/spf/src/core/tests/create-reactor.test.ts @@ -14,7 +14,7 @@ describe('createReactor', () => { const reactor = createReactor({ initial: 'idle' as const, context: { value: 0 }, - states: {}, + states: { idle: [] }, }); expect(reactor.snapshot.get().status).toBe('idle'); @@ -307,6 +307,156 @@ describe('createReactor — cleanup', () => { }); }); +// ============================================================================= +// createReactor — always +// ============================================================================= + +describe('createReactor — always', () => { + it('runs in the initial state', () => { + const fn = vi.fn(); + createReactor({ + initial: 'idle' as const, + context: {}, + always: [fn], + states: { idle: [] }, + }).destroy(); + + expect(fn).toHaveBeenCalledOnce(); + }); + + it('receives the current status in ctx', () => { + let capturedStatus: string | undefined; + createReactor({ + initial: 'idle' as const, + context: {}, + always: [ + ({ status }) => { + capturedStatus = status; + }, + ], + states: { idle: [] }, + }).destroy(); + + expect(capturedStatus).toBe('idle'); + }); + + it('re-runs on status change and receives the new status', async () => { + const src = signal(false); + const statuses: string[] = []; + + const reactor = createReactor<'waiting' | 'active', object>({ + initial: 'waiting', + context: {}, + always: [ + ({ status }) => { + statuses.push(status); + }, + ], + states: { + waiting: [ + ({ transition }) => { + if (src.get()) transition('active'); + }, + ], + active: [], + }, + }); + + expect(statuses).toEqual(['waiting']); + + src.set(true); + await tick(); + await tick(); + + expect(statuses).toEqual(['waiting', 'active']); + + reactor.destroy(); + }); + + it('can trigger transitions', async () => { + const src = signal(false); + + const reactor = createReactor<'waiting' | 'active', object>({ + initial: 'waiting', + context: {}, + always: [ + ({ status, transition }) => { + if (src.get() && status === 'waiting') transition('active'); + }, + ], + states: { waiting: [], active: [] }, + }); + + expect(reactor.snapshot.get().status).toBe('waiting'); + + src.set(true); + await tick(); + + expect(reactor.snapshot.get().status).toBe('active'); + + reactor.destroy(); + }); + + it('cleanup runs before each re-run', async () => { + const src = signal(false); + const cleanup = vi.fn(); + + const reactor = createReactor<'waiting' | 'active', object>({ + initial: 'waiting', + context: {}, + always: [() => cleanup], + states: { + waiting: [ + ({ transition }) => { + if (src.get()) transition('active'); + }, + ], + active: [], + }, + }); + + expect(cleanup).not.toHaveBeenCalled(); + + src.set(true); + await tick(); + + expect(cleanup).toHaveBeenCalledOnce(); + + reactor.destroy(); + }); + + it('does not run during destroying or destroyed', async () => { + const fn = vi.fn(); + const reactor = createReactor({ + initial: 'idle' as const, + context: {}, + always: [fn], + states: { idle: [] }, + }); + + fn.mockClear(); + reactor.destroy(); + await tick(); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('runs alongside per-state effects in the same state', () => { + const alwaysFn = vi.fn(); + const stateFn = vi.fn(); + + createReactor({ + initial: 'idle' as const, + context: {}, + always: [alwaysFn], + states: { idle: [stateFn] }, + }).destroy(); + + expect(alwaysFn).toHaveBeenCalledOnce(); + expect(stateFn).toHaveBeenCalledOnce(); + }); +}); + // ============================================================================= // createReactor — destroy // ============================================================================= From 1868f08c8b8fd1222aea86572c1eb8b6ab670173 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 1 Apr 2026 10:31:09 -0700 Subject: [PATCH 22/79] refactor(spf): simplify syncTextTracks FSM using always monitor Collapse `'setting-up'` into `'set-up'` by folding track creation into the first `set-up` effect. A single `always` entry replaces the per-state condition monitors, mirroring the resolvePresentation pattern. Co-Authored-By: Claude Sonnet 4.6 --- .../spf/src/dom/features/sync-text-tracks.ts | 75 +++++++------------ 1 file changed, 26 insertions(+), 49 deletions(-) diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index 418849ce4..743fb6823 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -8,24 +8,14 @@ import type { PartiallyResolvedTextTrack, Presentation, TextTrack } from '../../ * FSM states for text track sync. * * ``` - * 'preconditions-unmet' ──── mediaElement + tracks available ────→ 'setting-up' - * ↑ | - * | setup complete - * preconditions lost | - * (exit cleanup) ↓ - * └──────────────────────────────────────────────────────── 'set-up' - * | - * presentation changed │ - * (exit cleanup tears down │ - * old tracks, re-entry │ - * creates new ones) │ - * ↓ │ - * 'setting-up' ←──────────┘ + * 'preconditions-unmet' ──── mediaElement + tracks available ────→ 'set-up' + * ↑ | + * └──────────────── preconditions lost (exit cleanup) ──────────┘ * - * any non-final state ──── destroy() ────→ 'destroying' ────→ 'destroyed' + * any state ──── destroy() ────→ 'destroying' ────→ 'destroyed' * ``` */ -export type TextTrackSyncStatus = 'preconditions-unmet' | 'setting-up' | 'set-up'; +export type TextTrackSyncStatus = 'preconditions-unmet' | 'set-up'; /** * State shape for text track sync. @@ -78,17 +68,13 @@ function syncModes(textTracks: TextTrackList, selectedId: string | undefined): v /** * Text track sync orchestration. * - * Implements the TextTrackSync FSM via `createReactor` with one effect per - * concern per state: + * A single `always` monitor keeps the reactor in sync with preconditions. + * `'set-up'` owns the full lifecycle of `` elements: * - * - **`'preconditions-unmet'`** — waits for preconditions, then transitions - * to `'setting-up'`. - * - **`'setting-up'`** — creates `` elements, then transitions to - * `'set-up'`. - * - **`'set-up'` effect 1** — guards the state; exit cleanup removes `` - * elements and clears `selectedTextTrackId` on any outbound transition. - * - **`'set-up'` effect 2** — owns mode sync, the Chromium settling-window - * guard, and the `'change'` listener that bridges DOM state back to + * - **Effect 1** — creates `` elements on entry; exit cleanup removes + * them and clears `selectedTextTrackId` on any outbound transition. + * - **Effect 2** — owns mode sync, the Chromium settling-window guard, and + * the `'change'` listener that bridges DOM state back to * `selectedTextTrackId`. * * @example @@ -125,34 +111,25 @@ export function syncTextTracks({ initial: 'preconditions-unmet', context: {}, + always: [ + ({ status, transition }) => { + const target = preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'; + if (target !== status) transition(target); + }, + ], states: { - 'preconditions-unmet': [ - ({ transition }) => { - if (preconditionsMetSignal.get()) transition('setting-up'); - }, - ], - - 'setting-up': [ - ({ transition }) => { - const mediaElement = mediaElementSignal.get() as HTMLMediaElement; - const modelTextTracks = modelTextTracksSignal.get() as PartiallyResolvedTextTrack[]; - modelTextTracks.forEach((track) => mediaElement.appendChild(createTrackElement(track))); - transition('set-up'); - }, - ], + 'preconditions-unmet': [], 'set-up': [ - // Effect 1 — guards state; exit cleanup tears down track elements and - // clears selectedTextTrackId on any outbound transition (including destroy). - ({ transition }) => { - if (!preconditionsMetSignal.get()) { - transition('preconditions-unmet'); - return; - } - const currentMediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); + // Effect 1 — create elements on entry; exit cleanup removes them + // and clears selectedTextTrackId on any outbound transition. + () => { + const mediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); + const modelTextTracks = untrack(() => modelTextTracksSignal.get() as PartiallyResolvedTextTrack[]); + modelTextTracks.forEach((track) => mediaElement.appendChild(createTrackElement(track))); return () => { - currentMediaElement - .querySelectorAll('track[data-src-track]:is([kind="subtitles"],[kind="captions"') + mediaElement + .querySelectorAll('track[data-src-track]:is([kind="subtitles"],[kind="captions"]') .forEach((trackEl) => trackEl.remove()); update(state, { selectedTextTrackId: undefined } as Partial); }; From 59f7196cf14d0abb59ab39a2aaabf059382c6d6f Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 1 Apr 2026 11:32:05 -0700 Subject: [PATCH 23/79] refactor(spf): migrate loadTextTrackCues to createReactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces single-effect orchestration with a 4-state FSM: - 'preconditions-unmet': resets actors in owners (guarded — no-op if absent) - 'setting-up': creates TextTracksActor + TextTrackSegmentLoaderActor, writes to owners - 'pending': actors alive, waiting for selection/track resolution/DOM presence - 'monitoring-for-loads': reactive dispatch on state changes (currentTime, selection) Actors are now observable via owners signal. Callers are responsible for destroying them alongside reactor.destroy() (see JSDoc example). Also adds a design note to actor-reactor-factories.md on the open question of formalizing entry-once vs reactive-within-state effect semantics. Co-Authored-By: Claude Sonnet 4.6 --- .../design/spf/actor-reactor-factories.md | 24 ++ .../src/dom/features/load-text-track-cues.ts | 236 ++++++++++-------- .../tests/load-text-track-cues.test.ts | 117 ++------- 3 files changed, 175 insertions(+), 202 deletions(-) diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index 3a98c1256..60ded388a 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -343,3 +343,27 @@ The second argument to message handlers is currently sketched as `{ transition, runner, context, setContext }`. The exact shape — including whether `runner` is always present (or `undefined` when no runner is declared) and whether `context` is the full snapshot context or a subset — is to be finalized during implementation. + +--- + +### Reactor per-state effect semantics: entry vs. reactive + +In practice, per-state effects fall into two distinct categories: + +- **Enter-once effects** — run once on state entry, do setup work, return a cleanup. Signal reads + inside these should generally be wrapped in `untrack()` to prevent accidental re-runs. Example: + creating `` elements, starting a fetch. +- **Reactive-within-state effects** — intentionally re-run when a signal changes while the state + is active. Example: `syncTextTracks` effect 2, which re-runs whenever `selectedTextTrackId` + changes to re-apply mode sync. + +Currently both categories use the same `effect()` mechanism, and the distinction is enforced by +convention (explicit `untrack()` calls) rather than by the API. The `always` array is the primary +mechanism for reactive condition monitoring, but reactive-within-state effects are also a +legitimate use case. + +A possible future direction: distinguish these in the definition shape — e.g., `entry` for +enter-once effects (automatically untracked) and `on` (signal-keyed or otherwise) for +reactive-within-state effects. This would make intent explicit and prevent accidental tracking +bugs in entry effects. Worth revisiting once more examples of the reactive-within-state pattern +accumulate. diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 5b3599cc8..4e34f7ced 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -1,5 +1,6 @@ -import { effect } from '../../core/signals/effect'; -import { computed, type Signal } from '../../core/signals/primitives'; +import type { Reactor } from '../../core/create-reactor'; +import { createReactor } from '../../core/create-reactor'; +import { computed, type Signal, untrack, update } from '../../core/signals/primitives'; import type { Presentation, TextTrack } from '../../core/types'; import { isResolvedTrack } from '../../core/types'; import type { TextTrackSegmentLoaderActor } from './text-track-segment-loader-actor'; @@ -7,9 +8,25 @@ import { createTextTrackSegmentLoaderActor } from './text-track-segment-loader-a import type { TextTracksActor } from './text-tracks-actor'; import { createTextTracksActor } from './text-tracks-actor'; -// ============================================================================ -// STATE & OWNERS -// ============================================================================ +/** + * FSM states for text track cue loading. + * + * ``` + * 'preconditions-unmet' ── mediaElement + presentation + text tracks ──→ 'setting-up' + * ↑ | + * │ actors created + * │ in owners + * │ ↓ + * ├───────────────── preconditions lost ─────────────────────── 'pending' + * │ | + * │ selectedTrack resolved + in DOM + * │ ↓ + * └───────────────── preconditions lost ──────────── 'monitoring-for-loads' + * + * any state ──── destroy() ────→ 'destroying' ────→ 'destroyed' + * ``` + */ +export type LoadTextTrackCuesStatus = 'preconditions-unmet' | 'setting-up' | 'pending' | 'monitoring-for-loads'; /** * State shape for text track cue loading. @@ -23,81 +40,85 @@ export interface TextTrackCueLoadingState { /** * Owners shape for text track cue loading. + * + * `textTracksActor` and `segmentLoaderActor` are managed by this reactor: + * written to owners in `'setting-up'` and reset to `undefined` on entry to + * `'preconditions-unmet'` or `'setting-up'`. Callers are responsible for + * destroying them when the reactor itself is destroyed (actors are not + * auto-cleaned on `destroy()` — read them from owners and destroy explicitly). */ export interface TextTrackCueLoadingOwners { mediaElement?: HTMLMediaElement | undefined; + textTracksActor?: TextTracksActor | undefined; + segmentLoaderActor?: TextTrackSegmentLoaderActor | undefined; } -/** - * Find the selected text track in the presentation. - */ -function findSelectedTextTrack(state: TextTrackCueLoadingState): TextTrack | undefined { - if (!state.presentation || !state.selectedTextTrackId) { - return undefined; - } - - const textSet = state.presentation.selectionSets.find((set) => set.type === 'text'); - if (!textSet?.switchingSets?.[0]?.tracks) { - return undefined; - } - - const track = textSet.switchingSets[0].tracks.find((t) => t.id === state.selectedTextTrackId); - - return track as TextTrack | undefined; -} - -/** - * Get the browser's TextTrack object for the selected text track. - */ -function getSelectedTextTrackFromOwners( - state: TextTrackCueLoadingState, - owners: TextTrackCueLoadingOwners -): globalThis.TextTrack | undefined { - const trackId = state.selectedTextTrackId; - if (!trackId || !owners.mediaElement) { - return undefined; - } +// ============================================================================ +// Helpers +// ============================================================================ - return Array.from(owners.mediaElement.textTracks).find((t) => t.id === trackId); +function getTextTracks(presentation: Presentation | undefined) { + return presentation?.selectionSets?.find((s) => s.type === 'text')?.switchingSets[0]?.tracks; } -/** - * Check if we can load text track cues. - */ -export function canLoadTextTrackCues(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): boolean { - return ( - !!state.selectedTextTrackId && - !!owners.mediaElement && - Array.from(owners.mediaElement.textTracks).some((t) => t.id === state.selectedTextTrackId) - ); +function findSelectedTrack(state: TextTrackCueLoadingState): TextTrack | undefined { + const track = getTextTracks(state.presentation)?.find((t) => t.id === state.selectedTextTrackId); + return track && isResolvedTrack(track) ? track : undefined; } /** - * Check if we should load text track cues. + * Derives the correct status from current state and owners. + * + * States are mutually exclusive and exhaustive: + * - `'preconditions-unmet'`: no mediaElement, or no resolved presentation with text tracks + * - `'setting-up'`: preconditions met; actors not yet in owners + * - `'pending'`: actors alive; no selection, or selected track not yet resolved/in DOM + * - `'monitoring-for-loads'`: selected track resolved, in DOM — ready to dispatch load messages */ -export function shouldLoadTextTrackCues(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): boolean { - if (!canLoadTextTrackCues(state, owners)) { - return false; +function deriveStatus(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): LoadTextTrackCuesStatus { + if (!owners.mediaElement || !getTextTracks(state.presentation)?.length) { + return 'preconditions-unmet'; } - - const track = findSelectedTextTrack(state); - if (!track || !isResolvedTrack(track) || track.segments.length === 0) { - return false; + if (!owners.textTracksActor || !owners.segmentLoaderActor) { + return 'setting-up'; } - - const textTrack = getSelectedTextTrackFromOwners(state, owners); - if (!textTrack) { - return false; + const track = findSelectedTrack(state); + if (!track || track.segments.length === 0) return 'pending'; + if (!Array.from(owners.mediaElement.textTracks).some((t) => t.id === state.selectedTextTrackId)) { + return 'pending'; } - - return true; + return 'monitoring-for-loads'; } +// ============================================================================ +// Main export +// ============================================================================ + /** - * Load text track cues orchestration. + * Text track cue loading orchestration. + * + * A single `always` monitor keeps the reactor in sync with conditions. + * Actor lifecycle is managed across two states: + * + * - **`'setting-up'`** — creates `TextTracksActor` and `TextTrackSegmentLoaderActor` + * and writes them to owners. Entry resets any stale actors first. + * - **`'preconditions-unmet'`** — destroys any actors in owners and resets them to + * `undefined`. Handles all paths back from active states. + * - **`'monitoring-for-loads'`** — reactive dispatch: re-runs whenever `state` + * changes (selection, currentTime, presentation) and sends a `load` message to + * the segment loader. + * + * **Destroy note:** actors written to owners are NOT auto-destroyed when + * `reactor.destroy()` is called. Callers must read them from owners and destroy + * them explicitly alongside the reactor. * * @example - * const cleanup = loadTextTrackCues({ state, owners }); + * const reactor = loadTextTrackCues({ state, owners }); + * // later: + * const { textTracksActor, segmentLoaderActor } = owners.get(); + * textTracksActor?.destroy(); + * segmentLoaderActor?.destroy(); + * reactor.destroy(); */ export function loadTextTrackCues({ state, @@ -105,46 +126,59 @@ export function loadTextTrackCues; owners: Signal; -}): () => void { - // Actor lifecycle: both actors are tied to the mediaElement. - // Recreated together when mediaElement changes. - let textTracksActor: TextTracksActor | undefined; - let segmentLoaderActor: TextTrackSegmentLoaderActor | undefined; - let actorMediaElement: HTMLMediaElement | undefined; - - const mediaElement = computed(() => owners.get().mediaElement); - - const cleanupEffect = effect(() => { - const s = state.get(); - const o = owners.get(); - - // Manage actor lifecycle when mediaElement changes. - const currentMediaElement = mediaElement.get(); - if (currentMediaElement !== actorMediaElement) { - textTracksActor?.destroy(); - segmentLoaderActor?.destroy(); - if (currentMediaElement) { - textTracksActor = createTextTracksActor(currentMediaElement); - segmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor); - } else { - textTracksActor = undefined; - segmentLoaderActor = undefined; - } - actorMediaElement = currentMediaElement; - } - - if (!segmentLoaderActor) return; - if (!shouldLoadTextTrackCues(s, o)) return; - - const track = findSelectedTextTrack(s); - if (!track || !isResolvedTrack(track)) return; - - segmentLoaderActor.send({ type: 'load', track, currentTime: s.currentTime ?? 0 }); +}): Reactor { + const derivedStatusSignal = computed(() => deriveStatus(state.get(), owners.get())); + + return createReactor({ + initial: 'preconditions-unmet', + context: {}, + always: [ + ({ status, transition }) => { + const target = derivedStatusSignal.get(); + if (target !== status) transition(target); + }, + ], + states: { + 'preconditions-unmet': [ + () => { + const { textTracksActor, segmentLoaderActor } = untrack(() => owners.get()); + // Only update owners if there are actors to clean up — avoids spurious + // signal writes on initial startup that would re-trigger other features. + if (!textTracksActor && !segmentLoaderActor) return; + textTracksActor?.destroy(); + segmentLoaderActor?.destroy(); + update(owners, { textTracksActor: undefined, segmentLoaderActor: undefined } as Partial); + }, + ], + + 'setting-up': [ + () => { + // Defensive reset before creating fresh actors (no-op if already undefined). + const existing = untrack(() => owners.get()); + if (existing.textTracksActor || existing.segmentLoaderActor) { + existing.textTracksActor?.destroy(); + existing.segmentLoaderActor?.destroy(); + update(owners, { textTracksActor: undefined, segmentLoaderActor: undefined } as Partial); + } + const mediaElement = untrack(() => owners.get().mediaElement as HTMLMediaElement); + const textTracksActor = createTextTracksActor(mediaElement); + const segmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor); + update(owners, { textTracksActor, segmentLoaderActor } as Partial); + }, + ], + + pending: [], + + 'monitoring-for-loads': [ + () => { + const s = state.get(); // tracked — re-runs on currentTime / selection / presentation changes + const { segmentLoaderActor } = untrack(() => owners.get()); + if (!segmentLoaderActor) return; + const track = findSelectedTrack(s); + if (!track) return; + segmentLoaderActor.send({ type: 'load', track, currentTime: s.currentTime ?? 0 }); + }, + ], + }, }); - - return () => { - textTracksActor?.destroy(); - segmentLoaderActor?.destroy(); - cleanupEffect(); - }; } diff --git a/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts b/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts index 1b7e81307..5c89cd5aa 100644 --- a/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts +++ b/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts @@ -2,20 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { signal } from '../../../core/signals/primitives'; import type { Presentation, Segment, TextTrack } from '../../../core/types'; import { - canLoadTextTrackCues, loadTextTrackCues, - shouldLoadTextTrackCues, type TextTrackCueLoadingOwners, type TextTrackCueLoadingState, } from '../load-text-track-cues'; -function setupLoadTextTrackCues(initialState: TextTrackCueLoadingState, initialOwners: TextTrackCueLoadingOwners) { - const state = signal(initialState); - const owners = signal(initialOwners); - const cleanup = loadTextTrackCues({ state, owners }); - return { state, owners, cleanup }; -} - // Mock parseVttSegment vi.mock('../../text/parse-vtt-segment', () => ({ parseVttSegment: vi.fn((url: string) => { @@ -64,98 +55,22 @@ function createMockSegments(count: number): Segment[] { })); } -describe('canLoadTextTrackCues', () => { - it('returns false when no selected track', () => { - const state: TextTrackCueLoadingState = { - presentation: createMockPresentation([]), - }; - const owners: TextTrackCueLoadingOwners = {}; - - expect(canLoadTextTrackCues(state, owners)).toBe(false); - }); - - it('returns false when no track elements', () => { - const state: TextTrackCueLoadingState = { - selectedTextTrackId: 'text-1', - presentation: createMockPresentation([]), - }; - const owners: TextTrackCueLoadingOwners = {}; - - expect(canLoadTextTrackCues(state, owners)).toBe(false); - }); - - it('returns false when track element does not exist for selected track', () => { - const state: TextTrackCueLoadingState = { - selectedTextTrackId: 'text-1', - presentation: createMockPresentation([]), - }; - const owners: TextTrackCueLoadingOwners = { - mediaElement: document.createElement('video'), - }; - - expect(canLoadTextTrackCues(state, owners)).toBe(false); - }); - - it('returns true when track selected and elements exist', () => { - const state: TextTrackCueLoadingState = { - selectedTextTrackId: 'text-1', - presentation: createMockPresentation([]), - }; - const video = document.createElement('video'); - const trackElement = document.createElement('track'); - trackElement.id = 'text-1'; - video.appendChild(trackElement); - const owners: TextTrackCueLoadingOwners = { - mediaElement: video, - }; - - expect(canLoadTextTrackCues(state, owners)).toBe(true); - }); -}); - -describe('shouldLoadTextTrackCues', () => { - it('returns false when track not resolved', () => { - const state: TextTrackCueLoadingState = { - selectedTextTrackId: 'text-1', - presentation: createMockPresentation([ - { - id: 'text-1', - // No segments = not resolved - }, - ]), - }; - const trackElement = document.createElement('track'); - trackElement.id = 'text-1'; - const video = document.createElement('video'); - video.appendChild(trackElement); - const owners: TextTrackCueLoadingOwners = { mediaElement: video }; - - expect(shouldLoadTextTrackCues(state, owners)).toBe(false); - }); - - // Note: "returns false when cues already loaded" was removed — with forward - // buffer windowing, existing cues don't prevent loading new in-window segments. - - it('returns true when track resolved and no cues', () => { - const state: TextTrackCueLoadingState = { - selectedTextTrackId: 'text-1', - presentation: createMockPresentation([ - { - id: 'text-1', - segments: createMockSegments(1), - }, - ]), - }; - const trackElement = document.createElement('track'); - trackElement.id = 'text-1'; - const video = document.createElement('video'); - video.appendChild(trackElement); - trackElement.track.mode = 'hidden'; // Enable cue access - const owners: TextTrackCueLoadingOwners = { mediaElement: video }; - - expect(shouldLoadTextTrackCues(state, owners)).toBe(true); - }); -}); +/** + * Sets up loadTextTrackCues with the given state/owners and returns a + * combined cleanup that destroys actors from owners before destroying the reactor. + */ +function setupLoadTextTrackCues(initialState: TextTrackCueLoadingState, initialOwners: TextTrackCueLoadingOwners) { + const state = signal(initialState); + const owners = signal(initialOwners); + const reactor = loadTextTrackCues({ state, owners }); + const cleanup = () => { + const { textTracksActor, segmentLoaderActor } = owners.get(); + textTracksActor?.destroy(); + segmentLoaderActor?.destroy(); + reactor.destroy(); + }; + return { state, owners, cleanup }; +} describe('loadTextTrackCues', () => { beforeEach(() => { From 4626b468d06a629862f702239e9fd649c4e5e061 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 1 Apr 2026 11:53:16 -0700 Subject: [PATCH 24/79] docs(spf): document always-before-state ordering guarantee in createReactor; remove redundant guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The always monitor is guaranteed to fire before per-state effects in every flush due to registration order + Set insertion-order iteration. Add a comment explaining this is load-bearing, then remove the defensive `if (!track) return` from the monitoring-for-loads effect in loadTextTrackCues — deriveStatus + the ordering guarantee makes it unreachable. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 10 +++++++++- packages/spf/src/dom/features/load-text-track-cues.ts | 6 ++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index ee5384cce..0c333a270 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -133,7 +133,15 @@ export function createReactor // 'always' effects run in every non-terminal state — processed first so that // cross-cutting condition monitors fire before per-state effects in the - // initial synchronous run. + // initial synchronous run AND on every subsequent re-run. + // + // The ordering guarantee: effect() calls watcher.watch(computed) in order of + // registration. runPending() drains watcher.getPending() into a Set (insertion- + // ordered) before iterating it. Because 'always' effects are registered before + // per-state effects here, they are guaranteed to run first in every flush. + // This is load-bearing: it means a transition triggered by an 'always' monitor + // takes effect before the per-state effects of the (now-exited) state re-run, + // so per-state effects can rely on the invariants established by 'always'. for (const fn of def.always ?? []) { const dispose = effect(() => { const snapshot = snapshotSignal.get(); diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 4e34f7ced..51214815a 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -174,8 +174,10 @@ export function loadTextTrackCues owners.get()); if (!segmentLoaderActor) return; - const track = findSelectedTrack(s); - if (!track) return; + // deriveStatus guarantees a valid, resolved track when in this state. + // The always monitor (registered before this effect) transitions us out + // before this re-runs if that invariant ever stops holding. + const track = findSelectedTrack(s)!; segmentLoaderActor.send({ type: 'load', track, currentTime: s.currentTime ?? 0 }); }, ], From f6eb96d2bfe4ed2706e7d4b0322de8001a9f2a70 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 1 Apr 2026 11:53:54 -0700 Subject: [PATCH 25/79] refactor(spf): remove segmentLoaderActor guard from monitoring-for-loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deriveStatus guarantees segmentLoaderActor is present in owners when in this state — same invariant as the track guard removed in the prior commit. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/dom/features/load-text-track-cues.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 51214815a..fece42eed 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -172,13 +172,13 @@ export function loadTextTrackCues { const s = state.get(); // tracked — re-runs on currentTime / selection / presentation changes + // deriveStatus guarantees segmentLoaderActor is in owners and findSelectedTrack + // returns a valid resolved track when in this state. The always monitor + // (registered before this effect) transitions us out before this re-runs + // if either invariant ever stops holding. const { segmentLoaderActor } = untrack(() => owners.get()); - if (!segmentLoaderActor) return; - // deriveStatus guarantees a valid, resolved track when in this state. - // The always monitor (registered before this effect) transitions us out - // before this re-runs if that invariant ever stops holding. const track = findSelectedTrack(s)!; - segmentLoaderActor.send({ type: 'load', track, currentTime: s.currentTime ?? 0 }); + segmentLoaderActor!.send({ type: 'load', track, currentTime: s.currentTime ?? 0 }); }, ], }, From 88ab0f72279882d0cd13bbf4da6c9f37ecc02967 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 1 Apr 2026 12:06:12 -0700 Subject: [PATCH 26/79] refactor(spf): extract teardownActors helper; scope monitoring-for-loads signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract teardownActors() helper to deduplicate actor destroy+reset logic shared between 'preconditions-unmet' and 'setting-up' states - Hoist currentTimeSignal and selectedTrackSignal outside the effect body so they persist across runs and provide memoization — the effect now only re-runs when currentTime or the resolved track reference actually changes, not on every state write Co-Authored-By: Claude Sonnet 4.6 --- .../src/dom/features/load-text-track-cues.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index fece42eed..a8ea89357 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -90,6 +90,16 @@ function deriveStatus(state: TextTrackCueLoadingState, owners: TextTrackCueLoadi return 'monitoring-for-loads'; } +function teardownActors(owners: Signal) { + const { textTracksActor, segmentLoaderActor } = untrack(() => owners.get()); + // Only update owners if there are actors to clean up — avoids spurious + // signal writes on initial startup that would re-trigger other features. + if (!textTracksActor && !segmentLoaderActor) return; + textTracksActor?.destroy(); + segmentLoaderActor?.destroy(); + update(owners, { textTracksActor: undefined, segmentLoaderActor: undefined }); +} + // ============================================================================ // Main export // ============================================================================ @@ -128,6 +138,8 @@ export function loadTextTrackCues; }): Reactor { const derivedStatusSignal = computed(() => deriveStatus(state.get(), owners.get())); + const currentTimeSignal = computed(() => state.get().currentTime ?? 0); + const selectedTrackSignal = computed(() => findSelectedTrack(state.get())); return createReactor({ initial: 'preconditions-unmet', @@ -141,25 +153,15 @@ export function loadTextTrackCues { - const { textTracksActor, segmentLoaderActor } = untrack(() => owners.get()); - // Only update owners if there are actors to clean up — avoids spurious - // signal writes on initial startup that would re-trigger other features. - if (!textTracksActor && !segmentLoaderActor) return; - textTracksActor?.destroy(); - segmentLoaderActor?.destroy(); - update(owners, { textTracksActor: undefined, segmentLoaderActor: undefined } as Partial); + // Defensive reset before creating fresh actors (no-op if already undefined). + teardownActors(owners); }, ], 'setting-up': [ () => { // Defensive reset before creating fresh actors (no-op if already undefined). - const existing = untrack(() => owners.get()); - if (existing.textTracksActor || existing.segmentLoaderActor) { - existing.textTracksActor?.destroy(); - existing.segmentLoaderActor?.destroy(); - update(owners, { textTracksActor: undefined, segmentLoaderActor: undefined } as Partial); - } + teardownActors(owners); const mediaElement = untrack(() => owners.get().mediaElement as HTMLMediaElement); const textTracksActor = createTextTracksActor(mediaElement); const segmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor); @@ -171,14 +173,14 @@ export function loadTextTrackCues { - const s = state.get(); // tracked — re-runs on currentTime / selection / presentation changes + const currentTime = currentTimeSignal.get(); + const track = selectedTrackSignal.get()!; // deriveStatus guarantees segmentLoaderActor is in owners and findSelectedTrack // returns a valid resolved track when in this state. The always monitor // (registered before this effect) transitions us out before this re-runs // if either invariant ever stops holding. const { segmentLoaderActor } = untrack(() => owners.get()); - const track = findSelectedTrack(s)!; - segmentLoaderActor!.send({ type: 'load', track, currentTime: s.currentTime ?? 0 }); + segmentLoaderActor!.send({ type: 'load', track, currentTime }); }, ], }, From 2809fd2287c8d7d9ef04edabd15e505eec7527fb Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 1 Apr 2026 12:15:40 -0700 Subject: [PATCH 27/79] fix(spf): destroy owner actors on engine destroy; type text track actors in owners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Engine destroy now iterates owners and calls destroy() on any value that has one — handles textTracksActor and segmentLoaderActor written by loadTextTrackCues, and any future actors stored in owners - Add textTracksActor and segmentLoaderActor to PlaybackEngineOwners Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/dom/playback-engine/engine.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/spf/src/dom/playback-engine/engine.ts b/packages/spf/src/dom/playback-engine/engine.ts index 9b461d270..21c3bf5a0 100644 --- a/packages/spf/src/dom/playback-engine/engine.ts +++ b/packages/spf/src/dom/playback-engine/engine.ts @@ -13,6 +13,8 @@ import { loadTextTrackCues } from '../features/load-text-track-cues'; import { setupMediaSource } from '../features/setup-mediasource'; import { setupSourceBuffers } from '../features/setup-sourcebuffer'; import { syncTextTracks } from '../features/sync-text-tracks'; +import type { TextTrackSegmentLoaderActor } from '../features/text-track-segment-loader-actor'; +import type { TextTracksActor } from '../features/text-tracks-actor'; import { trackCurrentTime } from '../features/track-current-time'; import { trackPlaybackInitiated } from '../features/track-playback-initiated'; import { updateDuration } from '../features/update-duration'; @@ -102,6 +104,10 @@ export interface PlaybackEngineOwners { audioBuffer?: SourceBuffer; videoBufferActor?: SourceBufferActor; audioBufferActor?: SourceBufferActor; + + // Text track actors (written by loadTextTrackCues; destroyed by engine on destroy) + textTracksActor?: TextTracksActor; + segmentLoaderActor?: TextTrackSegmentLoaderActor; } /** @@ -281,6 +287,16 @@ export function createPlaybackEngine(config: PlaybackEngineConfig = {}): Playbac owners, destroy: () => { cleanups.forEach((cleanup) => (typeof cleanup === 'function' ? cleanup() : cleanup.destroy())); + // Destroy any actors that orchestrations wrote into owners during their lifetime. + for (const value of Object.values(owners.get())) { + if ( + value !== null && + typeof value === 'object' && + typeof (value as { destroy?: unknown }).destroy === 'function' + ) { + (value as { destroy(): void }).destroy(); + } + } destroyVttParser(); }, }; From c77573531b483d2ea8943154635ed85a9bd058b4 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 2 Apr 2026 09:16:19 -0700 Subject: [PATCH 28/79] docs(spf): document Actor/Reactor factories and text track reference architecture Update actor-reactor-factories.md to match the implemented API: adds the `always` array and `context` field to ReactorDefinition, corrects examples to use the actual always-driven shape, documents the always-before-state ordering guarantee, entry vs. reactive effect convention, and resolves the handler context API open question. Add text-track-architecture.md as the reference Actor/Reactor implementation with state machine diagrams, key pattern documentation, spike goal assessment, friction points, possible future improvements, and implications for video/audio migration. Update decisions.md with four new decisions from the spike. Update primitives.md with current approach for Actors, Reactors, and Observable State. Update index.md. --- .../design/spf/actor-reactor-factories.md | 298 +++++++--- internal/design/spf/decisions.md | 61 ++ internal/design/spf/index.md | 5 +- internal/design/spf/primitives.md | 39 +- .../design/spf/text-track-architecture.md | 530 ++++++++++++++++++ 5 files changed, 840 insertions(+), 93 deletions(-) create mode 100644 internal/design/spf/text-track-architecture.md diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index 60ded388a..2c5b7fea4 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -8,9 +8,10 @@ date: 2026-03-31 Design for `createActor` and `createReactor` — the declarative factory functions that replace bespoke Actor classes and function-based Reactors in SPF. -Motivated by the text track architecture spike (see `.claude/plans/foamy-finding-quasar.md`), -which produced the first proper `SignalActor` class implementations and surfaced the need for -shared, principled primitives. +Motivated by the text track architecture spike (videojs/v10#1158), which produced the first +`createActor` / `createReactor`-based implementations in SPF and surfaced the need for +shared, principled primitives. See [text-track-architecture.md](text-track-architecture.md) +for the reference implementation and spike assessment. --- @@ -37,27 +38,33 @@ Both return instances that implement `SignalActor` and expose `snapshot` and `de ### Shape ```typescript -type ActorDefinition = { - runner?: () => RunnerLike; // factory — called once at createActor() time +type ActorDefinition< + UserStatus extends string, + Context extends object, + Message extends { type: string }, + RunnerFactory extends (() => RunnerLike) | undefined = undefined, +> = { + runner?: RunnerFactory; // factory — called once at createActor() time initial: UserStatus; context: Context; - states: { - [S in UserStatus]: { - onSettled?: UserStatus; // when runner settles in this state → transition here - on?: { - [M in Message as M['type']]?: ( - message: M, - ctx: { - transition: (to: UserStatus) => void; - runner: RunnerLike; - context: Context; - setContext: (next: Context) => void; - } - ) => void; - }; + states: Partial, + ctx: HandlerContext + ) => void; }; - }; + }>>; }; + +// runner is present and typed as the exact runner only when runner: is declared. +// When omitted, runner is absent from the type entirely (not undefined). +type HandlerContext = { + transition: (to: UserStatus) => void; + context: Context; + setContext: (next: Context) => void; +} & (RunnerFactory extends () => infer R ? { runner: R } : object); ``` ### Example — `TextTrackSegmentLoaderActor` @@ -124,50 +131,140 @@ const textTracksActorDef = { ### Shape ```typescript -type ReactorDefinition = { +type ReactorDefinition = { initial: UserStatus; - states: { - [S in UserStatus]: Array< - (ctx: { transition: (to: UserStatus) => void }) => (() => void) | void - >; - }; + context: Context; + /** + * Cross-cutting effects that run in every non-terminal state. + * Each element becomes one independent effect() call. The current status is + * available in ctx, allowing a single effect to monitor conditions and drive + * transitions from any state without duplicating guards in every state block. + * + * ORDERING GUARANTEE: always effects run before per-state effects in every flush. + * This is load-bearing — see the "always-before-state ordering" decision below. + */ + always?: ReactorAlwaysEffectFn[]; + /** + * Per-state effect arrays. Every valid status must be declared (use [] for + * states with no effects). Each element becomes one independent effect() call + * gated on that state, with its own dependency tracking and cleanup lifecycle. + */ + states: Record[]>; }; + +type ReactorEffectFn = (ctx: { + transition: (to: UserStatus) => void; + context: Context; + setContext: (next: Context) => void; +}) => (() => void) | { abort(): void } | void; + +type ReactorAlwaysEffectFn = (ctx: { + status: UserStatus; // current status — not available in per-state effects + transition: (to: UserStatus) => void; + context: Context; + setContext: (next: Context) => void; +}) => (() => void) | { abort(): void } | void; ``` -Each array element becomes one independent `effect()` call gated on that state. Multiple entries -for the same state produce multiple effects — each with independent dependency tracking and -cleanup. This is the mechanism that replaces multiple named `cleanupX` variables in the current -function-based reactors. +Each array element becomes one independent `effect()` call. `always` entries run in every +non-terminal state and fire *before* per-state entries — see the `always`-before-state +ordering guarantee below. Multiple entries for the same state produce multiple effects — +each with independent dependency tracking and cleanup. This is the mechanism that replaces +multiple named `cleanupX` variables in the current function-based reactors. ### Example — `syncTextTracks` +Two states (`preconditions-unmet` ↔ `set-up`), one `always` monitor, two independent +effects in `set-up` with separate tracking and cleanup. + ```typescript const syncTextTracksDef = { initial: 'preconditions-unmet' as const, + context: {}, + // Single always effect drives all transitions from one place. + always: [ + ({ status, transition }) => { + const target = preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'; + if (target !== status) transition(target); + } + ], + states: { + 'preconditions-unmet': [], // no effects — always monitor handles exit + + 'set-up': [ + // Effect 1 — enter-once: create elements, return teardown cleanup. + // untrack() prevents re-runs on mediaElement/modelTextTracks changes. + () => { + const el = untrack(() => mediaElementSignal.get()!); + const tracks = untrack(() => modelTextTracksSignal.get()!); + tracks.forEach(t => el.appendChild(createTrackElement(t))); + return () => { + el.querySelectorAll('track[data-src-track]').forEach(t => t.remove()); + update(state, { selectedTextTrackId: undefined }); + }; + }, + + // Effect 2 — reactive-within-state: re-runs when selectedId changes. + // Independent tracking and cleanup from Effect 1. + () => { + const el = untrack(() => mediaElementSignal.get()!); + const selectedId = selectedIdSignal.get(); // tracked — re-run on change + syncModes(el.textTracks, selectedId); + const unlisten = listen(el.textTracks, 'change', onChange); + return () => unlisten(); + } + ] + } +}; +``` + +### Example — `loadTextTrackCues` + +Four states with actor lifecycle managed across states, the `deriveStatus` pattern for +complex multi-condition transitions, and `untrack()` for non-reactive owner reads. + +```typescript +// Hoist computeds outside effects — computed() inside an effect body +// creates a new Computed node on every re-run with no memoization. +const derivedStatusSignal = computed(() => deriveStatus(state.get(), owners.get())); +const currentTimeSignal = computed(() => state.get().currentTime ?? 0); +const selectedTrackSignal = computed(() => findSelectedTrack(state.get())); + +const loadTextTrackCuesDef = { + initial: 'preconditions-unmet' as const, + context: {}, + always: [ + ({ status, transition }) => { + const target = derivedStatusSignal.get(); + if (target !== status) transition(target); + } + ], states: { 'preconditions-unmet': [ - ({ transition }) => { - if (preconditionsMet.get()) transition('setting-up'); + () => { + // Entry-reset: destroy any stale actors; no-op if already undefined. + // Runs on every entry, handling all paths back from active states. + teardownActors(owners); } ], 'setting-up': [ - ({ transition }) => { - setupTextTracks(mediaElement.get()!, modelTextTracks.get()!); - transition('set-up'); + () => { + teardownActors(owners); // defensive — same as preconditions-unmet + const mediaElement = untrack(() => owners.get().mediaElement!); + const textTracksActor = createTextTracksActor(mediaElement); + const segmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor); + update(owners, { textTracksActor, segmentLoaderActor }); + // No return — deriveStatus drives the onward transition automatically. } ], - 'set-up': [ - // Effect #1 — guards state; exit cleanup tears down track elements - ({ transition }) => { - if (!preconditionsMet.get()) { transition('preconditions-unmet'); return; } - const el = untrack(() => mediaElement.get()!); - return () => teardownTextTracks(el); - }, - // Effect #2 — mode sync + DOM change listener (independent tracking/cleanup) + pending: [], // neutral waiting state — no effects + 'monitoring-for-loads': [ () => { - syncModes(mediaElement.textTracks, selectedId.get()); - const unlisten = listen(mediaElement.textTracks, 'change', onChange); - return () => unlisten(); + const currentTime = currentTimeSignal.get(); // tracked — re-run on advance + const track = selectedTrackSignal.get()!; // tracked — re-run on change + // untrack owners — actor snapshot changes must not re-trigger this effect. + const { segmentLoaderActor } = untrack(() => owners.get()); + segmentLoaderActor!.send({ type: 'load', track, currentTime }); } ] } @@ -259,6 +356,28 @@ stale one) is handled by the framework internally rather than by runner scope. --- +### `always`-before-state ordering guarantee + +**Decision:** `always` effects are registered before per-state effects in `createReactor`. +This ordering is **load-bearing**: per-state effects can rely on invariants established by +`always` monitors having already run. + +**How it works:** The effect scheduler drains pending computeds into an insertion-ordered +`Set` before executing them. Because `always` effects are registered first, they are +guaranteed to execute before per-state effects in every flush. + +**What this enables:** When an `always` monitor calls `transition(newState)`, the snapshot +signal updates immediately. By the time per-state effects run, the reactor is already in +`newState` — so a per-state effect gated on `snapshot.status !== state` correctly no-ops +without needing to re-check conditions that the `always` monitor just resolved. + +**Important caveat:** This guarantee is specific to `createReactor`'s registration order. +It is not a formal guarantee of the TC39 Signals proposal — it depends on the polyfill's +`Watcher` implementation preserving insertion order in `getPending()`. Do not assume this +ordering holds outside of `createReactor`. + +--- + ### Per-state `on` handlers **Decision:** Message handlers are declared per state. The same message type can appear in @@ -312,58 +431,69 @@ instantiation. SPF's current factory approach is compatible with this future dir `runner: () => new SerialRunner()` today becomes a named reference resolved against a provided implementation map later. The migration path is additive — no existing definitions need to change. +### Handler context API + +The second argument to Actor message handlers is: +```typescript +{ transition: (to: UserStatus) => void; context: Context; setContext: (next: Context) => void } + & (RunnerFactory extends () => infer R ? { runner: R } : {}) +``` + +`runner` is present and typed as the exact runner instance *only* when the definition +declares a `runner` factory. When no runner is declared, `runner` is absent from the type +entirely (not `undefined` — it simply doesn't exist). This is enforced at the type level via +conditional intersection. + --- -## Open Questions +### Entry vs. reactive per-state effects -### `settled` on `ConcurrentRunner` +Per-state effects fall into two distinct categories: -`SerialRunner` exposes `.settled` (the current promise chain tail). `ConcurrentRunner` does not. -`onSettled` at the state level implies the runner has a way to signal completion. +- **Enter-once effects** — run once on state entry, do setup work, return a cleanup. + Signal reads inside these should be wrapped in `untrack()` to prevent accidental re-runs. + Example: creating `` elements, reading `mediaElement` from owners, starting a fetch. +- **Reactive-within-state effects** — intentionally re-run when a tracked signal changes while + the state is active. Example: `syncTextTracks` Effect 2, which re-runs whenever + `selectedTextTrackId` changes to re-apply mode sync. -Options: -- Add `settled` to `ConcurrentRunner` (resolves when `#pending` map empties — same concept) -- Define a `SettledRunner` interface and make `onSettled` only valid for runners that implement it +Both categories use the same `effect()` mechanism. The distinction is enforced by convention +(`untrack()` for enter-once reads) rather than by the API — nothing in the definition shape +prevents an enter-once effect from accidentally tracking a signal and re-running. -Leaning toward the former: `settled` is a generally useful concept for any runner. +**Inline computed anti-pattern:** `computed()` inside an effect body creates a new `Computed` +node on every re-run with no memoization. `Computed`s that gate effect re-runs must be hoisted +*outside* the effect body (typically at the factory function scope, before `createReactor()`). +This applies regardless of whether the effect is enter-once or reactive. -### Reactor `context` +**Future direction:** Distinguish these in the definition shape — e.g., `entry` for enter-once +effects (automatically untracked) and `reactive` (or signal-keyed `on`) for reactive-within-state +effects. Revisit once more reactive-within-state examples accumulate. -The current design gives Reactors no context (no non-finite state). This matches the current -function-based reactors. If a Reactor needs to track something across effect re-runs (e.g., -`prevInputs` in `loadSegments`), it currently uses a closure variable. +--- -Open: should `createReactor` support an optional `context` field, or should Reactor context -always be held via closure? Closure is simpler; a formal context field would make Reactor -snapshots richer and more inspectable. +## Open Questions -### Handler context API stability +### `settled` on `ConcurrentRunner` -The second argument to message handlers is currently sketched as -`{ transition, runner, context, setContext }`. The exact shape — including whether `runner` is -always present (or `undefined` when no runner is declared) and whether `context` is the full -snapshot context or a subset — is to be finalized during implementation. +`SerialRunner` exposes `.whenSettled()`. `ConcurrentRunner` does not. `onSettled` at the +state level implies the runner has a way to signal completion. ---- +Options: +- Add `whenSettled()` to `ConcurrentRunner` (triggers when `#pending` map empties) +- Define a `SettledRunner` interface and make `onSettled` only valid for runners that implement it -### Reactor per-state effect semantics: entry vs. reactive +Leaning toward the former: `whenSettled` is a generally useful concept for any runner. -In practice, per-state effects fall into two distinct categories: +### Reactor `context` — what belongs where -- **Enter-once effects** — run once on state entry, do setup work, return a cleanup. Signal reads - inside these should generally be wrapped in `untrack()` to prevent accidental re-runs. Example: - creating `` elements, starting a fetch. -- **Reactive-within-state effects** — intentionally re-run when a signal changes while the state - is active. Example: `syncTextTracks` effect 2, which re-runs whenever `selectedTextTrackId` - changes to re-apply mode sync. +`createReactor` accepts a `context` field, and effects receive `context` + `setContext`. +Reactor context is non-finite state visible in the snapshot. -Currently both categories use the same `effect()` mechanism, and the distinction is enforced by -convention (explicit `untrack()` calls) rather than by the API. The `always` array is the primary -mechanism for reactive condition monitoring, but reactive-within-state effects are also a -legitimate use case. +In practice, the text track spike used empty `context: {}` throughout — reactor state was +held via closure variables and the `owners` signal. The formal `context` field is available +but its usage patterns are not yet settled. -A possible future direction: distinguish these in the definition shape — e.g., `entry` for -enter-once effects (automatically untracked) and `on` (signal-keyed or otherwise) for -reactive-within-state effects. This would make intent explicit and prevent accidental tracking -bugs in entry effects. Worth revisiting once more examples of the reactive-within-state pattern -accumulate. +Open: what belongs in Reactor `context` vs. closure variables vs. the `owners` signal? +Tradeoffs: observability (context is in the snapshot; closure is not) vs. simplicity +(closure is zero API surface). Revisit as more Reactors are written. diff --git a/internal/design/spf/decisions.md b/internal/design/spf/decisions.md index 864cbfc88..49617cab2 100644 --- a/internal/design/spf/decisions.md +++ b/internal/design/spf/decisions.md @@ -11,6 +11,67 @@ Rationale behind SPF's key choices. --- +## Actor/Reactor Pattern (from text track spike) + +These decisions were made or confirmed during the text track architecture spike +(videojs/v10#1158). See [text-track-architecture.md](text-track-architecture.md) for +the full reference implementation and assessment. + +--- + +### `always`-before-state ordering as a load-bearing guarantee + +**Decision:** `always` effects in `createReactor` always run before per-state effects. +This ordering guarantee is documented in `createReactor`'s source and must be preserved. + +**Rationale:** Per-state effects rely on invariants established by `always` monitors. +When an `always` monitor calls `transition(newState)`, the snapshot updates before any +per-state effect fires — so per-state effects that no-op when `status !== expectedState` +do so correctly without needing to re-check conditions themselves. + +**Caveat:** The guarantee is specific to `createReactor`'s registration order. It depends +on the TC39 `signal-polyfill`'s `Watcher` preserving insertion order in `getPending()` — +not a formal guarantee of the TC39 Signals proposal. + +--- + +### `deriveStatus` pattern for transition logic + +**Decision:** Transition conditions live in a pure `deriveStatus` function, wrapped in a +`computed()` signal outside any effect body, consumed by the `always` monitor to drive +transitions. The `always` effect contains only: read the computed, compare, call `transition`. + +**Rationale:** Keeps `always` minimal and machine-readable; makes transition conditions +independently testable as a plain function; prevents the inline computed anti-pattern +(see [actor-reactor-factories.md](actor-reactor-factories.md)). + +--- + +### Actors in owners as the lifecycle contract + +**Decision:** Actors created by a reactor are written to the shared `owners` signal. +The engine's `destroy()` generically destroys any value in owners with a `destroy()` +method. The reactor does not destroy its own actors. + +**Rationale:** Keeps reactor cleanup simple — no tracking of which actors were created, +no custom destroy logic. Gives the engine a single, uniform cleanup point. The tradeoff +is an implicit contract: callers using a reactor outside the engine must destroy actors +from owners before destroying the reactor. + +--- + +### Entry-reset as a defensive pattern for actor-creating states + +**Decision:** States that create actors (`'setting-up'`) and states that are reset points +(`'preconditions-unmet'`) both call `teardownActors()` on entry. `teardownActors` is a +guarded no-op when actors are already `undefined`, preventing spurious signal writes. + +**Rationale:** Any transition to a reset state may arrive from a state where actors were +alive. Defensive teardown on *both* states eliminates the need to track "did I come from +an actor-alive state?" — the entry effect is always safe to run. + +--- + ## Architecture ### Reactor / Actor Separation diff --git a/internal/design/spf/index.md b/internal/design/spf/index.md index a918c3c15..42ed2bf6c 100644 --- a/internal/design/spf/index.md +++ b/internal/design/spf/index.md @@ -7,7 +7,7 @@ date: 2026-03-11 > **This is a living design document for a highly tentative codebase.** The current implementation captures useful early lessons but is expected to undergo significant architectural change in the near term. [architecture.md](architecture.md) and [decisions.md](decisions.md) document the current state; [primitives.md](primitives.md) is the forward-looking design. -A lean, actor-based framework for HLS playback over MSE. Handles manifest parsing, quality selection, segment buffering, and end-of-stream coordination — without a monolithic player. +A lean, actor-based framework for HLS playback over MSE. Handles manifest parsing, quality selection, segment buffering, and end-of-stream coordination — without a monolithic player. Actors and Reactors are defined via declarative factory functions (`createActor`, `createReactor`) backed by TC39 Signals. ## Contents @@ -16,6 +16,7 @@ A lean, actor-based framework for HLS playback over MSE. Handles manifest parsin | [index.md](index.md) | Overview, problem, quick start, surface API | | [primitives.md](primitives.md) | Foundational building blocks (Tasks, Actors, Reactors, State) | | [actor-reactor-factories.md](actor-reactor-factories.md) | Decided design for `createActor` / `createReactor` factories | +| [text-track-architecture.md](text-track-architecture.md) | Reference Actor/Reactor implementation + spike assessment | | [architecture.md](architecture.md) | Current implementation: layers, components, data flow | | [decisions.md](decisions.md) | Decided and open design decisions | @@ -122,6 +123,8 @@ interface PlaybackEngineOwners { audioBuffer?: SourceBuffer; videoBufferActor?: SourceBufferActor; audioBufferActor?: SourceBufferActor; + textTracksActor?: TextTracksActor; + segmentLoaderActor?: TextTrackSegmentLoaderActor; } ``` diff --git a/internal/design/spf/primitives.md b/internal/design/spf/primitives.md index c66666085..70e856171 100644 --- a/internal/design/spf/primitives.md +++ b/internal/design/spf/primitives.md @@ -107,7 +107,13 @@ An Actor does not know about state outside itself. It receives messages and prod ### Current approach -The concept is approximately right in the current codebase, but implementations are bespoke closures rather than classes. They will need to be refactored into classes with a formal interface. Beyond that structural change, additional structure is likely to emerge — for example, an Actor may define an explicit message map from message type to Task, making the relationship between inputs and work more declarative and inspectable. +`createActor` in `core/create-actor.ts` — a declarative factory replacing bespoke closures. +Actors define state, context, message handlers per state, and an optional runner factory in +a definition object. The factory manages the snapshot signal, runner lifecycle, and +`'destroyed'` terminal state. See [actor-reactor-factories.md](actor-reactor-factories.md). + +The existing `SourceBufferActor` predates `createActor` and has not been migrated — the +behavioral contract is equivalent, but the factory pattern is not yet used there. ### Decided @@ -146,19 +152,28 @@ A Reactor is typically the bridge between observable state and one or more Actor ### Current approach -The current codebase has top-level functions in `dom/features/` that gesture at the Reactor concept — they observe state and produce side effects — but lack the formal structure entirely: no class, no status, no snapshot, no defined lifecycle. These will need significant rework to become first-class Reactors. +`createReactor` in `core/create-reactor.ts` — a declarative factory. The first Reactor +implementations are in `dom/features/` as part of the text track spike (videojs/v10#1158): +`syncTextTracks` and `loadTextTrackCues`. See [text-track-architecture.md](text-track-architecture.md) +for the reference implementation. + +Older features in `dom/features/` (e.g., `loadSegments`, `endOfStream`) are still +function-based with no formal status or snapshot — they remain to be migrated. ### Decided -- **Snapshot as signal** — same decision as Actors. `snapshot` is a `ReadonlySignal<{ status: ReactorStatus }>`. -- **Factory function, not base class** — `createReactor(definition)`. Per-state effects arrays; each element becomes one independent `effect()` call. See [actor-reactor-factories.md](actor-reactor-factories.md). +- **Snapshot as signal** — same decision as Actors. `snapshot` is a `ReadonlySignal<{ status, context }>`. +- **Factory function, not base class** — `createReactor(definition)`. Per-state effect arrays; each element becomes one independent `effect()` call. See [actor-reactor-factories.md](actor-reactor-factories.md). - **Reactors do not send to other Reactors** — coordination flows through state or via `actor.send()`. +- **`always` effects for cross-cutting monitors** — a dedicated `always` array runs before per-state effects in every flush. The primary use case is condition monitoring that drives transitions from one place. See the ordering guarantee in [actor-reactor-factories.md](actor-reactor-factories.md). +- **Context via closure (current direction)** — the text track spike used `context: {}` throughout; Reactor non-finite state lived in closure variables and the `owners` signal. A formal `context` field exists in `createReactor` but usage patterns are not yet settled. ### Open questions -- **Effect scheduling** — when observed state changes, does a Reactor's response fire synchronously within the same update batch, or always deferred? Tightly coupled to §5. -- **Lifecycle ownership** — who creates and destroys Reactors? Currently the engine owns all of this explicitly. With a signal-based state primitive, Reactors could self-scope to a signal context and auto-dispose. -- **Reactor context** — should `createReactor` support an optional `context` field for non-finite state, or should Reactor context always be held via closure? See [actor-reactor-factories.md](actor-reactor-factories.md). +- **Effect scheduling** — when observed state changes, does a Reactor's response fire synchronously within the same update batch, or always deferred? The current implementation defers via `queueMicrotask`; the exact semantics under compound state changes are not fully characterized. +- **Lifecycle ownership** — who creates and destroys Reactors? Currently the engine owns this explicitly. With a signal-based state primitive, Reactors could self-scope to a signal context and auto-dispose. +- **Reactor context — what belongs where** — see the "Reactor `context`" open question in [actor-reactor-factories.md](actor-reactor-factories.md). +- **Entry vs. reactive per-state effect distinction** — currently a `untrack()` convention rather than an API distinction. A future `entry` / `reactive` split in the definition shape would make intent explicit. See [actor-reactor-factories.md](actor-reactor-factories.md). --- @@ -231,7 +246,15 @@ A disciplined hybrid could work: signals for state (current values, derived valu ### Current approach -A minimal hand-rolled observable in `core/state/` and `core/reactive/`. The concept is directionally correct but the primitive is insufficient for SPF's needs: no operators, no caching, no scheduling control, manual dependency wiring. This will be replaced entirely — the current implementation should be treated as a placeholder that established the pattern, not a foundation to build on. +The TC39 `signal-polyfill` with a thin effect layer in `core/signals/effect.ts`. Writable +`Signal.State`, `Signal.Computed`, and `Signal.subtle.Watcher` are used directly. SPF adds +`signal()`, `computed()`, `untrack()`, `update()`, and `effect()` as thin wrappers. + +This is the approach used by the text track spike and is tentatively committed. It is not +a final decision — if the TC39 proposal diverges significantly from the polyfill, or if +bundle size / scheduling requirements favor a different library, this could change. The +pre-existing `core/state/` observable is no longer used for new code and should be +treated as legacy. ### Open questions diff --git a/internal/design/spf/text-track-architecture.md b/internal/design/spf/text-track-architecture.md new file mode 100644 index 000000000..e6bd1b2bb --- /dev/null +++ b/internal/design/spf/text-track-architecture.md @@ -0,0 +1,530 @@ +--- +status: draft +date: 2026-04-02 +--- + +# Text Track Architecture + +The text track implementation is the **reference implementation** for the +`createActor` / `createReactor` 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](primitives.md) and [actor-reactor-factories.md](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 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 `` 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 `` 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 `always` 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 `always` monitor that evaluates a `deriveStatus()` +computed signal. + +--- + +### `TextTrackSegmentLoaderActor` + +Fetches VTT segments and delegates cue management to `TextTracksActor`. + +``` +'idle' ──── load (segments to fetch) ────→ 'loading' + ↑ │ + └──── onSettled (runner chain empties) ───────┘ + ↑ + └──── load (nothing to fetch) — stays idle +``` + +Both states handle `load`. The `idle` handler transitions to `'loading'`; +the `loading` handler stays `loading` (re-plans in place by aborting + rescheduling). +`onSettled: 'idle'` in the `loading` state definition handles the auto-return once +all tasks complete. + +Uses a `SerialRunner` — segments are fetched one at a time. + +--- + +### `TextTracksActor` + +Wraps a `HTMLMediaElement`'s `textTracks`, owns cue deduplication and +the cue record snapshot. + +``` +'idle' ──── add-cues ────→ 'idle' (single state; all messages synchronous) +``` + +No runner — all message handling is synchronous. `'destroyed'` is the only +other state (implicit, added by `createActor`). + +--- + +## Key Patterns + +### 1. `deriveStatus` + `always` monitor + +Complex multi-condition transition logic lives in a pure function that is memoized +into a `computed()` signal *outside* any effect body. The `always` monitor reads +the signal and drives the transition: + +```typescript +// Hoist outside the reactor — computed() inside an effect creates a new node +// on every re-run with no memoization. +const derivedStatusSignal = computed(() => deriveStatus(state.get(), owners.get())); + +createReactor({ + always: [ + ({ status, transition }) => { + const target = derivedStatusSignal.get(); + if (target !== status) transition(target); + } + ], + // ... +}); +``` + +`deriveStatus` is a plain function, independently testable. The `always` effect is +kept to one comparison and one transition call — no logic lives there. + +--- + +### 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: + +```typescript +function teardownActors(owners: Signal) { + 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': [() => { teardownActors(owners); }], +'setting-up': [() => { + 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: + +```typescript +// 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: + +```typescript +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: + +**Enter-once setup** — reading `owners` or `state` in an enter-once effect. Without +`untrack()`, a change to `owners.mediaElement` would re-run an effect that was only +meant to run once on state entry: + +```typescript +'setting-up': [() => { + // untrack: mediaElement might change later; we only need it at setup time. + const mediaElement = untrack(() => owners.get().mediaElement!); + const textTracksActor = createTextTracksActor(mediaElement); + // ... +}], +``` + +**Preventing feedback loops** — reading actor snapshot in a monitoring effect. +`segmentLoaderActor.snapshot` changes every time the actor processes a message. +Without `untrack()`, the monitoring effect would re-run on every snapshot change, +creating a tight feedback loop: + +```typescript +'monitoring-for-loads': [() => { + const currentTime = currentTimeSignal.get(); // tracked intentionally + const track = selectedTrackSignal.get()!; // tracked intentionally + // untrack: actor snapshot changes must not re-trigger this effect. + 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 `` 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 `` 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`: + +```typescript +let syncTimeout: ReturnType | 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 `createReactor` and `createActor` produce explicit FSMs with named states | +| **Non-finite context** | ✓ | `TextTracksActor.context` holds unbounded `loaded` + `segments` maps; observable via snapshot | +| **Teardown / abort propagation** | ✓ | `destroy()` fires effect cleanups; `SerialRunner.abortAll()` aborts in-flight Tasks; actors in owners destroyed by engine | +| **Message → task IoC** | ✓ | `createActor` decouples message dispatch from task execution; `SerialRunner` handles scheduling | +| **Observable 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 `always`-before-state ordering guarantee** requires care — it's an implementation + guarantee of `createReactor`, not a formal TC39 Signals guarantee. It cannot be assumed + outside `createReactor`. +- **Entry vs. reactive effect intent is invisible in the definition shape.** `untrack()` is + a convention, not API enforcement. An enter-once effect that accidentally tracks a signal + produces no error — just unexpected re-runs. + +--- + +## 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. `Computed`s that gate re-runs must be hoisted outside the +effect body. This is easy to miss because the code looks correct: + +```typescript +// WRONG — new Computed on every re-run, no memoization +states: { + 'monitoring-for-loads': [() => { + const trackSignal = computed(() => findSelectedTrack(state.get())); // new node each time! + const track = trackSignal.get(); + segmentLoaderActor.send({ type: 'load', track, currentTime }); + }] +} + +// CORRECT — hoist outside createReactor() +const trackSignal = computed(() => findSelectedTrack(state.get())); +createReactor({ states: { 'monitoring-for-loads': [() => { + const track = trackSignal.get(); + segmentLoaderActor.send({ type: 'load', track, currentTime }); +}] } }); +``` + +### `untrack()` — convention without enforcement + +Nothing in the API prevents an enter-once effect from tracking signals it shouldn't. +The author must know to use `untrack()` for reads that are setup-only. Missing it +produces unexpected re-runs when the read signal changes, which can cause duplicate +DOM mutations or redundant actor messages. + +### 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 + +The `untrack()` convention for enter-once effects is a footgun. A future definition shape +might distinguish: + +```typescript +states: { + 'set-up': { + entry: [/* automatically untracked, run once */], + reactive: [/* re-run when tracked signals change */] + } +} +``` + +This would make intent explicit and eliminate the class of bugs where an enter-once effect +accidentally tracks a signal. The `always` array already provides the primary reactive +mechanism for cross-cutting monitors; `reactive` within-state effects are a secondary but +real use case. Worth revisiting as more examples accumulate. + +### 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: + +```typescript +'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 usage on Reactor + +`createReactor` accepts `context` + `setContext`, but `loadTextTrackCues` and +`syncTextTracks` both use `context: {}` throughout — reactor non-finite state is held in +closure variables and the `owners` signal instead. + +The tradeoff: `owners` is externally visible (other features can observe actor state); +closure variables are not inspectable from outside; Reactor `context` would be observable +via `snapshot` but adds API surface. The right answer likely 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:52–57) +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 + +### `always`-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 `always`-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., `always` effects check +`status` 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 `deriveStatus` + `always` 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`**: Already a proper actor with observable snapshot, `SerialRunner`, +and a well-defined message interface. It predates `createActor` and has not been migrated +to the factory, but the behavioral contract is equivalent. Migration would be additive. + +**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. From a8a3eb6ee99154bc901d0457f93daa9c72f3a387 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 2 Apr 2026 12:46:15 -0700 Subject: [PATCH 29/79] docs(spf): add signals design doc; update primitives.md and index Document the committed decision to use TC39 signal-polyfill as SPF's reactive substrate. Covers the three roles signals play in the architecture (state substrate, Reactor execution model, Actor observability), the reactive context tension (ambient vs. syntactic reactive context), points of friction (inline computed anti-pattern, no native distinctUntilChanged, update() deep spreading, event-sequence patterns), TC39 risk framing, and future directions. Update primitives.md Observable State section to reflect the committed direction and close the "signals vs. observables" open question. Update index.md with the new doc. --- internal/design/spf/index.md | 1 + internal/design/spf/primitives.md | 39 ++-- internal/design/spf/signals.md | 349 ++++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+), 14 deletions(-) create mode 100644 internal/design/spf/signals.md diff --git a/internal/design/spf/index.md b/internal/design/spf/index.md index 42ed2bf6c..2c3ef7fb9 100644 --- a/internal/design/spf/index.md +++ b/internal/design/spf/index.md @@ -15,6 +15,7 @@ A lean, actor-based framework for HLS playback over MSE. Handles manifest parsin | ---------------------------------------------------------- | ------------------------------------------------------------- | | [index.md](index.md) | Overview, problem, quick start, surface API | | [primitives.md](primitives.md) | Foundational building blocks (Tasks, Actors, Reactors, State) | +| [signals.md](signals.md) | Signals as the reactive primitive — decision, tradeoffs, friction | | [actor-reactor-factories.md](actor-reactor-factories.md) | Decided design for `createActor` / `createReactor` factories | | [text-track-architecture.md](text-track-architecture.md) | Reference Actor/Reactor implementation + spike assessment | | [architecture.md](architecture.md) | Current implementation: layers, components, data flow | diff --git a/internal/design/spf/primitives.md b/internal/design/spf/primitives.md index 70e856171..7c271e442 100644 --- a/internal/design/spf/primitives.md +++ b/internal/design/spf/primitives.md @@ -179,7 +179,13 @@ function-based with no formal status or snapshot — they remain to be migrated. ## 5. Observable State -The reactive primitive that drives everything. State that can be observed over time, derived from other state, and composed in complex ways. The most consequential open design question in SPF. +The reactive primitive that drives everything. State that can be observed over time, derived +from other state, and composed in complex ways. + +The choice of signals as this primitive is a **committed architectural direction** — not an +open question. See [signals.md](signals.md) for the full decision rationale, tradeoffs, +and known friction. The sections below preserve the original conceptual comparison for +context; the "Current approach" and "Open questions" sections reflect the current state. ### Concept @@ -246,23 +252,28 @@ A disciplined hybrid could work: signals for state (current values, derived valu ### Current approach -The TC39 `signal-polyfill` with a thin effect layer in `core/signals/effect.ts`. Writable -`Signal.State`, `Signal.Computed`, and `Signal.subtle.Watcher` are used directly. SPF adds -`signal()`, `computed()`, `untrack()`, `update()`, and `effect()` as thin wrappers. +The TC39 `signal-polyfill` with a thin effect layer in `core/signals/effect.ts`. SPF wraps +this as `signal()`, `computed()`, `untrack()`, `update()`, and `effect()` in `core/signals/`. + +This is a committed architectural direction. The text track spike (videojs/v10#1158) proved +that Actors and Reactors can be cleanly built on top of signals. The pre-existing +`core/state/` observable layer is no longer used for new code and should be treated as legacy. -This is the approach used by the text track spike and is tentatively committed. It is not -a final decision — if the TC39 proposal diverges significantly from the polyfill, or if -bundle size / scheduling requirements favor a different library, this could change. The -pre-existing `core/state/` observable is no longer used for new code and should be -treated as legacy. +See [signals.md](signals.md) for full decision rationale, TC39 risks and mitigations, +points of friction, and future directions. ### Open questions -- **Signals vs observables as the canonical state primitive** — or a defined hybrid with explicit bridge points? -- **Home-grown vs. off-the-shelf** — given SPF's bundle size goals, a home-grown implementation that covers exactly what SPF needs is the most likely path, regardless of whether signals or observables are chosen. Off-the-shelf libraries are unlikely to satisfy both requirements simultaneously: full feature coverage and acceptable size. A possible exception is the TC39 Signals polyfill, which may prove small enough and well-aligned enough to be viable — but this isn't obvious yet and warrants evaluation. -- **Does "always having a current value" cause problems in practice?** The initialization question is solvable; the real question is whether reading-outside-reactive-context is a discipline problem or a design problem. -- **Scheduling model for Reactors** — if signal effects are synchronous, do Reactors fire mid-batch? If so, is that correct for all Reactors, or should some defer? Should the Reactor abstraction impose a scheduling policy, or leave it to the state primitive? -- **How does abort/cleanup compose with the state primitive?** An explicit answer here would clean up a lot of the current manual AbortController management scattered across features. +- **Scheduling model for Reactors** — effects are currently deferred via `queueMicrotask`. + The exact semantics under compound state changes (multiple signal writes in the same turn) + are not fully characterized. SPF controls the scheduler via the `Watcher` API; whether + different parts of the system ever need different scheduling is open. +- **How does abort/cleanup compose with the state primitive?** Cleanup today is manual + (`effect()` returns a disposal function, wired by hand). A more principled integration + with `AbortController` or signal-scoped lifetimes could reduce boilerplate. +- **Reading outside reactive context** — is this a discipline problem or a design problem? + Currently discipline (`untrack()` conventions). The `entry`/`reactive` split in + `createReactor` would address the most common case structurally. --- diff --git a/internal/design/spf/signals.md b/internal/design/spf/signals.md new file mode 100644 index 000000000..b8a1713d2 --- /dev/null +++ b/internal/design/spf/signals.md @@ -0,0 +1,349 @@ +--- +status: draft +date: 2026-04-02 +--- + +# Observable 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 `always`-before-state ordering guarantee in +`createReactor` possible. The effect scheduler drains pending computeds in an +insertion-ordered `Set`, so registration order determines execution order. + +--- + +## 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 (presentation, tracks, bandwidth, ...) + PlaybackEngineOwners — signal (mediaElement, actors, buffers, ...) + +2. Reactor execution model + createReactor() — always[] and states[][] each become effect() calls + Transitions fire when a computed signal changes and an always monitor detects it + +3. Actor observability + createActor() — snapshot is a signal<{ status, 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. + +SPF's current answer: **accept the ambient context, compensate through conventions.** +The conventions are: +- `untrack()` at every enter-once read inside an effect +- Naming effect intent in comments (`// tracked — re-run on change`, `// untrack: ...`) +- The `always` array for reactive condition monitors vs. per-state effects for state-scoped work +- Code review attention to accidental tracking + +A future API improvement would encode some of this intent structurally — an `entry` / +`reactive` distinction in the `createReactor` definition shape — so that enter-once +effects are automatically untracked and the distinction is visible in the definition +rather than in the effect body. See [actor-reactor-factories.md](actor-reactor-factories.md). +That improvement would address the "enter-once" category but not the broader ambient +context concern for reactive-within-state effects. + +--- + +## 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. `Computed`s that gate effect re-runs must be hoisted outside +the effect body, typically at the factory function scope before `createReactor()`. + +```typescript +// Wrong — new Computed on every effect re-run +states: { + 'monitoring-for-loads': [() => { + const trackSignal = computed(() => findSelectedTrack(state.get())); // new each time + const track = trackSignal.get(); + // ... + }] +} + +// Correct — hoist to factory scope +const trackSignal = computed(() => findSelectedTrack(state.get())); +createReactor({ states: { 'monitoring-for-loads': [() => { + const track = trackSignal.get(); + // ... +}] } }); +``` + +This mistake is uniquely hard to detect because it produces incorrect behavior (no +memoization) rather than an error, and the incorrect code looks structurally reasonable. +With observable pipelines, the equivalent mistake (a new `.pipe()` chain inside a +subscription) creates extra upstream subscriptions — a more visible correctness failure. + +--- + +### 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`. For deeply nested context +objects — like `TextTracksActor.context`, which holds `Record` 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. The Observable equivalent uses +`switchMap` to alternate between two subscription modes, `fromEvent` for the play +listener, `distinctUntilChanged` for change detection, and `withLatestFrom` to sample +current values at event time. These operators compose naturally because they were designed +for event-sequence reasoning. + +The signals version must simulate the same structure imperatively: a local intermediate +signal as a scratch pad, closure variables (`lastPresentationUrl`, `lastMediaElement`) to +manually implement change detection, and multiple coordinated effects that are harder to +follow than a single pipeline. + +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. + +--- + +## The `always`-Before-State Ordering: A Polyfill Dependency + +The `createReactor` ordering guarantee — `always` effects run before per-state effects — +is load-bearing for all Reactor FSMs. It depends on the `Signal.subtle.Watcher` +implementation returning pending computeds in insertion order, and on the effect scheduler +draining them into an insertion-ordered `Set`. + +This is the behavior of the current `signal-polyfill`. It is **not a formal guarantee of +the TC39 proposal specification**. If a future polyfill version or native implementation +chose to deliver pending computeds in a different order (e.g., for optimization), all +Reactor FSMs that rely on `always`-before-state ordering would silently break. + +This is worth naming as a known dependency: SPF's Reactor model is correct as implemented, +but correctness depends on a behavioral property of the polyfill, not on a spec guarantee. + +--- + +## Future Directions + +### `entry` vs. `reactive` in `createReactor` definition shape + +The most impactful near-term improvement would be distinguishing enter-once effects from +reactive-within-state effects in the definition itself: + +```typescript +states: { + 'set-up': { + entry: [/* automatically untracked — run once on state entry */], + reactive: [/* tracked — re-run when dependencies change */], + } +} +``` + +This would make intent visible in the definition, eliminate the class of bugs where an +enter-once effect accidentally tracks a signal, and remove the need for `untrack()` in +the common case. It would not fully resolve the ambient reactive context concern for +reactive effects, but it would address the most frequent footgun. + +### 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. From afc3756f7ed93bd5879fdf9374a537bd26684269 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 2 Apr 2026 13:01:25 -0700 Subject: [PATCH 30/79] docs(spf): expand signals doc with three additional reasons for choosing signals Add: lower barrier to entry (shallower learning curve vs. observables), imperative and functional versatility (no impedance mismatch with actor layer; gentler integration surface for third parties), and community validation from Luke Curley (MoQ) and Casey Occhialini (Common Media Library / Paramount). --- internal/design/spf/signals.md | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/internal/design/spf/signals.md b/internal/design/spf/signals.md index b8a1713d2..0a70ab5bb 100644 --- a/internal/design/spf/signals.md +++ b/internal/design/spf/signals.md @@ -70,6 +70,72 @@ This scheduling control is what makes the `always`-before-state ordering guarant `createReactor` 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. + +### Imperative and functional versatility + +Signals support both imperative and reactive usage patterns — and the two compose +naturally. A signal can be written from anywhere: a DOM event handler, a Promise callback, +an actor message handler, an async Task. It can be read reactively inside `computed()` or +`effect()`, and read imperatively (with or without `untrack()`) outside those contexts. +There is no barrier between the two modes. + +This versatility matters in two directions: + +**Internal**: SPF's Actors are inherently message-driven and execute work imperatively. +Message handlers call `setContext()`, `transition()`, and `send()` — not observable +operators. The fact that these imperative writes immediately update the signal graph, +and that Reactors will pick them up on the next microtask, means there is no impedance +mismatch between the reactive layer and the imperative actor layer. + +**External**: Third-party integrators and higher-level abstractions built on SPF can +interact with the engine's state without adopting the full reactive model. They can call +`state.get()` to read current values imperatively, `state.set()` / `owners.patch()` to +write, and observe changes by wrapping a read in their own `effect()` — or just poll. 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. Signals offer a gentler integration surface. + +### 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 From bcede928d4c49996646baa876c412ebac83bec73 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 2 Apr 2026 13:19:39 -0700 Subject: [PATCH 31/79] docs(spf): add RxJS-style and signals implementations to trackPlaybackInitiated section --- internal/design/spf/signals.md | 89 ++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/internal/design/spf/signals.md b/internal/design/spf/signals.md index 0a70ab5bb..da512e95f 100644 --- a/internal/design/spf/signals.md +++ b/internal/design/spf/signals.md @@ -313,16 +313,85 @@ happened, in what order, relative to other events. > *When initiated:* listen for the next qualifying reset — url changes, or element swaps > while paused. -This is fundamentally temporal / sequential reasoning. The Observable equivalent uses -`switchMap` to alternate between two subscription modes, `fromEvent` for the play -listener, `distinctUntilChanged` for change detection, and `withLatestFrom` to sample -current values at event time. These operators compose naturally because they were designed -for event-sequence reasoning. - -The signals version must simulate the same structure imperatively: a local intermediate -signal as a scratch pad, closure variables (`lastPresentationUrl`, `lastMediaElement`) to -manually implement change detection, and multiple coordinated effects that are harder to -follow than a single pipeline. +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(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" From 26fde91e08d1d6fadb3ad3b549080104aeb9a7ed Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 2 Apr 2026 13:24:01 -0700 Subject: [PATCH 32/79] docs(spf): add lower structural overhead section to signals doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document that signals allow ad-hoc, inline reactive behaviors without conforming to a pipeline paradigm, whereas observables require operator composition even for one-off cases. Includes nuance that RxJS's library is comprehensive — the constraint is structural ceremony, not operator authorship. --- internal/design/spf/signals.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/design/spf/signals.md b/internal/design/spf/signals.md index da512e95f..a36b6a814 100644 --- a/internal/design/spf/signals.md +++ b/internal/design/spf/signals.md @@ -97,6 +97,33 @@ This is not a knock on observables — the power they provide is real. But for a 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. +### Lower structural overhead for new patterns + +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 of existing +operators or writing a custom operator (`new Observable(subscriber => ...)` / a lifting +function). Either way, the paradigm overhead is always present. + +RxJS's operator library is comprehensive enough that "no existing operator fits" is +genuinely rare. The constraint is more subtle: even when existing operators *do* cover +a scenario, expressing it requires conforming to the pipeline shape — types, subscription +semantics, operator chaining. There is no option to step outside and write a few +imperative lines instead. + +With signals, new behaviors can be expressed ad-hoc directly in effect bodies, mixing +reactive reads and imperative writes as needed. Reusable patterns can be extracted into +utilities — `update()` emerged this way, as did `teardownActors` — but that extraction +is a convenience choice, not a structural requirement. The code without the utility works +and reads reasonably; the utility just reduces repetition. With observables, the equivalent +extraction produces an operator — a first-class typed thing with the full observable +contract — which is more powerful but also more ceremony. + +The practical effect: the floor for "trying something new" is lower with signals. An +exploratory or one-off reactive behavior can be written inline without committing to a +reusable abstraction. If the pattern proves stable and recurring, extraction into a utility +is straightforward. If it doesn't recur, nothing was wasted. + ### Imperative and functional versatility Signals support both imperative and reactive usage patterns — and the two compose From 421139d03802bbbc377b71719cfb990660a9d9eb Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 2 Apr 2026 13:25:50 -0700 Subject: [PATCH 33/79] docs(spf): merge structural overhead and versatility sections; reorder why-signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combine "lower structural overhead" and "imperative and functional versatility" into a single "Flexibility: no paradigm overhead" section covering three angles: authoring new behaviors ad-hoc, the actor/reactor integration surface, and external integrators. Section order is now: technical fit → ergonomics → flexibility → community validation. --- internal/design/spf/signals.md | 83 +++++++++++++--------------------- 1 file changed, 32 insertions(+), 51 deletions(-) diff --git a/internal/design/spf/signals.md b/internal/design/spf/signals.md index a36b6a814..f334b9a6d 100644 --- a/internal/design/spf/signals.md +++ b/internal/design/spf/signals.md @@ -97,57 +97,38 @@ This is not a knock on observables — the power they provide is real. But for a 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. -### Lower structural overhead for new patterns - -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 of existing -operators or writing a custom operator (`new Observable(subscriber => ...)` / a lifting -function). Either way, the paradigm overhead is always present. - -RxJS's operator library is comprehensive enough that "no existing operator fits" is -genuinely rare. The constraint is more subtle: even when existing operators *do* cover -a scenario, expressing it requires conforming to the pipeline shape — types, subscription -semantics, operator chaining. There is no option to step outside and write a few -imperative lines instead. - -With signals, new behaviors can be expressed ad-hoc directly in effect bodies, mixing -reactive reads and imperative writes as needed. Reusable patterns can be extracted into -utilities — `update()` emerged this way, as did `teardownActors` — but that extraction -is a convenience choice, not a structural requirement. The code without the utility works -and reads reasonably; the utility just reduces repetition. With observables, the equivalent -extraction produces an operator — a first-class typed thing with the full observable -contract — which is more powerful but also more ceremony. - -The practical effect: the floor for "trying something new" is lower with signals. An -exploratory or one-off reactive behavior can be written inline without committing to a -reusable abstraction. If the pattern proves stable and recurring, extraction into a utility -is straightforward. If it doesn't recur, nothing was wasted. - -### Imperative and functional versatility - -Signals support both imperative and reactive usage patterns — and the two compose -naturally. A signal can be written from anywhere: a DOM event handler, a Promise callback, -an actor message handler, an async Task. It can be read reactively inside `computed()` or -`effect()`, and read imperatively (with or without `untrack()`) outside those contexts. -There is no barrier between the two modes. - -This versatility matters in two directions: - -**Internal**: SPF's Actors are inherently message-driven and execute work imperatively. -Message handlers call `setContext()`, `transition()`, and `send()` — not observable -operators. The fact that these imperative writes immediately update the signal graph, -and that Reactors will pick them up on the next microtask, means there is no impedance -mismatch between the reactive layer and the imperative actor layer. - -**External**: Third-party integrators and higher-level abstractions built on SPF can -interact with the engine's state without adopting the full reactive model. They can call -`state.get()` to read current values imperatively, `state.set()` / `owners.patch()` to -write, and observe changes by wrapping a read in their own `effect()` — or just poll. 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. Signals offer a gentler integration surface. +### 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 From c101bd47ab4583c8d434d8ea02cf3615576f3a46 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 2 Apr 2026 13:46:01 -0700 Subject: [PATCH 34/79] docs(spf): reframe signals.md sections as general primitives concerns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four sections were previously framed around SPF's specific Actor/Reactor implementation rather than the underlying signals primitive: - "SPF's current answer" in Reactive Context Tension → reframed as two general approaches any signals-based system can take (accept ambient context + conventions, or encode reactive participation at abstraction API surface) - Inline computed anti-pattern → show simpler non-Reactor example first; note that abstractions compound the problem by hiding the effect boundary - "always-Before-State Ordering: A Polyfill Dependency" → retitled to "Effect Execution Order: Behavioral, Not Guaranteed"; createReactor is now a case study of a general scheduling order dependency - "entry vs. reactive in createReactor" → retitled to "Making reactive participation explicit at abstraction boundaries"; frames the design principle before the createReactor example Co-Authored-By: Claude Sonnet 4.6 --- internal/design/spf/signals.md | 132 ++++++++++++++++++++------------- 1 file changed, 80 insertions(+), 52 deletions(-) diff --git a/internal/design/spf/signals.md b/internal/design/spf/signals.md index f334b9a6d..36f60bb2d 100644 --- a/internal/design/spf/signals.md +++ b/internal/design/spf/signals.md @@ -212,19 +212,18 @@ gap. The observable model makes reactive participation explicit at the cost of p ceremony. The signal model makes reactive participation implicit at the cost of requiring discipline (and footgun risk) at every read site. -SPF's current answer: **accept the ambient context, compensate through conventions.** -The conventions are: -- `untrack()` at every enter-once read inside an effect -- Naming effect intent in comments (`// tracked — re-run on change`, `// untrack: ...`) -- The `always` array for reactive condition monitors vs. per-state effects for state-scoped work -- Code review attention to accidental tracking - -A future API improvement would encode some of this intent structurally — an `entry` / -`reactive` distinction in the `createReactor` definition shape — so that enter-once -effects are automatically untracked and the distinction is visible in the definition -rather than in the effect body. See [actor-reactor-factories.md](actor-reactor-factories.md). -That improvement would address the "enter-once" category but not the broader ambient -context concern for reactive-within-state effects. +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 +`createReactor` specifically. --- @@ -233,31 +232,36 @@ context concern for reactive-within-state effects. ### Inline computed anti-pattern `computed()` inside an effect body creates a new `Computed` node on every re-run — no -memoization, no shared cache. `Computed`s that gate effect re-runs must be hoisted outside -the effect body, typically at the factory function scope before `createReactor()`. +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 -states: { - 'monitoring-for-loads': [() => { - const trackSignal = computed(() => findSelectedTrack(state.get())); // new each time - const track = trackSignal.get(); - // ... - }] -} +// 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 to factory scope +// Correct — hoist computed to the same scope as the effect const trackSignal = computed(() => findSelectedTrack(state.get())); -createReactor({ states: { 'monitoring-for-loads': [() => { +effect(() => { const track = trackSignal.get(); // ... -}] } }); +}); ``` -This mistake is uniquely hard to detect because it produces incorrect behavior (no -memoization) rather than an error, and the incorrect code looks structurally reasonable. -With observable pipelines, the equivalent mistake (a new `.pipe()` chain inside a -subscription) creates extra upstream subscriptions — a more visible correctness failure. +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 `createReactor` 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. --- @@ -439,43 +443,67 @@ evaluating alternatives (minimal hand-roll, alternative polyfill) at the time. --- -## The `always`-Before-State Ordering: A Polyfill Dependency - -The `createReactor` ordering guarantee — `always` effects run before per-state effects — -is load-bearing for all Reactor FSMs. It depends on the `Signal.subtle.Watcher` -implementation returning pending computeds in insertion order, and on the effect scheduler -draining them into an insertion-ordered `Set`. - -This is the behavior of the current `signal-polyfill`. It is **not a formal guarantee of -the TC39 proposal specification**. If a future polyfill version or native implementation -chose to deliver pending computeds in a different order (e.g., for optimization), all -Reactor FSMs that rely on `always`-before-state ordering would silently break. - -This is worth naming as a known dependency: SPF's Reactor model is correct as implemented, -but correctness depends on a behavioral property of the polyfill, not on a spec guarantee. +## 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. + +`createReactor` takes a load-bearing dependency on this property. The `always`-before-state +ordering guarantee — that `always` effects always run before per-state effects — is an +explicit guarantee of `createReactor`'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 -### `entry` vs. `reactive` in `createReactor` definition shape +### 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. -The most impactful near-term improvement would be distinguishing enter-once effects from -reactive-within-state effects in the definition itself: +An abstraction that distinguishes "run once on entry" from "re-run reactively" in its +definition shape makes reactive participation a declaration rather than a runtime property +of call-site context: ```typescript +// Hypothetical: intent encoded in definition shape states: { 'set-up': { - entry: [/* automatically untracked — run once on state entry */], + entry: [/* automatically untracked — run once */], reactive: [/* tracked — re-run when dependencies change */], } } ``` -This would make intent visible in the definition, eliminate the class of bugs where an -enter-once effect accidentally tracks a signal, and remove the need for `untrack()` in -the common case. It would not fully resolve the ambient reactive context concern for -reactive effects, but it would address the most frequent footgun. +For `createReactor`, this would make `untrack()` unnecessary in the common case of +enter-once effects, eliminate the class of bugs where accidental tracking causes unexpected +re-runs, and make the author's intent visible in the definition rather than in `untrack()` +calls buried inside effect bodies. + +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 From b20eb38c17d79e7dae97a55c4d9562b8e50f154d Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 2 Apr 2026 13:49:46 -0700 Subject: [PATCH 35/79] docs(spf): update primitives.md to reflect settled decisions post-spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Intro: replace "most decisions are open" with accurate framing - §4 Reactors: align "context via closure" wording with actor-reactor-factories.md - Composition: remove "not a decision yet" / "whatever primitive is chosen" stale hedges Co-Authored-By: Claude Sonnet 4.6 --- internal/design/spf/primitives.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/design/spf/primitives.md b/internal/design/spf/primitives.md index 7c271e442..9e257b974 100644 --- a/internal/design/spf/primitives.md +++ b/internal/design/spf/primitives.md @@ -5,7 +5,7 @@ date: 2026-03-11 # SPF Primitives -The five foundational building blocks of SPF. Most design decisions here are **open** — this document captures the intended shape and unresolved questions, not final answers. +The five foundational building blocks of SPF. Each section tracks what is decided, what is the current approach, and what remains open — the balance has shifted significantly as the text track spike (videojs/v10#1158) settled the factory designs and committed the signals primitive. --- @@ -166,7 +166,7 @@ function-based with no formal status or snapshot — they remain to be migrated. - **Factory function, not base class** — `createReactor(definition)`. Per-state effect arrays; each element becomes one independent `effect()` call. See [actor-reactor-factories.md](actor-reactor-factories.md). - **Reactors do not send to other Reactors** — coordination flows through state or via `actor.send()`. - **`always` effects for cross-cutting monitors** — a dedicated `always` array runs before per-state effects in every flush. The primary use case is condition monitoring that drives transitions from one place. See the ordering guarantee in [actor-reactor-factories.md](actor-reactor-factories.md). -- **Context via closure (current direction)** — the text track spike used `context: {}` throughout; Reactor non-finite state lived in closure variables and the `owners` signal. A formal `context` field exists in `createReactor` but usage patterns are not yet settled. +- **Context via closure (tested approach)** — the text track spike used closure variables for Reactor non-finite state throughout. A formal `context` field in `createReactor` has been prototyped but is tracked as a future improvement, not current practice. ### Open questions @@ -303,13 +303,13 @@ Currently the `PlaybackEngine` explicitly creates, wires, and destroys every Act An alternative: if Reactors self-scope to the reactive graph (e.g., signal effects are owned by a context that the engine controls), destroying the engine's reactive scope could automatically dispose all Reactors. Actors would still need explicit lifecycle management since they hold external resources (SourceBuffer, MediaSource). -This is not a decision yet — it's worth understanding what the Observable State primitive makes possible before committing to a lifecycle model. +The signals primitive is now committed, so this is a real option — but it has not been evaluated against the explicit engine ownership model. See Open Questions. ### Scheduling coordination The current `patch()` + `flush()` model exists because batching is needed for correctness (multiple synchronous patches shouldn't fire N subscriber callbacks), but immediate propagation is sometimes needed (bandwidth sampling must reach ABR before the next fetch starts). -Whatever Observable State primitive is chosen, SPF needs an explicit answer for: *when does a state change propagate to subscribers?* Options: +With signals as the committed primitive, SPF still needs an explicit answer for: *when does a state change propagate to subscribers?* Options: - **Always synchronous** (within batch): predictable, but requires careful batch discipline - **Always deferred** (microtask): safe default, but requires explicit "flush" for time-sensitive paths From a9c58ceea2924f7d8ab8149aaf69da1fd568c1f4 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 08:54:25 -0700 Subject: [PATCH 36/79] docs(spf): add cross-reference from factory doc ordering caveat to signals.md The always-before-state caveat noted it's not a TC39 spec guarantee but didn't point readers to the fuller treatment in signals.md. Co-Authored-By: Claude Sonnet 4.6 --- internal/design/spf/actor-reactor-factories.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index 2c5b7fea4..b94fd99b5 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -374,7 +374,8 @@ without needing to re-check conditions that the `always` monitor just resolved. **Important caveat:** This guarantee is specific to `createReactor`'s registration order. It is not a formal guarantee of the TC39 Signals proposal — it depends on the polyfill's `Watcher` implementation preserving insertion order in `getPending()`. Do not assume this -ordering holds outside of `createReactor`. +ordering holds outside of `createReactor`. See [signals.md § Effect Execution Order](signals.md) +for the general principle. --- From 52c247ecb5f643244a6806cda6b99132e057fc05 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 10:07:18 -0700 Subject: [PATCH 37/79] refactor(spf): migrate trackPlaybackInitiated to createReactor FSM Replaces 3 coordinated effects + 2 closure variables with a clean three-state Reactor: 'preconditions-unmet', 'monitoring', and 'playback-initiated'. deriveStatus drives all transitions via the always monitor, including URL-change and element-swap resets detected via captured signals. The exit cleanup on 'playback-initiated' resets state.playbackInitiated to false on any outbound transition. Co-Authored-By: Claude Sonnet 4.6 --- .../tests/track-playback-initiated.test.ts | 39 +++-- .../dom/features/track-playback-initiated.ts | 154 +++++++++++------- 2 files changed, 117 insertions(+), 76 deletions(-) diff --git a/packages/spf/src/dom/features/tests/track-playback-initiated.test.ts b/packages/spf/src/dom/features/tests/track-playback-initiated.test.ts index 0ef1195aa..eb842df4d 100644 --- a/packages/spf/src/dom/features/tests/track-playback-initiated.test.ts +++ b/packages/spf/src/dom/features/tests/track-playback-initiated.test.ts @@ -12,14 +12,14 @@ function setupTrackPlaybackInitiated( ) { const state = signal(initialState); const owners = signal(initialOwners); - const cleanup = trackPlaybackInitiated({ state, owners }); - return { state, owners, cleanup }; + const reactor = trackPlaybackInitiated({ state, owners }); + return { state, owners, reactor }; } describe('trackPlaybackInitiated', () => { it('sets playbackInitiated to true when mediaElement fires play event', async () => { const mediaElement = document.createElement('video'); - const { state, cleanup } = setupTrackPlaybackInitiated( + const { state, reactor } = setupTrackPlaybackInitiated( { presentation: { url: 'http://example.com/stream.m3u8' } }, { mediaElement } ); @@ -28,12 +28,12 @@ describe('trackPlaybackInitiated', () => { await new Promise((resolve) => setTimeout(resolve, 10)); expect(state.get().playbackInitiated).toBe(true); - cleanup(); + reactor.destroy(); }); it('resets playbackInitiated to false when presentation URL changes', async () => { const mediaElement = document.createElement('video'); - const { state, cleanup } = setupTrackPlaybackInitiated( + const { state, reactor } = setupTrackPlaybackInitiated( { presentation: { url: 'http://example.com/stream1.m3u8' } }, { mediaElement } ); @@ -46,12 +46,12 @@ describe('trackPlaybackInitiated', () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(state.get().playbackInitiated).toBe(false); - cleanup(); + reactor.destroy(); }); - it('sets playbackInitiated back to true if play fires after a URL change (e.g. autoplay)', async () => { + it('sets playbackInitiated back to true if play fires after a URL change', async () => { const mediaElement = document.createElement('video'); - const { state, cleanup } = setupTrackPlaybackInitiated( + const { state, reactor } = setupTrackPlaybackInitiated( { presentation: { url: 'http://example.com/stream1.m3u8' } }, { mediaElement } ); @@ -67,12 +67,12 @@ describe('trackPlaybackInitiated', () => { await new Promise((resolve) => setTimeout(resolve, 10)); expect(state.get().playbackInitiated).toBe(true); - cleanup(); + reactor.destroy(); }); it('resets playbackInitiated to false when the media element is swapped', async () => { const mediaElement = document.createElement('video'); - const { state, owners, cleanup } = setupTrackPlaybackInitiated( + const { state, owners, reactor } = setupTrackPlaybackInitiated( { presentation: { url: 'http://example.com/stream.m3u8' } }, { mediaElement } ); @@ -85,12 +85,12 @@ describe('trackPlaybackInitiated', () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(state.get().playbackInitiated).toBe(false); - cleanup(); + reactor.destroy(); }); it('does not reset playbackInitiated on unrelated state changes', async () => { const mediaElement = document.createElement('video'); - const { state, cleanup } = setupTrackPlaybackInitiated( + const { state, reactor } = setupTrackPlaybackInitiated( { presentation: { url: 'http://example.com/stream.m3u8' } }, { mediaElement } ); @@ -103,31 +103,34 @@ describe('trackPlaybackInitiated', () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(state.get().playbackInitiated).toBe(true); - cleanup(); + reactor.destroy(); }); it('does not re-attach listener on unrelated owner changes', async () => { const mediaElement = document.createElement('video'); const addEventListenerSpy = vi.spyOn(mediaElement, 'addEventListener'); - const { owners, cleanup } = setupTrackPlaybackInitiated({}, { mediaElement }); + const { owners, reactor } = setupTrackPlaybackInitiated( + { presentation: { url: 'http://example.com/stream.m3u8' } }, + { mediaElement } + ); const callsBefore = addEventListenerSpy.mock.calls.length; owners.set({ ...owners.get(), videoBuffer: {} } as PlaybackInitiatedOwners & { videoBuffer?: unknown }); await new Promise((resolve) => setTimeout(resolve, 10)); expect(addEventListenerSpy.mock.calls.length).toBe(callsBefore); - cleanup(); + reactor.destroy(); }); - it('stops tracking after cleanup', async () => { + it('stops tracking after destroy', async () => { const mediaElement = document.createElement('video'); - const { state, cleanup } = setupTrackPlaybackInitiated( + const { state, reactor } = setupTrackPlaybackInitiated( { presentation: { url: 'http://example.com/stream1.m3u8' } }, { mediaElement } ); - cleanup(); + reactor.destroy(); mediaElement.dispatchEvent(new Event('play')); state.set({ ...state.get(), presentation: { url: 'http://example.com/stream2.m3u8' } }); diff --git a/packages/spf/src/dom/features/track-playback-initiated.ts b/packages/spf/src/dom/features/track-playback-initiated.ts index d9f5d9b32..f659262a3 100644 --- a/packages/spf/src/dom/features/track-playback-initiated.ts +++ b/packages/spf/src/dom/features/track-playback-initiated.ts @@ -1,6 +1,7 @@ import { listen } from '@videojs/utils/dom'; -import { effect } from '../../core/signals/effect'; -import { computed, type Signal, signal, update } from '../../core/signals/primitives'; +import type { Reactor } from '../../core/create-reactor'; +import { createReactor } from '../../core/create-reactor'; +import { computed, type Signal, signal, untrack, update } from '../../core/signals/primitives'; /** * State shape for playback initiation tracking. @@ -20,18 +21,50 @@ export interface PlaybackInitiatedOwners { } /** - * Track whether playback has been initiated for the current presentation URL. + * FSM states for playback initiation tracking. * - * Uses a local intermediate signal written by two effect streams: - * - false stream: resets on URL change - * - true stream: sets on play event + * ``` + * 'preconditions-unmet' ──── element + URL ────→ 'monitoring' + * ↑ ↑ │ + * │ preconditions lost │ play event + * │ │ ↓ + * └────────────── 'playback-initiated' ←──────┘ + * (exit cleanup resets state.playbackInitiated → false) + * URL change / element swap → 'monitoring' via deriveStatus * - * A third merge effect reads the local signal and writes to state, reading - * `state.get()` at merge time so the spread uses the up-to-date value after - * the async forward bridge has run. + * any state ──── destroy() ────→ 'destroying' ────→ 'destroyed' + * ``` + */ +type PlaybackInitiatedStatus = 'preconditions-unmet' | 'monitoring' | 'playback-initiated'; + +function deriveStatus( + element: HTMLMediaElement | undefined, + url: string | undefined, + playbackInitiated: boolean | undefined, + capturedUrl: string | undefined, + capturedElement: HTMLMediaElement | undefined +): PlaybackInitiatedStatus { + if (!element || !url) return 'preconditions-unmet'; + if (playbackInitiated && url === capturedUrl && element === capturedElement) { + return 'playback-initiated'; + } + return 'monitoring'; +} + +/** + * Track whether playback has been initiated for the current presentation URL. + * + * A three-state Reactor FSM driven by `state.playbackInitiated` and the + * `deriveStatus` pattern: + * - `'preconditions-unmet'` — no element or URL yet; no effects. + * - `'monitoring'` — listens for a `play` event; re-attaches when element changes. + * - `'playback-initiated'` — writes `true` to state; exit cleanup resets to `false` + * on any outbound transition (URL change, element swap, or lost preconditions). * * @example - * const cleanup = trackPlaybackInitiated({ state, owners, events }); + * const reactor = trackPlaybackInitiated({ state, owners }); + * // later: + * reactor.destroy(); */ export function trackPlaybackInitiated({ state, @@ -39,59 +72,64 @@ export function trackPlaybackInitiated; owners: Signal; -}): () => void { - const presentationUrl = computed(() => state.get().presentation?.url); - const mediaElement = computed(() => owners.get().mediaElement); - - // 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(undefined); - - let lastPresentationUrl: string | undefined; - let lastMediaElement: HTMLMediaElement | undefined; +}): Reactor { + const presentationUrlSignal = computed(() => state.get().presentation?.url); + const mediaElementSignal = computed(() => owners.get().mediaElement); + const playbackInitiatedSignal = computed(() => state.get().playbackInitiated); - // False stream: reset on URL change or element swap. - const cleanupResetEffect = effect(() => { - const url = presentationUrl.get(); - const el = mediaElement.get(); + // Captured at the moment playback is initiated — used by deriveStatus to detect + // URL changes or element swaps that should reset to 'monitoring'. + const capturedUrlSignal = signal(undefined); + const capturedElementSignal = signal(undefined); - const urlChanged = url !== lastPresentationUrl; - const elChanged = el !== lastMediaElement; + const derivedStatusSignal = computed(() => + deriveStatus( + mediaElementSignal.get(), + presentationUrlSignal.get(), + playbackInitiatedSignal.get(), + capturedUrlSignal.get(), + capturedElementSignal.get() + ) + ); - if ((urlChanged && lastPresentationUrl !== undefined) || (elChanged && lastMediaElement !== undefined)) { - playbackInitiated.set(false); - } + function initiate() { + capturedUrlSignal.set(untrack(() => presentationUrlSignal.get())); + capturedElementSignal.set(untrack(() => mediaElementSignal.get())); + update(state, { playbackInitiated: true } as Partial); + } - lastPresentationUrl = url; - lastMediaElement = el; - }); + return createReactor({ + initial: 'preconditions-unmet', + context: {}, + always: [ + ({ status, transition }) => { + const target = derivedStatusSignal.get(); + if (target !== status) transition(target); + }, + ], + states: { + 'preconditions-unmet': [], - // True stream: set on play event. Cleanup return removes the listener on element swap. - const cleanupPlayEffect = effect(() => { - const el = mediaElement.get(); - if (!el) return; - return listen(el, 'play', () => { - playbackInitiated.set(true); - }); - }); + monitoring: [ + // Reactive: attach play listener; re-runs when element changes. + () => { + const el = mediaElementSignal.get(); // tracked + if (!el) return; + return listen(el, 'play', initiate); + }, + ], - // Merge effect: bridge local signal → state. - // Reads state.get() at merge time (after the async forward bridge has flushed) - // so the spread captures the current value rather than a stale event-time snapshot. - // The guard makes the write idempotent. - const cleanupMergeEffect = effect(() => { - const pi = playbackInitiated.get(); - if (pi === undefined) return; - const current = state.get(); - if (current.playbackInitiated !== pi) { - const patch: Partial = { playbackInitiated: pi }; - update(state, patch); - } + 'playback-initiated': [ + // Enter-once: write true to state. + // Exit cleanup: reset to false on any outbound transition — URL change, + // element swap, or lost preconditions all go through here. + () => { + update(state, { playbackInitiated: true } as Partial); + return () => { + update(state, { playbackInitiated: false } as Partial); + }; + }, + ], + }, }); - - return () => { - cleanupResetEffect(); - cleanupPlayEffect(); - cleanupMergeEffect(); - }; } From b74830f70f096677e7abe734e0f1c8ddad1cc548 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 10:59:14 -0700 Subject: [PATCH 38/79] feat(spf): migrate trackPlaybackInitiated to createReactor FSM Replaces 3 coordinated effects + 2 closure variables with a named-state Reactor: 'preconditions-unmet', 'monitoring', 'playback-initiated'. The 'playback-initiated' state tracks element and URL reactively so its exit cleanup resets state.playbackInitiated on any change or lost preconditions. Tests updated with a makeMediaElement() helper that controls el.paused via Object.defineProperty, working around JSDOM not updating paused on synthetic play events. Co-Authored-By: Claude Sonnet 4.6 --- .../tests/track-playback-initiated.test.ts | 111 +++++++++++++----- .../dom/features/track-playback-initiated.ts | 79 +++++-------- 2 files changed, 108 insertions(+), 82 deletions(-) diff --git a/packages/spf/src/dom/features/tests/track-playback-initiated.test.ts b/packages/spf/src/dom/features/tests/track-playback-initiated.test.ts index eb842df4d..c9be5ad86 100644 --- a/packages/spf/src/dom/features/tests/track-playback-initiated.test.ts +++ b/packages/spf/src/dom/features/tests/track-playback-initiated.test.ts @@ -16,15 +16,45 @@ function setupTrackPlaybackInitiated( return { state, owners, reactor }; } +/** Creates a video element with a controllable `paused` state. */ +function makeMediaElement(initiallyPaused = true) { + const el = document.createElement('video'); + let paused = initiallyPaused; + Object.defineProperty(el, 'paused', { get: () => paused, configurable: true }); + return { + el, + play() { + paused = false; + el.dispatchEvent(new Event('play')); + }, + pause() { + paused = true; + }, + }; +} + describe('trackPlaybackInitiated', () => { it('sets playbackInitiated to true when mediaElement fires play event', async () => { - const mediaElement = document.createElement('video'); + const { el, play } = makeMediaElement(); + const { state, reactor } = setupTrackPlaybackInitiated( + { presentation: { url: 'http://example.com/stream.m3u8' } }, + { mediaElement: el } + ); + + play(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(state.get().playbackInitiated).toBe(true); + reactor.destroy(); + }); + + it('sets playbackInitiated to true immediately if element is already playing', async () => { + const { el } = makeMediaElement(false); const { state, reactor } = setupTrackPlaybackInitiated( { presentation: { url: 'http://example.com/stream.m3u8' } }, - { mediaElement } + { mediaElement: el } ); - mediaElement.dispatchEvent(new Event('play')); await new Promise((resolve) => setTimeout(resolve, 10)); expect(state.get().playbackInitiated).toBe(true); @@ -32,16 +62,18 @@ describe('trackPlaybackInitiated', () => { }); it('resets playbackInitiated to false when presentation URL changes', async () => { - const mediaElement = document.createElement('video'); + const { el, play, pause } = makeMediaElement(); const { state, reactor } = setupTrackPlaybackInitiated( { presentation: { url: 'http://example.com/stream1.m3u8' } }, - { mediaElement } + { mediaElement: el } ); - mediaElement.dispatchEvent(new Event('play')); + play(); await new Promise((resolve) => setTimeout(resolve, 10)); expect(state.get().playbackInitiated).toBe(true); + // Simulate source change: element pauses as new media loads. + pause(); state.set({ ...state.get(), presentation: { url: 'http://example.com/stream2.m3u8' } }); await new Promise((resolve) => setTimeout(resolve, 20)); @@ -49,39 +81,55 @@ describe('trackPlaybackInitiated', () => { reactor.destroy(); }); - it('sets playbackInitiated back to true if play fires after a URL change', async () => { - const mediaElement = document.createElement('video'); - const { state, reactor } = setupTrackPlaybackInitiated( - { presentation: { url: 'http://example.com/stream1.m3u8' } }, - { mediaElement } + it('resets playbackInitiated to false when the media element is swapped', async () => { + const { el, play } = makeMediaElement(); + const { state, owners, reactor } = setupTrackPlaybackInitiated( + { presentation: { url: 'http://example.com/stream.m3u8' } }, + { mediaElement: el } ); - mediaElement.dispatchEvent(new Event('play')); + play(); await new Promise((resolve) => setTimeout(resolve, 10)); + expect(state.get().playbackInitiated).toBe(true); + + // New element starts paused. + owners.set({ ...owners.get(), mediaElement: document.createElement('video') }); + await new Promise((resolve) => setTimeout(resolve, 20)); - state.set({ ...state.get(), presentation: { url: 'http://example.com/stream2.m3u8' } }); - await new Promise((resolve) => setTimeout(resolve, 10)); expect(state.get().playbackInitiated).toBe(false); + reactor.destroy(); + }); - mediaElement.dispatchEvent(new Event('play')); + it('resets playbackInitiated to false when element is removed', async () => { + const { el, play } = makeMediaElement(); + const { state, owners, reactor } = setupTrackPlaybackInitiated( + { presentation: { url: 'http://example.com/stream.m3u8' } }, + { mediaElement: el } + ); + + play(); await new Promise((resolve) => setTimeout(resolve, 10)); expect(state.get().playbackInitiated).toBe(true); + owners.set({ ...owners.get(), mediaElement: undefined }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(state.get().playbackInitiated).toBe(false); reactor.destroy(); }); - it('resets playbackInitiated to false when the media element is swapped', async () => { - const mediaElement = document.createElement('video'); - const { state, owners, reactor } = setupTrackPlaybackInitiated( + it('resets playbackInitiated to false when URL is cleared', async () => { + const { el, play } = makeMediaElement(); + const { state, reactor } = setupTrackPlaybackInitiated( { presentation: { url: 'http://example.com/stream.m3u8' } }, - { mediaElement } + { mediaElement: el } ); - mediaElement.dispatchEvent(new Event('play')); + play(); await new Promise((resolve) => setTimeout(resolve, 10)); expect(state.get().playbackInitiated).toBe(true); - owners.set({ ...owners.get(), mediaElement: document.createElement('video') }); + state.set({ ...state.get(), presentation: { url: undefined } }); await new Promise((resolve) => setTimeout(resolve, 20)); expect(state.get().playbackInitiated).toBe(false); @@ -89,13 +137,13 @@ describe('trackPlaybackInitiated', () => { }); it('does not reset playbackInitiated on unrelated state changes', async () => { - const mediaElement = document.createElement('video'); + const { el, play } = makeMediaElement(); const { state, reactor } = setupTrackPlaybackInitiated( { presentation: { url: 'http://example.com/stream.m3u8' } }, - { mediaElement } + { mediaElement: el } ); - mediaElement.dispatchEvent(new Event('play')); + play(); await new Promise((resolve) => setTimeout(resolve, 10)); expect(state.get().playbackInitiated).toBe(true); @@ -107,15 +155,17 @@ describe('trackPlaybackInitiated', () => { }); it('does not re-attach listener on unrelated owner changes', async () => { - const mediaElement = document.createElement('video'); - const addEventListenerSpy = vi.spyOn(mediaElement, 'addEventListener'); + const { el } = makeMediaElement(); + const addEventListenerSpy = vi.spyOn(el, 'addEventListener'); const { owners, reactor } = setupTrackPlaybackInitiated( { presentation: { url: 'http://example.com/stream.m3u8' } }, - { mediaElement } + { mediaElement: el } ); + await new Promise((resolve) => setTimeout(resolve, 10)); const callsBefore = addEventListenerSpy.mock.calls.length; + owners.set({ ...owners.get(), videoBuffer: {} } as PlaybackInitiatedOwners & { videoBuffer?: unknown }); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -124,16 +174,15 @@ describe('trackPlaybackInitiated', () => { }); it('stops tracking after destroy', async () => { - const mediaElement = document.createElement('video'); + const { el, play } = makeMediaElement(); const { state, reactor } = setupTrackPlaybackInitiated( { presentation: { url: 'http://example.com/stream1.m3u8' } }, - { mediaElement } + { mediaElement: el } ); reactor.destroy(); - mediaElement.dispatchEvent(new Event('play')); - state.set({ ...state.get(), presentation: { url: 'http://example.com/stream2.m3u8' } }); + play(); await new Promise((resolve) => setTimeout(resolve, 20)); expect(state.get().playbackInitiated).toBeFalsy(); diff --git a/packages/spf/src/dom/features/track-playback-initiated.ts b/packages/spf/src/dom/features/track-playback-initiated.ts index f659262a3..fe4b30491 100644 --- a/packages/spf/src/dom/features/track-playback-initiated.ts +++ b/packages/spf/src/dom/features/track-playback-initiated.ts @@ -1,7 +1,7 @@ import { listen } from '@videojs/utils/dom'; import type { Reactor } from '../../core/create-reactor'; import { createReactor } from '../../core/create-reactor'; -import { computed, type Signal, signal, untrack, update } from '../../core/signals/primitives'; +import { computed, type Signal, untrack, update } from '../../core/signals/primitives'; /** * State shape for playback initiation tracking. @@ -26,28 +26,19 @@ export interface PlaybackInitiatedOwners { * ``` * 'preconditions-unmet' ──── element + URL ────→ 'monitoring' * ↑ ↑ │ - * │ preconditions lost │ play event + * │ preconditions lost │ play / !paused * │ │ ↓ * └────────────── 'playback-initiated' ←──────┘ * (exit cleanup resets state.playbackInitiated → false) - * URL change / element swap → 'monitoring' via deriveStatus * * any state ──── destroy() ────→ 'destroying' ────→ 'destroyed' * ``` */ type PlaybackInitiatedStatus = 'preconditions-unmet' | 'monitoring' | 'playback-initiated'; -function deriveStatus( - element: HTMLMediaElement | undefined, - url: string | undefined, - playbackInitiated: boolean | undefined, - capturedUrl: string | undefined, - capturedElement: HTMLMediaElement | undefined -): PlaybackInitiatedStatus { - if (!element || !url) return 'preconditions-unmet'; - if (playbackInitiated && url === capturedUrl && element === capturedElement) { - return 'playback-initiated'; - } +function deriveStatus(state: PlaybackInitiatedState, owners: PlaybackInitiatedOwners): PlaybackInitiatedStatus { + if (!owners.mediaElement || !state.presentation?.url) return 'preconditions-unmet'; + if (state.playbackInitiated) return 'playback-initiated'; return 'monitoring'; } @@ -57,9 +48,9 @@ function deriveStatus( * A three-state Reactor FSM driven by `state.playbackInitiated` and the * `deriveStatus` pattern: * - `'preconditions-unmet'` — no element or URL yet; no effects. - * - `'monitoring'` — listens for a `play` event; re-attaches when element changes. - * - `'playback-initiated'` — writes `true` to state; exit cleanup resets to `false` - * on any outbound transition (URL change, element swap, or lost preconditions). + * - `'monitoring'` — checks `!el.paused` on entry; listens for `play`. + * - `'playback-initiated'` — tracks element and URL; exit cleanup resets + * `state.playbackInitiated` to `false` on any change or lost preconditions. * * @example * const reactor = trackPlaybackInitiated({ state, owners }); @@ -73,30 +64,9 @@ export function trackPlaybackInitiated; owners: Signal; }): Reactor { - const presentationUrlSignal = computed(() => state.get().presentation?.url); + const derivedStatusSignal = computed(() => deriveStatus(state.get(), owners.get())); const mediaElementSignal = computed(() => owners.get().mediaElement); - const playbackInitiatedSignal = computed(() => state.get().playbackInitiated); - - // Captured at the moment playback is initiated — used by deriveStatus to detect - // URL changes or element swaps that should reset to 'monitoring'. - const capturedUrlSignal = signal(undefined); - const capturedElementSignal = signal(undefined); - - const derivedStatusSignal = computed(() => - deriveStatus( - mediaElementSignal.get(), - presentationUrlSignal.get(), - playbackInitiatedSignal.get(), - capturedUrlSignal.get(), - capturedElementSignal.get() - ) - ); - - function initiate() { - capturedUrlSignal.set(untrack(() => presentationUrlSignal.get())); - capturedElementSignal.set(untrack(() => mediaElementSignal.get())); - update(state, { playbackInitiated: true } as Partial); - } + const urlSignal = computed(() => state.get().presentation?.url); return createReactor({ initial: 'preconditions-unmet', @@ -111,23 +81,30 @@ export function trackPlaybackInitiated { - const el = mediaElementSignal.get(); // tracked - if (!el) return; - return listen(el, 'play', initiate); + const el = untrack(() => mediaElementSignal.get())!; + update(state, { playbackInitiated: !el.paused } as Partial); + return listen(el, 'play', () => { + update(state, { playbackInitiated: !el.paused } as Partial); + }); }, ], 'playback-initiated': [ - // Enter-once: write true to state. - // Exit cleanup: reset to false on any outbound transition — URL change, - // element swap, or lost preconditions all go through here. + // Reactive: tracks element and URL while initiated. When either changes, + // the effect re-runs — the exit cleanup fires first, resetting + // state.playbackInitiated to false. deriveStatus then returns 'monitoring' + // on the next microtask, and the always monitor drives the transition. + // + // This covers both the preconditions-lost path (element/URL → undefined, + // which also triggers a deriveStatus → 'preconditions-unmet' transition) + // and the URL-change / element-swap path (preconditions still met but + // values changed, handled entirely by this effect's cleanup). () => { - update(state, { playbackInitiated: true } as Partial); - return () => { - update(state, { playbackInitiated: false } as Partial); - }; + mediaElementSignal.get(); // tracked — re-run on element change + urlSignal.get(); // tracked — re-run on URL change + return () => update(state, { playbackInitiated: false } as Partial); }, ], }, From 651cbc0c4ad090d546a9b0e9e399b610e5cb4344 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 11:35:27 -0700 Subject: [PATCH 39/79] feat(spf): add entry/reactions split to createReactor state definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the flat ReactorEffectFn[] per-state array with a ReactorStateDefinition object distinguishing entry and reactions effects: - entry: fn body is automatically untracked — no untrack() calls needed inside. Runs once on state entry; cleanup fires on exit. - reactions: fn body is tracked normally; re-runs when read signals change. Use untrack() explicitly for reads you do not want to track. Updates all existing reactors (trackPlaybackInitiated, syncTextTracks, loadTextTrackCues, resolvePresentation) and createReactor tests. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 86 ++++--- .../src/core/features/resolve-presentation.ts | 47 ++-- .../spf/src/core/tests/create-reactor.test.ts | 223 +++++++++++------- .../src/dom/features/load-text-track-cues.ts | 68 +++--- .../spf/src/dom/features/sync-text-tracks.ts | 123 +++++----- .../dom/features/track-playback-initiated.ts | 43 ++-- 6 files changed, 354 insertions(+), 236 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index 0c333a270..6fe5c4629 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -9,7 +9,6 @@ import { signal, untrack, update } from './signals/primitives'; /** * A single effect function within a reactor state. * - * Called when the reactor enters or re-evaluates the state it belongs to. * May return a cleanup function that runs before each re-evaluation and on * state exit (including destroy). */ @@ -19,6 +18,23 @@ export type ReactorEffectFn = setContext: (next: Context) => void; }) => (() => void) | { abort(): void } | void; +/** + * Per-state effect grouping for a single reactor state. + * + * - `entry` effects run once on state entry. The fn body is automatically + * untracked — no `untrack()` calls are needed inside. Use this for + * one-time setup: reading current values, attaching event listeners, etc. + * - `reactions` effects run on state entry and re-run whenever a signal read + * inside the fn body changes. Use `untrack()` for reads you do not want to + * track. Use this for work that must stay in sync with reactive state. + * + * Both arrays are optional; pass `{}` for states with no effects. + */ +export type ReactorStateDefinition = { + entry?: ReactorEffectFn[]; + reactions?: ReactorEffectFn[]; +}; + /** * A cross-cutting effect function that runs in every non-terminal state. * @@ -53,12 +69,11 @@ export type ReactorDefinition */ always?: ReactorAlwaysEffectFn[]; /** - * Per-state effect arrays. Every valid status must be declared — use an empty - * array for states with no effects. Each element becomes one independent - * `effect()` call gated on that state, with its own dependency tracking and - * cleanup lifecycle. + * Per-state effect groupings. Every valid status must be declared — pass `{}` + * for states with no effects. `entry` and `reactions` each become independent + * `effect()` calls gated on that state, with their own cleanup lifecycles. */ - states: Record[]>; + states: Record>; }; // ============================================================================= @@ -99,13 +114,13 @@ export type Reactor = SignalActor * } * ], * states: { - * active: [ - * // State-specific work with its own cleanup lifecycle. - * () => { - * const unsub = subscribe(valueSignal.get(), handler); - * return () => unsub(); - * } - * ] + * active: { + * // entry: runs once on state entry; fn body is automatically untracked. + * entry: [() => listen(el, 'play', handler)], + * // reactions: re-runs whenever tracked signals change. + * reactions: [() => { currentTimeSignal.get(); return cleanup; }], + * }, + * idle: {}, // no effects * } * }); */ @@ -166,24 +181,41 @@ export function createReactor } // Per-state effects — each is gated on its matching status. - for (const [state, fns] of Object.entries(def.states) as Array< - [UserStatus, ReactorEffectFn[]] + // `entry` effects wrap the fn call in `untrack()` so the fn body creates no + // reactive dependencies; they run once on state entry and clean up on exit. + // `reactions` effects leave the fn body tracked normally. + for (const [state, stateDef] of Object.entries(def.states) as Array< + [UserStatus, ReactorStateDefinition] >) { - for (const fn of fns) { + const makeCtx = (snapshot: ActorSnapshot) => ({ + transition: (to: UserStatus) => { + if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; + transition(to as FullStatus); + }, + context: snapshot.context, + setContext: (context: Context) => { + if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; + setContext(context); + }, + }); + + for (const fn of stateDef.entry ?? []) { + const dispose = effect(() => { + const snapshot = snapshotSignal.get(); + if (snapshot.status !== state) return; + const result = untrack(() => fn(makeCtx(snapshot))); + if (!result) return undefined; + if (typeof result === 'function') return result; + return () => result.abort(); + }); + effectDisposals.push(dispose); + } + + for (const fn of stateDef.reactions ?? []) { const dispose = effect(() => { const snapshot = snapshotSignal.get(); if (snapshot.status !== state) return; - const result = fn({ - transition: (to: UserStatus) => { - if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; - transition(to as FullStatus); - }, - context: snapshot.context, - setContext: (context: Context) => { - if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; - setContext(context); - }, - }); + const result = fn(makeCtx(snapshot)); if (!result) return undefined; if (typeof result === 'function') return result; return () => result.abort(); diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index e0ce2a045..ffb5e5bd1 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -2,7 +2,7 @@ import { fetchResolvable, getResponseText } from '../../dom/network/fetch'; import type { Reactor } from '../create-reactor'; import { createReactor } from '../create-reactor'; import { parseMultivariantPlaylist } from '../hls/parse-multivariant'; -import { computed, type Signal, untrack, update } from '../signals/primitives'; +import { computed, type Signal, update } from '../signals/primitives'; import type { AddressableObject, Presentation } from '../types'; /** @@ -103,29 +103,32 @@ export function resolvePresentation({ }, ], states: { - 'preconditions-unmet': [], - idle: [], - resolving: [ - // Fetch task — returns the AbortController so the framework aborts on exit. - () => { - const presentation = untrack(() => state.get().presentation) as UnresolvedPresentation; - const ac = new AbortController(); + 'preconditions-unmet': {}, + idle: {}, + resolving: { + // Entry: start fetch on state entry; return AbortController so the + // framework aborts the in-flight request on state exit. + entry: [ + () => { + const presentation = state.get().presentation as UnresolvedPresentation; + const ac = new AbortController(); - fetchResolvable(presentation, { signal: ac.signal }) - .then((response) => getResponseText(response)) - .then((text) => { - const parsed = parseMultivariantPlaylist(text, presentation); - update(state, { presentation: parsed } as Partial); - }) - .catch((error) => { - if (error instanceof Error && error.name === 'AbortError') return; - throw error; - }); + fetchResolvable(presentation, { signal: ac.signal }) + .then((response) => getResponseText(response)) + .then((text) => { + const parsed = parseMultivariantPlaylist(text, presentation); + update(state, { presentation: parsed } as Partial); + }) + .catch((error) => { + if (error instanceof Error && error.name === 'AbortError') return; + throw error; + }); - return ac; - }, - ], - resolved: [], + return ac; + }, + ], + }, + resolved: {}, }, }); } diff --git a/packages/spf/src/core/tests/create-reactor.test.ts b/packages/spf/src/core/tests/create-reactor.test.ts index d4a3fba34..0798e7aaf 100644 --- a/packages/spf/src/core/tests/create-reactor.test.ts +++ b/packages/spf/src/core/tests/create-reactor.test.ts @@ -14,7 +14,7 @@ describe('createReactor', () => { const reactor = createReactor({ initial: 'idle' as const, context: { value: 0 }, - states: { idle: [] }, + states: { idle: {} }, }); expect(reactor.snapshot.get().status).toBe('idle'); @@ -23,12 +23,23 @@ describe('createReactor', () => { reactor.destroy(); }); - it('runs the effect for the initial state on creation', () => { + it('runs the entry effect for the initial state on creation', () => { const fn = vi.fn(); createReactor({ initial: 'idle' as const, context: {}, - states: { idle: [fn] }, + states: { idle: { entry: [fn] } }, + }).destroy(); + + expect(fn).toHaveBeenCalledOnce(); + }); + + it('runs the reaction effect for the initial state on creation', () => { + const fn = vi.fn(); + createReactor({ + initial: 'idle' as const, + context: {}, + states: { idle: { reactions: [fn] } }, }).destroy(); expect(fn).toHaveBeenCalledOnce(); @@ -40,25 +51,27 @@ describe('createReactor', () => { initial: 'idle', context: {}, states: { - idle: [], - other: [otherFn], + idle: {}, + other: { entry: [otherFn] }, }, }).destroy(); expect(otherFn).not.toHaveBeenCalled(); }); - it('passes transition, context, and setContext to effect fns', () => { + it('passes transition, context, and setContext to entry effect fns', () => { let captured: unknown; createReactor({ initial: 'idle' as const, context: { x: 1 }, states: { - idle: [ - (ctx) => { - captured = ctx; - }, - ], + idle: { + entry: [ + (ctx) => { + captured = ctx; + }, + ], + }, }, }).destroy(); @@ -67,18 +80,20 @@ describe('createReactor', () => { expect(typeof (captured as { setContext: unknown }).setContext).toBe('function'); }); - it('transitions status via transition()', async () => { + it('transitions status via transition() in a reaction', async () => { const src = signal(false); const reactor = createReactor<'waiting' | 'active', object>({ initial: 'waiting', context: {}, states: { - waiting: [ - ({ transition }) => { - if (src.get()) transition('active'); - }, - ], - active: [], + waiting: { + reactions: [ + ({ transition }) => { + if (src.get()) transition('active'); + }, + ], + }, + active: {}, }, }); @@ -100,12 +115,14 @@ describe('createReactor', () => { initial: 'waiting', context: {}, states: { - waiting: [ - ({ transition }) => { - if (src.get()) transition('active'); - }, - ], - active: [activeFn], + waiting: { + reactions: [ + ({ transition }) => { + if (src.get()) transition('active'); + }, + ], + }, + active: { entry: [activeFn] }, }, }); @@ -126,12 +143,14 @@ describe('createReactor', () => { initial: 'idle' as const, context: { count: 0 }, states: { - idle: [ - ({ context, setContext }) => { - captured = context.count; - setContext({ count: context.count + 1 }); - }, - ], + idle: { + entry: [ + ({ context, setContext }) => { + captured = context.count; + setContext({ count: context.count + 1 }); + }, + ], + }, }, }); @@ -142,7 +161,7 @@ describe('createReactor', () => { reactor.destroy(); }); - it('multiple effects in the same state run independently', () => { + it('multiple entry effects in the same state run independently', () => { const fn1 = vi.fn(); const fn2 = vi.fn(); const fn3 = vi.fn(); @@ -150,7 +169,7 @@ describe('createReactor', () => { createReactor({ initial: 'idle' as const, context: {}, - states: { idle: [fn1, fn2, fn3] }, + states: { idle: { entry: [fn1, fn2, fn3] } }, }).destroy(); expect(fn1).toHaveBeenCalledOnce(); @@ -158,7 +177,7 @@ describe('createReactor', () => { expect(fn3).toHaveBeenCalledOnce(); }); - it('re-runs only the effect whose dependency changed', async () => { + it('re-runs only the reaction whose dependency changed', async () => { const src1 = signal(0); const src2 = signal(0); const fn1 = vi.fn(() => { @@ -171,7 +190,7 @@ describe('createReactor', () => { const reactor = createReactor({ initial: 'idle' as const, context: {}, - states: { idle: [fn1, fn2] }, + states: { idle: { reactions: [fn1, fn2] } }, }); expect(fn1).toHaveBeenCalledOnce(); @@ -186,18 +205,42 @@ describe('createReactor', () => { reactor.destroy(); }); + it('entry effect does not re-run when a signal read inside it changes', async () => { + const src = signal(0); + const fn = vi.fn(() => { + src.get(); // read inside entry — should NOT create a reactive dep + }); + + const reactor = createReactor({ + initial: 'idle' as const, + context: {}, + states: { idle: { entry: [fn] } }, + }); + + expect(fn).toHaveBeenCalledOnce(); + + src.set(1); + await tick(); + + expect(fn).toHaveBeenCalledOnce(); // NOT re-run + + reactor.destroy(); + }); + it('snapshot is reactive', async () => { const src = signal(false); const reactor = createReactor<'waiting' | 'active', object>({ initial: 'waiting', context: {}, states: { - waiting: [ - ({ transition }) => { - if (src.get()) transition('active'); - }, - ], - active: [], + waiting: { + reactions: [ + ({ transition }) => { + if (src.get()) transition('active'); + }, + ], + }, + active: {}, }, }); @@ -218,7 +261,7 @@ describe('createReactor', () => { // ============================================================================= describe('createReactor — cleanup', () => { - it('calls the effect cleanup on state exit', async () => { + it('calls the entry effect cleanup on state exit', async () => { const src = signal(false); const cleanup = vi.fn(); @@ -226,13 +269,20 @@ describe('createReactor — cleanup', () => { initial: 'active', context: {}, states: { - active: [ - ({ transition }) => { - if (src.get()) transition('done'); - return cleanup; - }, - ], - done: [], + active: { + // entry: cleanup fires on exit regardless of whether fn is tracked + entry: [ + () => { + return cleanup; + }, + ], + reactions: [ + ({ transition }) => { + if (src.get()) transition('done'); + }, + ], + }, + done: {}, }, }); @@ -246,7 +296,7 @@ describe('createReactor — cleanup', () => { reactor.destroy(); }); - it('calls the effect cleanup before re-running when a dependency changes', async () => { + it('calls the reaction cleanup before re-running when a dependency changes', async () => { const src = signal(0); const cleanup = vi.fn(); @@ -254,12 +304,14 @@ describe('createReactor — cleanup', () => { initial: 'idle' as const, context: {}, states: { - idle: [ - () => { - src.get(); - return cleanup; - }, - ], + idle: { + reactions: [ + () => { + src.get(); + return cleanup; + }, + ], + }, }, }); @@ -274,19 +326,24 @@ describe('createReactor — cleanup', () => { }); it('calls effect cleanups on destroy()', () => { - const cleanup = vi.fn(); + const entryCleanup = vi.fn(); + const reactionCleanup = vi.fn(); const reactor = createReactor({ initial: 'idle' as const, context: {}, states: { - idle: [() => cleanup], + idle: { + entry: [() => entryCleanup], + reactions: [() => reactionCleanup], + }, }, }); reactor.destroy(); - expect(cleanup).toHaveBeenCalledOnce(); + expect(entryCleanup).toHaveBeenCalledOnce(); + expect(reactionCleanup).toHaveBeenCalledOnce(); }); it('does not call cleanup for inactive state effects on destroy()', () => { @@ -297,8 +354,8 @@ describe('createReactor — cleanup', () => { initial: 'idle', context: {}, states: { - idle: [() => activeCleanup], - other: [() => inactiveCleanup], + idle: { entry: [() => activeCleanup] }, + other: { entry: [() => inactiveCleanup] }, }, }).destroy(); @@ -318,7 +375,7 @@ describe('createReactor — always', () => { initial: 'idle' as const, context: {}, always: [fn], - states: { idle: [] }, + states: { idle: {} }, }).destroy(); expect(fn).toHaveBeenCalledOnce(); @@ -334,7 +391,7 @@ describe('createReactor — always', () => { capturedStatus = status; }, ], - states: { idle: [] }, + states: { idle: {} }, }).destroy(); expect(capturedStatus).toBe('idle'); @@ -353,12 +410,14 @@ describe('createReactor — always', () => { }, ], states: { - waiting: [ - ({ transition }) => { - if (src.get()) transition('active'); - }, - ], - active: [], + waiting: { + reactions: [ + ({ transition }) => { + if (src.get()) transition('active'); + }, + ], + }, + active: {}, }, }); @@ -384,7 +443,7 @@ describe('createReactor — always', () => { if (src.get() && status === 'waiting') transition('active'); }, ], - states: { waiting: [], active: [] }, + states: { waiting: {}, active: {} }, }); expect(reactor.snapshot.get().status).toBe('waiting'); @@ -406,12 +465,14 @@ describe('createReactor — always', () => { context: {}, always: [() => cleanup], states: { - waiting: [ - ({ transition }) => { - if (src.get()) transition('active'); - }, - ], - active: [], + waiting: { + reactions: [ + ({ transition }) => { + if (src.get()) transition('active'); + }, + ], + }, + active: {}, }, }); @@ -431,7 +492,7 @@ describe('createReactor — always', () => { initial: 'idle' as const, context: {}, always: [fn], - states: { idle: [] }, + states: { idle: {} }, }); fn.mockClear(); @@ -449,7 +510,7 @@ describe('createReactor — always', () => { initial: 'idle' as const, context: {}, always: [alwaysFn], - states: { idle: [stateFn] }, + states: { idle: { entry: [stateFn] } }, }).destroy(); expect(alwaysFn).toHaveBeenCalledOnce(); @@ -466,7 +527,7 @@ describe('createReactor — destroy', () => { const reactor = createReactor({ initial: 'idle' as const, context: {}, - states: { idle: [] }, + states: { idle: {} }, }); reactor.destroy(); @@ -478,7 +539,7 @@ describe('createReactor — destroy', () => { const reactor = createReactor({ initial: 'idle' as const, context: {}, - states: { idle: [] }, + states: { idle: {} }, }); reactor.destroy(); @@ -486,7 +547,7 @@ describe('createReactor — destroy', () => { expect(reactor.snapshot.get().status).toBe('destroyed'); }); - it('does not run effects after destroy()', async () => { + it('does not run reactions after destroy()', async () => { const src = signal(0); const fn = vi.fn(() => { src.get(); @@ -495,7 +556,7 @@ describe('createReactor — destroy', () => { const reactor = createReactor({ initial: 'idle' as const, context: {}, - states: { idle: [fn] }, + states: { idle: { reactions: [fn] } }, }); reactor.destroy(); diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index a8ea89357..800ec93f5 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -151,38 +151,48 @@ export function loadTextTrackCues { - // Defensive reset before creating fresh actors (no-op if already undefined). - teardownActors(owners); - }, - ], + 'preconditions-unmet': { + // Entry: defensive actor reset on state entry (no-op if already undefined). + entry: [ + () => { + teardownActors(owners); + }, + ], + }, - 'setting-up': [ - () => { - // Defensive reset before creating fresh actors (no-op if already undefined). - teardownActors(owners); - const mediaElement = untrack(() => owners.get().mediaElement as HTMLMediaElement); - const textTracksActor = createTextTracksActor(mediaElement); - const segmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor); - update(owners, { textTracksActor, segmentLoaderActor } as Partial); - }, - ], + 'setting-up': { + // Entry: reset any stale actors, then create fresh ones and write to owners. + // The fn body is automatically untracked — no untrack() needed for mediaElement. + entry: [ + () => { + teardownActors(owners); + const mediaElement = owners.get().mediaElement as HTMLMediaElement; + const textTracksActor = createTextTracksActor(mediaElement); + const segmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor); + update(owners, { textTracksActor, segmentLoaderActor } as Partial); + }, + ], + }, - pending: [], + pending: {}, - 'monitoring-for-loads': [ - () => { - const currentTime = currentTimeSignal.get(); - const track = selectedTrackSignal.get()!; - // deriveStatus guarantees segmentLoaderActor is in owners and findSelectedTrack - // returns a valid resolved track when in this state. The always monitor - // (registered before this effect) transitions us out before this re-runs - // if either invariant ever stops holding. - const { segmentLoaderActor } = untrack(() => owners.get()); - segmentLoaderActor!.send({ type: 'load', track, currentTime }); - }, - ], + 'monitoring-for-loads': { + // Reaction: re-runs whenever currentTime or selectedTrack changes, dispatching + // a load message to the segment loader. owners is read with untrack() since + // actor presence is guaranteed by deriveStatus when in this state. + reactions: [ + () => { + const currentTime = currentTimeSignal.get(); + const track = selectedTrackSignal.get()!; + // deriveStatus guarantees segmentLoaderActor is in owners and findSelectedTrack + // returns a valid resolved track when in this state. The always monitor + // (registered before this effect) transitions us out before this re-runs + // if either invariant ever stops holding. + const { segmentLoaderActor } = untrack(() => owners.get()); + segmentLoaderActor!.send({ type: 'load', track, currentTime }); + }, + ], + }, }, }); } diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index 743fb6823..8773bc49d 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -118,65 +118,72 @@ export function syncTextTracks elements on entry; exit cleanup removes them - // and clears selectedTextTrackId on any outbound transition. - () => { - const mediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); - const modelTextTracks = untrack(() => modelTextTracksSignal.get() as PartiallyResolvedTextTrack[]); - modelTextTracks.forEach((track) => mediaElement.appendChild(createTrackElement(track))); - return () => { - mediaElement - .querySelectorAll('track[data-src-track]:is([kind="subtitles"],[kind="captions"]') - .forEach((trackEl) => trackEl.remove()); - update(state, { selectedTextTrackId: undefined } as Partial); - }; - }, - - // Effect 2 — mode sync + DOM change listener (independent tracking/cleanup). - () => { - const mediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); - const selectedId = selectedTextTrackIdSignal.get(); - - syncModes(mediaElement.textTracks, selectedId); - let syncTimeout: ReturnType | undefined = setTimeout(() => { - syncTimeout = undefined; - }, 0); - - const onChange = () => { - if (syncTimeout) { - // Inside the settling window: browser auto-selection is overriding our - // modes. Re-apply to restore the intended state without touching state. - // change events are queued as tasks (async), so no re-entrancy risk. - syncModes( - mediaElement.textTracks, - untrack(() => selectedTextTrackIdSignal.get()) + 'preconditions-unmet': {}, + + 'set-up': { + // Entry: create elements once on state entry; exit cleanup removes + // them and clears selectedTextTrackId on any outbound transition. + // The fn body is automatically untracked — no untrack() needed. + entry: [ + () => { + const mediaElement = mediaElementSignal.get() as HTMLMediaElement; + const modelTextTracks = modelTextTracksSignal.get() as PartiallyResolvedTextTrack[]; + modelTextTracks.forEach((track) => mediaElement.appendChild(createTrackElement(track))); + return () => { + mediaElement + .querySelectorAll('track[data-src-track]:is([kind="subtitles"],[kind="captions"]') + .forEach((trackEl) => trackEl.remove()); + update(state, { selectedTextTrackId: undefined } as Partial); + }; + }, + ], + + // Reaction: mode sync + DOM change listener; re-runs when selectedId changes. + // mediaElement is read with untrack() since element changes go through the + // always monitor (preconditions-unmet path), not this effect. + reactions: [ + () => { + const mediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); + const selectedId = selectedTextTrackIdSignal.get(); + + syncModes(mediaElement.textTracks, selectedId); + let syncTimeout: ReturnType | undefined = setTimeout(() => { + syncTimeout = undefined; + }, 0); + + const onChange = () => { + if (syncTimeout) { + // Inside the settling window: browser auto-selection is overriding our + // modes. Re-apply to restore the intended state without touching state. + // change events are queued as tasks (async), so no re-entrancy risk. + syncModes( + mediaElement.textTracks, + untrack(() => selectedTextTrackIdSignal.get()) + ); + return; + } + + const showingTrack = Array.from(mediaElement.textTracks).find( + (t) => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions') ); - return; - } - - const showingTrack = Array.from(mediaElement.textTracks).find( - (t) => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions') - ); - - // showingTrack.id matches the SPF track ID set by createTrackElement above. - // Fall back to undefined for empty-string IDs (non-SPF-managed tracks). - const newId = showingTrack?.id; - const currentModelId = untrack(() => selectedTextTrackIdSignal.get()); - if (newId === currentModelId) return; - update(state, { selectedTextTrackId: newId } as Partial); - }; - - const unlisten = listen(mediaElement.textTracks, 'change', onChange); - - return () => { - clearTimeout(syncTimeout ?? undefined); - unlisten(); - }; - }, - ], + + // showingTrack.id matches the SPF track ID set by createTrackElement above. + // Fall back to undefined for empty-string IDs (non-SPF-managed tracks). + const newId = showingTrack?.id; + const currentModelId = untrack(() => selectedTextTrackIdSignal.get()); + if (newId === currentModelId) return; + update(state, { selectedTextTrackId: newId } as Partial); + }; + + const unlisten = listen(mediaElement.textTracks, 'change', onChange); + + return () => { + clearTimeout(syncTimeout ?? undefined); + unlisten(); + }; + }, + ], + }, }, }); } diff --git a/packages/spf/src/dom/features/track-playback-initiated.ts b/packages/spf/src/dom/features/track-playback-initiated.ts index fe4b30491..ae0ac1565 100644 --- a/packages/spf/src/dom/features/track-playback-initiated.ts +++ b/packages/spf/src/dom/features/track-playback-initiated.ts @@ -1,7 +1,7 @@ import { listen } from '@videojs/utils/dom'; import type { Reactor } from '../../core/create-reactor'; import { createReactor } from '../../core/create-reactor'; -import { computed, type Signal, untrack, update } from '../../core/signals/primitives'; +import { computed, type Signal, update } from '../../core/signals/primitives'; /** * State shape for playback initiation tracking. @@ -78,21 +78,24 @@ export function trackPlaybackInitiated { - const el = untrack(() => mediaElementSignal.get())!; - update(state, { playbackInitiated: !el.paused } as Partial); - return listen(el, 'play', () => { + monitoring: { + // Entry: check if already playing; otherwise listen for play. + // The fn body is automatically untracked — el is read at entry time only. + entry: [ + () => { + const el = mediaElementSignal.get()!; update(state, { playbackInitiated: !el.paused } as Partial); - }); - }, - ], + return listen(el, 'play', () => { + update(state, { playbackInitiated: !el.paused } as Partial); + }); + }, + ], + }, - 'playback-initiated': [ - // Reactive: tracks element and URL while initiated. When either changes, + 'playback-initiated': { + // Reaction: tracks element and URL while initiated. When either changes, // the effect re-runs — the exit cleanup fires first, resetting // state.playbackInitiated to false. deriveStatus then returns 'monitoring' // on the next microtask, and the always monitor drives the transition. @@ -101,12 +104,14 @@ export function trackPlaybackInitiated { - mediaElementSignal.get(); // tracked — re-run on element change - urlSignal.get(); // tracked — re-run on URL change - return () => update(state, { playbackInitiated: false } as Partial); - }, - ], + reactions: [ + () => { + mediaElementSignal.get(); // tracked — re-run on element change + urlSignal.get(); // tracked — re-run on URL change + return () => update(state, { playbackInitiated: false } as Partial); + }, + ], + }, }, }); } From e4c838517aef56a7bf404580f86ca754a099ff8a Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 11:40:04 -0700 Subject: [PATCH 40/79] refactor(spf): unify ReactorEffectFn and lift makeCtx/wrapResult in createReactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds status to ReactorEffectFn ctx (matching what always effects already received), making ReactorAlwaysEffectFn redundant — removed. makeCtx and wrapResult are now shared closures used by all three effect loops (always, entry, reactions), eliminating the duplicated inline ctx construction. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 84 +++++++++---------------- 1 file changed, 31 insertions(+), 53 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index 6fe5c4629..2d11eafb4 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -7,12 +7,17 @@ import { signal, untrack, update } from './signals/primitives'; // ============================================================================= /** - * A single effect function within a reactor state. + * An effect function used in reactor states and `always` blocks. * - * May return a cleanup function that runs before each re-evaluation and on - * state exit (including destroy). + * Receives `status` (the current reactor status), `transition`, `context`, and + * `setContext`. May return a cleanup function that runs before each re-evaluation + * and on state exit (including destroy). + * + * In `always` blocks, `status` reflects whichever non-terminal state is active. + * In per-state `entry`/`reactions` blocks, `status` is always that state's value. */ export type ReactorEffectFn = (ctx: { + status: UserStatus; transition: (to: UserStatus) => void; context: Context; setContext: (next: Context) => void; @@ -35,20 +40,6 @@ export type ReactorStateDefinition[]; }; -/** - * A cross-cutting effect function that runs in every non-terminal state. - * - * Identical to `ReactorEffectFn` except the current `status` is also passed, - * allowing a single effect to monitor conditions and drive transitions from - * any state without duplicating the same guard in every state block. - */ -export type ReactorAlwaysEffectFn = (ctx: { - status: UserStatus; - transition: (to: UserStatus) => void; - context: Context; - setContext: (next: Context) => void; -}) => (() => void) | { abort(): void } | void; - /** * Full reactor definition passed to `createReactor`. * @@ -67,7 +58,7 @@ export type ReactorDefinition * status or context change, with the current `status` available in ctx. * Useful for condition monitors that apply uniformly across all states. */ - always?: ReactorAlwaysEffectFn[]; + always?: ReactorEffectFn[]; /** * Per-state effect groupings. Every valid status must be declared — pass `{}` * for states with no effects. `entry` and `reactions` each become independent @@ -146,6 +137,25 @@ export function createReactor const effectDisposals: Array<() => void> = []; + const makeCtx = (snapshot: ActorSnapshot) => ({ + status: snapshot.status as UserStatus, + transition: (to: UserStatus) => { + if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; + transition(to as FullStatus); + }, + context: snapshot.context, + setContext: (context: Context) => { + if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; + setContext(context); + }, + }); + + const wrapResult = (result: ReturnType>) => { + if (!result) return undefined; + if (typeof result === 'function') return result; + return () => result.abort(); + }; + // 'always' effects run in every non-terminal state — processed first so that // cross-cutting condition monitors fire before per-state effects in the // initial synchronous run AND on every subsequent re-run. @@ -161,21 +171,7 @@ export function createReactor const dispose = effect(() => { const snapshot = snapshotSignal.get(); if (snapshot.status === 'destroying' || snapshot.status === 'destroyed') return; - const result = fn({ - status: snapshot.status as UserStatus, - transition: (to: UserStatus) => { - if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; - transition(to as FullStatus); - }, - context: snapshot.context, - setContext: (context: Context) => { - if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; - setContext(context); - }, - }); - if (!result) return undefined; - if (typeof result === 'function') return result; - return () => result.abort(); + return wrapResult(fn(makeCtx(snapshot))); }); effectDisposals.push(dispose); } @@ -187,26 +183,11 @@ export function createReactor for (const [state, stateDef] of Object.entries(def.states) as Array< [UserStatus, ReactorStateDefinition] >) { - const makeCtx = (snapshot: ActorSnapshot) => ({ - transition: (to: UserStatus) => { - if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; - transition(to as FullStatus); - }, - context: snapshot.context, - setContext: (context: Context) => { - if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; - setContext(context); - }, - }); - for (const fn of stateDef.entry ?? []) { const dispose = effect(() => { const snapshot = snapshotSignal.get(); if (snapshot.status !== state) return; - const result = untrack(() => fn(makeCtx(snapshot))); - if (!result) return undefined; - if (typeof result === 'function') return result; - return () => result.abort(); + return wrapResult(untrack(() => fn(makeCtx(snapshot)))); }); effectDisposals.push(dispose); } @@ -215,10 +196,7 @@ export function createReactor const dispose = effect(() => { const snapshot = snapshotSignal.get(); if (snapshot.status !== state) return; - const result = fn(makeCtx(snapshot)); - if (!result) return undefined; - if (typeof result === 'function') return result; - return () => result.abort(); + return wrapResult(fn(makeCtx(snapshot))); }); effectDisposals.push(dispose); } From 6a96c4cddbf5ab696b578eea719188251392dba4 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 11:42:27 -0700 Subject: [PATCH 41/79] refactor(spf): extract registerEffects helper in createReactor The three effect registration loops (always, entry, reactions) shared the same effect() + snapshotSignal + wrapResult pattern, varying only in the skip predicate and whether the fn call is untracked. registerEffects parameterizes both, collapsing all three loops to two lines each. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 54 +++++++++++++------------ 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index 2d11eafb4..32ee54262 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -156,6 +156,29 @@ export function createReactor return () => result.abort(); }; + // Registers each fn as an independent effect. The effect reads snapshotSignal, + // skips if shouldSkip returns true, then calls fn — untracked for entry effects + // (so only snapshotSignal is tracked), tracked for reactions and always effects. + const registerEffects = ( + fns: ReactorEffectFn[], + shouldSkip: (snapshot: ActorSnapshot) => boolean, + untracked = false + ) => { + for (const fn of fns) { + effectDisposals.push( + effect(() => { + const snapshot = snapshotSignal.get(); + if (shouldSkip(snapshot)) return; + const call = () => fn(makeCtx(snapshot)); + return wrapResult(untracked ? untrack(call) : call()); + }) + ); + } + }; + + const isTerminal = (snapshot: ActorSnapshot) => + snapshot.status === 'destroying' || snapshot.status === 'destroyed'; + // 'always' effects run in every non-terminal state — processed first so that // cross-cutting condition monitors fire before per-state effects in the // initial synchronous run AND on every subsequent re-run. @@ -167,39 +190,18 @@ export function createReactor // This is load-bearing: it means a transition triggered by an 'always' monitor // takes effect before the per-state effects of the (now-exited) state re-run, // so per-state effects can rely on the invariants established by 'always'. - for (const fn of def.always ?? []) { - const dispose = effect(() => { - const snapshot = snapshotSignal.get(); - if (snapshot.status === 'destroying' || snapshot.status === 'destroyed') return; - return wrapResult(fn(makeCtx(snapshot))); - }); - effectDisposals.push(dispose); - } + registerEffects(def.always ?? [], isTerminal); // Per-state effects — each is gated on its matching status. - // `entry` effects wrap the fn call in `untrack()` so the fn body creates no + // `entry` effects are registered with untracked=true so the fn body creates no // reactive dependencies; they run once on state entry and clean up on exit. // `reactions` effects leave the fn body tracked normally. for (const [state, stateDef] of Object.entries(def.states) as Array< [UserStatus, ReactorStateDefinition] >) { - for (const fn of stateDef.entry ?? []) { - const dispose = effect(() => { - const snapshot = snapshotSignal.get(); - if (snapshot.status !== state) return; - return wrapResult(untrack(() => fn(makeCtx(snapshot)))); - }); - effectDisposals.push(dispose); - } - - for (const fn of stateDef.reactions ?? []) { - const dispose = effect(() => { - const snapshot = snapshotSignal.get(); - if (snapshot.status !== state) return; - return wrapResult(fn(makeCtx(snapshot))); - }); - effectDisposals.push(dispose); - } + const isNotState = (snapshot: ActorSnapshot) => snapshot.status !== state; + registerEffects(stateDef.entry ?? [], isNotState, true); + registerEffects(stateDef.reactions ?? [], isNotState); } return { From e7da5d6115bf8051972c90b15a226dd27ec42c17 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 11:49:00 -0700 Subject: [PATCH 42/79] feat(spf): allow single-fn shorthand for always/entry/reactions in createReactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entry, reactions, and always now accept a single ReactorEffectFn or an array, matching the user's change to registerEffects. Also fixes a normalization bug in that change: fnOrfns ?? [] evaluates to [] when fnOrfns is undefined, so Array.isArray returned true and fns was assigned undefined rather than []. Replaced with a clear three-way check: undefined → [], array → array, fn → [fn]. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index 32ee54262..7e9cf2b61 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -36,8 +36,8 @@ export type ReactorEffectFn = * Both arrays are optional; pass `{}` for states with no effects. */ export type ReactorStateDefinition = { - entry?: ReactorEffectFn[]; - reactions?: ReactorEffectFn[]; + entry?: ReactorEffectFn | ReactorEffectFn[]; + reactions?: ReactorEffectFn | ReactorEffectFn[]; }; /** @@ -58,7 +58,7 @@ export type ReactorDefinition * status or context change, with the current `status` available in ctx. * Useful for condition monitors that apply uniformly across all states. */ - always?: ReactorEffectFn[]; + always?: ReactorEffectFn | ReactorEffectFn[]; /** * Per-state effect groupings. Every valid status must be declared — pass `{}` * for states with no effects. `entry` and `reactions` each become independent @@ -160,10 +160,11 @@ export function createReactor // skips if shouldSkip returns true, then calls fn — untracked for entry effects // (so only snapshotSignal is tracked), tracked for reactions and always effects. const registerEffects = ( - fns: ReactorEffectFn[], + effects: ReactorEffectFn | ReactorEffectFn[] | undefined, shouldSkip: (snapshot: ActorSnapshot) => boolean, untracked = false ) => { + const fns = effects === undefined ? [] : Array.isArray(effects) ? effects : [effects]; for (const fn of fns) { effectDisposals.push( effect(() => { @@ -190,7 +191,7 @@ export function createReactor // This is load-bearing: it means a transition triggered by an 'always' monitor // takes effect before the per-state effects of the (now-exited) state re-run, // so per-state effects can rely on the invariants established by 'always'. - registerEffects(def.always ?? [], isTerminal); + registerEffects(def.always, isTerminal); // Per-state effects — each is gated on its matching status. // `entry` effects are registered with untracked=true so the fn body creates no @@ -200,8 +201,8 @@ export function createReactor [UserStatus, ReactorStateDefinition] >) { const isNotState = (snapshot: ActorSnapshot) => snapshot.status !== state; - registerEffects(stateDef.entry ?? [], isNotState, true); - registerEffects(stateDef.reactions ?? [], isNotState); + registerEffects(stateDef.entry, isNotState, true); + registerEffects(stateDef.reactions, isNotState); } return { From 52d5b2f84fc3175991f3ba34a82196609f67d141 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 11:49:34 -0700 Subject: [PATCH 43/79] fix(spf): restore forEach in registerEffects Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index 7e9cf2b61..f33f862c8 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -165,7 +165,7 @@ export function createReactor untracked = false ) => { const fns = effects === undefined ? [] : Array.isArray(effects) ? effects : [effects]; - for (const fn of fns) { + fns.forEach((fn) => { effectDisposals.push( effect(() => { const snapshot = snapshotSignal.get(); @@ -174,7 +174,7 @@ export function createReactor return wrapResult(untracked ? untrack(call) : call()); }) ); - } + }); }; const isTerminal = (snapshot: ActorSnapshot) => From 329f018d5708dde7ae9848c65c4f7421a0ae9073 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 11:55:05 -0700 Subject: [PATCH 44/79] refactor(spf): use single-fn shorthand in all reactors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All always/entry/reactions in the four existing reactors had exactly one element — drop the array wrapper throughout. Co-Authored-By: Claude Sonnet 4.6 --- .../src/core/features/resolve-presentation.ts | 42 +++---- .../src/dom/features/load-text-track-cues.ts | 56 ++++----- .../spf/src/dom/features/sync-text-tracks.ts | 114 +++++++++--------- .../dom/features/track-playback-initiated.ts | 36 +++--- 4 files changed, 112 insertions(+), 136 deletions(-) diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index ffb5e5bd1..ae363a7e4 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -96,37 +96,33 @@ export function resolvePresentation({ return createReactor({ initial: 'preconditions-unmet', context: {}, - always: [ - ({ status, transition }) => { - const target = derivedStatusSignal.get(); - if (target !== status) transition(target); - }, - ], + always: ({ status, transition }) => { + const target = derivedStatusSignal.get(); + if (target !== status) transition(target); + }, states: { 'preconditions-unmet': {}, idle: {}, resolving: { // Entry: start fetch on state entry; return AbortController so the // framework aborts the in-flight request on state exit. - entry: [ - () => { - const presentation = state.get().presentation as UnresolvedPresentation; - const ac = new AbortController(); + entry: () => { + const presentation = state.get().presentation as UnresolvedPresentation; + const ac = new AbortController(); - fetchResolvable(presentation, { signal: ac.signal }) - .then((response) => getResponseText(response)) - .then((text) => { - const parsed = parseMultivariantPlaylist(text, presentation); - update(state, { presentation: parsed } as Partial); - }) - .catch((error) => { - if (error instanceof Error && error.name === 'AbortError') return; - throw error; - }); + fetchResolvable(presentation, { signal: ac.signal }) + .then((response) => getResponseText(response)) + .then((text) => { + const parsed = parseMultivariantPlaylist(text, presentation); + update(state, { presentation: parsed } as Partial); + }) + .catch((error) => { + if (error instanceof Error && error.name === 'AbortError') return; + throw error; + }); - return ac; - }, - ], + return ac; + }, }, resolved: {}, }, diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 800ec93f5..9de1a6c13 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -144,34 +144,28 @@ export function loadTextTrackCues({ initial: 'preconditions-unmet', context: {}, - always: [ - ({ status, transition }) => { - const target = derivedStatusSignal.get(); - if (target !== status) transition(target); - }, - ], + always: ({ status, transition }) => { + const target = derivedStatusSignal.get(); + if (target !== status) transition(target); + }, states: { 'preconditions-unmet': { // Entry: defensive actor reset on state entry (no-op if already undefined). - entry: [ - () => { - teardownActors(owners); - }, - ], + entry: () => { + teardownActors(owners); + }, }, 'setting-up': { // Entry: reset any stale actors, then create fresh ones and write to owners. // The fn body is automatically untracked — no untrack() needed for mediaElement. - entry: [ - () => { - teardownActors(owners); - const mediaElement = owners.get().mediaElement as HTMLMediaElement; - const textTracksActor = createTextTracksActor(mediaElement); - const segmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor); - update(owners, { textTracksActor, segmentLoaderActor } as Partial); - }, - ], + entry: () => { + teardownActors(owners); + const mediaElement = owners.get().mediaElement as HTMLMediaElement; + const textTracksActor = createTextTracksActor(mediaElement); + const segmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor); + update(owners, { textTracksActor, segmentLoaderActor } as Partial); + }, }, pending: {}, @@ -180,18 +174,16 @@ export function loadTextTrackCues { - const currentTime = currentTimeSignal.get(); - const track = selectedTrackSignal.get()!; - // deriveStatus guarantees segmentLoaderActor is in owners and findSelectedTrack - // returns a valid resolved track when in this state. The always monitor - // (registered before this effect) transitions us out before this re-runs - // if either invariant ever stops holding. - const { segmentLoaderActor } = untrack(() => owners.get()); - segmentLoaderActor!.send({ type: 'load', track, currentTime }); - }, - ], + reactions: () => { + const currentTime = currentTimeSignal.get(); + const track = selectedTrackSignal.get()!; + // deriveStatus guarantees segmentLoaderActor is in owners and findSelectedTrack + // returns a valid resolved track when in this state. The always monitor + // (registered before this effect) transitions us out before this re-runs + // if either invariant ever stops holding. + const { segmentLoaderActor } = untrack(() => owners.get()); + segmentLoaderActor!.send({ type: 'load', track, currentTime }); + }, }, }, }); diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index 8773bc49d..5a7a80f09 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -111,12 +111,10 @@ export function syncTextTracks({ initial: 'preconditions-unmet', context: {}, - always: [ - ({ status, transition }) => { - const target = preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'; - if (target !== status) transition(target); - }, - ], + always: ({ status, transition }) => { + const target = preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'; + if (target !== status) transition(target); + }, states: { 'preconditions-unmet': {}, @@ -124,65 +122,61 @@ export function syncTextTracks elements once on state entry; exit cleanup removes // them and clears selectedTextTrackId on any outbound transition. // The fn body is automatically untracked — no untrack() needed. - entry: [ - () => { - const mediaElement = mediaElementSignal.get() as HTMLMediaElement; - const modelTextTracks = modelTextTracksSignal.get() as PartiallyResolvedTextTrack[]; - modelTextTracks.forEach((track) => mediaElement.appendChild(createTrackElement(track))); - return () => { - mediaElement - .querySelectorAll('track[data-src-track]:is([kind="subtitles"],[kind="captions"]') - .forEach((trackEl) => trackEl.remove()); - update(state, { selectedTextTrackId: undefined } as Partial); - }; - }, - ], + entry: () => { + const mediaElement = mediaElementSignal.get() as HTMLMediaElement; + const modelTextTracks = modelTextTracksSignal.get() as PartiallyResolvedTextTrack[]; + modelTextTracks.forEach((track) => mediaElement.appendChild(createTrackElement(track))); + return () => { + mediaElement + .querySelectorAll('track[data-src-track]:is([kind="subtitles"],[kind="captions"]') + .forEach((trackEl) => trackEl.remove()); + update(state, { selectedTextTrackId: undefined } as Partial); + }; + }, // Reaction: mode sync + DOM change listener; re-runs when selectedId changes. // mediaElement is read with untrack() since element changes go through the // always monitor (preconditions-unmet path), not this effect. - reactions: [ - () => { - const mediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); - const selectedId = selectedTextTrackIdSignal.get(); - - syncModes(mediaElement.textTracks, selectedId); - let syncTimeout: ReturnType | undefined = setTimeout(() => { - syncTimeout = undefined; - }, 0); - - const onChange = () => { - if (syncTimeout) { - // Inside the settling window: browser auto-selection is overriding our - // modes. Re-apply to restore the intended state without touching state. - // change events are queued as tasks (async), so no re-entrancy risk. - syncModes( - mediaElement.textTracks, - untrack(() => selectedTextTrackIdSignal.get()) - ); - return; - } - - const showingTrack = Array.from(mediaElement.textTracks).find( - (t) => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions') + reactions: () => { + const mediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); + const selectedId = selectedTextTrackIdSignal.get(); + + syncModes(mediaElement.textTracks, selectedId); + let syncTimeout: ReturnType | undefined = setTimeout(() => { + syncTimeout = undefined; + }, 0); + + const onChange = () => { + if (syncTimeout) { + // Inside the settling window: browser auto-selection is overriding our + // modes. Re-apply to restore the intended state without touching state. + // change events are queued as tasks (async), so no re-entrancy risk. + syncModes( + mediaElement.textTracks, + untrack(() => selectedTextTrackIdSignal.get()) ); - - // showingTrack.id matches the SPF track ID set by createTrackElement above. - // Fall back to undefined for empty-string IDs (non-SPF-managed tracks). - const newId = showingTrack?.id; - const currentModelId = untrack(() => selectedTextTrackIdSignal.get()); - if (newId === currentModelId) return; - update(state, { selectedTextTrackId: newId } as Partial); - }; - - const unlisten = listen(mediaElement.textTracks, 'change', onChange); - - return () => { - clearTimeout(syncTimeout ?? undefined); - unlisten(); - }; - }, - ], + return; + } + + const showingTrack = Array.from(mediaElement.textTracks).find( + (t) => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions') + ); + + // showingTrack.id matches the SPF track ID set by createTrackElement above. + // Fall back to undefined for empty-string IDs (non-SPF-managed tracks). + const newId = showingTrack?.id; + const currentModelId = untrack(() => selectedTextTrackIdSignal.get()); + if (newId === currentModelId) return; + update(state, { selectedTextTrackId: newId } as Partial); + }; + + const unlisten = listen(mediaElement.textTracks, 'change', onChange); + + return () => { + clearTimeout(syncTimeout ?? undefined); + unlisten(); + }; + }, }, }, }); diff --git a/packages/spf/src/dom/features/track-playback-initiated.ts b/packages/spf/src/dom/features/track-playback-initiated.ts index ae0ac1565..4d1345332 100644 --- a/packages/spf/src/dom/features/track-playback-initiated.ts +++ b/packages/spf/src/dom/features/track-playback-initiated.ts @@ -71,27 +71,23 @@ export function trackPlaybackInitiated({ initial: 'preconditions-unmet', context: {}, - always: [ - ({ status, transition }) => { - const target = derivedStatusSignal.get(); - if (target !== status) transition(target); - }, - ], + always: ({ status, transition }) => { + const target = derivedStatusSignal.get(); + if (target !== status) transition(target); + }, states: { 'preconditions-unmet': {}, monitoring: { // Entry: check if already playing; otherwise listen for play. // The fn body is automatically untracked — el is read at entry time only. - entry: [ - () => { - const el = mediaElementSignal.get()!; + entry: () => { + const el = mediaElementSignal.get()!; + update(state, { playbackInitiated: !el.paused } as Partial); + return listen(el, 'play', () => { update(state, { playbackInitiated: !el.paused } as Partial); - return listen(el, 'play', () => { - update(state, { playbackInitiated: !el.paused } as Partial); - }); - }, - ], + }); + }, }, 'playback-initiated': { @@ -104,13 +100,11 @@ export function trackPlaybackInitiated { - mediaElementSignal.get(); // tracked — re-run on element change - urlSignal.get(); // tracked — re-run on URL change - return () => update(state, { playbackInitiated: false } as Partial); - }, - ], + reactions: () => { + mediaElementSignal.get(); // tracked — re-run on element change + urlSignal.get(); // tracked — re-run on URL change + return () => update(state, { playbackInitiated: false } as Partial); + }, }, }, }); From f323518e6964256c8742586513536e0b8e30c9a5 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 12:03:59 -0700 Subject: [PATCH 45/79] feat(spf): replace always with derive in createReactor always was exclusively used to derive the target status from reactive signals and call transition(). derive makes that contract explicit: the fn takes no arguments and returns the target UserStatus; the framework handles the if (target !== status) transition(target) boilerplate internally. Adds ReactorDeriveFn type. Updates all four reactors and rewrites the always test block as derive with semantically appropriate tests. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 73 +++++--- .../src/core/features/resolve-presentation.ts | 5 +- .../spf/src/core/tests/create-reactor.test.ts | 160 +++++++----------- .../src/dom/features/load-text-track-cues.ts | 5 +- .../spf/src/dom/features/sync-text-tracks.ts | 5 +- .../dom/features/track-playback-initiated.ts | 5 +- 6 files changed, 111 insertions(+), 142 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index f33f862c8..c8dfef401 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -7,14 +7,21 @@ import { signal, untrack, update } from './signals/primitives'; // ============================================================================= /** - * An effect function used in reactor states and `always` blocks. + * A reactive status-deriving function used in the `derive` field. * - * Receives `status` (the current reactor status), `transition`, `context`, and - * `setContext`. May return a cleanup function that runs before each re-evaluation - * and on state exit (including destroy). + * Returns the target status the reactor should be in. Any signals read inside + * the fn body create reactive dependencies — the framework re-evaluates it when + * those signals change and automatically calls `transition()` when the returned + * status differs from the current one. + */ +export type ReactorDeriveFn = () => UserStatus; + +/** + * An effect function used in reactor `entry` and `reactions` blocks. * - * In `always` blocks, `status` reflects whichever non-terminal state is active. - * In per-state `entry`/`reactions` blocks, `status` is always that state's value. + * Receives `status`, `transition`, `context`, and `setContext`. May return a + * cleanup function that runs before each re-evaluation and on state exit + * (including destroy). */ export type ReactorEffectFn = (ctx: { status: UserStatus; @@ -53,12 +60,11 @@ export type ReactorDefinition /** Initial context. */ context: Context; /** - * Cross-cutting effects that run in every non-terminal state. - * Each element becomes one independent `effect()` call that re-runs on any - * status or context change, with the current `status` available in ctx. - * Useful for condition monitors that apply uniformly across all states. + * Reactive status derivation. Registered before per-state effects — the + * ordering guarantee ensures transitions fired here take effect before + * per-state effects re-evaluate in the same flush. */ - always?: ReactorEffectFn | ReactorEffectFn[]; + derive?: ReactorDeriveFn | ReactorDeriveFn[]; /** * Per-state effect groupings. Every valid status must be declared — pass `{}` * for states with no effects. `entry` and `reactions` each become independent @@ -160,11 +166,12 @@ export function createReactor // skips if shouldSkip returns true, then calls fn — untracked for entry effects // (so only snapshotSignal is tracked), tracked for reactions and always effects. const registerEffects = ( - effects: ReactorEffectFn | ReactorEffectFn[] | undefined, + fnOrFns: ReactorEffectFn | ReactorEffectFn[] | undefined, shouldSkip: (snapshot: ActorSnapshot) => boolean, untracked = false ) => { - const fns = effects === undefined ? [] : Array.isArray(effects) ? effects : [effects]; + if (!fnOrFns) return; + const fns = Array.isArray(fnOrFns) ? fnOrFns : [fnOrFns]; fns.forEach((fn) => { effectDisposals.push( effect(() => { @@ -180,30 +187,42 @@ export function createReactor const isTerminal = (snapshot: ActorSnapshot) => snapshot.status === 'destroying' || snapshot.status === 'destroyed'; - // 'always' effects run in every non-terminal state — processed first so that - // cross-cutting condition monitors fire before per-state effects in the - // initial synchronous run AND on every subsequent re-run. + // `derive` fns are registered first — the ordering guarantee ensures transitions + // they trigger take effect before per-state effects re-evaluate in the same flush. // // The ordering guarantee: effect() calls watcher.watch(computed) in order of // registration. runPending() drains watcher.getPending() into a Set (insertion- - // ordered) before iterating it. Because 'always' effects are registered before + // ordered) before iterating it. Because derive effects are registered before // per-state effects here, they are guaranteed to run first in every flush. - // This is load-bearing: it means a transition triggered by an 'always' monitor + // This is load-bearing: it means a transition triggered by a derive fn // takes effect before the per-state effects of the (now-exited) state re-run, - // so per-state effects can rely on the invariants established by 'always'. - registerEffects(def.always, isTerminal); + // so per-state effects can rely on the invariants established by derive. + const fnOrFns = def.derive; + if (fnOrFns) { + const fns = Array.isArray(fnOrFns) ? fnOrFns : [fnOrFns]; + fns.forEach((fn) => { + effectDisposals.push( + effect(() => { + const snapshot = snapshotSignal.get(); + if (isTerminal(snapshot)) return; + const target = fn(); + if ((target as FullStatus) !== snapshot.status) transition(target as FullStatus); + }) + ); + }); + } // Per-state effects — each is gated on its matching status. // `entry` effects are registered with untracked=true so the fn body creates no // reactive dependencies; they run once on state entry and clean up on exit. // `reactions` effects leave the fn body tracked normally. - for (const [state, stateDef] of Object.entries(def.states) as Array< - [UserStatus, ReactorStateDefinition] - >) { - const isNotState = (snapshot: ActorSnapshot) => snapshot.status !== state; - registerEffects(stateDef.entry, isNotState, true); - registerEffects(stateDef.reactions, isNotState); - } + (Object.entries(def.states) as Array<[UserStatus, ReactorStateDefinition]>).forEach( + ([state, stateDef]) => { + const isNotState = (snapshot: ActorSnapshot) => snapshot.status !== state; + registerEffects(stateDef.entry, isNotState, true); + registerEffects(stateDef.reactions, isNotState); + } + ); return { get snapshot() { diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index ae363a7e4..16fa0ef9c 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -96,10 +96,7 @@ export function resolvePresentation({ return createReactor({ initial: 'preconditions-unmet', context: {}, - always: ({ status, transition }) => { - const target = derivedStatusSignal.get(); - if (target !== status) transition(target); - }, + derive: () => derivedStatusSignal.get(), states: { 'preconditions-unmet': {}, idle: {}, diff --git a/packages/spf/src/core/tests/create-reactor.test.ts b/packages/spf/src/core/tests/create-reactor.test.ts index 0798e7aaf..0e34f0ce4 100644 --- a/packages/spf/src/core/tests/create-reactor.test.ts +++ b/packages/spf/src/core/tests/create-reactor.test.ts @@ -365,156 +365,118 @@ describe('createReactor — cleanup', () => { }); // ============================================================================= -// createReactor — always +// createReactor — derive // ============================================================================= -describe('createReactor — always', () => { - it('runs in the initial state', () => { - const fn = vi.fn(); - createReactor({ - initial: 'idle' as const, - context: {}, - always: [fn], - states: { idle: {} }, - }).destroy(); - - expect(fn).toHaveBeenCalledOnce(); - }); - - it('receives the current status in ctx', () => { - let capturedStatus: string | undefined; - createReactor({ - initial: 'idle' as const, - context: {}, - always: [ - ({ status }) => { - capturedStatus = status; - }, - ], - states: { idle: {} }, - }).destroy(); - - expect(capturedStatus).toBe('idle'); - }); - - it('re-runs on status change and receives the new status', async () => { - const src = signal(false); - const statuses: string[] = []; - +describe('createReactor — derive', () => { + it('transitions to the status returned by the derive fn', async () => { + const src = signal<'waiting' | 'active'>('waiting'); const reactor = createReactor<'waiting' | 'active', object>({ initial: 'waiting', context: {}, - always: [ - ({ status }) => { - statuses.push(status); - }, - ], - states: { - waiting: { - reactions: [ - ({ transition }) => { - if (src.get()) transition('active'); - }, - ], - }, - active: {}, - }, + derive: () => src.get(), + states: { waiting: {}, active: {} }, }); - expect(statuses).toEqual(['waiting']); + expect(reactor.snapshot.get().status).toBe('waiting'); - src.set(true); - await tick(); + src.set('active'); await tick(); - expect(statuses).toEqual(['waiting', 'active']); + expect(reactor.snapshot.get().status).toBe('active'); reactor.destroy(); }); - it('can trigger transitions', async () => { - const src = signal(false); - - const reactor = createReactor<'waiting' | 'active', object>({ - initial: 'waiting', + it('does not transition when the derive fn returns the current status', async () => { + const activeFn = vi.fn(); + const reactor = createReactor<'idle' | 'active', object>({ + initial: 'idle', context: {}, - always: [ - ({ status, transition }) => { - if (src.get() && status === 'waiting') transition('active'); - }, - ], - states: { waiting: {}, active: {} }, + derive: () => 'idle', + states: { idle: {}, active: { entry: activeFn } }, }); - expect(reactor.snapshot.get().status).toBe('waiting'); - - src.set(true); await tick(); - expect(reactor.snapshot.get().status).toBe('active'); + expect(reactor.snapshot.get().status).toBe('idle'); + expect(activeFn).not.toHaveBeenCalled(); reactor.destroy(); }); - it('cleanup runs before each re-run', async () => { - const src = signal(false); - const cleanup = vi.fn(); + it('re-runs when reactive dependencies change', async () => { + const src = signal<'waiting' | 'active'>('waiting'); + const deriveFn = vi.fn(() => src.get()); const reactor = createReactor<'waiting' | 'active', object>({ initial: 'waiting', context: {}, - always: [() => cleanup], - states: { - waiting: { - reactions: [ - ({ transition }) => { - if (src.get()) transition('active'); - }, - ], - }, - active: {}, - }, + derive: deriveFn, + states: { waiting: {}, active: {} }, }); - expect(cleanup).not.toHaveBeenCalled(); + expect(deriveFn).toHaveBeenCalledOnce(); - src.set(true); + src.set('active'); await tick(); - expect(cleanup).toHaveBeenCalledOnce(); + expect(deriveFn).toHaveBeenCalledTimes(2); reactor.destroy(); }); it('does not run during destroying or destroyed', async () => { - const fn = vi.fn(); + const deriveFn = vi.fn(() => 'idle' as const); const reactor = createReactor({ initial: 'idle' as const, context: {}, - always: [fn], + derive: deriveFn, states: { idle: {} }, }); - fn.mockClear(); + deriveFn.mockClear(); reactor.destroy(); await tick(); - expect(fn).not.toHaveBeenCalled(); + expect(deriveFn).not.toHaveBeenCalled(); }); - it('runs alongside per-state effects in the same state', () => { - const alwaysFn = vi.fn(); - const stateFn = vi.fn(); + it('runs before per-state effects so its transitions take effect first', async () => { + const src = signal<'waiting' | 'active'>('waiting'); + const order: string[] = []; - createReactor({ - initial: 'idle' as const, + const reactor = createReactor<'waiting' | 'active', object>({ + initial: 'waiting', context: {}, - always: [alwaysFn], - states: { idle: { entry: [stateFn] } }, - }).destroy(); + derive: () => { + order.push('derive'); + return src.get(); + }, + states: { + waiting: { + entry: () => { + order.push('waiting-entry'); + }, + }, + active: { + entry: () => { + order.push('active-entry'); + }, + }, + }, + }); - expect(alwaysFn).toHaveBeenCalledOnce(); - expect(stateFn).toHaveBeenCalledOnce(); + order.length = 0; + src.set('active'); + await tick(); + await tick(); + + expect(order[0]).toBe('derive'); + expect(order).toContain('active-entry'); + expect(order).not.toContain('waiting-entry'); + + reactor.destroy(); }); }); diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 9de1a6c13..14614f3d4 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -144,10 +144,7 @@ export function loadTextTrackCues({ initial: 'preconditions-unmet', context: {}, - always: ({ status, transition }) => { - const target = derivedStatusSignal.get(); - if (target !== status) transition(target); - }, + derive: () => derivedStatusSignal.get(), states: { 'preconditions-unmet': { // Entry: defensive actor reset on state entry (no-op if already undefined). diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index 5a7a80f09..0fb982d69 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -111,10 +111,7 @@ export function syncTextTracks({ initial: 'preconditions-unmet', context: {}, - always: ({ status, transition }) => { - const target = preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'; - if (target !== status) transition(target); - }, + derive: () => (preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'), states: { 'preconditions-unmet': {}, diff --git a/packages/spf/src/dom/features/track-playback-initiated.ts b/packages/spf/src/dom/features/track-playback-initiated.ts index 4d1345332..7eb53de20 100644 --- a/packages/spf/src/dom/features/track-playback-initiated.ts +++ b/packages/spf/src/dom/features/track-playback-initiated.ts @@ -71,10 +71,7 @@ export function trackPlaybackInitiated({ initial: 'preconditions-unmet', context: {}, - always: ({ status, transition }) => { - const target = derivedStatusSignal.get(); - if (target !== status) transition(target); - }, + derive: () => derivedStatusSignal.get(), states: { 'preconditions-unmet': {}, From 5d7c09155bcf41b7b53e736d07010a48d4ffdcd2 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 12:15:55 -0700 Subject: [PATCH 46/79] refactor(spf): give ReactorDeriveFn the same ctx signature as ReactorEffectFn Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index c8dfef401..05dac6cf1 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -14,7 +14,12 @@ import { signal, untrack, update } from './signals/primitives'; * those signals change and automatically calls `transition()` when the returned * status differs from the current one. */ -export type ReactorDeriveFn = () => UserStatus; +export type ReactorDeriveFn = (ctx: { + status: UserStatus; + transition: (to: UserStatus) => void; + context: Context; + setContext: (next: Context) => void; +}) => UserStatus; /** * An effect function used in reactor `entry` and `reactions` blocks. @@ -64,7 +69,7 @@ export type ReactorDefinition * ordering guarantee ensures transitions fired here take effect before * per-state effects re-evaluate in the same flush. */ - derive?: ReactorDeriveFn | ReactorDeriveFn[]; + derive?: ReactorDeriveFn | ReactorDeriveFn[]; /** * Per-state effect groupings. Every valid status must be declared — pass `{}` * for states with no effects. `entry` and `reactions` each become independent @@ -205,7 +210,7 @@ export function createReactor effect(() => { const snapshot = snapshotSignal.get(); if (isTerminal(snapshot)) return; - const target = fn(); + const target = fn(makeCtx(snapshot)); if ((target as FullStatus) !== snapshot.status) transition(target as FullStatus); }) ); From 2a0f6539e24ddbb05a77f9fb432eede625439326 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 12:40:24 -0700 Subject: [PATCH 47/79] refactor(spf): hoist toCall out of effect body in registerEffects; add derive call wrapper Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 44 ++++++++++++++++++------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index 05dac6cf1..2aa5dbb6d 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -177,16 +177,33 @@ export function createReactor ) => { if (!fnOrFns) return; const fns = Array.isArray(fnOrFns) ? fnOrFns : [fnOrFns]; - fns.forEach((fn) => { - effectDisposals.push( - effect(() => { - const snapshot = snapshotSignal.get(); - if (shouldSkip(snapshot)) return; - const call = () => fn(makeCtx(snapshot)); - return wrapResult(untracked ? untrack(call) : call()); - }) - ); - }); + const toCall = untracked + ? ( + baseCall: () => + | void + | (() => void) + | { + abort(): void; + } + ) => + () => + untrack(baseCall) + : ( + baseCall: () => + | void + | (() => void) + | { + abort(): void; + } + ) => baseCall; + const toEffect = (fn: ReactorEffectFn) => + effect(() => { + const snapshot = snapshotSignal.get(); + if (shouldSkip(snapshot)) return; + const baseCall = () => fn(makeCtx(snapshot)); + return wrapResult(toCall(baseCall)()); + }); + effectDisposals.push(...fns.map(toEffect)); }; const isTerminal = (snapshot: ActorSnapshot) => @@ -210,8 +227,11 @@ export function createReactor effect(() => { const snapshot = snapshotSignal.get(); if (isTerminal(snapshot)) return; - const target = fn(makeCtx(snapshot)); - if ((target as FullStatus) !== snapshot.status) transition(target as FullStatus); + const call = () => { + const target = fn(makeCtx(snapshot)); + if ((target as FullStatus) !== snapshot.status) transition(target as FullStatus); + }; + return wrapResult(call()); }) ); }); From eb18955126cd703d3af77dea5b9e70f6e94b9bea Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 12:45:25 -0700 Subject: [PATCH 48/79] refactor(spf): replace untracked boolean with toFnCall parameter in registerEffects Replaces the `untracked = false` boolean parameter with a `toFnCall` transformer function (defaulting to identity), making the abstraction more composable. Entry effects now pass `(baseCall) => () => untrack(baseCall)` explicitly at the call site. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 32 +++++++------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index 2aa5dbb6d..c3457aef4 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -167,41 +167,25 @@ export function createReactor return () => result.abort(); }; + type EffectCall = () => ReturnType>; + // Registers each fn as an independent effect. The effect reads snapshotSignal, - // skips if shouldSkip returns true, then calls fn — untracked for entry effects - // (so only snapshotSignal is tracked), tracked for reactions and always effects. + // skips if shouldSkip returns true, then calls toFnCall(baseCall)(). The default + // toFnCall is the identity — pass (baseCall) => () => untrack(baseCall) for entry + // effects so the fn body creates no reactive dependencies. const registerEffects = ( fnOrFns: ReactorEffectFn | ReactorEffectFn[] | undefined, shouldSkip: (snapshot: ActorSnapshot) => boolean, - untracked = false + toFnCall: (baseCall: EffectCall) => EffectCall = (baseCall) => baseCall ) => { if (!fnOrFns) return; const fns = Array.isArray(fnOrFns) ? fnOrFns : [fnOrFns]; - const toCall = untracked - ? ( - baseCall: () => - | void - | (() => void) - | { - abort(): void; - } - ) => - () => - untrack(baseCall) - : ( - baseCall: () => - | void - | (() => void) - | { - abort(): void; - } - ) => baseCall; const toEffect = (fn: ReactorEffectFn) => effect(() => { const snapshot = snapshotSignal.get(); if (shouldSkip(snapshot)) return; const baseCall = () => fn(makeCtx(snapshot)); - return wrapResult(toCall(baseCall)()); + return wrapResult(toFnCall(baseCall)()); }); effectDisposals.push(...fns.map(toEffect)); }; @@ -244,7 +228,7 @@ export function createReactor (Object.entries(def.states) as Array<[UserStatus, ReactorStateDefinition]>).forEach( ([state, stateDef]) => { const isNotState = (snapshot: ActorSnapshot) => snapshot.status !== state; - registerEffects(stateDef.entry, isNotState, true); + registerEffects(stateDef.entry, isNotState, (baseCall) => () => untrack(baseCall)); registerEffects(stateDef.reactions, isNotState); } ); From 216abcc44e14ec750ef18088e0f997f9d54d316b Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 13:10:08 -0700 Subject: [PATCH 49/79] refactor(spf): replace registerEffects with descriptor-list pattern in createReactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the `registerEffects` helper and separate derive loop with a flat `EffectDescriptor[]` built upfront from the definition. Each descriptor carries its `fn`, `shouldSkip`, and `toFnCall` — registration is a single uniform `.map(toEffect)` pass. Array normalization moves to a module-scope `toArray` utility; derive fns are mapped to effect fn shape at descriptor-build time. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 118 +++++++++++------------- 1 file changed, 52 insertions(+), 66 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index c3457aef4..446e8b586 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -85,6 +85,12 @@ export type ReactorDefinition /** Live reactor instance returned by `createReactor`. */ export type Reactor = SignalActor; +// ============================================================================= +// Implementation helpers +// ============================================================================= + +const toArray = (x: T | T[] | undefined): T[] => (x === undefined ? [] : Array.isArray(x) ? x : [x]); + // ============================================================================= // Implementation // ============================================================================= @@ -108,19 +114,17 @@ export type Reactor = SignalActor * const reactor = createReactor({ * initial: 'waiting', * context: {}, - * // Runs in every non-terminal state — drives transitions from a single place. - * always: [ - * ({ status, transition }) => { - * const target = deriveStatus(); - * if (target !== status) transition(target); - * } - * ], + * derive: ({ status, transition }) => { + * const target = deriveStatus(); + * if (target !== status) transition(target); + * return target; + * }, * states: { * active: { * // entry: runs once on state entry; fn body is automatically untracked. - * entry: [() => listen(el, 'play', handler)], + * entry: () => listen(el, 'play', handler), * // reactions: re-runs whenever tracked signals change. - * reactions: [() => { currentTimeSignal.get(); return cleanup; }], + * reactions: () => { currentTimeSignal.get(); return cleanup; }, * }, * idle: {}, // no effects * } @@ -169,69 +173,51 @@ export function createReactor type EffectCall = () => ReturnType>; - // Registers each fn as an independent effect. The effect reads snapshotSignal, - // skips if shouldSkip returns true, then calls toFnCall(baseCall)(). The default - // toFnCall is the identity — pass (baseCall) => () => untrack(baseCall) for entry - // effects so the fn body creates no reactive dependencies. - const registerEffects = ( - fnOrFns: ReactorEffectFn | ReactorEffectFn[] | undefined, - shouldSkip: (snapshot: ActorSnapshot) => boolean, - toFnCall: (baseCall: EffectCall) => EffectCall = (baseCall) => baseCall - ) => { - if (!fnOrFns) return; - const fns = Array.isArray(fnOrFns) ? fnOrFns : [fnOrFns]; - const toEffect = (fn: ReactorEffectFn) => - effect(() => { - const snapshot = snapshotSignal.get(); - if (shouldSkip(snapshot)) return; - const baseCall = () => fn(makeCtx(snapshot)); - return wrapResult(toFnCall(baseCall)()); - }); - effectDisposals.push(...fns.map(toEffect)); + type EffectDescriptor = { + fn: ReactorEffectFn; + shouldSkip: (snapshot: ActorSnapshot) => boolean; + toFnCall: (baseCall: EffectCall) => EffectCall; }; + const identity: EffectDescriptor['toFnCall'] = (baseCall) => baseCall; + const untracked: EffectDescriptor['toFnCall'] = (baseCall) => () => untrack(baseCall); + const isTerminal = (snapshot: ActorSnapshot) => snapshot.status === 'destroying' || snapshot.status === 'destroyed'; - // `derive` fns are registered first — the ordering guarantee ensures transitions - // they trigger take effect before per-state effects re-evaluate in the same flush. - // - // The ordering guarantee: effect() calls watcher.watch(computed) in order of - // registration. runPending() drains watcher.getPending() into a Set (insertion- - // ordered) before iterating it. Because derive effects are registered before - // per-state effects here, they are guaranteed to run first in every flush. - // This is load-bearing: it means a transition triggered by a derive fn - // takes effect before the per-state effects of the (now-exited) state re-run, - // so per-state effects can rely on the invariants established by derive. - const fnOrFns = def.derive; - if (fnOrFns) { - const fns = Array.isArray(fnOrFns) ? fnOrFns : [fnOrFns]; - fns.forEach((fn) => { - effectDisposals.push( - effect(() => { - const snapshot = snapshotSignal.get(); - if (isTerminal(snapshot)) return; - const call = () => { - const target = fn(makeCtx(snapshot)); - if ((target as FullStatus) !== snapshot.status) transition(target as FullStatus); - }; - return wrapResult(call()); - }) - ); + // `derive` descriptors are built first — the ordering guarantee ensures + // transitions they trigger take effect before per-state effects re-evaluate + // in the same flush. See the comment on effect registration order in the + // previous implementation for full details. + const descriptors: EffectDescriptor[] = [ + ...toArray(def.derive).map((fn) => ({ + fn: (ctx: Parameters>[0]) => { + const target = fn(ctx); + if (target !== ctx.status) ctx.transition(target); + }, + shouldSkip: isTerminal, + toFnCall: identity, + })), + ...(Object.entries(def.states) as Array<[UserStatus, ReactorStateDefinition]>).flatMap( + ([state, stateDef]) => { + const isNotState = (snapshot: ActorSnapshot) => snapshot.status !== state; + return [ + ...toArray(stateDef.entry).map((fn) => ({ fn, shouldSkip: isNotState, toFnCall: untracked })), + ...toArray(stateDef.reactions).map((fn) => ({ fn, shouldSkip: isNotState, toFnCall: identity })), + ]; + } + ), + ]; + + const toEffect = ({ fn, shouldSkip, toFnCall }: EffectDescriptor) => + effect(() => { + const snapshot = snapshotSignal.get(); + if (shouldSkip(snapshot)) return; + const baseCall = () => fn(makeCtx(snapshot)); + return wrapResult(toFnCall(baseCall)()); }); - } - - // Per-state effects — each is gated on its matching status. - // `entry` effects are registered with untracked=true so the fn body creates no - // reactive dependencies; they run once on state entry and clean up on exit. - // `reactions` effects leave the fn body tracked normally. - (Object.entries(def.states) as Array<[UserStatus, ReactorStateDefinition]>).forEach( - ([state, stateDef]) => { - const isNotState = (snapshot: ActorSnapshot) => snapshot.status !== state; - registerEffects(stateDef.entry, isNotState, (baseCall) => () => untrack(baseCall)); - registerEffects(stateDef.reactions, isNotState); - } - ); + + effectDisposals.push(...descriptors.map(toEffect)); return { get snapshot() { From a91274f48a7964b519468c1274e6d17d0a229d29 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 13:18:07 -0700 Subject: [PATCH 50/79] refactor(spf): make toFnCall optional on EffectDescriptor with identity default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the explicit `identity` constant — the default is now inlined into `toEffect`'s destructure parameter. Derive and reactions descriptors no longer need to pass `toFnCall` at all; only entry descriptors pass `untracked`. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index 446e8b586..019ed7ea0 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -176,10 +176,9 @@ export function createReactor type EffectDescriptor = { fn: ReactorEffectFn; shouldSkip: (snapshot: ActorSnapshot) => boolean; - toFnCall: (baseCall: EffectCall) => EffectCall; + toFnCall?: (baseCall: EffectCall) => EffectCall; }; - const identity: EffectDescriptor['toFnCall'] = (baseCall) => baseCall; const untracked: EffectDescriptor['toFnCall'] = (baseCall) => () => untrack(baseCall); const isTerminal = (snapshot: ActorSnapshot) => @@ -196,20 +195,19 @@ export function createReactor if (target !== ctx.status) ctx.transition(target); }, shouldSkip: isTerminal, - toFnCall: identity, })), ...(Object.entries(def.states) as Array<[UserStatus, ReactorStateDefinition]>).flatMap( ([state, stateDef]) => { const isNotState = (snapshot: ActorSnapshot) => snapshot.status !== state; return [ ...toArray(stateDef.entry).map((fn) => ({ fn, shouldSkip: isNotState, toFnCall: untracked })), - ...toArray(stateDef.reactions).map((fn) => ({ fn, shouldSkip: isNotState, toFnCall: identity })), + ...toArray(stateDef.reactions).map((fn) => ({ fn, shouldSkip: isNotState })), ]; } ), ]; - const toEffect = ({ fn, shouldSkip, toFnCall }: EffectDescriptor) => + const toEffect = ({ fn, shouldSkip, toFnCall = (baseCall) => baseCall }: EffectDescriptor) => effect(() => { const snapshot = snapshotSignal.get(); if (shouldSkip(snapshot)) return; From 3702730d08ba8f1308447458fa7babc4c3d5eff4 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 13:24:59 -0700 Subject: [PATCH 51/79] refactor(spf): remove context and transition from reactor public API `context`, `setContext`, and `transition` were never used by any reactor implementation. Removing them simplifies `ReactorEffectFn` to `() => cleanup | void`, drops `Context` from all reactor types, eliminates `makeCtx`, and reduces `Reactor` to a minimal `{ snapshot, destroy }` shape independent of `SignalActor`. Derive fns still trigger transitions internally via `getStatus()` comparison. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-reactor.ts | 115 ++++++---------- .../src/core/features/resolve-presentation.ts | 5 +- .../spf/src/core/tests/create-reactor.test.ts | 125 +++--------------- .../src/dom/features/load-text-track-cues.ts | 5 +- .../spf/src/dom/features/sync-text-tracks.ts | 5 +- .../dom/features/track-playback-initiated.ts | 5 +- 6 files changed, 66 insertions(+), 194 deletions(-) diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index 019ed7ea0..930b7afa5 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -1,5 +1,5 @@ -import type { ActorSnapshot, SignalActor } from './actor'; import { effect } from './signals/effect'; +import type { ReadonlySignal } from './signals/primitives'; import { signal, untrack, update } from './signals/primitives'; // ============================================================================= @@ -14,26 +14,15 @@ import { signal, untrack, update } from './signals/primitives'; * those signals change and automatically calls `transition()` when the returned * status differs from the current one. */ -export type ReactorDeriveFn = (ctx: { - status: UserStatus; - transition: (to: UserStatus) => void; - context: Context; - setContext: (next: Context) => void; -}) => UserStatus; +export type ReactorDeriveFn = () => UserStatus; /** * An effect function used in reactor `entry` and `reactions` blocks. * - * Receives `status`, `transition`, `context`, and `setContext`. May return a - * cleanup function that runs before each re-evaluation and on state exit - * (including destroy). + * May return a cleanup function that runs before each re-evaluation and on + * state exit (including destroy). */ -export type ReactorEffectFn = (ctx: { - status: UserStatus; - transition: (to: UserStatus) => void; - context: Context; - setContext: (next: Context) => void; -}) => (() => void) | { abort(): void } | void; +export type ReactorEffectFn = () => (() => void) | { abort(): void } | void; /** * Per-state effect grouping for a single reactor state. @@ -45,11 +34,11 @@ export type ReactorEffectFn = * inside the fn body changes. Use `untrack()` for reads you do not want to * track. Use this for work that must stay in sync with reactive state. * - * Both arrays are optional; pass `{}` for states with no effects. + * Both are optional; pass `{}` for states with no effects. */ -export type ReactorStateDefinition = { - entry?: ReactorEffectFn | ReactorEffectFn[]; - reactions?: ReactorEffectFn | ReactorEffectFn[]; +export type ReactorStateDefinition = { + entry?: ReactorEffectFn | ReactorEffectFn[]; + reactions?: ReactorEffectFn | ReactorEffectFn[]; }; /** @@ -59,23 +48,21 @@ export type ReactorStateDefinition = { +export type ReactorDefinition = { /** Initial status. */ initial: UserStatus; - /** Initial context. */ - context: Context; /** * Reactive status derivation. Registered before per-state effects — the * ordering guarantee ensures transitions fired here take effect before * per-state effects re-evaluate in the same flush. */ - derive?: ReactorDeriveFn | ReactorDeriveFn[]; + derive?: ReactorDeriveFn | ReactorDeriveFn[]; /** * Per-state effect groupings. Every valid status must be declared — pass `{}` * for states with no effects. `entry` and `reactions` each become independent * `effect()` calls gated on that state, with their own cleanup lifecycles. */ - states: Record>; + states: Record; }; // ============================================================================= @@ -83,7 +70,10 @@ export type ReactorDefinition // ============================================================================= /** Live reactor instance returned by `createReactor`. */ -export type Reactor = SignalActor; +export type Reactor = { + readonly snapshot: ReadonlySignal<{ status: Status }>; + destroy(): void; +}; // ============================================================================= // Implementation helpers @@ -101,9 +91,7 @@ const toArray = (x: T | T[] | undefined): T[] => (x === undefined ? [] : Arra * A Reactor is driven by subscriptions to external signals rather than * imperative messages. Each state holds an array of effect functions — * every element becomes one independent `effect()` call gated on that state, - * with its own dependency tracking and cleanup lifecycle. This replaces the - * pattern of multiple named `cleanupX = effect(...)` variables in function-based - * reactors. + * with its own dependency tracking and cleanup lifecycle. * * `'destroying'` and `'destroyed'` are always implicit terminal states. * `destroy()` transitions through both in sequence: `'destroying'` first (for @@ -113,12 +101,7 @@ const toArray = (x: T | T[] | undefined): T[] => (x === undefined ? [] : Arra * @example * const reactor = createReactor({ * initial: 'waiting', - * context: {}, - * derive: ({ status, transition }) => { - * const target = deriveStatus(); - * if (target !== status) transition(target); - * return target; - * }, + * derive: () => srcSignal.get() ? 'active' : 'waiting', * states: { * active: { * // entry: runs once on state entry; fn body is automatically untracked. @@ -126,18 +109,17 @@ const toArray = (x: T | T[] | undefined): T[] => (x === undefined ? [] : Arra * // reactions: re-runs whenever tracked signals change. * reactions: () => { currentTimeSignal.get(); return cleanup; }, * }, - * idle: {}, // no effects + * waiting: {}, * } * }); */ -export function createReactor( - def: ReactorDefinition -): Reactor { +export function createReactor( + def: ReactorDefinition +): Reactor { type FullStatus = UserStatus | 'destroying' | 'destroyed'; - const snapshotSignal = signal>({ + const snapshotSignal = signal<{ status: FullStatus }>({ status: def.initial as FullStatus, - context: def.context, }); const getStatus = (): FullStatus => untrack(() => snapshotSignal.get().status); @@ -146,42 +128,25 @@ export function createReactor update(snapshotSignal, { status: to }); }; - const setContext = (context: Context): void => { - update(snapshotSignal, { context }); - }; - const effectDisposals: Array<() => void> = []; - const makeCtx = (snapshot: ActorSnapshot) => ({ - status: snapshot.status as UserStatus, - transition: (to: UserStatus) => { - if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; - transition(to as FullStatus); - }, - context: snapshot.context, - setContext: (context: Context) => { - if (getStatus() === 'destroying' || getStatus() === 'destroyed') return; - setContext(context); - }, - }); - - const wrapResult = (result: ReturnType>) => { + const wrapResult = (result: ReturnType) => { if (!result) return undefined; if (typeof result === 'function') return result; return () => result.abort(); }; - type EffectCall = () => ReturnType>; + type EffectCall = () => ReturnType; type EffectDescriptor = { - fn: ReactorEffectFn; - shouldSkip: (snapshot: ActorSnapshot) => boolean; + fn: ReactorEffectFn; + shouldSkip: (snapshot: { status: FullStatus }) => boolean; toFnCall?: (baseCall: EffectCall) => EffectCall; }; const untracked: EffectDescriptor['toFnCall'] = (baseCall) => () => untrack(baseCall); - const isTerminal = (snapshot: ActorSnapshot) => + const isTerminal = (snapshot: { status: FullStatus }) => snapshot.status === 'destroying' || snapshot.status === 'destroyed'; // `derive` descriptors are built first — the ordering guarantee ensures @@ -190,28 +155,26 @@ export function createReactor // previous implementation for full details. const descriptors: EffectDescriptor[] = [ ...toArray(def.derive).map((fn) => ({ - fn: (ctx: Parameters>[0]) => { - const target = fn(ctx); - if (target !== ctx.status) ctx.transition(target); + fn: () => { + const target = fn(); + if (target !== (getStatus() as UserStatus)) transition(target as FullStatus); }, shouldSkip: isTerminal, })), - ...(Object.entries(def.states) as Array<[UserStatus, ReactorStateDefinition]>).flatMap( - ([state, stateDef]) => { - const isNotState = (snapshot: ActorSnapshot) => snapshot.status !== state; - return [ - ...toArray(stateDef.entry).map((fn) => ({ fn, shouldSkip: isNotState, toFnCall: untracked })), - ...toArray(stateDef.reactions).map((fn) => ({ fn, shouldSkip: isNotState })), - ]; - } - ), + ...(Object.entries(def.states) as Array<[UserStatus, ReactorStateDefinition]>).flatMap(([state, stateDef]) => { + const isNotState = (snapshot: { status: FullStatus }) => snapshot.status !== state; + return [ + ...toArray(stateDef.entry).map((fn) => ({ fn, shouldSkip: isNotState, toFnCall: untracked })), + ...toArray(stateDef.reactions).map((fn) => ({ fn, shouldSkip: isNotState })), + ]; + }), ]; const toEffect = ({ fn, shouldSkip, toFnCall = (baseCall) => baseCall }: EffectDescriptor) => effect(() => { const snapshot = snapshotSignal.get(); if (shouldSkip(snapshot)) return; - const baseCall = () => fn(makeCtx(snapshot)); + const baseCall = () => fn(); return wrapResult(toFnCall(baseCall)()); }); diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index 16fa0ef9c..5813ce381 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -90,12 +90,11 @@ export function resolvePresentation({ state, }: { state: Signal; -}): Reactor { +}): Reactor { const derivedStatusSignal = computed(() => deriveStatus(state.get())); - return createReactor({ + return createReactor({ initial: 'preconditions-unmet', - context: {}, derive: () => derivedStatusSignal.get(), states: { 'preconditions-unmet': {}, diff --git a/packages/spf/src/core/tests/create-reactor.test.ts b/packages/spf/src/core/tests/create-reactor.test.ts index 0e34f0ce4..2f1836bd3 100644 --- a/packages/spf/src/core/tests/create-reactor.test.ts +++ b/packages/spf/src/core/tests/create-reactor.test.ts @@ -10,15 +10,13 @@ const tick = () => new Promise((resolve) => queueMicrotask(resolve)); // ============================================================================= describe('createReactor', () => { - it('starts with the initial status and context', () => { + it('starts with the initial status', () => { const reactor = createReactor({ initial: 'idle' as const, - context: { value: 0 }, states: { idle: {} }, }); expect(reactor.snapshot.get().status).toBe('idle'); - expect(reactor.snapshot.get().context).toEqual({ value: 0 }); reactor.destroy(); }); @@ -27,7 +25,6 @@ describe('createReactor', () => { const fn = vi.fn(); createReactor({ initial: 'idle' as const, - context: {}, states: { idle: { entry: [fn] } }, }).destroy(); @@ -38,7 +35,6 @@ describe('createReactor', () => { const fn = vi.fn(); createReactor({ initial: 'idle' as const, - context: {}, states: { idle: { reactions: [fn] } }, }).destroy(); @@ -47,9 +43,8 @@ describe('createReactor', () => { it('does not run effects for states other than the initial state', () => { const otherFn = vi.fn(); - createReactor<'idle' | 'other', object>({ + createReactor<'idle' | 'other'>({ initial: 'idle', - context: {}, states: { idle: {}, other: { entry: [otherFn] }, @@ -59,40 +54,13 @@ describe('createReactor', () => { expect(otherFn).not.toHaveBeenCalled(); }); - it('passes transition, context, and setContext to entry effect fns', () => { - let captured: unknown; - createReactor({ - initial: 'idle' as const, - context: { x: 1 }, - states: { - idle: { - entry: [ - (ctx) => { - captured = ctx; - }, - ], - }, - }, - }).destroy(); - - expect(typeof (captured as { transition: unknown }).transition).toBe('function'); - expect((captured as { context: unknown }).context).toEqual({ x: 1 }); - expect(typeof (captured as { setContext: unknown }).setContext).toBe('function'); - }); - - it('transitions status via transition() in a reaction', async () => { + it('transitions status via derive', async () => { const src = signal(false); - const reactor = createReactor<'waiting' | 'active', object>({ + const reactor = createReactor<'waiting' | 'active'>({ initial: 'waiting', - context: {}, + derive: () => (src.get() ? 'active' : 'waiting'), states: { - waiting: { - reactions: [ - ({ transition }) => { - if (src.get()) transition('active'); - }, - ], - }, + waiting: {}, active: {}, }, }); @@ -111,17 +79,11 @@ describe('createReactor', () => { const src = signal(false); const activeFn = vi.fn(); - const reactor = createReactor<'waiting' | 'active', object>({ + const reactor = createReactor<'waiting' | 'active'>({ initial: 'waiting', - context: {}, + derive: () => (src.get() ? 'active' : 'waiting'), states: { - waiting: { - reactions: [ - ({ transition }) => { - if (src.get()) transition('active'); - }, - ], - }, + waiting: {}, active: { entry: [activeFn] }, }, }); @@ -137,30 +99,6 @@ describe('createReactor', () => { reactor.destroy(); }); - it('updates context via setContext()', () => { - let captured = 0; - const reactor = createReactor({ - initial: 'idle' as const, - context: { count: 0 }, - states: { - idle: { - entry: [ - ({ context, setContext }) => { - captured = context.count; - setContext({ count: context.count + 1 }); - }, - ], - }, - }, - }); - - // Effect ran on creation with count: 0, then setContext wrote count: 1 - expect(captured).toBe(0); - expect(reactor.snapshot.get().context.count).toBe(1); - - reactor.destroy(); - }); - it('multiple entry effects in the same state run independently', () => { const fn1 = vi.fn(); const fn2 = vi.fn(); @@ -168,7 +106,6 @@ describe('createReactor', () => { createReactor({ initial: 'idle' as const, - context: {}, states: { idle: { entry: [fn1, fn2, fn3] } }, }).destroy(); @@ -189,7 +126,6 @@ describe('createReactor', () => { const reactor = createReactor({ initial: 'idle' as const, - context: {}, states: { idle: { reactions: [fn1, fn2] } }, }); @@ -213,7 +149,6 @@ describe('createReactor', () => { const reactor = createReactor({ initial: 'idle' as const, - context: {}, states: { idle: { entry: [fn] } }, }); @@ -229,17 +164,11 @@ describe('createReactor', () => { it('snapshot is reactive', async () => { const src = signal(false); - const reactor = createReactor<'waiting' | 'active', object>({ + const reactor = createReactor<'waiting' | 'active'>({ initial: 'waiting', - context: {}, + derive: () => (src.get() ? 'active' : 'waiting'), states: { - waiting: { - reactions: [ - ({ transition }) => { - if (src.get()) transition('active'); - }, - ], - }, + waiting: {}, active: {}, }, }); @@ -265,9 +194,9 @@ describe('createReactor — cleanup', () => { const src = signal(false); const cleanup = vi.fn(); - const reactor = createReactor<'active' | 'done', object>({ + const reactor = createReactor<'active' | 'done'>({ initial: 'active', - context: {}, + derive: () => (src.get() ? 'done' : 'active'), states: { active: { // entry: cleanup fires on exit regardless of whether fn is tracked @@ -276,11 +205,6 @@ describe('createReactor — cleanup', () => { return cleanup; }, ], - reactions: [ - ({ transition }) => { - if (src.get()) transition('done'); - }, - ], }, done: {}, }, @@ -302,7 +226,6 @@ describe('createReactor — cleanup', () => { const reactor = createReactor({ initial: 'idle' as const, - context: {}, states: { idle: { reactions: [ @@ -331,7 +254,6 @@ describe('createReactor — cleanup', () => { const reactor = createReactor({ initial: 'idle' as const, - context: {}, states: { idle: { entry: [() => entryCleanup], @@ -350,9 +272,8 @@ describe('createReactor — cleanup', () => { const activeCleanup = vi.fn(); const inactiveCleanup = vi.fn(); - createReactor<'idle' | 'other', object>({ + createReactor<'idle' | 'other'>({ initial: 'idle', - context: {}, states: { idle: { entry: [() => activeCleanup] }, other: { entry: [() => inactiveCleanup] }, @@ -371,9 +292,8 @@ describe('createReactor — cleanup', () => { describe('createReactor — derive', () => { it('transitions to the status returned by the derive fn', async () => { const src = signal<'waiting' | 'active'>('waiting'); - const reactor = createReactor<'waiting' | 'active', object>({ + const reactor = createReactor<'waiting' | 'active'>({ initial: 'waiting', - context: {}, derive: () => src.get(), states: { waiting: {}, active: {} }, }); @@ -390,9 +310,8 @@ describe('createReactor — derive', () => { it('does not transition when the derive fn returns the current status', async () => { const activeFn = vi.fn(); - const reactor = createReactor<'idle' | 'active', object>({ + const reactor = createReactor<'idle' | 'active'>({ initial: 'idle', - context: {}, derive: () => 'idle', states: { idle: {}, active: { entry: activeFn } }, }); @@ -409,9 +328,8 @@ describe('createReactor — derive', () => { const src = signal<'waiting' | 'active'>('waiting'); const deriveFn = vi.fn(() => src.get()); - const reactor = createReactor<'waiting' | 'active', object>({ + const reactor = createReactor<'waiting' | 'active'>({ initial: 'waiting', - context: {}, derive: deriveFn, states: { waiting: {}, active: {} }, }); @@ -430,7 +348,6 @@ describe('createReactor — derive', () => { const deriveFn = vi.fn(() => 'idle' as const); const reactor = createReactor({ initial: 'idle' as const, - context: {}, derive: deriveFn, states: { idle: {} }, }); @@ -446,9 +363,8 @@ describe('createReactor — derive', () => { const src = signal<'waiting' | 'active'>('waiting'); const order: string[] = []; - const reactor = createReactor<'waiting' | 'active', object>({ + const reactor = createReactor<'waiting' | 'active'>({ initial: 'waiting', - context: {}, derive: () => { order.push('derive'); return src.get(); @@ -488,7 +404,6 @@ describe('createReactor — destroy', () => { it('transitions to destroyed on destroy()', () => { const reactor = createReactor({ initial: 'idle' as const, - context: {}, states: { idle: {} }, }); @@ -500,7 +415,6 @@ describe('createReactor — destroy', () => { it('destroy() is idempotent', () => { const reactor = createReactor({ initial: 'idle' as const, - context: {}, states: { idle: {} }, }); @@ -517,7 +431,6 @@ describe('createReactor — destroy', () => { const reactor = createReactor({ initial: 'idle' as const, - context: {}, states: { idle: { reactions: [fn] } }, }); diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 14614f3d4..d0409dc57 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -136,14 +136,13 @@ export function loadTextTrackCues; owners: Signal; -}): Reactor { +}): Reactor { const derivedStatusSignal = computed(() => deriveStatus(state.get(), owners.get())); const currentTimeSignal = computed(() => state.get().currentTime ?? 0); const selectedTrackSignal = computed(() => findSelectedTrack(state.get())); - return createReactor({ + return createReactor({ initial: 'preconditions-unmet', - context: {}, derive: () => derivedStatusSignal.get(), states: { 'preconditions-unmet': { diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index 0fb982d69..64b889aad 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -88,7 +88,7 @@ export function syncTextTracks; owners: Signal; -}): Reactor { +}): Reactor { const mediaElementSignal = computed(() => owners.get().mediaElement); const modelTextTracksSignal = computed(() => getModelTextTracks(state.get().presentation), { /** @TODO Make generic and abstract away for Array | undefined (CJP) */ @@ -108,9 +108,8 @@ export function syncTextTracks state.get().selectedTextTrackId); const preconditionsMetSignal = computed(() => !!mediaElementSignal.get() && !!modelTextTracksSignal.get()?.length); - return createReactor({ + return createReactor({ initial: 'preconditions-unmet', - context: {}, derive: () => (preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'), states: { 'preconditions-unmet': {}, diff --git a/packages/spf/src/dom/features/track-playback-initiated.ts b/packages/spf/src/dom/features/track-playback-initiated.ts index 7eb53de20..8d69ea95e 100644 --- a/packages/spf/src/dom/features/track-playback-initiated.ts +++ b/packages/spf/src/dom/features/track-playback-initiated.ts @@ -63,14 +63,13 @@ export function trackPlaybackInitiated; owners: Signal; -}): Reactor { +}): Reactor { const derivedStatusSignal = computed(() => deriveStatus(state.get(), owners.get())); const mediaElementSignal = computed(() => owners.get().mediaElement); const urlSignal = computed(() => state.get().presentation?.url); - return createReactor({ + return createReactor({ initial: 'preconditions-unmet', - context: {}, derive: () => derivedStatusSignal.get(), states: { 'preconditions-unmet': {}, From 7119cd6f59d1e7cd5300fb80513dbb075c8708b8 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 13:49:52 -0700 Subject: [PATCH 52/79] =?UTF-8?q?refactor(spf):=20align=20naming=20?= =?UTF-8?q?=E2=80=94=20snapshot.value,=20State=20type=20params,=20monitor?= =?UTF-8?q?=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ActorSnapshot.status` → `.value` (aligns with XState snapshot.value) - `Status` type params → `State` across actor.ts, create-actor.ts, create-reactor.ts - `SourceBufferActorStatus` → `SourceBufferActorState` - `TextTrackSegmentLoaderStatus` → `TextTrackSegmentLoaderState` - `derive` field → `monitor` on ReactorDefinition - `UserStatus`/`FullStatus` local names → `UserState`/`FullState` Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/actor.ts | 20 ++++---- packages/spf/src/core/create-actor.ts | 50 +++++++++---------- packages/spf/src/core/create-reactor.ts | 50 +++++++++---------- .../src/core/features/resolve-presentation.ts | 2 +- .../spf/src/core/tests/create-actor.test.ts | 24 ++++----- .../spf/src/core/tests/create-reactor.test.ts | 38 +++++++------- .../spf/src/dom/features/end-of-stream.ts | 8 +-- .../src/dom/features/load-text-track-cues.ts | 2 +- .../spf/src/dom/features/sync-text-tracks.ts | 2 +- .../text-track-segment-loader-actor.test.ts | 32 ++++++------ .../features/tests/text-tracks-actor.test.ts | 4 +- .../text-track-segment-loader-actor.ts | 8 +-- .../dom/features/track-playback-initiated.ts | 2 +- .../spf/src/dom/media/source-buffer-actor.ts | 38 +++++++------- .../media/tests/source-buffer-actor.test.ts | 26 +++++----- 15 files changed, 152 insertions(+), 154 deletions(-) diff --git a/packages/spf/src/core/actor.ts b/packages/spf/src/core/actor.ts index 8f5c1a805..f2dbb7ca9 100644 --- a/packages/spf/src/core/actor.ts +++ b/packages/spf/src/core/actor.ts @@ -1,24 +1,24 @@ /** * Generic actor types. * - * An actor owns its snapshot (finite status + non-finite context) and + * An actor owns its snapshot (finite state + non-finite context) and * notifies observers when it changes. Mirrors the XState snapshot model: - * `snapshot.status` is the bounded operational mode, `snapshot.context` + * `snapshot.value` is the bounded operational mode, `snapshot.context` * holds arbitrary non-finite data. */ import type { ReadonlySignal } from './signals/primitives'; -/** Complete actor snapshot: finite status + non-finite context. */ -export interface ActorSnapshot { - status: Status; +/** Complete actor snapshot: finite state + non-finite context. */ +export interface ActorSnapshot { + value: State; context: Context; } /** Generic actor interface: owns its snapshot and notifies observers. */ -export interface Actor { +export interface Actor { /** Current snapshot. */ - readonly snapshot: ActorSnapshot; + readonly snapshot: ActorSnapshot; /** * Subscribe to snapshot changes. Fires immediately with the current @@ -26,16 +26,16 @@ export interface Actor { * * @returns Unsubscribe function. */ - subscribe(listener: (snapshot: ActorSnapshot) => void): () => void; + subscribe(listener: (snapshot: ActorSnapshot) => void): () => void; /** Tear down the actor. */ destroy(): void; } /** Generic actor interface: owns its snapshot and notifies observers. */ -export interface SignalActor { +export interface SignalActor { /** Current snapshot. Readable and reactive; not writable by consumers. */ - readonly snapshot: ReadonlySignal>; + readonly snapshot: ReadonlySignal>; /** Tear down the actor. */ destroy(): void; } diff --git a/packages/spf/src/core/create-actor.ts b/packages/spf/src/core/create-actor.ts index 2dfa53490..8f80e1b81 100644 --- a/packages/spf/src/core/create-actor.ts +++ b/packages/spf/src/core/create-actor.ts @@ -26,11 +26,11 @@ export interface RunnerLike { * definition includes a runner factory. */ export type HandlerContext< - UserStatus extends string, + UserState extends string, Context extends object, RunnerFactory extends (() => RunnerLike) | undefined, > = { - transition: (to: UserStatus) => void; + transition: (to: UserState) => void; context: Context; setContext: (next: Context) => void; } & (RunnerFactory extends () => infer R ? { runner: R } : object); @@ -39,7 +39,7 @@ export type HandlerContext< * Definition for a single user-defined state. */ export type ActorStateDefinition< - UserStatus extends string, + UserState extends string, Context extends object, Message extends { type: string }, RunnerFactory extends (() => RunnerLike) | undefined, @@ -50,12 +50,12 @@ export type ActorStateDefinition< * re-registering after each `runner.schedule()` call so that * `abortAll()` + reschedule correctly supersedes stale callbacks. */ - onSettled?: UserStatus; + onSettled?: UserState; /** Message handlers active in this state. Messages with no handler are silently dropped. */ on?: { [M in Message as M['type']]?: ( message: Extract, - ctx: HandlerContext + ctx: HandlerContext ) => void; }; }; @@ -63,11 +63,11 @@ export type ActorStateDefinition< /** * Full actor definition passed to `createActor`. * - * `UserStatus` is the set of domain-meaningful states. `'destroyed'` is always + * `UserState` is the set of domain-meaningful states. `'destroyed'` is always * added by the framework as the implicit terminal state — do not include it here. */ export type ActorDefinition< - UserStatus extends string, + UserState extends string, Context extends object, Message extends { type: string }, RunnerFactory extends (() => RunnerLike) | undefined = undefined, @@ -81,14 +81,14 @@ export type ActorDefinition< */ runner?: RunnerFactory; /** Initial status. */ - initial: UserStatus; + initial: UserState; /** Initial context. */ context: Context; /** * Per-state definitions. States with no definition silently drop all messages. - * All user-defined states must appear as keys in the `UserStatus` union. + * All user-defined states must appear as keys in the `UserState` union. */ - states: Partial>>; + states: Partial>>; }; // ============================================================================= @@ -145,26 +145,26 @@ export interface MessageActor RunnerLike) | undefined = undefined, >( - def: ActorDefinition -): MessageActor { - type FullStatus = UserStatus | 'destroyed'; + def: ActorDefinition +): MessageActor { + type FullState = UserState | 'destroyed'; const runner = def.runner?.() as RunnerLike | undefined; - const snapshotSignal = signal>({ - status: def.initial as FullStatus, + const snapshotSignal = signal>({ + value: def.initial as FullState, context: def.context, }); - const getStatus = (): FullStatus => untrack(() => snapshotSignal.get().status); + const getStatus = (): FullState => untrack(() => snapshotSignal.get().value); const getContext = (): Context => untrack(() => snapshotSignal.get().context); - const transition = (to: FullStatus): void => { - update(snapshotSignal, { status: to }); + const transition = (to: FullState): void => { + update(snapshotSignal, { value: to }); }; const setContext = (context: Context): void => { @@ -179,23 +179,23 @@ export function createActor< send(message: Message): void { const status = getStatus(); if (status === 'destroyed') return; - const stateDef = def.states[status as UserStatus]; + const stateDef = def.states[status as UserState]; const handler = stateDef?.on?.[message.type as keyof typeof stateDef.on] as - | ((msg: Message, ctx: HandlerContext) => void) + | ((msg: Message, ctx: HandlerContext) => void) | undefined; if (!handler) return; handler(message, { context: getContext(), - transition: (to: UserStatus) => transition(to as FullStatus), + transition: (to: UserState) => transition(to as FullState), setContext, ...(runner ? { runner } : {}), - } as HandlerContext); + } as HandlerContext); // Register onSettled after the handler so we read the post-transition status. const newStatus = getStatus(); if (newStatus !== 'destroyed') { - const newStateDef = def.states[newStatus as UserStatus]; + const newStateDef = def.states[newStatus as UserState]; if (newStateDef?.onSettled && runner) { - const targetStatus = newStateDef.onSettled as FullStatus; + const targetStatus = newStateDef.onSettled as FullState; runner.whenSettled(() => { if (getStatus() !== newStatus) return; transition(targetStatus); diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index 930b7afa5..17f0e261d 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -7,14 +7,14 @@ import { signal, untrack, update } from './signals/primitives'; // ============================================================================= /** - * A reactive status-deriving function used in the `derive` field. + * A reactive status-deriving function used in the `monitor` field. * * Returns the target status the reactor should be in. Any signals read inside * the fn body create reactive dependencies — the framework re-evaluates it when * those signals change and automatically calls `transition()` when the returned * status differs from the current one. */ -export type ReactorDeriveFn = () => UserStatus; +export type ReactorDeriveFn = () => State; /** * An effect function used in reactor `entry` and `reactions` blocks. @@ -44,25 +44,25 @@ export type ReactorStateDefinition = { /** * Full reactor definition passed to `createReactor`. * - * `UserStatus` is the set of domain-meaningful states. `'destroying'` and + * `State` is the set of domain-meaningful states. `'destroying'` and * `'destroyed'` are always added by the framework as implicit terminal states — * do not include them here. */ -export type ReactorDefinition = { +export type ReactorDefinition = { /** Initial status. */ - initial: UserStatus; + initial: State; /** * Reactive status derivation. Registered before per-state effects — the * ordering guarantee ensures transitions fired here take effect before * per-state effects re-evaluate in the same flush. */ - derive?: ReactorDeriveFn | ReactorDeriveFn[]; + monitor?: ReactorDeriveFn | ReactorDeriveFn[]; /** * Per-state effect groupings. Every valid status must be declared — pass `{}` * for states with no effects. `entry` and `reactions` each become independent * `effect()` calls gated on that state, with their own cleanup lifecycles. */ - states: Record; + states: Record; }; // ============================================================================= @@ -101,7 +101,7 @@ const toArray = (x: T | T[] | undefined): T[] => (x === undefined ? [] : Arra * @example * const reactor = createReactor({ * initial: 'waiting', - * derive: () => srcSignal.get() ? 'active' : 'waiting', + * monitor: () => srcSignal.get() ? 'active' : 'waiting', * states: { * active: { * // entry: runs once on state entry; fn body is automatically untracked. @@ -113,19 +113,19 @@ const toArray = (x: T | T[] | undefined): T[] => (x === undefined ? [] : Arra * } * }); */ -export function createReactor( - def: ReactorDefinition -): Reactor { - type FullStatus = UserStatus | 'destroying' | 'destroyed'; +export function createReactor( + def: ReactorDefinition +): Reactor { + type FullState = State | 'destroying' | 'destroyed'; - const snapshotSignal = signal<{ status: FullStatus }>({ - status: def.initial as FullStatus, + const snapshotSignal = signal<{ value: FullState }>({ + value: def.initial as FullState, }); - const getStatus = (): FullStatus => untrack(() => snapshotSignal.get().status); + const getStatus = (): FullState => untrack(() => snapshotSignal.get().value); - const transition = (to: FullStatus): void => { - update(snapshotSignal, { status: to }); + const transition = (to: FullState): void => { + update(snapshotSignal, { value: to }); }; const effectDisposals: Array<() => void> = []; @@ -140,29 +140,29 @@ export function createReactor( type EffectDescriptor = { fn: ReactorEffectFn; - shouldSkip: (snapshot: { status: FullStatus }) => boolean; + shouldSkip: (snapshot: { value: FullState }) => boolean; toFnCall?: (baseCall: EffectCall) => EffectCall; }; const untracked: EffectDescriptor['toFnCall'] = (baseCall) => () => untrack(baseCall); - const isTerminal = (snapshot: { status: FullStatus }) => - snapshot.status === 'destroying' || snapshot.status === 'destroyed'; + const isTerminal = (snapshot: { value: FullState }) => + snapshot.value === 'destroying' || snapshot.value === 'destroyed'; - // `derive` descriptors are built first — the ordering guarantee ensures + // `monitor` descriptors are built first — the ordering guarantee ensures // transitions they trigger take effect before per-state effects re-evaluate // in the same flush. See the comment on effect registration order in the // previous implementation for full details. const descriptors: EffectDescriptor[] = [ - ...toArray(def.derive).map((fn) => ({ + ...toArray(def.monitor).map((fn) => ({ fn: () => { const target = fn(); - if (target !== (getStatus() as UserStatus)) transition(target as FullStatus); + if (target !== (getStatus() as State)) transition(target as FullState); }, shouldSkip: isTerminal, })), - ...(Object.entries(def.states) as Array<[UserStatus, ReactorStateDefinition]>).flatMap(([state, stateDef]) => { - const isNotState = (snapshot: { status: FullStatus }) => snapshot.status !== state; + ...(Object.entries(def.states) as Array<[State, ReactorStateDefinition]>).flatMap(([state, stateDef]) => { + const isNotState = (snapshot: { value: FullState }) => snapshot.value !== state; return [ ...toArray(stateDef.entry).map((fn) => ({ fn, shouldSkip: isNotState, toFnCall: untracked })), ...toArray(stateDef.reactions).map((fn) => ({ fn, shouldSkip: isNotState })), diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index 5813ce381..4f3cf5d05 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -95,7 +95,7 @@ export function resolvePresentation({ return createReactor({ initial: 'preconditions-unmet', - derive: () => derivedStatusSignal.get(), + monitor: () => derivedStatusSignal.get(), states: { 'preconditions-unmet': {}, idle: {}, diff --git a/packages/spf/src/core/tests/create-actor.test.ts b/packages/spf/src/core/tests/create-actor.test.ts index e00ae2076..f730f426d 100644 --- a/packages/spf/src/core/tests/create-actor.test.ts +++ b/packages/spf/src/core/tests/create-actor.test.ts @@ -34,7 +34,7 @@ describe('createActor', () => { it('starts with the initial status and context', () => { const actor = makeCounter(); - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); expect(actor.snapshot.get().context).toEqual({ count: 0 }); actor.destroy(); @@ -88,7 +88,7 @@ describe('createActor', () => { actor.send({ type: 'start' }); - expect(actor.snapshot.get().status).toBe('running'); + expect(actor.snapshot.get().value).toBe('running'); actor.destroy(); }); @@ -163,9 +163,9 @@ describe('createActor', () => { actor.send({ type: 'start' }); const after = actor.snapshot.get(); - expect(before.status).toBe('idle'); + expect(before.value).toBe('idle'); expect(before.context.count).toBe(0); - expect(after.status).toBe('running'); + expect(after.value).toBe('running'); expect(after.context.count).toBe(1); actor.destroy(); @@ -182,7 +182,7 @@ describe('createActor — destroy', () => { actor.destroy(); - expect(actor.snapshot.get().status).toBe('destroyed'); + expect(actor.snapshot.get().value).toBe('destroyed'); }); it('destroy() is idempotent', () => { @@ -190,7 +190,7 @@ describe('createActor — destroy', () => { actor.destroy(); expect(() => actor.destroy()).not.toThrow(); - expect(actor.snapshot.get().status).toBe('destroyed'); + expect(actor.snapshot.get().value).toBe('destroyed'); }); it('drops send() after destroy()', () => { @@ -299,10 +299,10 @@ describe('createActor — runner', () => { }); actor.send({ type: 'load' }); - expect(actor.snapshot.get().status).toBe('loading'); + expect(actor.snapshot.get().value).toBe('loading'); await vi.waitFor(() => { - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); }); actor.destroy(); @@ -344,13 +344,13 @@ describe('createActor — runner', () => { await vi.waitFor(() => expect(resolveTask).toBeDefined()); actor.send({ type: 'cancel' }); - expect(actor.snapshot.get().status).toBe('cancelled'); + expect(actor.snapshot.get().value).toBe('cancelled'); // Unblock the task — the settled callback fires but the state check prevents transition resolveTask(); await new Promise((r) => setTimeout(r, 10)); - expect(actor.snapshot.get().status).toBe('cancelled'); // not 'idle' + expect(actor.snapshot.get().value).toBe('cancelled'); // not 'idle' actor.destroy(); }); @@ -405,10 +405,10 @@ describe('createActor — runner', () => { resolveFirst(); await vi.waitFor(() => { - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); }); - expect(actor.snapshot.get().status).toBe('idle'); // exactly one transition, not two + expect(actor.snapshot.get().value).toBe('idle'); // exactly one transition, not two actor.destroy(); }); diff --git a/packages/spf/src/core/tests/create-reactor.test.ts b/packages/spf/src/core/tests/create-reactor.test.ts index 2f1836bd3..35eedafeb 100644 --- a/packages/spf/src/core/tests/create-reactor.test.ts +++ b/packages/spf/src/core/tests/create-reactor.test.ts @@ -16,7 +16,7 @@ describe('createReactor', () => { states: { idle: {} }, }); - expect(reactor.snapshot.get().status).toBe('idle'); + expect(reactor.snapshot.get().value).toBe('idle'); reactor.destroy(); }); @@ -58,19 +58,19 @@ describe('createReactor', () => { const src = signal(false); const reactor = createReactor<'waiting' | 'active'>({ initial: 'waiting', - derive: () => (src.get() ? 'active' : 'waiting'), + monitor: () => (src.get() ? 'active' : 'waiting'), states: { waiting: {}, active: {}, }, }); - expect(reactor.snapshot.get().status).toBe('waiting'); + expect(reactor.snapshot.get().value).toBe('waiting'); src.set(true); await tick(); - expect(reactor.snapshot.get().status).toBe('active'); + expect(reactor.snapshot.get().value).toBe('active'); reactor.destroy(); }); @@ -81,7 +81,7 @@ describe('createReactor', () => { const reactor = createReactor<'waiting' | 'active'>({ initial: 'waiting', - derive: () => (src.get() ? 'active' : 'waiting'), + monitor: () => (src.get() ? 'active' : 'waiting'), states: { waiting: {}, active: { entry: [activeFn] }, @@ -166,7 +166,7 @@ describe('createReactor', () => { const src = signal(false); const reactor = createReactor<'waiting' | 'active'>({ initial: 'waiting', - derive: () => (src.get() ? 'active' : 'waiting'), + monitor: () => (src.get() ? 'active' : 'waiting'), states: { waiting: {}, active: {}, @@ -178,8 +178,8 @@ describe('createReactor', () => { await tick(); const after = reactor.snapshot.get(); - expect(before.status).toBe('waiting'); - expect(after.status).toBe('active'); + expect(before.value).toBe('waiting'); + expect(after.value).toBe('active'); reactor.destroy(); }); @@ -196,7 +196,7 @@ describe('createReactor — cleanup', () => { const reactor = createReactor<'active' | 'done'>({ initial: 'active', - derive: () => (src.get() ? 'done' : 'active'), + monitor: () => (src.get() ? 'done' : 'active'), states: { active: { // entry: cleanup fires on exit regardless of whether fn is tracked @@ -294,16 +294,16 @@ describe('createReactor — derive', () => { const src = signal<'waiting' | 'active'>('waiting'); const reactor = createReactor<'waiting' | 'active'>({ initial: 'waiting', - derive: () => src.get(), + monitor: () => src.get(), states: { waiting: {}, active: {} }, }); - expect(reactor.snapshot.get().status).toBe('waiting'); + expect(reactor.snapshot.get().value).toBe('waiting'); src.set('active'); await tick(); - expect(reactor.snapshot.get().status).toBe('active'); + expect(reactor.snapshot.get().value).toBe('active'); reactor.destroy(); }); @@ -312,13 +312,13 @@ describe('createReactor — derive', () => { const activeFn = vi.fn(); const reactor = createReactor<'idle' | 'active'>({ initial: 'idle', - derive: () => 'idle', + monitor: () => 'idle', states: { idle: {}, active: { entry: activeFn } }, }); await tick(); - expect(reactor.snapshot.get().status).toBe('idle'); + expect(reactor.snapshot.get().value).toBe('idle'); expect(activeFn).not.toHaveBeenCalled(); reactor.destroy(); @@ -330,7 +330,7 @@ describe('createReactor — derive', () => { const reactor = createReactor<'waiting' | 'active'>({ initial: 'waiting', - derive: deriveFn, + monitor: deriveFn, states: { waiting: {}, active: {} }, }); @@ -348,7 +348,7 @@ describe('createReactor — derive', () => { const deriveFn = vi.fn(() => 'idle' as const); const reactor = createReactor({ initial: 'idle' as const, - derive: deriveFn, + monitor: deriveFn, states: { idle: {} }, }); @@ -365,7 +365,7 @@ describe('createReactor — derive', () => { const reactor = createReactor<'waiting' | 'active'>({ initial: 'waiting', - derive: () => { + monitor: () => { order.push('derive'); return src.get(); }, @@ -409,7 +409,7 @@ describe('createReactor — destroy', () => { reactor.destroy(); - expect(reactor.snapshot.get().status).toBe('destroyed'); + expect(reactor.snapshot.get().value).toBe('destroyed'); }); it('destroy() is idempotent', () => { @@ -420,7 +420,7 @@ describe('createReactor — destroy', () => { reactor.destroy(); expect(() => reactor.destroy()).not.toThrow(); - expect(reactor.snapshot.get().status).toBe('destroyed'); + expect(reactor.snapshot.get().value).toBe('destroyed'); }); it('does not run reactions after destroy()', async () => { diff --git a/packages/spf/src/dom/features/end-of-stream.ts b/packages/spf/src/dom/features/end-of-stream.ts index 46ccd54fe..cb00160e4 100644 --- a/packages/spf/src/dom/features/end-of-stream.ts +++ b/packages/spf/src/dom/features/end-of-stream.ts @@ -134,8 +134,8 @@ export function shouldEndStream(state: EndOfStreamState, owners: EndOfStreamOwne // SourceBufferActors must be idle — setting duration while a SourceBuffer is // updating throws InvalidStateError. The actor subscriber in endOfStream() will // re-evaluate when each actor transitions back to idle. - if (owners.videoBufferActor?.snapshot.get().status === 'updating') return false; - if (owners.audioBufferActor?.snapshot.get().status === 'updating') return false; + if (owners.videoBufferActor?.snapshot.get().value === 'updating') return false; + if (owners.audioBufferActor?.snapshot.get().value === 'updating') return false; // Last segment must be appended for each selected track if (!hasLastSegmentLoaded(state, owners)) return false; @@ -169,7 +169,7 @@ export function shouldEndStream(state: EndOfStreamState, owners: EndOfStreamOwne */ function waitForSourceBuffersReady(owners: EndOfStreamOwners): Promise { const updatingActors = [owners.videoBufferActor, owners.audioBufferActor].filter( - (actor): actor is SourceBufferActor => actor !== undefined && actor.snapshot.get().status === 'updating' + (actor): actor is SourceBufferActor => actor !== undefined && actor.snapshot.get().value === 'updating' ); if (updatingActors.length === 0) return Promise.resolve(); @@ -186,7 +186,7 @@ function waitForSourceBuffersReady(owners: EndOfStreamOwners): Promise { let cleanup: (() => void) | undefined; let resolved = false; cleanup = effect(() => { - if (actor.snapshot.get().status !== 'updating') { + if (actor.snapshot.get().value !== 'updating') { if (!resolved) { resolved = true; resolve(); diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index d0409dc57..1073f377b 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -143,7 +143,7 @@ export function loadTextTrackCues({ initial: 'preconditions-unmet', - derive: () => derivedStatusSignal.get(), + monitor: () => derivedStatusSignal.get(), states: { 'preconditions-unmet': { // Entry: defensive actor reset on state entry (no-op if already undefined). diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index 64b889aad..cd15cd8ee 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -110,7 +110,7 @@ export function syncTextTracks({ initial: 'preconditions-unmet', - derive: () => (preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'), + monitor: () => (preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'), states: { 'preconditions-unmet': {}, diff --git a/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts b/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts index 4a1039ea7..ebc1bb7ff 100644 --- a/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts +++ b/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts @@ -57,7 +57,7 @@ describe('TextTrackSegmentLoaderActor', () => { const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); expect(actor.snapshot.get().context).toEqual({}); actor.destroy(); @@ -72,7 +72,7 @@ describe('TextTrackSegmentLoaderActor', () => { actor.send({ type: 'load', track, currentTime: 0 }); - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); actor.destroy(); textTracksActor.destroy(); @@ -85,10 +85,10 @@ describe('TextTrackSegmentLoaderActor', () => { const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); actor.send({ type: 'load', track, currentTime: 0 }); - expect(actor.snapshot.get().status).toBe('loading'); + expect(actor.snapshot.get().value).toBe('loading'); await vi.waitFor(() => { - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); }); actor.destroy(); @@ -104,7 +104,7 @@ describe('TextTrackSegmentLoaderActor', () => { actor.send({ type: 'load', track, currentTime: 0 }); await vi.waitFor(() => { - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); }); expect(textTracksActor.snapshot.get().context.segments['track-en']).toHaveLength(2); @@ -123,12 +123,12 @@ describe('TextTrackSegmentLoaderActor', () => { const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt', 'https://example.com/seg-1.vtt']); actor.send({ type: 'load', track, currentTime: 0 }); - await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('idle')); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(parseVttSegment).toHaveBeenCalledTimes(2); // Repeat send — all segments already in TextTracksActor context actor.send({ type: 'load', track, currentTime: 0 }); - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); expect(parseVttSegment).toHaveBeenCalledTimes(2); actor.destroy(); @@ -155,7 +155,7 @@ describe('TextTrackSegmentLoaderActor', () => { actor.send({ type: 'load', track, currentTime: 0 }); - await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('idle')); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load VTT segment:', expect.any(Error)); // Segments 0 and 2 succeeded; the failed segment is not recorded @@ -188,7 +188,7 @@ describe('TextTrackSegmentLoaderActor', () => { // Start loading track1 — paused waiting for seg0 actor.send({ type: 'load', track: track1, currentTime: 0 }); - expect(actor.snapshot.get().status).toBe('loading'); + expect(actor.snapshot.get().value).toBe('loading'); // Wait for the Task to actually start running — resolveSeg0 is assigned inside // the Promise constructor, which executes when parseVttSegment is called async. @@ -200,7 +200,7 @@ describe('TextTrackSegmentLoaderActor', () => { // Unblock seg0 — signal is already aborted, so the cue is discarded resolveSeg0([]); - await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('idle')); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); // track-en was preempted — no cues recorded expect(textTracksActor.snapshot.get().context.segments['track-en']).toBeUndefined(); @@ -218,7 +218,7 @@ describe('TextTrackSegmentLoaderActor', () => { actor.destroy(); - expect(actor.snapshot.get().status).toBe('destroyed'); + expect(actor.snapshot.get().value).toBe('destroyed'); textTracksActor.destroy(); }); @@ -237,7 +237,7 @@ describe('TextTrackSegmentLoaderActor', () => { await new Promise((resolve) => setTimeout(resolve, 10)); expect(parseVttSegment).not.toHaveBeenCalled(); - expect(actor.snapshot.get().status).toBe('destroyed'); + expect(actor.snapshot.get().value).toBe('destroyed'); textTracksActor.destroy(); }); @@ -248,13 +248,13 @@ describe('TextTrackSegmentLoaderActor', () => { const actor = createTextTrackSegmentLoaderActor(textTracksActor); const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); - const observed = [actor.snapshot.get().status]; + const observed = [actor.snapshot.get().value]; actor.send({ type: 'load', track, currentTime: 0 }); - observed.push(actor.snapshot.get().status); + observed.push(actor.snapshot.get().value); - await vi.waitFor(() => expect(actor.snapshot.get().status).toBe('idle')); - observed.push(actor.snapshot.get().status); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); + observed.push(actor.snapshot.get().value); expect(observed).toEqual(['idle', 'loading', 'idle']); diff --git a/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts b/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts index a2d04310d..5383dbf4b 100644 --- a/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts +++ b/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts @@ -22,7 +22,7 @@ describe('TextTracksActor', () => { const video = makeMediaElement(['track-en']); const actor = createTextTracksActor(video); - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); expect(actor.snapshot.get().context.loaded).toEqual({}); expect(actor.snapshot.get().context.segments).toEqual({}); }); @@ -156,7 +156,7 @@ describe('TextTracksActor', () => { actor.destroy(); - expect(actor.snapshot.get().status).toBe('destroyed'); + expect(actor.snapshot.get().value).toBe('destroyed'); }); it('ignores send() after destroy()', () => { diff --git a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts index 90dc8c32b..98224b557 100644 --- a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts +++ b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts @@ -11,7 +11,7 @@ import type { TextTracksActor } from './text-tracks-actor'; // Types // ============================================================================= -export type TextTrackSegmentLoaderStatus = 'idle' | 'loading'; +export type TextTrackSegmentLoaderState = 'idle' | 'loading'; export type TextTrackSegmentLoaderMessage = { type: 'load'; @@ -20,7 +20,7 @@ export type TextTrackSegmentLoaderMessage = { }; export type TextTrackSegmentLoaderActor = MessageActor< - TextTrackSegmentLoaderStatus | 'destroyed', + TextTrackSegmentLoaderState | 'destroyed', object, TextTrackSegmentLoaderMessage >; @@ -42,7 +42,7 @@ export type TextTrackSegmentLoaderActor = MessageActor< export function createTextTrackSegmentLoaderActor(textTracksActor: TextTracksActor): TextTrackSegmentLoaderActor { const loadHandler = ( message: TextTrackSegmentLoaderMessage, - { transition, runner }: { transition: (to: TextTrackSegmentLoaderStatus) => void; runner: SerialRunner } + { transition, runner }: { transition: (to: TextTrackSegmentLoaderState) => void; runner: SerialRunner } ): void => { const { track, currentTime } = message; const trackId = track.id; @@ -80,7 +80,7 @@ export function createTextTrackSegmentLoaderActor(textTracksActor: TextTracksAct return createActor({ runner: () => new SerialRunner(), - initial: 'idle' as TextTrackSegmentLoaderStatus, + initial: 'idle' as TextTrackSegmentLoaderState, context: {} as object, states: { idle: { diff --git a/packages/spf/src/dom/features/track-playback-initiated.ts b/packages/spf/src/dom/features/track-playback-initiated.ts index 8d69ea95e..c8db476da 100644 --- a/packages/spf/src/dom/features/track-playback-initiated.ts +++ b/packages/spf/src/dom/features/track-playback-initiated.ts @@ -70,7 +70,7 @@ export function trackPlaybackInitiated({ initial: 'preconditions-unmet', - derive: () => derivedStatusSignal.get(), + monitor: () => derivedStatusSignal.get(), states: { 'preconditions-unmet': {}, diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index 342fb3175..ab5ca5fae 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -27,8 +27,8 @@ export type AppendSegmentMessage = { type: 'append-segment'; data: AppendData; m export type RemoveMessage = { type: 'remove'; start: number; end: number }; export type SourceBufferMessage = AppendInitMessage | AppendSegmentMessage | RemoveMessage; -/** Finite (bounded) operational modes of the actor. */ -export type SourceBufferActorStatus = 'idle' | 'updating' | 'destroyed'; +/** Finite states of the actor. */ +export type SourceBufferActorState = 'idle' | 'updating' | 'destroyed'; /** Non-finite (extended) data managed by the actor — the XState "context". */ export interface SourceBufferActorContext { @@ -49,7 +49,7 @@ export interface SourceBufferActorContext { } /** Complete snapshot of a SourceBufferActor. */ -export type SourceBufferActorSnapshot = ActorSnapshot; +export type SourceBufferActorSnapshot = ActorSnapshot; /** * Thrown when a message is sent to the actor in a state that does not @@ -63,7 +63,7 @@ export class SourceBufferActorError extends Error { } /** SourceBuffer actor: queues operations, owns its snapshot. */ -export interface SourceBufferActor extends SignalActor { +export interface SourceBufferActor extends SignalActor { send(message: SourceBufferMessage, signal: AbortSignal): Promise; batch(messages: SourceBufferMessage[], signal: AbortSignal): Promise; } @@ -231,7 +231,7 @@ export function createSourceBufferActor( initialContext?: Partial ): SourceBufferActor { const snapshotSignal = signal({ - status: 'idle', + value: 'idle', context: { segments: [], bufferedRanges: [], initTrackId: undefined, ...initialContext }, }); @@ -241,8 +241,8 @@ export function createSourceBufferActor( // If the actor was destroyed while the operation was in flight, preserve // 'destroyed' — do not regress to 'idle'. function applyResult(newContext: SourceBufferActorContext): void { - const status = snapshotSignal.get().status === 'destroyed' ? 'destroyed' : 'idle'; - snapshotSignal.set({ status, context: newContext }); + const status = snapshotSignal.get().value === 'destroyed' ? 'destroyed' : 'idle'; + snapshotSignal.set({ value: status, context: newContext }); } function handleError(e: unknown): never { @@ -251,8 +251,8 @@ export function createSourceBufferActor( // improvement should detect QuotaExceededError specifically and use total // bytes-in-buffer as a heuristic to identify the effective buffer capacity, // enabling targeted flush-and-retry rather than silent model drift. - const status = snapshotSignal.get().status === 'destroyed' ? 'destroyed' : 'idle'; - update(snapshotSignal, { status }); + const status = snapshotSignal.get().value === 'destroyed' ? 'destroyed' : 'idle'; + update(snapshotSignal, { value: status }); throw e; } @@ -262,18 +262,16 @@ export function createSourceBufferActor( }, send(message: SourceBufferMessage, signal: AbortSignal): Promise { - if (snapshotSignal.get().status !== 'idle') { - return Promise.reject( - new SourceBufferActorError(`send() called while actor is ${snapshotSignal.get().status}`) - ); + if (snapshotSignal.get().value !== 'idle') { + return Promise.reject(new SourceBufferActorError(`send() called while actor is ${snapshotSignal.get().value}`)); } // Transition synchronously so any subsequent send/batch within the same // tick is rejected — the actor is now committed to this operation. - update(snapshotSignal, { status: 'updating' }); + update(snapshotSignal, { value: 'updating' }); const onPartialContext = (ctx: SourceBufferActorContext) => { - snapshotSignal.set({ status: 'updating', context: ctx }); + snapshotSignal.set({ value: 'updating', context: ctx }); }; const task = messageToTask(message, { @@ -287,16 +285,16 @@ export function createSourceBufferActor( }, batch(messages: SourceBufferMessage[], signal: AbortSignal): Promise { - if (snapshotSignal.get().status !== 'idle') { + if (snapshotSignal.get().value !== 'idle') { return Promise.reject( - new SourceBufferActorError(`batch() called while actor is ${snapshotSignal.get().status}`) + new SourceBufferActorError(`batch() called while actor is ${snapshotSignal.get().value}`) ); } if (messages.length === 0) return Promise.resolve(); // Transition synchronously — the entire batch is one 'updating' period. - update(snapshotSignal, { status: 'updating' }); + update(snapshotSignal, { value: 'updating' }); // Each message is its own Task on the runner, executed in submission order. // workingCtx threads the result of each task into the next without @@ -318,7 +316,7 @@ export function createSourceBufferActor( // directly so external subscribers see in-progress state, but workingCtx // is only advanced on task completion to preserve batch context threading. const onPartialContext = (ctx: SourceBufferActorContext) => { - snapshotSignal.set({ status: 'updating', context: ctx }); + snapshotSignal.set({ value: 'updating', context: ctx }); }; for (const message of messages.slice(0, -1)) { @@ -339,7 +337,7 @@ export function createSourceBufferActor( }, destroy(): void { - update(snapshotSignal, { status: 'destroyed' }); + update(snapshotSignal, { value: 'destroyed' }); runner.destroy(); }, }; diff --git a/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts b/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts index 5d64422c2..8fe74f899 100644 --- a/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts +++ b/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts @@ -119,7 +119,7 @@ describe('createSourceBufferActor', () => { await actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); await actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }, neverAborted); @@ -186,9 +186,9 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - const statusValues: string[] = []; + const stateValues: string[] = []; const cleanup = effect(() => { - statusValues.push(actor.snapshot.get().status); + stateValues.push(actor.snapshot.get().value); }); await actor.batch( @@ -207,8 +207,8 @@ describe('createSourceBufferActor', () => { // Initial idle (immediate subscribe fire) → updating → idle // No intermediate context-update-while-updating snapshots - expect(statusValues).toContain('updating'); - expect(statusValues[statusValues.length - 1]).toBe('idle'); + expect(stateValues).toContain('updating'); + expect(stateValues[stateValues.length - 1]).toBe('idle'); actor.destroy(); }); @@ -282,7 +282,7 @@ describe('createSourceBufferActor', () => { await actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); expect(actor.snapshot.get().context.initTrackId).toBe('track-1'); - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); actor.destroy(); }); @@ -311,7 +311,7 @@ describe('createSourceBufferActor', () => { duration: 10, trackId: 'track-1', }); - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); actor.destroy(); }); @@ -392,7 +392,7 @@ describe('createSourceBufferActor', () => { expect(ids).not.toContain('s1'); expect(ids).not.toContain('s2'); expect(ids).toContain('s3'); - expect(actor.snapshot.get().status).toBe('idle'); + expect(actor.snapshot.get().value).toBe('idle'); actor.destroy(); }); @@ -405,9 +405,9 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - const statusValues: string[] = []; + const stateValues: string[] = []; const cleanup = effect(() => { - statusValues.push(actor.snapshot.get().status); + stateValues.push(actor.snapshot.get().value); }); await actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); @@ -415,8 +415,8 @@ describe('createSourceBufferActor', () => { cleanup(); // Initial 'idle' (from immediate subscribe fire) → 'updating' → 'idle' - expect(statusValues).toContain('updating'); - expect(statusValues[statusValues.length - 1]).toBe('idle'); + expect(stateValues).toContain('updating'); + expect(stateValues[stateValues.length - 1]).toBe('idle'); actor.destroy(); }); @@ -429,7 +429,7 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - expect(actor.snapshot.get()).toMatchObject({ status: 'idle', context: { segments: [], bufferedRanges: [] } }); + expect(actor.snapshot.get()).toMatchObject({ value: 'idle', context: { segments: [], bufferedRanges: [] } }); actor.destroy(); }); From df0d3a168ceca10c01498a9d0341c4d920f82170 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 13:53:23 -0700 Subject: [PATCH 53/79] refactor(spf): rename internal status variables to state Renames local `status`/`newStatus`/`targetStatus` variables to `state`/`newState`/`targetState` for consistency with the public API rename. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-actor.ts | 20 +++++++++---------- packages/spf/src/core/create-reactor.ts | 4 ++-- .../spf/src/dom/features/end-of-stream.ts | 2 +- .../spf/src/dom/media/source-buffer-actor.ts | 12 +++++------ 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/spf/src/core/create-actor.ts b/packages/spf/src/core/create-actor.ts index 8f80e1b81..026cd693e 100644 --- a/packages/spf/src/core/create-actor.ts +++ b/packages/spf/src/core/create-actor.ts @@ -177,9 +177,9 @@ export function createActor< }, send(message: Message): void { - const status = getStatus(); - if (status === 'destroyed') return; - const stateDef = def.states[status as UserState]; + const state = getStatus(); + if (state === 'destroyed') return; + const stateDef = def.states[state as UserState]; const handler = stateDef?.on?.[message.type as keyof typeof stateDef.on] as | ((msg: Message, ctx: HandlerContext) => void) | undefined; @@ -190,15 +190,15 @@ export function createActor< setContext, ...(runner ? { runner } : {}), } as HandlerContext); - // Register onSettled after the handler so we read the post-transition status. - const newStatus = getStatus(); - if (newStatus !== 'destroyed') { - const newStateDef = def.states[newStatus as UserState]; + // Register onSettled after the handler so we read the post-transition state. + const newState = getStatus(); + if (newState !== 'destroyed') { + const newStateDef = def.states[newState as UserState]; if (newStateDef?.onSettled && runner) { - const targetStatus = newStateDef.onSettled as FullState; + const targetState = newStateDef.onSettled as FullState; runner.whenSettled(() => { - if (getStatus() !== newStatus) return; - transition(targetStatus); + if (getStatus() !== newState) return; + transition(targetState); }); } } diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index 17f0e261d..4aacecd17 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -186,8 +186,8 @@ export function createReactor( }, destroy(): void { - const status = getStatus(); - if (status === 'destroying' || status === 'destroyed') return; + const state = getStatus(); + if (state === 'destroying' || state === 'destroyed') return; // Two-step teardown: transition through 'destroying' first to leave room // for async teardown in a future extension, then immediately 'destroyed' // for the synchronous base case. Active effect cleanups fire via disposal. diff --git a/packages/spf/src/dom/features/end-of-stream.ts b/packages/spf/src/dom/features/end-of-stream.ts index cb00160e4..c8edd3b6b 100644 --- a/packages/spf/src/dom/features/end-of-stream.ts +++ b/packages/spf/src/dom/features/end-of-stream.ts @@ -164,7 +164,7 @@ export function shouldEndStream(state: EndOfStreamState, owners: EndOfStreamOwne /** * Wait for all currently-updating SourceBufferActors to finish. - * Uses actor status rather than raw SourceBuffer.updating so the wait is + * Uses actor state rather than raw SourceBuffer.updating so the wait is * aligned with the same abstraction that owns all buffer operations. */ function waitForSourceBuffersReady(owners: EndOfStreamOwners): Promise { diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index ab5ca5fae..590b44a17 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -241,8 +241,8 @@ export function createSourceBufferActor( // If the actor was destroyed while the operation was in flight, preserve // 'destroyed' — do not regress to 'idle'. function applyResult(newContext: SourceBufferActorContext): void { - const status = snapshotSignal.get().value === 'destroyed' ? 'destroyed' : 'idle'; - snapshotSignal.set({ value: status, context: newContext }); + const state = snapshotSignal.get().value === 'destroyed' ? 'destroyed' : 'idle'; + snapshotSignal.set({ value: state, context: newContext }); } function handleError(e: unknown): never { @@ -251,8 +251,8 @@ export function createSourceBufferActor( // improvement should detect QuotaExceededError specifically and use total // bytes-in-buffer as a heuristic to identify the effective buffer capacity, // enabling targeted flush-and-retry rather than silent model drift. - const status = snapshotSignal.get().value === 'destroyed' ? 'destroyed' : 'idle'; - update(snapshotSignal, { value: status }); + const state = snapshotSignal.get().value === 'destroyed' ? 'destroyed' : 'idle'; + update(snapshotSignal, { value: state }); throw e; } @@ -301,9 +301,9 @@ export function createSourceBufferActor( // writing to the signal between steps — context is only written atomically // when the last task completes. // - // workingCtx is captured here (synchronously after status → 'updating'), + // workingCtx is captured here (synchronously after state → 'updating'), // so it reflects the current context at the moment the batch was accepted. - // This is correct: status is now 'updating' so no other sender can modify + // This is correct: state is now 'updating' so no other sender can modify // context between this line and the first task executing. // // NOTE: if an intermediate task fails (e.g. SourceBuffer error event), From b1abae41c3859c8d43d77d57ced83366576dbd05 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 14:02:11 -0700 Subject: [PATCH 54/79] =?UTF-8?q?refactor(spf):=20align=20naming=20?= =?UTF-8?q?=E2=80=94=20State=20type=20params,=20monitor=20field,=20inline?= =?UTF-8?q?=20FSM=20state=20literals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename UserStatus/Status → State type params across createReactor, createActor, actor.ts - Rename snapshot.status → snapshot.value (XState alignment) - Rename derive: → monitor: in ReactorDefinition - Replace exported FSM-state union types with inlined literals where they collided with existing *State interfaces (sync-text-tracks, track-playback-initiated) - Rename internal variables (getStatus → getState, const status → const state, etc.) - Rename deriveStatus → deriveState, derivedStatusSignal → derivedStateSignal in resolve-presentation, load-text-track-cues, track-playback-initiated Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/create-actor.ts | 20 ++++++++--------- packages/spf/src/core/create-reactor.ts | 22 +++++++++---------- .../src/core/features/resolve-presentation.ts | 16 +++++++------- .../src/dom/features/load-text-track-cues.ts | 18 +++++++-------- .../spf/src/dom/features/sync-text-tracks.ts | 6 ++--- .../dom/features/track-playback-initiated.ts | 21 +++++++++--------- 6 files changed, 51 insertions(+), 52 deletions(-) diff --git a/packages/spf/src/core/create-actor.ts b/packages/spf/src/core/create-actor.ts index 026cd693e..9b2cc1ad8 100644 --- a/packages/spf/src/core/create-actor.ts +++ b/packages/spf/src/core/create-actor.ts @@ -46,7 +46,7 @@ export type ActorStateDefinition< > = { /** * When the actor's runner settles while in this state, automatically - * transition to this status. The framework owns the generation-token logic — + * transition to this state. The framework owns the generation-token logic — * re-registering after each `runner.schedule()` call so that * `abortAll()` + reschedule correctly supersedes stale callbacks. */ @@ -80,7 +80,7 @@ export type ActorDefinition< * runner: () => new SerialRunner() */ runner?: RunnerFactory; - /** Initial status. */ + /** Initial state. */ initial: UserState; /** Initial context. */ context: Context; @@ -96,8 +96,8 @@ export type ActorDefinition< // ============================================================================= /** Live actor instance returned by `createActor`. */ -export interface MessageActor - extends SignalActor { +export interface MessageActor + extends SignalActor { send(message: Message): void; } @@ -108,7 +108,7 @@ export interface MessageActor untrack(() => snapshotSignal.get().value); + const getState = (): FullState => untrack(() => snapshotSignal.get().value); const getContext = (): Context => untrack(() => snapshotSignal.get().context); const transition = (to: FullState): void => { @@ -177,7 +177,7 @@ export function createActor< }, send(message: Message): void { - const state = getStatus(); + const state = getState(); if (state === 'destroyed') return; const stateDef = def.states[state as UserState]; const handler = stateDef?.on?.[message.type as keyof typeof stateDef.on] as @@ -191,13 +191,13 @@ export function createActor< ...(runner ? { runner } : {}), } as HandlerContext); // Register onSettled after the handler so we read the post-transition state. - const newState = getStatus(); + const newState = getState(); if (newState !== 'destroyed') { const newStateDef = def.states[newState as UserState]; if (newStateDef?.onSettled && runner) { const targetState = newStateDef.onSettled as FullState; runner.whenSettled(() => { - if (getStatus() !== newState) return; + if (getState() !== newState) return; transition(targetState); }); } @@ -205,7 +205,7 @@ export function createActor< }, destroy(): void { - if (getStatus() === 'destroyed') return; + if (getState() === 'destroyed') return; runner?.destroy(); transition('destroyed'); }, diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index 4aacecd17..a4d2668a3 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -7,12 +7,12 @@ import { signal, untrack, update } from './signals/primitives'; // ============================================================================= /** - * A reactive status-deriving function used in the `monitor` field. + * A reactive state-deriving function used in the `monitor` field. * - * Returns the target status the reactor should be in. Any signals read inside + * Returns the target state the reactor should be in. Any signals read inside * the fn body create reactive dependencies — the framework re-evaluates it when * those signals change and automatically calls `transition()` when the returned - * status differs from the current one. + * state differs from the current one. */ export type ReactorDeriveFn = () => State; @@ -49,16 +49,16 @@ export type ReactorStateDefinition = { * do not include them here. */ export type ReactorDefinition = { - /** Initial status. */ + /** Initial state. */ initial: State; /** - * Reactive status derivation. Registered before per-state effects — the + * Reactive state derivation. Registered before per-state effects — the * ordering guarantee ensures transitions fired here take effect before * per-state effects re-evaluate in the same flush. */ monitor?: ReactorDeriveFn | ReactorDeriveFn[]; /** - * Per-state effect groupings. Every valid status must be declared — pass `{}` + * Per-state effect groupings. Every valid state must be declared — pass `{}` * for states with no effects. `entry` and `reactions` each become independent * `effect()` calls gated on that state, with their own cleanup lifecycles. */ @@ -70,8 +70,8 @@ export type ReactorDefinition = { // ============================================================================= /** Live reactor instance returned by `createReactor`. */ -export type Reactor = { - readonly snapshot: ReadonlySignal<{ status: Status }>; +export type Reactor = { + readonly snapshot: ReadonlySignal<{ value: State }>; destroy(): void; }; @@ -122,7 +122,7 @@ export function createReactor( value: def.initial as FullState, }); - const getStatus = (): FullState => untrack(() => snapshotSignal.get().value); + const getState = (): FullState => untrack(() => snapshotSignal.get().value); const transition = (to: FullState): void => { update(snapshotSignal, { value: to }); @@ -157,7 +157,7 @@ export function createReactor( ...toArray(def.monitor).map((fn) => ({ fn: () => { const target = fn(); - if (target !== (getStatus() as State)) transition(target as FullState); + if (target !== (getState() as State)) transition(target as FullState); }, shouldSkip: isTerminal, })), @@ -186,7 +186,7 @@ export function createReactor( }, destroy(): void { - const state = getStatus(); + const state = getState(); if (state === 'destroying' || state === 'destroyed') return; // Two-step teardown: transition through 'destroying' first to leave room // for async teardown in a future extension, then immediately 'destroyed' diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index 4f3cf5d05..bf13435c6 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -56,10 +56,10 @@ export function shouldResolve(state: PresentationState): boolean { ); } -export type ResolvePresentationStatus = 'preconditions-unmet' | 'idle' | 'resolving' | 'resolved'; +export type ResolvePresentationState = 'preconditions-unmet' | 'idle' | 'resolving' | 'resolved'; /** - * Derives the correct status from current state conditions. + * Derives the correct state from current state conditions. * * States are mutually exclusive and exhaustive: * - `'preconditions-unmet'`: no presentation, or presentation has no URL @@ -67,7 +67,7 @@ export type ResolvePresentationStatus = 'preconditions-unmet' | 'idle' | 'resolv * - `'resolving'`: URL present, unresolved (no id), shouldResolve met * - `'resolved'`: URL present, resolved (has id) */ -function deriveStatus(state: PresentationState): ResolvePresentationStatus { +function deriveState(state: PresentationState): ResolvePresentationState { const { presentation } = state; if (!presentation || !('url' in presentation)) return 'preconditions-unmet'; if ('id' in presentation) return 'resolved'; @@ -77,7 +77,7 @@ function deriveStatus(state: PresentationState): ResolvePresentationStatus { /** * Resolves unresolved presentations using reactive composition. * - * FSM driven by `deriveStatus` — a single `always` monitor keeps the status in + * FSM driven by `deriveState` — a single `always` monitor keeps the state in * sync with conditions at all times. `'resolving'` additionally runs the fetch * task and returns an AbortController so the framework aborts it on state exit. * @@ -90,12 +90,12 @@ export function resolvePresentation({ state, }: { state: Signal; -}): Reactor { - const derivedStatusSignal = computed(() => deriveStatus(state.get())); +}): Reactor { + const derivedStateSignal = computed(() => deriveState(state.get())); - return createReactor({ + return createReactor({ initial: 'preconditions-unmet', - monitor: () => derivedStatusSignal.get(), + monitor: () => derivedStateSignal.get(), states: { 'preconditions-unmet': {}, idle: {}, diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 1073f377b..fbc4a25dd 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -26,7 +26,7 @@ import { createTextTracksActor } from './text-tracks-actor'; * any state ──── destroy() ────→ 'destroying' ────→ 'destroyed' * ``` */ -export type LoadTextTrackCuesStatus = 'preconditions-unmet' | 'setting-up' | 'pending' | 'monitoring-for-loads'; +export type LoadTextTrackCuesState = 'preconditions-unmet' | 'setting-up' | 'pending' | 'monitoring-for-loads'; /** * State shape for text track cue loading. @@ -67,7 +67,7 @@ function findSelectedTrack(state: TextTrackCueLoadingState): TextTrack | undefin } /** - * Derives the correct status from current state and owners. + * Derives the correct state from current state and owners. * * States are mutually exclusive and exhaustive: * - `'preconditions-unmet'`: no mediaElement, or no resolved presentation with text tracks @@ -75,7 +75,7 @@ function findSelectedTrack(state: TextTrackCueLoadingState): TextTrack | undefin * - `'pending'`: actors alive; no selection, or selected track not yet resolved/in DOM * - `'monitoring-for-loads'`: selected track resolved, in DOM — ready to dispatch load messages */ -function deriveStatus(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): LoadTextTrackCuesStatus { +function deriveState(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): LoadTextTrackCuesState { if (!owners.mediaElement || !getTextTracks(state.presentation)?.length) { return 'preconditions-unmet'; } @@ -136,14 +136,14 @@ export function loadTextTrackCues; owners: Signal; -}): Reactor { - const derivedStatusSignal = computed(() => deriveStatus(state.get(), owners.get())); +}): Reactor { + const derivedStateSignal = computed(() => deriveState(state.get(), owners.get())); const currentTimeSignal = computed(() => state.get().currentTime ?? 0); const selectedTrackSignal = computed(() => findSelectedTrack(state.get())); - return createReactor({ + return createReactor({ initial: 'preconditions-unmet', - monitor: () => derivedStatusSignal.get(), + monitor: () => derivedStateSignal.get(), states: { 'preconditions-unmet': { // Entry: defensive actor reset on state entry (no-op if already undefined). @@ -169,11 +169,11 @@ export function loadTextTrackCues { const currentTime = currentTimeSignal.get(); const track = selectedTrackSignal.get()!; - // deriveStatus guarantees segmentLoaderActor is in owners and findSelectedTrack + // deriveState guarantees segmentLoaderActor is in owners and findSelectedTrack // returns a valid resolved track when in this state. The always monitor // (registered before this effect) transitions us out before this re-runs // if either invariant ever stops holding. diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index cd15cd8ee..1635c7e80 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -15,8 +15,6 @@ import type { PartiallyResolvedTextTrack, Presentation, TextTrack } from '../../ * any state ──── destroy() ────→ 'destroying' ────→ 'destroyed' * ``` */ -export type TextTrackSyncStatus = 'preconditions-unmet' | 'set-up'; - /** * State shape for text track sync. */ @@ -88,7 +86,7 @@ export function syncTextTracks; owners: Signal; -}): Reactor { +}): Reactor<'preconditions-unmet' | 'set-up' | 'destroying' | 'destroyed'> { const mediaElementSignal = computed(() => owners.get().mediaElement); const modelTextTracksSignal = computed(() => getModelTextTracks(state.get().presentation), { /** @TODO Make generic and abstract away for Array | undefined (CJP) */ @@ -108,7 +106,7 @@ export function syncTextTracks state.get().selectedTextTrackId); const preconditionsMetSignal = computed(() => !!mediaElementSignal.get() && !!modelTextTracksSignal.get()?.length); - return createReactor({ + return createReactor<'preconditions-unmet' | 'set-up'>({ initial: 'preconditions-unmet', monitor: () => (preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'), states: { diff --git a/packages/spf/src/dom/features/track-playback-initiated.ts b/packages/spf/src/dom/features/track-playback-initiated.ts index c8db476da..e769fa1f6 100644 --- a/packages/spf/src/dom/features/track-playback-initiated.ts +++ b/packages/spf/src/dom/features/track-playback-initiated.ts @@ -34,9 +34,10 @@ export interface PlaybackInitiatedOwners { * any state ──── destroy() ────→ 'destroying' ────→ 'destroyed' * ``` */ -type PlaybackInitiatedStatus = 'preconditions-unmet' | 'monitoring' | 'playback-initiated'; - -function deriveStatus(state: PlaybackInitiatedState, owners: PlaybackInitiatedOwners): PlaybackInitiatedStatus { +function deriveState( + state: PlaybackInitiatedState, + owners: PlaybackInitiatedOwners +): 'preconditions-unmet' | 'monitoring' | 'playback-initiated' { if (!owners.mediaElement || !state.presentation?.url) return 'preconditions-unmet'; if (state.playbackInitiated) return 'playback-initiated'; return 'monitoring'; @@ -46,7 +47,7 @@ function deriveStatus(state: PlaybackInitiatedState, owners: PlaybackInitiatedOw * Track whether playback has been initiated for the current presentation URL. * * A three-state Reactor FSM driven by `state.playbackInitiated` and the - * `deriveStatus` pattern: + * `deriveState` pattern: * - `'preconditions-unmet'` — no element or URL yet; no effects. * - `'monitoring'` — checks `!el.paused` on entry; listens for `play`. * - `'playback-initiated'` — tracks element and URL; exit cleanup resets @@ -63,14 +64,14 @@ export function trackPlaybackInitiated; owners: Signal; -}): Reactor { - const derivedStatusSignal = computed(() => deriveStatus(state.get(), owners.get())); +}): Reactor<'preconditions-unmet' | 'monitoring' | 'playback-initiated' | 'destroying' | 'destroyed'> { + const derivedStateSignal = computed(() => deriveState(state.get(), owners.get())); const mediaElementSignal = computed(() => owners.get().mediaElement); const urlSignal = computed(() => state.get().presentation?.url); - return createReactor({ + return createReactor<'preconditions-unmet' | 'monitoring' | 'playback-initiated'>({ initial: 'preconditions-unmet', - monitor: () => derivedStatusSignal.get(), + monitor: () => derivedStateSignal.get(), states: { 'preconditions-unmet': {}, @@ -89,11 +90,11 @@ export function trackPlaybackInitiated { From d940e29c35071b3e43a4933176435663d341dbaf Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 14:04:59 -0700 Subject: [PATCH 55/79] docs(spf): update actor-reactor-factories design doc to reflect settled API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename UserStatus/Status → UserState/State throughout - Replace always[] pattern with monitor field (ReactorDeriveFn) - Replace flat per-state effect arrays with entry/reactions keys - Remove context from ReactorDefinition (was removed from implementation) - Update both reactor examples (syncTextTracks, loadTextTrackCues) to new API - Rename "always-before-state" ordering section to "monitor-before-state" - Remove "Future direction" note for entry/reactions (already implemented) - Close Reactor context open question (context removed) - Update snapshot.status → snapshot.value, deriveStatus → deriveState references Co-Authored-By: Claude Sonnet 4.6 --- .../design/spf/actor-reactor-factories.md | 290 ++++++++---------- 1 file changed, 130 insertions(+), 160 deletions(-) diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index b94fd99b5..260408d7e 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -1,6 +1,6 @@ --- status: decided -date: 2026-03-31 +date: 2026-04-03 --- # Actor and Reactor Factories @@ -18,7 +18,7 @@ for the reference implementation and spike assessment. ## Decision Actors and Reactors are defined via a **declarative definition object** passed to a factory -function. The factory constructs the live instance — managing the status signal, runner +function. The factory constructs the live instance — managing the state signal, runner lifecycle, and `'destroyed'` terminal state. Consumers define behavior; the framework handles mechanics. @@ -39,20 +39,20 @@ Both return instances that implement `SignalActor` and expose `snapshot` and `de ```typescript type ActorDefinition< - UserStatus extends string, + UserState extends string, Context extends object, Message extends { type: string }, RunnerFactory extends (() => RunnerLike) | undefined = undefined, > = { runner?: RunnerFactory; // factory — called once at createActor() time - initial: UserStatus; + initial: UserState; context: Context; - states: Partial, - ctx: HandlerContext + ctx: HandlerContext ) => void; }; }>>; @@ -60,8 +60,8 @@ type ActorDefinition< // runner is present and typed as the exact runner only when runner: is declared. // When omitted, runner is absent from the type entirely (not undefined). -type HandlerContext = { - transition: (to: UserStatus) => void; +type HandlerContext = { + transition: (to: UserState) => void; context: Context; setContext: (next: Context) => void; } & (RunnerFactory extends () => infer R ? { runner: R } : object); @@ -131,72 +131,62 @@ const textTracksActorDef = { ### Shape ```typescript -type ReactorDefinition = { - initial: UserStatus; - context: Context; +type ReactorDefinition = { + initial: State; /** - * Cross-cutting effects that run in every non-terminal state. - * Each element becomes one independent effect() call. The current status is - * available in ctx, allowing a single effect to monitor conditions and drive - * transitions from any state without duplicating guards in every state block. + * Cross-cutting monitor — returns the target state. The framework compares + * to the current state and drives the transition. Registered before per-state + * effects — see the monitor-before-state ordering guarantee below. * - * ORDERING GUARANTEE: always effects run before per-state effects in every flush. - * This is load-bearing — see the "always-before-state ordering" decision below. + * Accepts a single function or an array of functions. */ - always?: ReactorAlwaysEffectFn[]; + monitor?: ReactorDeriveFn | ReactorDeriveFn[]; /** - * Per-state effect arrays. Every valid status must be declared (use [] for - * states with no effects). Each element becomes one independent effect() call - * gated on that state, with its own dependency tracking and cleanup lifecycle. + * Per-state definitions. States with no effects use `{}`. */ - states: Record[]>; + states: Record; }; -type ReactorEffectFn = (ctx: { - transition: (to: UserStatus) => void; - context: Context; - setContext: (next: Context) => void; -}) => (() => void) | { abort(): void } | void; +/** Returns the target state. Framework drives the transition. */ +type ReactorDeriveFn = () => State; -type ReactorAlwaysEffectFn = (ctx: { - status: UserStatus; // current status — not available in per-state effects - transition: (to: UserStatus) => void; - context: Context; - setContext: (next: Context) => void; -}) => (() => void) | { abort(): void } | void; -``` +type ReactorStateDefinition = { + /** + * Entry effects — run once on state entry, automatically untracked. + * No untrack() needed inside the fn body. Return a cleanup function or + * AbortController to run on state exit. + */ + entry?: ReactorEffectFn | ReactorEffectFn[]; + /** + * Reactive effects — re-run whenever a tracked signal changes while + * this state is active. Return a cleanup to run before each re-run + * and on state exit. + */ + reactions?: ReactorEffectFn | ReactorEffectFn[]; +}; -Each array element becomes one independent `effect()` call. `always` entries run in every -non-terminal state and fire *before* per-state entries — see the `always`-before-state -ordering guarantee below. Multiple entries for the same state produce multiple effects — -each with independent dependency tracking and cleanup. This is the mechanism that replaces -multiple named `cleanupX` variables in the current function-based reactors. +type ReactorEffectFn = () => (() => void) | { abort(): void } | void; +``` ### Example — `syncTextTracks` -Two states (`preconditions-unmet` ↔ `set-up`), one `always` monitor, two independent -effects in `set-up` with separate tracking and cleanup. +Two states (`preconditions-unmet` ↔ `set-up`), one `monitor`, and one `entry` + one +`reactions` effect in `set-up` with independent tracking and cleanup. ```typescript -const syncTextTracksDef = { - initial: 'preconditions-unmet' as const, - context: {}, - // Single always effect drives all transitions from one place. - always: [ - ({ status, transition }) => { - const target = preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'; - if (target !== status) transition(target); - } - ], +const reactor = createReactor<'preconditions-unmet' | 'set-up'>({ + initial: 'preconditions-unmet', + // monitor returns the target state; framework drives the transition. + monitor: () => preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet', states: { - 'preconditions-unmet': [], // no effects — always monitor handles exit - - 'set-up': [ - // Effect 1 — enter-once: create elements, return teardown cleanup. - // untrack() prevents re-runs on mediaElement/modelTextTracks changes. - () => { - const el = untrack(() => mediaElementSignal.get()!); - const tracks = untrack(() => modelTextTracksSignal.get()!); + 'preconditions-unmet': {}, // no effects — monitor handles exit + + 'set-up': { + // entry: automatically untracked — runs once on state entry. + // Reading mediaElement and modelTextTracks here does NOT create dependencies. + entry: () => { + const el = mediaElementSignal.get() as HTMLMediaElement; + const tracks = modelTextTracksSignal.get() as PartiallyResolvedTextTrack[]; tracks.forEach(t => el.appendChild(createTrackElement(t))); return () => { el.querySelectorAll('track[data-src-track]').forEach(t => t.remove()); @@ -204,71 +194,69 @@ const syncTextTracksDef = { }; }, - // Effect 2 — reactive-within-state: re-runs when selectedId changes. - // Independent tracking and cleanup from Effect 1. - () => { - const el = untrack(() => mediaElementSignal.get()!); - const selectedId = selectedIdSignal.get(); // tracked — re-run on change + // reactions: re-runs when selectedId changes. el is read with untrack() + // since element changes go through the monitor (preconditions-unmet path). + reactions: () => { + const el = untrack(() => mediaElementSignal.get() as HTMLMediaElement); + const selectedId = selectedIdSignal.get(); // tracked — re-run on change syncModes(el.textTracks, selectedId); const unlisten = listen(el.textTracks, 'change', onChange); return () => unlisten(); - } - ] - } -}; + }, + }, + }, +}); ``` ### Example — `loadTextTrackCues` -Four states with actor lifecycle managed across states, the `deriveStatus` pattern for +Four states with actor lifecycle managed across states, the `deriveState` pattern for complex multi-condition transitions, and `untrack()` for non-reactive owner reads. ```typescript -// Hoist computeds outside effects — computed() inside an effect body +// Hoist computeds outside the reactor — computed() inside an effect body // creates a new Computed node on every re-run with no memoization. -const derivedStatusSignal = computed(() => deriveStatus(state.get(), owners.get())); +const derivedStateSignal = computed(() => deriveState(state.get(), owners.get())); const currentTimeSignal = computed(() => state.get().currentTime ?? 0); const selectedTrackSignal = computed(() => findSelectedTrack(state.get())); -const loadTextTrackCuesDef = { - initial: 'preconditions-unmet' as const, - context: {}, - always: [ - ({ status, transition }) => { - const target = derivedStatusSignal.get(); - if (target !== status) transition(target); - } - ], +const reactor = createReactor({ + initial: 'preconditions-unmet', + monitor: () => derivedStateSignal.get(), states: { - 'preconditions-unmet': [ - () => { - // Entry-reset: destroy any stale actors; no-op if already undefined. - // Runs on every entry, handling all paths back from active states. - teardownActors(owners); - } - ], - 'setting-up': [ - () => { + 'preconditions-unmet': { + // entry: defensive actor reset on state entry (no-op if already undefined). + // Handles all paths back from active states. + entry: () => { teardownActors(owners); }, + }, + + 'setting-up': { + entry: () => { teardownActors(owners); // defensive — same as preconditions-unmet - const mediaElement = untrack(() => owners.get().mediaElement!); + const mediaElement = owners.get().mediaElement as HTMLMediaElement; const textTracksActor = createTextTracksActor(mediaElement); const segmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor); update(owners, { textTracksActor, segmentLoaderActor }); - // No return — deriveStatus drives the onward transition automatically. - } - ], - pending: [], // neutral waiting state — no effects - 'monitoring-for-loads': [ - () => { - const currentTime = currentTimeSignal.get(); // tracked — re-run on advance - const track = selectedTrackSignal.get()!; // tracked — re-run on change - // untrack owners — actor snapshot changes must not re-trigger this effect. + // No return — deriveState drives the onward transition automatically. + }, + }, + + pending: {}, // neutral waiting state — no effects + + 'monitoring-for-loads': { + // reactions: 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. + reactions: () => { + const currentTime = currentTimeSignal.get(); // tracked + const track = selectedTrackSignal.get()!; // tracked const { segmentLoaderActor } = untrack(() => owners.get()); segmentLoaderActor!.send({ type: 'load', track, currentTime }); - } - ] - } -}; + }, + }, + }, +}); ``` --- @@ -305,7 +293,7 @@ door open for a future definition-vs-implementation separation (see below). **Rationale:** Actors and Reactors have genuinely different input shapes and internal mechanics. A unified factory would produce a definition type with optional properties for both cases, losing type-level guarantees (e.g., a Reactor definition shouldn't have `runner` or `on`). -The shared core — status signal, `'destroyed'` terminal, `destroy()` — is thin enough to +The shared core — state signal, `'destroyed'` terminal, `destroy()` — is thin enough to extract as an internal `createMachineCore` without a unified public API. XState unifies because its actors ARE the reactive graph; in SPF, the separation between reactive observation (Reactor) and message dispatch (Actor) is intentional and worth preserving in the API surface. @@ -314,15 +302,15 @@ and message dispatch (Actor) is intentional and worth preserving in the API surf ### `'destroyed'` is implicit and always enforced -**Decision:** User-defined status types never include `'destroyed'`. The framework always adds it +**Decision:** User-defined state types never include `'destroyed'`. The framework always adds it as the terminal state. `destroy()` on any Actor or Reactor always transitions to `'destroyed'` and calls exit cleanup for the currently active state. ```typescript // User defines: -type LoaderUserStatus = 'idle' | 'loading'; +type LoaderUserState = 'idle' | 'loading'; // Framework produces: -type LoaderStatus = 'idle' | 'loading' | 'destroyed'; +type LoaderState = 'idle' | 'loading' | 'destroyed'; ``` **Rationale:** `'destroyed'` is universal — every Actor and Reactor has it. Making it implicit @@ -356,20 +344,21 @@ stale one) is handled by the framework internally rather than by runner scope. --- -### `always`-before-state ordering guarantee +### `monitor`-before-state ordering guarantee -**Decision:** `always` effects are registered before per-state effects in `createReactor`. +**Decision:** `monitor` effects are registered before per-state effects in `createReactor`. This ordering is **load-bearing**: per-state effects can rely on invariants established by -`always` monitors having already run. +`monitor` having already run. **How it works:** The effect scheduler drains pending computeds into an insertion-ordered -`Set` before executing them. Because `always` effects are registered first, they are +`Set` before executing them. Because `monitor` effects are registered first, they are guaranteed to execute before per-state effects in every flush. -**What this enables:** When an `always` monitor calls `transition(newState)`, the snapshot -signal updates immediately. By the time per-state effects run, the reactor is already in -`newState` — so a per-state effect gated on `snapshot.status !== state` correctly no-ops -without needing to re-check conditions that the `always` monitor just resolved. +**What this enables:** When a `monitor` fn returns a new state, `createReactor` calls +`transition()` immediately and updates the snapshot signal. By the time per-state effects run, +the reactor is already in the new state — so a per-state effect gated on +`snapshot.value !== state` correctly no-ops without needing to re-check conditions that the +`monitor` just resolved. **Important caveat:** This guarantee is specific to `createReactor`'s registration order. It is not a formal guarantee of the TC39 Signals proposal — it depends on the polyfill's @@ -393,7 +382,7 @@ states: { **Alternatives considered:** - **Top-level `on`** with internal state guard — one handler per message type, branches on - `context.status` internally. More compact for simple cases, but hides state-dependent + `context.state` internally. More compact for simple cases, but hides state-dependent behavior in imperative branches rather than making it explicit in the definition. **Rationale:** Matches XState's model. State-scoped handlers make valid message/state combinations @@ -403,9 +392,9 @@ explicit and inspectable from the definition alone — no need to trace imperati ### `onSettled` at the state level -**Decision:** Each state can declare `onSettled: 'targetStatus'`. When the actor's runner settles +**Decision:** Each state can declare `onSettled: 'targetState'`. When the actor's runner settles (all scheduled tasks have completed) while the actor is in that state, the framework automatically -transitions to `targetStatus`. +transitions to `targetState`. **Rationale:** This replaces the manual `runner.settled` reference-equality pattern in `TextTrackSegmentLoaderActor`. The framework owns the generation-token logic — re-subscribing to @@ -414,10 +403,30 @@ cancels the previous settled callback. --- +### `entry` vs `reactions` per-state effects + +Per-state effects fall into two distinct categories, each with its own key in the state definition: + +- **`entry`** — run once on state entry, **automatically untracked**. No `untrack()` needed inside + the fn body. Use for one-time setup: creating DOM elements, reading `owners`, starting a fetch. + Return a cleanup function or `AbortController` to run on state exit (or re-entry if the effect + runs again). +- **`reactions`** — intentionally re-run when a tracked signal changes while the state is active. + Use for effects that must stay in sync with reactive data: mode sync, message dispatch. + +Signals that should not trigger re-runs in a `reactions` effect must be wrapped with `untrack()`. +Signal reads inside `entry` are automatically untracked — the fn body runs inside `untrack()`. + +**Inline computed anti-pattern:** `computed()` inside an effect body creates a new `Computed` +node on every re-run with no memoization. `Computed`s that gate effect re-runs must be hoisted +*outside* the effect body (typically at the factory function scope, before `createReactor()`). + +--- + ## XState-style Definition vs. Implementation The current design uses a single definition object that contains both structure (states, runner -type, initial status) and behavior (handler functions). XState v5 separates these: +type, initial state) and behavior (handler functions). XState v5 separates these: ```typescript // Definition — pure structure, no runtime dependencies @@ -436,7 +445,7 @@ implementation map later. The migration path is additive — no existing definit The second argument to Actor message handlers is: ```typescript -{ transition: (to: UserStatus) => void; context: Context; setContext: (next: Context) => void } +{ transition: (to: UserState) => void; context: Context; setContext: (next: Context) => void } & (RunnerFactory extends () => infer R ? { runner: R } : {}) ``` @@ -447,32 +456,6 @@ conditional intersection. --- -### Entry vs. reactive per-state effects - -Per-state effects fall into two distinct categories: - -- **Enter-once effects** — run once on state entry, do setup work, return a cleanup. - Signal reads inside these should be wrapped in `untrack()` to prevent accidental re-runs. - Example: creating `` elements, reading `mediaElement` from owners, starting a fetch. -- **Reactive-within-state effects** — intentionally re-run when a tracked signal changes while - the state is active. Example: `syncTextTracks` Effect 2, which re-runs whenever - `selectedTextTrackId` changes to re-apply mode sync. - -Both categories use the same `effect()` mechanism. The distinction is enforced by convention -(`untrack()` for enter-once reads) rather than by the API — nothing in the definition shape -prevents an enter-once effect from accidentally tracking a signal and re-running. - -**Inline computed anti-pattern:** `computed()` inside an effect body creates a new `Computed` -node on every re-run with no memoization. `Computed`s that gate effect re-runs must be hoisted -*outside* the effect body (typically at the factory function scope, before `createReactor()`). -This applies regardless of whether the effect is enter-once or reactive. - -**Future direction:** Distinguish these in the definition shape — e.g., `entry` for enter-once -effects (automatically untracked) and `reactive` (or signal-keyed `on`) for reactive-within-state -effects. Revisit once more reactive-within-state examples accumulate. - ---- - ## Open Questions ### `settled` on `ConcurrentRunner` @@ -485,16 +468,3 @@ Options: - Define a `SettledRunner` interface and make `onSettled` only valid for runners that implement it Leaning toward the former: `whenSettled` is a generally useful concept for any runner. - -### Reactor `context` — what belongs where - -`createReactor` accepts a `context` field, and effects receive `context` + `setContext`. -Reactor context is non-finite state visible in the snapshot. - -In practice, the text track spike used empty `context: {}` throughout — reactor state was -held via closure variables and the `owners` signal. The formal `context` field is available -but its usage patterns are not yet settled. - -Open: what belongs in Reactor `context` vs. closure variables vs. the `owners` signal? -Tradeoffs: observability (context is in the snapshot; closure is not) vs. simplicity -(closure is zero API surface). Revisit as more Reactors are written. From 58f70faaf0b55f2f7dcb329cc4f996994d8e8728 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 3 Apr 2026 14:26:46 -0700 Subject: [PATCH 56/79] =?UTF-8?q?fix(spf):=20resolve=20pre-existing=20test?= =?UTF-8?q?=20failures=20=E2=80=94=20fetch=20pollution=20and=20paused=20mo?= =?UTF-8?q?ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adapter.test.ts: stub fetch as never-settling promise in beforeEach/afterEach. Tests that attach a media element to an engine with a src URL trigger syncPreloadAttribute (browser default preload="auto") → resolvePresentation tries to fetch with no mock → unhandled TypeError: Failed to fetch. playback-engine.test.ts: mock el.paused=false before synthetic play dispatch. trackPlaybackInitiated sets playbackInitiated=!el.paused on the play event. A synthetic dispatchEvent("play") doesn't change el.paused (only el.play() does), so !el.paused was false and playbackInitiated never became true. Mocking paused to false reflects real browser behavior where paused is false when play fires. Co-Authored-By: Claude Sonnet 4.6 --- .../dom/playback-engine/tests/adapter.test.ts | 16 +++++++++++++++- .../spf/src/dom/tests/playback-engine.test.ts | 4 +++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/spf/src/dom/playback-engine/tests/adapter.test.ts b/packages/spf/src/dom/playback-engine/tests/adapter.test.ts index 2332a3061..547858ea2 100644 --- a/packages/spf/src/dom/playback-engine/tests/adapter.test.ts +++ b/packages/spf/src/dom/playback-engine/tests/adapter.test.ts @@ -14,10 +14,24 @@ * * Future: consider web-platform-tests (wpt) fixtures for deeper spec coverage. */ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SpfMedia } from '../adapter'; describe('SpfMedia', () => { + // Prevent real network calls from engines that auto-trigger resolution + // (e.g. when a media element with default preload="auto" is attached alongside a src). + // A never-settling promise avoids unhandled rejections without affecting test assertions. + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => {})) + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + // --------------------------------------------------------------------------- // src — synchronous IDL attribute reflection (WHATWG §4.8.11.2) // --------------------------------------------------------------------------- diff --git a/packages/spf/src/dom/tests/playback-engine.test.ts b/packages/spf/src/dom/tests/playback-engine.test.ts index 649500656..787be2b8b 100644 --- a/packages/spf/src/dom/tests/playback-engine.test.ts +++ b/packages/spf/src/dom/tests/playback-engine.test.ts @@ -580,7 +580,9 @@ http://example.com/audio-seg1.m4s expect(state.presentation?.selectionSets).toBeUndefined(); expect(mockFetch).not.toHaveBeenCalled(); - // PHASE 2: Simulate play (via media element — sets playbackInitiated + dispatches to event stream) + // PHASE 2: Simulate play — reflect real browser behavior where paused becomes + // false before the play event fires (per WHATWG §4.8.11.8), then dispatch. + Object.defineProperty(mediaElement, 'paused', { get: () => false, configurable: true }); mediaElement.dispatchEvent(new Event('play')); // Wait for complete orchestration From 15d585f55589ec761e5f49f693908b73711691e3 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 09:26:21 -0700 Subject: [PATCH 57/79] refactor(spf): establish Machine type hierarchy with createMachineCore Introduce machine.ts with MachineSnapshot and Machine as the shared base for actors and reactors. createMachineCore provisions the signal, getState(), and transition() mechanics so createActor and createReactor no longer duplicate them. ActorSnapshot now explicitly extends MachineSnapshot; SignalActor extends Machine. Co-Authored-By: Claude Sonnet 4.6 --- .../design/spf/actor-reactor-factories.md | 11 +---- internal/design/spf/signals.md | 2 +- packages/spf/src/core/actor.ts | 37 ++++----------- packages/spf/src/core/create-actor.ts | 10 ++-- packages/spf/src/core/create-reactor.ts | 18 ++----- packages/spf/src/core/machine.ts | 47 +++++++++++++++++++ 6 files changed, 66 insertions(+), 59 deletions(-) create mode 100644 packages/spf/src/core/machine.ts diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index 260408d7e..444187790 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -458,13 +458,4 @@ conditional intersection. ## Open Questions -### `settled` on `ConcurrentRunner` - -`SerialRunner` exposes `.whenSettled()`. `ConcurrentRunner` does not. `onSettled` at the -state level implies the runner has a way to signal completion. - -Options: -- Add `whenSettled()` to `ConcurrentRunner` (triggers when `#pending` map empties) -- Define a `SettledRunner` interface and make `onSettled` only valid for runners that implement it - -Leaning toward the former: `whenSettled` is a generally useful concept for any runner. +_No open questions._ diff --git a/internal/design/spf/signals.md b/internal/design/spf/signals.md index 36f60bb2d..23e00a23f 100644 --- a/internal/design/spf/signals.md +++ b/internal/design/spf/signals.md @@ -66,7 +66,7 @@ via the `Signal.subtle.Watcher` API, leaving scheduling entirely to the caller. `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 `always`-before-state ordering guarantee in +This scheduling control is what makes the `monitor`-before-state ordering guarantee in `createReactor` possible. The effect scheduler drains pending computeds in an insertion-ordered `Set`, so registration order determines execution order. diff --git a/packages/spf/src/core/actor.ts b/packages/spf/src/core/actor.ts index f2dbb7ca9..4e95d88e6 100644 --- a/packages/spf/src/core/actor.ts +++ b/packages/spf/src/core/actor.ts @@ -7,35 +7,16 @@ * holds arbitrary non-finite data. */ -import type { ReadonlySignal } from './signals/primitives'; +import type { Machine, MachineSnapshot } from './machine'; -/** Complete actor snapshot: finite state + non-finite context. */ -export interface ActorSnapshot { - value: State; +/** + * Complete actor snapshot: finite state + non-finite context. + * Extends `MachineSnapshot` with context — the non-finite data managed by the actor. + */ +export interface ActorSnapshot extends MachineSnapshot { context: Context; } -/** Generic actor interface: owns its snapshot and notifies observers. */ -export interface Actor { - /** Current snapshot. */ - readonly snapshot: ActorSnapshot; - - /** - * Subscribe to snapshot changes. Fires immediately with the current - * snapshot, then on every subsequent change. - * - * @returns Unsubscribe function. - */ - subscribe(listener: (snapshot: ActorSnapshot) => void): () => void; - - /** Tear down the actor. */ - destroy(): void; -} - -/** Generic actor interface: owns its snapshot and notifies observers. */ -export interface SignalActor { - /** Current snapshot. Readable and reactive; not writable by consumers. */ - readonly snapshot: ReadonlySignal>; - /** Tear down the actor. */ - destroy(): void; -} +/** Generic actor interface: owns its snapshot as a reactive signal. */ +export interface SignalActor + extends Machine> {} diff --git a/packages/spf/src/core/create-actor.ts b/packages/spf/src/core/create-actor.ts index 9b2cc1ad8..8dfb8f3d0 100644 --- a/packages/spf/src/core/create-actor.ts +++ b/packages/spf/src/core/create-actor.ts @@ -1,5 +1,6 @@ import type { ActorSnapshot, SignalActor } from './actor'; -import { signal, untrack, update } from './signals/primitives'; +import { createMachineCore } from './machine'; +import { untrack, update } from './signals/primitives'; import type { TaskLike } from './task'; // ============================================================================= @@ -155,18 +156,13 @@ export function createActor< type FullState = UserState | 'destroyed'; const runner = def.runner?.() as RunnerLike | undefined; - const snapshotSignal = signal>({ + const { snapshotSignal, getState, transition } = createMachineCore>({ value: def.initial as FullState, context: def.context, }); - const getState = (): FullState => untrack(() => snapshotSignal.get().value); const getContext = (): Context => untrack(() => snapshotSignal.get().context); - const transition = (to: FullState): void => { - update(snapshotSignal, { value: to }); - }; - const setContext = (context: Context): void => { update(snapshotSignal, { context }); }; diff --git a/packages/spf/src/core/create-reactor.ts b/packages/spf/src/core/create-reactor.ts index a4d2668a3..c444443e0 100644 --- a/packages/spf/src/core/create-reactor.ts +++ b/packages/spf/src/core/create-reactor.ts @@ -1,6 +1,7 @@ +import type { Machine, MachineSnapshot } from './machine'; +import { createMachineCore } from './machine'; import { effect } from './signals/effect'; -import type { ReadonlySignal } from './signals/primitives'; -import { signal, untrack, update } from './signals/primitives'; +import { untrack } from './signals/primitives'; // ============================================================================= // Definition types @@ -70,10 +71,7 @@ export type ReactorDefinition = { // ============================================================================= /** Live reactor instance returned by `createReactor`. */ -export type Reactor = { - readonly snapshot: ReadonlySignal<{ value: State }>; - destroy(): void; -}; +export type Reactor = Machine>; // ============================================================================= // Implementation helpers @@ -118,16 +116,10 @@ export function createReactor( ): Reactor { type FullState = State | 'destroying' | 'destroyed'; - const snapshotSignal = signal<{ value: FullState }>({ + const { snapshotSignal, getState, transition } = createMachineCore>({ value: def.initial as FullState, }); - const getState = (): FullState => untrack(() => snapshotSignal.get().value); - - const transition = (to: FullState): void => { - update(snapshotSignal, { value: to }); - }; - const effectDisposals: Array<() => void> = []; const wrapResult = (result: ReturnType) => { diff --git a/packages/spf/src/core/machine.ts b/packages/spf/src/core/machine.ts new file mode 100644 index 000000000..c482c13cf --- /dev/null +++ b/packages/spf/src/core/machine.ts @@ -0,0 +1,47 @@ +import type { ReadonlySignal } from './signals/primitives'; +import { signal, untrack, update } from './signals/primitives'; + +// ============================================================================= +// Shared snapshot type +// ============================================================================= + +/** + * Base snapshot for all machine-like primitives (Actors and Reactors). + * Carries only the finite state value. Actors extend this with `context`. + */ +export interface MachineSnapshot { + value: State; +} + +// ============================================================================= +// Shared interface +// ============================================================================= + +/** + * Shared interface for all machine-like primitives. + * Both Actors (message-driven) and Reactors (signal-driven) implement this. + */ +export interface Machine> { + readonly snapshot: ReadonlySignal; + destroy(): void; +} + +// ============================================================================= +// Shared core factory +// ============================================================================= + +/** + * Provisions the shared mechanics for all machine-like primitives: a snapshot + * signal, an untracked state reader, and a transition function. + * + * Internal — consumed by `createActor` and `createReactor`. Not part of the + * public API. + */ +export function createMachineCore>( + initialSnapshot: Snapshot +) { + const snapshotSignal = signal(initialSnapshot); + const getState = (): FullState => untrack(() => snapshotSignal.get().value); + const transition = (to: FullState): void => update(snapshotSignal, { value: to }); + return { snapshotSignal, getState, transition }; +} From 8e1c5afd30690a9a4176cd2f6396b0ab81c1ef2d Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 09:26:39 -0700 Subject: [PATCH 58/79] feat(spf): add createTransitionActor, migrate TextTracksActor to reducer pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createTransitionActor models reducer-shaped actors: (context, msg) => context with 'active' | 'destroyed' lifecycle states and identity-equality optimization (skips signal update when context reference is unchanged). TextTracksActor switches from createActor (FSM) to createTransitionActor — it has no non-idle states, just context mutations, so the FSM overhead was unjustified. Same deduplication and DOM side-effect logic, now as a reducer. Co-Authored-By: Claude Sonnet 4.6 --- .../spf/src/core/create-transition-actor.ts | 75 +++++++++++++++++++ .../features/tests/text-tracks-actor.test.ts | 4 +- .../spf/src/dom/features/text-tracks-actor.ts | 74 ++++++++---------- 3 files changed, 109 insertions(+), 44 deletions(-) create mode 100644 packages/spf/src/core/create-transition-actor.ts diff --git a/packages/spf/src/core/create-transition-actor.ts b/packages/spf/src/core/create-transition-actor.ts new file mode 100644 index 000000000..0477d0428 --- /dev/null +++ b/packages/spf/src/core/create-transition-actor.ts @@ -0,0 +1,75 @@ +import type { ActorSnapshot } from './actor'; +import type { Machine } from './machine'; +import { createMachineCore } from './machine'; +import { untrack, update } from './signals/primitives'; + +// ============================================================================= +// Definition types +// ============================================================================= + +/** + * A reducer-shaped actor: `(context, message) => context`. + * + * No finite states — the snapshot carries `value: 'active' | 'destroyed'` + * as a universal lifecycle marker rather than domain state. The interesting + * state is entirely in the context, which is observable via `snapshot`. + * + * Use this when the actor has context that needs to be observable but no + * meaningful state machine (e.g., a message-driven model with DOM side + * effects). For actors that need per-state behavior, use `createActor`. + */ +export interface TransitionActor + extends Machine> { + send(message: Message): void; +} + +// ============================================================================= +// Implementation +// ============================================================================= + +/** + * Creates a reducer-shaped actor from an initial context and a reducer function. + * + * The reducer receives the current context and a message and returns the next + * context. Returning the same reference (by identity) skips the signal update — + * so early-returning `context` unchanged is both the no-op and the optimization. + * + * Side effects (e.g. DOM mutations) may be performed inside the reducer. + * They run synchronously before the signal is updated. + * + * @example + * const actor = createTransitionActor( + * { count: 0 }, + * (context, message: { type: 'increment' }) => ({ count: context.count + 1 }) + * ); + */ +export function createTransitionActor( + initialContext: Context, + reducer: (context: Context, message: Message) => Context +): TransitionActor { + const { snapshotSignal, getState, transition } = createMachineCore< + 'active' | 'destroyed', + ActorSnapshot<'active' | 'destroyed', Context> + >({ value: 'active', context: initialContext }); + + const getContext = (): Context => untrack(() => snapshotSignal.get().context); + const setContext = (context: Context): void => update(snapshotSignal, { context }); + + return { + get snapshot() { + return snapshotSignal; + }, + + send(message: Message): void { + if (getState() === 'destroyed') return; + const context = getContext(); + const newContext = reducer(context, message); + if (newContext !== context) setContext(newContext); + }, + + destroy(): void { + if (getState() === 'destroyed') return; + transition('destroyed'); + }, + }; +} diff --git a/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts b/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts index 5383dbf4b..5df3aa674 100644 --- a/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts +++ b/packages/spf/src/dom/features/tests/text-tracks-actor.test.ts @@ -18,11 +18,11 @@ function meta(trackId: string, id: string, startTime = 0, duration = 10): CueSeg } describe('TextTracksActor', () => { - it('starts with idle status and empty context', () => { + it('starts with active status and empty context', () => { const video = makeMediaElement(['track-en']); const actor = createTextTracksActor(video); - expect(actor.snapshot.get().value).toBe('idle'); + expect(actor.snapshot.get().value).toBe('active'); expect(actor.snapshot.get().context.loaded).toEqual({}); expect(actor.snapshot.get().context.segments).toEqual({}); }); diff --git a/packages/spf/src/dom/features/text-tracks-actor.ts b/packages/spf/src/dom/features/text-tracks-actor.ts index fed8cbed9..977407580 100644 --- a/packages/spf/src/dom/features/text-tracks-actor.ts +++ b/packages/spf/src/dom/features/text-tracks-actor.ts @@ -1,5 +1,5 @@ -import type { MessageActor } from '../../core/create-actor'; -import { createActor } from '../../core/create-actor'; +import type { TransitionActor } from '../../core/create-transition-actor'; +import { createTransitionActor } from '../../core/create-transition-actor'; import type { Segment } from '../../core/types'; // ============================================================================= @@ -27,7 +27,7 @@ export interface TextTracksActorContext { export type AddCuesMessage = { type: 'add-cues'; meta: CueSegmentMeta; cues: VTTCue[] }; export type TextTracksActorMessage = AddCuesMessage; -export type TextTracksActor = MessageActor<'idle' | 'destroyed', TextTracksActorContext, TextTracksActorMessage>; +export type TextTracksActor = TransitionActor; // ============================================================================= // Helpers @@ -43,48 +43,38 @@ function isDuplicateCue(cue: VTTCue, existing: CueRecord[]): boolean { /** TextTrack actor: wraps all text tracks on a media element, owns cue operations. */ export function createTextTracksActor(mediaElement: HTMLMediaElement): TextTracksActor { - return createActor({ - initial: 'idle' as const, - context: { loaded: {}, segments: {} } as TextTracksActorContext, - states: { - idle: { - on: { - 'add-cues': (message, { context, setContext }) => { - // NOTE: Currently assumes cues are applied to a non-disabled TextTrack. Discuss different approaches here, including: - // - Making the message responsible for auto-selection of the textTrack (changes logic in sync-text-tracks) - // - Silent gating/console warning + early bail - // - throwing a domain-specific error - // - accepting as is (which would result in errors, but also "shouldn't ever happen" unless a bug is introduced) - // (CJP) - const { meta, cues } = message; - const { trackId, id: segmentId, startTime, duration } = meta; - const textTrack = Array.from(mediaElement.textTracks).find((t) => t.id === trackId); - if (!textTrack) return; + return createTransitionActor({ loaded: {}, segments: {} } as TextTracksActorContext, (context, message) => { + // NOTE: Currently assumes cues are applied to a non-disabled TextTrack. Discuss different approaches here, including: + // - Making the message responsible for auto-selection of the textTrack (changes logic in sync-text-tracks) + // - Silent gating/console warning + early bail + // - throwing a domain-specific error + // - accepting as is (which would result in errors, but also "shouldn't ever happen" unless a bug is introduced) + // (CJP) + const { meta, cues } = message; + const { trackId, id: segmentId, startTime, duration } = meta; + const textTrack = Array.from(mediaElement.textTracks).find((t) => t.id === trackId); + if (!textTrack) return context; - const existingCues = context.loaded[trackId] ?? []; - const existingSegments = context.segments[trackId] ?? []; - const prunedCues = cues.filter((cue) => !isDuplicateCue(cue, existingCues)); - const segmentAlreadyLoaded = existingSegments.some((s) => s.id === segmentId); + const existingCues = context.loaded[trackId] ?? []; + const existingSegments = context.segments[trackId] ?? []; + const prunedCues = cues.filter((cue) => !isDuplicateCue(cue, existingCues)); + const segmentAlreadyLoaded = existingSegments.some((s) => s.id === segmentId); - if (prunedCues.length === 0 && segmentAlreadyLoaded) return; + if (prunedCues.length === 0 && segmentAlreadyLoaded) return context; - for (const cue of prunedCues) textTrack.addCue(cue); - setContext({ - ...context, - loaded: { - ...context.loaded, - [trackId]: [...existingCues, ...prunedCues], - }, - segments: segmentAlreadyLoaded - ? context.segments - : { - ...context.segments, - [trackId]: [...existingSegments, { id: segmentId, startTime, duration }], - }, - }); - }, - }, + for (const cue of prunedCues) textTrack.addCue(cue); + return { + ...context, + loaded: { + ...context.loaded, + [trackId]: [...existingCues, ...prunedCues], }, - }, + segments: segmentAlreadyLoaded + ? context.segments + : { + ...context.segments, + [trackId]: [...existingSegments, { id: segmentId, startTime, duration }], + }, + }; }); } From 240b9a0648189c64f019b34d493d0a89796ec8ff Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 09:26:52 -0700 Subject: [PATCH 59/79] refactor(spf): replace TextTrackSegmentLoaderActor FSM with CallbackActor; harden runner lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextTrackSegmentLoaderActor had no meaningful finite states — the FSM was pure overhead. Replace with a plain CallbackActor (send + destroy) backed by SerialRunner; remove the snapshot-based tests in favour of behavioral assertions against TextTracksActor context. Add CallbackActor to actor.ts as the canonical type for task-coordinator actors with no observable snapshot (XState fromCallback analog). SegmentLoaderActor switches from an inline interface to CallbackActor. Harden SerialRunner and ConcurrentRunner: schedule() now guards on #destroyed so post-destroy sends from caller code are silently dropped. abortAll() remains idempotent so destroy() needs no re-entry guard. Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/actor.ts | 11 +++ packages/spf/src/core/task.ts | 6 ++ .../src/dom/features/segment-loader-actor.ts | 6 +- .../text-track-segment-loader-actor.test.ts | 85 +++--------------- .../text-track-segment-loader-actor.ts | 90 +++++++------------ 5 files changed, 64 insertions(+), 134 deletions(-) diff --git a/packages/spf/src/core/actor.ts b/packages/spf/src/core/actor.ts index 4e95d88e6..9e6c82071 100644 --- a/packages/spf/src/core/actor.ts +++ b/packages/spf/src/core/actor.ts @@ -20,3 +20,14 @@ export interface ActorSnapshot ext /** Generic actor interface: owns its snapshot as a reactive signal. */ export interface SignalActor extends Machine> {} + +/** + * A message-driven actor with no observable snapshot. + * + * Use for actors that coordinate async work but have no state that external + * consumers need to observe. Analogous to XState's `fromCallback`. + */ +export interface CallbackActor { + send(message: Message): void; + destroy(): void; +} diff --git a/packages/spf/src/core/task.ts b/packages/spf/src/core/task.ts index 815f06f40..935228e15 100644 --- a/packages/spf/src/core/task.ts +++ b/packages/spf/src/core/task.ts @@ -127,8 +127,10 @@ export class ConcurrentRunner { readonly #pending = new Map; promise: Promise }>(); #settled: Promise = Promise.resolve(); #resolveSettled: (() => void) | null = null; + #destroyed = false; schedule(task: TaskLike): Promise { + if (this.#destroyed) return Promise.resolve() as Promise; const existing = this.#pending.get(task.id); if (existing) return existing.promise as Promise; @@ -185,6 +187,7 @@ export class ConcurrentRunner { } destroy(): void { + this.#destroyed = true; this.abortAll(); } } @@ -211,8 +214,10 @@ export class SerialRunner { #chain: Promise = Promise.resolve(); readonly #pending = new Set>(); #current: TaskLike | null = null; + #destroyed = false; schedule(task: TaskLike): Promise { + if (this.#destroyed) return Promise.resolve() as Promise; const t = task as TaskLike; this.#pending.add(t); @@ -271,6 +276,7 @@ export class SerialRunner { } destroy(): void { + this.#destroyed = true; this.abortAll(); } } diff --git a/packages/spf/src/dom/features/segment-loader-actor.ts b/packages/spf/src/dom/features/segment-loader-actor.ts index 3aa108992..b59e82a52 100644 --- a/packages/spf/src/dom/features/segment-loader-actor.ts +++ b/packages/spf/src/dom/features/segment-loader-actor.ts @@ -1,3 +1,4 @@ +import type { CallbackActor } from '../../core/actor'; import { calculateBackBufferFlushPoint } from '../../core/buffer/back-buffer'; import { calculateForwardFlushPoint, getSegmentsToLoad } from '../../core/buffer/forward-buffer'; import type { AddressableObject, AudioTrack, Segment, VideoTrack } from '../../core/types'; @@ -79,10 +80,7 @@ export type LoadTask = // ACTOR INTERFACE // ============================================================================ -export interface SegmentLoaderActor { - send(message: SegmentLoaderMessage): void; - destroy(): void; -} +export type SegmentLoaderActor = CallbackActor; // ============================================================================ // IMPLEMENTATION diff --git a/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts b/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts index ebc1bb7ff..382e6cd20 100644 --- a/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts +++ b/packages/spf/src/dom/features/tests/text-track-segment-loader-actor.test.ts @@ -52,44 +52,25 @@ describe('TextTrackSegmentLoaderActor', () => { vi.clearAllMocks(); }); - it('starts with idle status and empty context', () => { + it('can be created and destroyed without error', () => { const video = makeMediaElement(['track-en']); const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); - - expect(actor.snapshot.get().value).toBe('idle'); - expect(actor.snapshot.get().context).toEqual({}); - actor.destroy(); textTracksActor.destroy(); }); - it('stays idle when no segments need loading', () => { - const video = makeMediaElement(['track-en']); - const textTracksActor = createTextTracksActor(video); - const actor = createTextTrackSegmentLoaderActor(textTracksActor); - const track = makeResolvedTextTrack('track-en', []); - - actor.send({ type: 'load', track, currentTime: 0 }); - - expect(actor.snapshot.get().value).toBe('idle'); - - actor.destroy(); - textTracksActor.destroy(); - }); + it('does not fetch when no segments need loading', async () => { + const { parseVttSegment } = await import('../../text/parse-vtt-segment'); - it('transitions loading → idle after all segments are fetched', async () => { const video = makeMediaElement(['track-en']); const textTracksActor = createTextTracksActor(video); const actor = createTextTrackSegmentLoaderActor(textTracksActor); - const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); + const track = makeResolvedTextTrack('track-en', []); actor.send({ type: 'load', track, currentTime: 0 }); - expect(actor.snapshot.get().value).toBe('loading'); - await vi.waitFor(() => { - expect(actor.snapshot.get().value).toBe('idle'); - }); + expect(parseVttSegment).not.toHaveBeenCalled(); actor.destroy(); textTracksActor.destroy(); @@ -104,10 +85,9 @@ describe('TextTrackSegmentLoaderActor', () => { actor.send({ type: 'load', track, currentTime: 0 }); await vi.waitFor(() => { - expect(actor.snapshot.get().value).toBe('idle'); + expect(textTracksActor.snapshot.get().context.segments['track-en']).toHaveLength(2); }); - expect(textTracksActor.snapshot.get().context.segments['track-en']).toHaveLength(2); expect(textTracksActor.snapshot.get().context.loaded['track-en']).toHaveLength(2); actor.destroy(); @@ -123,12 +103,11 @@ describe('TextTrackSegmentLoaderActor', () => { const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt', 'https://example.com/seg-1.vtt']); actor.send({ type: 'load', track, currentTime: 0 }); - await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); + await vi.waitFor(() => expect(textTracksActor.snapshot.get().context.segments['track-en']).toHaveLength(2)); expect(parseVttSegment).toHaveBeenCalledTimes(2); // Repeat send — all segments already in TextTracksActor context actor.send({ type: 'load', track, currentTime: 0 }); - expect(actor.snapshot.get().value).toBe('idle'); expect(parseVttSegment).toHaveBeenCalledTimes(2); actor.destroy(); @@ -155,11 +134,9 @@ describe('TextTrackSegmentLoaderActor', () => { actor.send({ type: 'load', track, currentTime: 0 }); - await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); - - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load VTT segment:', expect.any(Error)); // Segments 0 and 2 succeeded; the failed segment is not recorded - expect(textTracksActor.snapshot.get().context.segments['track-en']).toHaveLength(2); + await vi.waitFor(() => expect(textTracksActor.snapshot.get().context.segments['track-en']).toHaveLength(2)); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load VTT segment:', expect.any(Error)); consoleErrorSpy.mockRestore(); actor.destroy(); @@ -188,10 +165,8 @@ describe('TextTrackSegmentLoaderActor', () => { // Start loading track1 — paused waiting for seg0 actor.send({ type: 'load', track: track1, currentTime: 0 }); - expect(actor.snapshot.get().value).toBe('loading'); - // Wait for the Task to actually start running — resolveSeg0 is assigned inside - // the Promise constructor, which executes when parseVttSegment is called async. + // Wait for the Task to actually start running await vi.waitFor(() => expect(parseVttSegment).toHaveBeenCalledTimes(1)); // Switch to track2 — preempts track1 @@ -200,30 +175,17 @@ describe('TextTrackSegmentLoaderActor', () => { // Unblock seg0 — signal is already aborted, so the cue is discarded resolveSeg0([]); - await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); + // track-es completes + await vi.waitFor(() => expect(textTracksActor.snapshot.get().context.segments['track-es']).toHaveLength(1)); // track-en was preempted — no cues recorded expect(textTracksActor.snapshot.get().context.segments['track-en']).toBeUndefined(); - // track-es completed successfully - expect(textTracksActor.snapshot.get().context.segments['track-es']).toHaveLength(1); - - actor.destroy(); - textTracksActor.destroy(); - }); - - it('transitions to destroyed on destroy()', () => { - const video = makeMediaElement(['track-en']); - const textTracksActor = createTextTracksActor(video); - const actor = createTextTrackSegmentLoaderActor(textTracksActor); actor.destroy(); - - expect(actor.snapshot.get().value).toBe('destroyed'); - textTracksActor.destroy(); }); - it('ignores send() after destroy()', async () => { + it('does not schedule work after destroy()', async () => { const { parseVttSegment } = await import('../../text/parse-vtt-segment'); const video = makeMediaElement(['track-en']); @@ -237,28 +199,7 @@ describe('TextTrackSegmentLoaderActor', () => { await new Promise((resolve) => setTimeout(resolve, 10)); expect(parseVttSegment).not.toHaveBeenCalled(); - expect(actor.snapshot.get().value).toBe('destroyed'); - - textTracksActor.destroy(); - }); - it('snapshot is reactive — status transitions are observable via signal', async () => { - const video = makeMediaElement(['track-en']); - const textTracksActor = createTextTracksActor(video); - const actor = createTextTrackSegmentLoaderActor(textTracksActor); - const track = makeResolvedTextTrack('track-en', ['https://example.com/seg-0.vtt']); - - const observed = [actor.snapshot.get().value]; - - actor.send({ type: 'load', track, currentTime: 0 }); - observed.push(actor.snapshot.get().value); - - await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); - observed.push(actor.snapshot.get().value); - - expect(observed).toEqual(['idle', 'loading', 'idle']); - - actor.destroy(); textTracksActor.destroy(); }); }); diff --git a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts index 98224b557..37ee97257 100644 --- a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts +++ b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts @@ -1,6 +1,5 @@ +import type { CallbackActor } from '../../core/actor'; import { getSegmentsToLoad } from '../../core/buffer/forward-buffer'; -import type { MessageActor } from '../../core/create-actor'; -import { createActor } from '../../core/create-actor'; import { untrack } from '../../core/signals/primitives'; import { SerialRunner, Task } from '../../core/task'; import type { TextTrack } from '../../core/types'; @@ -11,19 +10,13 @@ import type { TextTracksActor } from './text-tracks-actor'; // Types // ============================================================================= -export type TextTrackSegmentLoaderState = 'idle' | 'loading'; - export type TextTrackSegmentLoaderMessage = { type: 'load'; track: TextTrack; currentTime: number; }; -export type TextTrackSegmentLoaderActor = MessageActor< - TextTrackSegmentLoaderState | 'destroyed', - object, - TextTrackSegmentLoaderMessage ->; +export type TextTrackSegmentLoaderActor = CallbackActor; // ============================================================================= // Implementation @@ -36,60 +29,41 @@ export type TextTrackSegmentLoaderActor = MessageActor< * * Planning is done in the load handler: segments already recorded in * TextTracksActor's context are skipped. Each load preempts in-flight work - * via abortAll() before scheduling fresh tasks. The runner's onSettled - * callback transitions back to idle when all tasks complete. + * via abortAll() before scheduling fresh tasks. */ export function createTextTrackSegmentLoaderActor(textTracksActor: TextTracksActor): TextTrackSegmentLoaderActor { - const loadHandler = ( - message: TextTrackSegmentLoaderMessage, - { transition, runner }: { transition: (to: TextTrackSegmentLoaderState) => void; runner: SerialRunner } - ): void => { - const { track, currentTime } = message; - const trackId = track.id; - const bufferedSegments = untrack(() => textTracksActor.snapshot.get().context.segments[trackId] ?? []); - const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime); + const runner = new SerialRunner(); - // Preempt any in-flight work before scheduling the new plan. - runner.abortAll(); - if (!segmentsToLoad.length) { - transition('idle'); - return; - } + return { + send({ track, currentTime }: TextTrackSegmentLoaderMessage): void { + const trackId = track.id; + const bufferedSegments = untrack(() => textTracksActor.snapshot.get().context.segments[trackId] ?? []); + const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime); - transition('loading'); - for (const segment of segmentsToLoad) { - runner.schedule( - new Task(async (signal) => { - if (signal.aborted) return; - try { - const cues = await parseVttSegment(segment.url); + runner.abortAll(); + for (const segment of segmentsToLoad) { + runner.schedule( + new Task(async (signal) => { if (signal.aborted) return; - textTracksActor.send({ - type: 'add-cues', - meta: { trackId, id: segment.id, startTime: segment.startTime, duration: segment.duration }, - cues, - }); - } catch (error) { - // Graceful degradation: log and continue to the next segment. - console.error('Failed to load VTT segment:', error); - } - }) - ); - } - }; + try { + const cues = await parseVttSegment(segment.url); + if (signal.aborted) return; + textTracksActor.send({ + type: 'add-cues', + meta: { trackId, id: segment.id, startTime: segment.startTime, duration: segment.duration }, + cues, + }); + } catch (error) { + // Graceful degradation: log and continue to the next segment. + console.error('Failed to load VTT segment:', error); + } + }) + ); + } + }, - return createActor({ - runner: () => new SerialRunner(), - initial: 'idle' as TextTrackSegmentLoaderState, - context: {} as object, - states: { - idle: { - on: { load: loadHandler }, - }, - loading: { - onSettled: 'idle', - on: { load: loadHandler }, - }, + destroy(): void { + runner.destroy(); }, - }); + }; } From 2336b51a935ba4b173c1de408910f7c02b319111 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 10:02:25 -0700 Subject: [PATCH 60/79] refactor(spf): use createMachineCore in SourceBufferActor Replace the manual signal() + update() plumbing with createMachineCore, consistent with createActor and createReactor. getState() replaces inline snapshotSignal.get().value reads; transition() replaces update(snapshotSignal, { value }). No behaviour change. Co-Authored-By: Claude Sonnet 4.6 --- .../spf/src/dom/media/source-buffer-actor.ts | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index 590b44a17..3b34af4bb 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -1,5 +1,5 @@ import type { ActorSnapshot, SignalActor } from '../../core/actor'; -import { type ReadonlySignal, signal, update } from '../../core/signals/primitives'; +import { createMachineCore } from '../../core/machine'; import { SerialRunner, Task } from '../../core/task'; import type { Segment, Track } from '../../core/types'; import { type AppendData, appendSegment } from './append-segment'; @@ -230,10 +230,12 @@ export function createSourceBufferActor( sourceBuffer: SourceBuffer, initialContext?: Partial ): SourceBufferActor { - const snapshotSignal = signal({ - value: 'idle', - context: { segments: [], bufferedRanges: [], initTrackId: undefined, ...initialContext }, - }); + const { snapshotSignal, getState, transition } = createMachineCore( + { + value: 'idle', + context: { segments: [], bufferedRanges: [], initTrackId: undefined, ...initialContext }, + } + ); const runner = new SerialRunner(); @@ -241,7 +243,7 @@ export function createSourceBufferActor( // If the actor was destroyed while the operation was in flight, preserve // 'destroyed' — do not regress to 'idle'. function applyResult(newContext: SourceBufferActorContext): void { - const state = snapshotSignal.get().value === 'destroyed' ? 'destroyed' : 'idle'; + const state = getState() === 'destroyed' ? 'destroyed' : 'idle'; snapshotSignal.set({ value: state, context: newContext }); } @@ -251,24 +253,23 @@ export function createSourceBufferActor( // improvement should detect QuotaExceededError specifically and use total // bytes-in-buffer as a heuristic to identify the effective buffer capacity, // enabling targeted flush-and-retry rather than silent model drift. - const state = snapshotSignal.get().value === 'destroyed' ? 'destroyed' : 'idle'; - update(snapshotSignal, { value: state }); + transition(getState() === 'destroyed' ? 'destroyed' : 'idle'); throw e; } return { - get snapshot(): ReadonlySignal { + get snapshot() { return snapshotSignal; }, send(message: SourceBufferMessage, signal: AbortSignal): Promise { - if (snapshotSignal.get().value !== 'idle') { - return Promise.reject(new SourceBufferActorError(`send() called while actor is ${snapshotSignal.get().value}`)); + if (getState() !== 'idle') { + return Promise.reject(new SourceBufferActorError(`send() called while actor is ${getState()}`)); } // Transition synchronously so any subsequent send/batch within the same // tick is rejected — the actor is now committed to this operation. - update(snapshotSignal, { value: 'updating' }); + transition('updating'); const onPartialContext = (ctx: SourceBufferActorContext) => { snapshotSignal.set({ value: 'updating', context: ctx }); @@ -285,16 +286,14 @@ export function createSourceBufferActor( }, batch(messages: SourceBufferMessage[], signal: AbortSignal): Promise { - if (snapshotSignal.get().value !== 'idle') { - return Promise.reject( - new SourceBufferActorError(`batch() called while actor is ${snapshotSignal.get().value}`) - ); + if (getState() !== 'idle') { + return Promise.reject(new SourceBufferActorError(`batch() called while actor is ${getState()}`)); } if (messages.length === 0) return Promise.resolve(); // Transition synchronously — the entire batch is one 'updating' period. - update(snapshotSignal, { value: 'updating' }); + transition('updating'); // Each message is its own Task on the runner, executed in submission order. // workingCtx threads the result of each task into the next without @@ -337,7 +336,7 @@ export function createSourceBufferActor( }, destroy(): void { - update(snapshotSignal, { value: 'destroyed' }); + transition('destroyed'); runner.destroy(); }, }; From 737ba4cbf2c39cba7830a80d88459939b15270c2 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 10:03:30 -0700 Subject: [PATCH 61/79] refactor(spf): collapse batch() into send() as BatchMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add BatchMessage = { type: 'batch'; messages: IndividualSourceBufferMessage[] } to the SourceBufferMessage union and handle it in send() alongside the existing individual message types. Remove the separate batch() method from the interface and implementation — send() is now the single entry point for all SourceBuffer operations. Co-Authored-By: Claude Sonnet 4.6 --- .../dom/features/tests/end-of-stream.test.ts | 29 ++-- .../spf/src/dom/media/source-buffer-actor.ts | 110 +++++++------ .../media/tests/source-buffer-actor.test.ts | 148 ++++++++++-------- 3 files changed, 153 insertions(+), 134 deletions(-) diff --git a/packages/spf/src/dom/features/tests/end-of-stream.test.ts b/packages/spf/src/dom/features/tests/end-of-stream.test.ts index 42418db9f..be9fb8ed6 100644 --- a/packages/spf/src/dom/features/tests/end-of-stream.test.ts +++ b/packages/spf/src/dom/features/tests/end-of-stream.test.ts @@ -543,19 +543,22 @@ describe('endOfStream', () => { // Append the last two segments via the actor — this updates actor context // and triggers the actor subscribers that endOfStream watches. - await actor.batch( - [ - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 'seg-2', startTime: 5, duration: 2.5, trackId: 'video-1' }, - }, - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 'seg-3', startTime: 7.5, duration: 2.5, trackId: 'video-1' }, - }, - ], + await actor.send( + { + type: 'batch', + messages: [ + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 'seg-2', startTime: 5, duration: 2.5, trackId: 'video-1' }, + }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 'seg-3', startTime: 7.5, duration: 2.5, trackId: 'video-1' }, + }, + ], + }, neverAborted ); diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index 3b34af4bb..a75e51e8e 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -25,7 +25,9 @@ export type { AppendData }; export type AppendInitMessage = { type: 'append-init'; data: AppendData; meta: { trackId: Track['id'] } }; export type AppendSegmentMessage = { type: 'append-segment'; data: AppendData; meta: AppendSegmentMeta }; export type RemoveMessage = { type: 'remove'; start: number; end: number }; -export type SourceBufferMessage = AppendInitMessage | AppendSegmentMessage | RemoveMessage; +export type IndividualSourceBufferMessage = AppendInitMessage | AppendSegmentMessage | RemoveMessage; +export type BatchMessage = { type: 'batch'; messages: IndividualSourceBufferMessage[] }; +export type SourceBufferMessage = IndividualSourceBufferMessage | BatchMessage; /** Finite states of the actor. */ export type SourceBufferActorState = 'idle' | 'updating' | 'destroyed'; @@ -65,7 +67,6 @@ export class SourceBufferActorError extends Error { /** SourceBuffer actor: queues operations, owns its snapshot. */ export interface SourceBufferActor extends SignalActor { send(message: SourceBufferMessage, signal: AbortSignal): Promise; - batch(messages: SourceBufferMessage[], signal: AbortSignal): Promise; } // ============================================================================= @@ -206,7 +207,7 @@ function removeTask( ); } -type MessageTaskFactory = ( +type MessageTaskFactory = ( message: T, options: MessageTaskOptions ) => Task; @@ -215,9 +216,14 @@ const messageTaskFactories = { 'append-init': appendInitTask, 'append-segment': appendSegmentTask, remove: removeTask, -} satisfies { [K in SourceBufferMessage['type']]: MessageTaskFactory> }; +} satisfies { + [K in IndividualSourceBufferMessage['type']]: MessageTaskFactory>; +}; -function messageToTask(message: SourceBufferMessage, options: MessageTaskOptions): Task { +function messageToTask( + message: IndividualSourceBufferMessage, + options: MessageTaskOptions +): Task { const factory = messageTaskFactories[message.type] as MessageTaskFactory; return factory(message, options); } @@ -267,72 +273,62 @@ export function createSourceBufferActor( return Promise.reject(new SourceBufferActorError(`send() called while actor is ${getState()}`)); } - // Transition synchronously so any subsequent send/batch within the same - // tick is rejected — the actor is now committed to this operation. - transition('updating'); - - const onPartialContext = (ctx: SourceBufferActorContext) => { - snapshotSignal.set({ value: 'updating', context: ctx }); - }; + // Empty batch — guard must pass (actor is idle) but nothing to do. + if (message.type === 'batch' && message.messages.length === 0) return Promise.resolve(); - const task = messageToTask(message, { - signal, - getCtx: () => snapshotSignal.get().context, - sourceBuffer, - onPartialContext, - }); - - return runner.schedule(task).then(applyResult).catch(handleError); - }, - - batch(messages: SourceBufferMessage[], signal: AbortSignal): Promise { - if (getState() !== 'idle') { - return Promise.reject(new SourceBufferActorError(`batch() called while actor is ${getState()}`)); - } - - if (messages.length === 0) return Promise.resolve(); - - // Transition synchronously — the entire batch is one 'updating' period. + // Transition synchronously so any subsequent send() within the same tick + // is rejected — the actor is now committed to this operation. transition('updating'); - // Each message is its own Task on the runner, executed in submission order. - // workingCtx threads the result of each task into the next without - // writing to the signal between steps — context is only written atomically - // when the last task completes. - // - // workingCtx is captured here (synchronously after state → 'updating'), - // so it reflects the current context at the moment the batch was accepted. - // This is correct: state is now 'updating' so no other sender can modify - // context between this line and the first task executing. - // - // NOTE: if an intermediate task fails (e.g. SourceBuffer error event), - // workingCtx is not updated for that step and subsequent tasks in the - // batch will operate on a stale context. This is an edge case — happy-path - // appends do not fail — but worth revisiting if MSE error recovery lands. - let workingCtx = snapshotSignal.get().context; - - // Partial context updates from streaming appends write to the signal - // directly so external subscribers see in-progress state, but workingCtx - // is only advanced on task completion to preserve batch context threading. const onPartialContext = (ctx: SourceBufferActorContext) => { snapshotSignal.set({ value: 'updating', context: ctx }); }; - for (const message of messages.slice(0, -1)) { - const task = messageToTask(message, { signal, getCtx: () => workingCtx, sourceBuffer, onPartialContext }); - const result = runner.schedule(task); - result.then((newCtx) => { - workingCtx = newCtx; + if (message.type === 'batch') { + const { messages } = message; + // Each message is its own Task on the runner, executed in submission order. + // workingCtx threads the result of each task into the next without + // writing to the signal between steps — context is only written atomically + // when the last task completes. + // + // workingCtx is captured here (synchronously after state → 'updating'), + // so it reflects the current context at the moment the batch was accepted. + // This is correct: state is now 'updating' so no other sender can modify + // context between this line and the first task executing. + // + // NOTE: if an intermediate task fails (e.g. SourceBuffer error event), + // workingCtx is not updated for that step and subsequent tasks in the + // batch will operate on a stale context. This is an edge case — happy-path + // appends do not fail — but worth revisiting if MSE error recovery lands. + let workingCtx = snapshotSignal.get().context; + + // Partial context updates from streaming appends write to the signal + // directly so external subscribers see in-progress state, but workingCtx + // is only advanced on task completion to preserve batch context threading. + for (const msg of messages.slice(0, -1)) { + const task = messageToTask(msg, { signal, getCtx: () => workingCtx, sourceBuffer, onPartialContext }); + const result = runner.schedule(task); + result.then((newCtx) => { + workingCtx = newCtx; + }); + } + + const lastTask = messageToTask(messages[messages.length - 1]!, { + signal, + getCtx: () => workingCtx, + sourceBuffer, + onPartialContext, }); + return runner.schedule(lastTask).then(applyResult).catch(handleError); } - const lastTask = messageToTask(messages[messages.length - 1]!, { + const task = messageToTask(message, { signal, - getCtx: () => workingCtx, + getCtx: () => snapshotSignal.get().context, sourceBuffer, onPartialContext, }); - return runner.schedule(lastTask).then(applyResult).catch(handleError); + return runner.schedule(task).then(applyResult).catch(handleError); }, destroy(): void { diff --git a/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts b/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts index 8fe74f899..d9e78bc59 100644 --- a/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts +++ b/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts @@ -96,7 +96,7 @@ describe('createSourceBufferActor', () => { actor.destroy(); }); - it('rejects batch() with SourceBufferActorError when actor is updating', async () => { + it('rejects batch message with SourceBufferActorError when actor is updating', async () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); @@ -106,7 +106,10 @@ describe('createSourceBufferActor', () => { ); await expect( - actor.batch([{ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }], neverAborted) + actor.send( + { type: 'batch', messages: [{ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }] }, + neverAborted + ) ).rejects.toBeInstanceOf(SourceBufferActorError); await p1; @@ -131,20 +134,24 @@ describe('createSourceBufferActor', () => { // Batch — individual tasks, context threading // --------------------------------------------------------------------------- - it('batch executes all messages in order as individual tasks', async () => { + it('batch message executes all messages in order as individual tasks', async () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - const messages = [ - { type: 'append-init' as const, data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, + await actor.send( { - type: 'append-segment' as const, - data: new ArrayBuffer(8), - meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + type: 'batch', + messages: [ + { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + }, + ], }, - ]; - - await actor.batch(messages, neverAborted); + neverAborted + ); expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(2); expect(actor.snapshot.get().context.initTrackId).toBe('track-1'); @@ -153,24 +160,27 @@ describe('createSourceBufferActor', () => { actor.destroy(); }); - it('batch threads context between tasks so overlap detection works', async () => { + it('batch message threads context between tasks so overlap detection works', async () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); // Two segments at the same time range — the second should replace the first - await actor.batch( - [ - { - type: 'append-segment' as const, - data: new ArrayBuffer(8), - meta: { id: 's1-low', startTime: 0, duration: 10, trackId: 'track-low' }, - }, - { - type: 'append-segment' as const, - data: new ArrayBuffer(8), - meta: { id: 's1-high', startTime: 0, duration: 10, trackId: 'track-high' }, - }, - ], + await actor.send( + { + type: 'batch', + messages: [ + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1-low', startTime: 0, duration: 10, trackId: 'track-low' }, + }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1-high', startTime: 0, duration: 10, trackId: 'track-high' }, + }, + ], + }, neverAborted ); @@ -182,7 +192,7 @@ describe('createSourceBufferActor', () => { actor.destroy(); }); - it('batch status stays idle until after last task completes', async () => { + it('batch message status stays idle until after last task completes', async () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); @@ -191,15 +201,18 @@ describe('createSourceBufferActor', () => { stateValues.push(actor.snapshot.get().value); }); - await actor.batch( - [ - { type: 'append-init' as const, data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, - { - type: 'append-segment' as const, - data: new ArrayBuffer(8), - meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - }, - ], + await actor.send( + { + type: 'batch', + messages: [ + { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + }, + ], + }, neverAborted ); @@ -238,21 +251,12 @@ describe('createSourceBufferActor', () => { // Abort: during batch execution // --------------------------------------------------------------------------- - it('batch: aborts mid-flight; subsequent messages in batch are skipped', async () => { + it('batch message: aborts mid-flight; subsequent messages in batch are skipped', async () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); const controller = new AbortController(); - const messages = [ - { type: 'append-init' as const, data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, - { - type: 'append-segment' as const, - data: new ArrayBuffer(8), - meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - }, - ]; - const mockedAppend = vi.mocked(sourceBuffer.appendBuffer); const origAppend = mockedAppend.getMockImplementation(); let firstCall = true; @@ -264,7 +268,20 @@ describe('createSourceBufferActor', () => { return origAppend?.(data); }); - await actor.batch(messages, controller.signal); + await actor.send( + { + type: 'batch', + messages: [ + { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + }, + ], + }, + controller.signal + ); expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1); @@ -363,24 +380,27 @@ describe('createSourceBufferActor', () => { ]); const actor = createSourceBufferActor(sourceBuffer); - await actor.batch( - [ - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - }, - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's2', startTime: 10, duration: 10, trackId: 'track-1' }, - }, - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's3', startTime: 20, duration: 10, trackId: 'track-1' }, - }, - ], + await actor.send( + { + type: 'batch', + messages: [ + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's2', startTime: 10, duration: 10, trackId: 'track-1' }, + }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's3', startTime: 20, duration: 10, trackId: 'track-1' }, + }, + ], + }, neverAborted ); From 75d6ab58c4172c3bb472e77c2775e2d61fd88dcb Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 11:49:49 -0700 Subject: [PATCH 62/79] refactor(spf): make SourceBufferActor.send() fire-and-forget; bridge via waitForIdle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SourceBufferActor.send() now returns void. SegmentLoaderActor sequences SourceBuffer operations by watching snapshot state transitions via a waitForIdle() helper (effect-based promise) instead of awaiting the send() return value. Removes SourceBufferActorError — errors are logged and the actor self-heals to idle. Tests updated to use vi.waitFor() for actor settling. Co-Authored-By: Claude Sonnet 4.6 --- .../src/dom/features/segment-loader-actor.ts | 59 ++++++++- .../spf/src/dom/media/source-buffer-actor.ts | 35 ++--- .../media/tests/source-buffer-actor.test.ts | 120 ++++++++---------- 3 files changed, 122 insertions(+), 92 deletions(-) diff --git a/packages/spf/src/dom/features/segment-loader-actor.ts b/packages/spf/src/dom/features/segment-loader-actor.ts index b59e82a52..0c4cf0275 100644 --- a/packages/spf/src/dom/features/segment-loader-actor.ts +++ b/packages/spf/src/dom/features/segment-loader-actor.ts @@ -1,6 +1,7 @@ import type { CallbackActor } from '../../core/actor'; import { calculateBackBufferFlushPoint } from '../../core/buffer/back-buffer'; import { calculateForwardFlushPoint, getSegmentsToLoad } from '../../core/buffer/forward-buffer'; +import { effect } from '../../core/signals/effect'; import type { AddressableObject, AudioTrack, Segment, VideoTrack } from '../../core/types'; import type { AppendInitMessage, @@ -82,6 +83,53 @@ export type LoadTask = export type SegmentLoaderActor = CallbackActor; +// ============================================================================ +// HELPERS +// ============================================================================ + +/** + * Resolves when the SourceBufferActor snapshot reaches 'idle'. + * Rejects if the signal is aborted or the actor is destroyed. + * + * Used to sequence SourceBufferActor operations without awaiting send() + * directly — send() is fire-and-forget; callers observe completion via + * state transition. + */ +function waitForIdle(snapshot: SourceBufferActor['snapshot'], signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (snapshot.get().value === 'idle') { + resolve(); + return; + } + if (snapshot.get().value === 'destroyed') { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + if (signal.aborted) { + reject(signal.reason); + return; + } + + let stop: (() => void) | undefined; + + const cleanup = (fn: () => void) => { + stop?.(); + signal.removeEventListener('abort', onAbort); + fn(); + }; + + const onAbort = () => cleanup(() => reject(signal.reason)); + + stop = effect(() => { + const value = snapshot.get().value; + if (value === 'idle') cleanup(resolve); + else if (value === 'destroyed') cleanup(() => reject(new DOMException('Aborted', 'AbortError'))); + }); + + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + // ============================================================================ // IMPLEMENTATION // ============================================================================ @@ -217,7 +265,8 @@ export function createSegmentLoaderActor( const signal = abortController!.signal; try { if (task.type === 'remove') { - await sourceBufferActor.send(task, signal); + sourceBufferActor.send(task, signal); + await waitForIdle(sourceBufferActor.snapshot, signal); return; } @@ -238,7 +287,8 @@ export function createSegmentLoaderActor( ); if (!signal.aborted || !isTrackSwitch) { const appendSignal = signal.aborted ? new AbortController().signal : signal; - await sourceBufferActor.send({ type: 'append-init', data, meta: task.meta }, appendSignal); + sourceBufferActor.send({ type: 'append-init', data, meta: task.meta }, appendSignal); + await waitForIdle(sourceBufferActor.snapshot, appendSignal); } } return; @@ -251,7 +301,8 @@ export function createSegmentLoaderActor( if (!signal.aborted) { const stream = await fetchBytes(task, { signal }); if (!signal.aborted) { - await sourceBufferActor.send({ type: 'append-segment', data: stream, meta: task.meta }, signal); + sourceBufferActor.send({ type: 'append-segment', data: stream, meta: task.meta }, signal); + await waitForIdle(sourceBufferActor.snapshot, signal); } } } finally { @@ -281,7 +332,7 @@ export function createSegmentLoaderActor( // Abort is handled in the post-task check below. } else { console.error('Unexpected error in segment loader:', error); - // Non-abort error (e.g. network failure on init): don't continue with + // Non-abort error (e.g. fetch failure on init): don't continue with // remaining tasks in this sequence — they depend on the failed step. // A pending replacement plan (if any) will still be picked up below. scheduled = []; diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index a75e51e8e..6d6906782 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -53,20 +53,9 @@ export interface SourceBufferActorContext { /** Complete snapshot of a SourceBufferActor. */ export type SourceBufferActorSnapshot = ActorSnapshot; -/** - * Thrown when a message is sent to the actor in a state that does not - * accept messages (currently: 'updating'). - */ -export class SourceBufferActorError extends Error { - constructor(message: string) { - super(message); - this.name = 'SourceBufferActorError'; - } -} - /** SourceBuffer actor: queues operations, owns its snapshot. */ export interface SourceBufferActor extends SignalActor { - send(message: SourceBufferMessage, signal: AbortSignal): Promise; + send(message: SourceBufferMessage, signal: AbortSignal): void; } // ============================================================================= @@ -253,14 +242,16 @@ export function createSourceBufferActor( snapshotSignal.set({ value: state, context: newContext }); } - function handleError(e: unknown): never { + function handleError(e: unknown): void { // TODO: QuotaExceededError and other SourceBuffer errors leave the physical // buffer in an unknown partial state while context goes unchanged. A future // improvement should detect QuotaExceededError specifically and use total // bytes-in-buffer as a heuristic to identify the effective buffer capacity, // enabling targeted flush-and-retry rather than silent model drift. transition(getState() === 'destroyed' ? 'destroyed' : 'idle'); - throw e; + if (!(e instanceof Error && e.name === 'AbortError')) { + console.error('SourceBuffer operation failed:', e); + } } return { @@ -268,16 +259,15 @@ export function createSourceBufferActor( return snapshotSignal; }, - send(message: SourceBufferMessage, signal: AbortSignal): Promise { - if (getState() !== 'idle') { - return Promise.reject(new SourceBufferActorError(`send() called while actor is ${getState()}`)); - } + send(message: SourceBufferMessage, signal: AbortSignal): void { + // Silently drop if not idle — callers observe state via snapshot. + if (getState() !== 'idle') return; // Empty batch — guard must pass (actor is idle) but nothing to do. - if (message.type === 'batch' && message.messages.length === 0) return Promise.resolve(); + if (message.type === 'batch' && message.messages.length === 0) return; // Transition synchronously so any subsequent send() within the same tick - // is rejected — the actor is now committed to this operation. + // is dropped — the actor is now committed to this operation. transition('updating'); const onPartialContext = (ctx: SourceBufferActorContext) => { @@ -319,7 +309,8 @@ export function createSourceBufferActor( sourceBuffer, onPartialContext, }); - return runner.schedule(lastTask).then(applyResult).catch(handleError); + runner.schedule(lastTask).then(applyResult, handleError); + return; } const task = messageToTask(message, { @@ -328,7 +319,7 @@ export function createSourceBufferActor( sourceBuffer, onPartialContext, }); - return runner.schedule(task).then(applyResult).catch(handleError); + runner.schedule(task).then(applyResult, handleError); }, destroy(): void { diff --git a/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts b/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts index d9e78bc59..a6eb832c3 100644 --- a/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts +++ b/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { effect } from '../../../core/signals/effect'; -import { createSourceBufferActor, SourceBufferActorError } from '../source-buffer-actor'; +import { createSourceBufferActor } from '../source-buffer-actor'; // --------------------------------------------------------------------------- // Helpers @@ -77,42 +77,19 @@ describe('createSourceBufferActor', () => { // State guard — messages rejected when not idle // --------------------------------------------------------------------------- - it('rejects send() with SourceBufferActorError when actor is updating', async () => { + it('silently drops send() when actor is updating', async () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - // status transitions to 'updating' synchronously when send() is called, - // so the second send() in the same tick sees 'updating' and rejects. - const p1 = actor.send( - { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, - neverAborted - ); - - await expect( - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }, neverAborted) - ).rejects.toBeInstanceOf(SourceBufferActorError); - - await p1; - actor.destroy(); - }); - - it('rejects batch message with SourceBufferActorError when actor is updating', async () => { - const sourceBuffer = makeSourceBuffer(); - const actor = createSourceBufferActor(sourceBuffer); + // state transitions to 'updating' synchronously, so the second send() in + // the same tick sees 'updating' and is dropped. + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }, neverAborted); - const p1 = actor.send( - { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, - neverAborted - ); - - await expect( - actor.send( - { type: 'batch', messages: [{ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }] }, - neverAborted - ) - ).rejects.toBeInstanceOf(SourceBufferActorError); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); + expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1); + expect(actor.snapshot.get().context.initTrackId).toBe('track-1'); - await p1; actor.destroy(); }); @@ -120,13 +97,12 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - await actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); - - expect(actor.snapshot.get().value).toBe('idle'); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); - await actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }, neverAborted); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }, neverAborted); + await vi.waitFor(() => expect(actor.snapshot.get().context.initTrackId).toBe('track-2')); - expect(actor.snapshot.get().context.initTrackId).toBe('track-2'); actor.destroy(); }); @@ -138,7 +114,7 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - await actor.send( + actor.send( { type: 'batch', messages: [ @@ -152,6 +128,7 @@ describe('createSourceBufferActor', () => { }, neverAborted ); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(2); expect(actor.snapshot.get().context.initTrackId).toBe('track-1'); @@ -165,7 +142,7 @@ describe('createSourceBufferActor', () => { const actor = createSourceBufferActor(sourceBuffer); // Two segments at the same time range — the second should replace the first - await actor.send( + actor.send( { type: 'batch', messages: [ @@ -183,6 +160,7 @@ describe('createSourceBufferActor', () => { }, neverAborted ); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); const ids = actor.snapshot.get().context.segments.map((s) => s.id); expect(ids).not.toContain('s1-low'); @@ -201,7 +179,7 @@ describe('createSourceBufferActor', () => { stateValues.push(actor.snapshot.get().value); }); - await actor.send( + actor.send( { type: 'batch', messages: [ @@ -215,7 +193,7 @@ describe('createSourceBufferActor', () => { }, neverAborted ); - + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); cleanup(); // Initial idle (immediate subscribe fire) → updating → idle @@ -237,10 +215,11 @@ describe('createSourceBufferActor', () => { const abortedController = new AbortController(); abortedController.abort(); - await actor.send( + actor.send( { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, abortedController.signal ); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(sourceBuffer.appendBuffer).not.toHaveBeenCalled(); @@ -268,7 +247,7 @@ describe('createSourceBufferActor', () => { return origAppend?.(data); }); - await actor.send( + actor.send( { type: 'batch', messages: [ @@ -282,6 +261,7 @@ describe('createSourceBufferActor', () => { }, controller.signal ); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1); @@ -296,10 +276,10 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - await actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(actor.snapshot.get().context.initTrackId).toBe('track-1'); - expect(actor.snapshot.get().value).toBe('idle'); actor.destroy(); }); @@ -312,7 +292,7 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - await actor.send( + actor.send( { type: 'append-segment', data: new ArrayBuffer(8), @@ -320,6 +300,7 @@ describe('createSourceBufferActor', () => { }, neverAborted ); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(actor.snapshot.get().context.segments).toHaveLength(1); expect(actor.snapshot.get().context.segments[0]).toMatchObject({ @@ -341,7 +322,7 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - await actor.send( + actor.send( { type: 'append-segment', data: new ArrayBuffer(8), @@ -349,8 +330,9 @@ describe('createSourceBufferActor', () => { }, neverAborted ); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); - await actor.send( + actor.send( { type: 'append-segment', data: new ArrayBuffer(8), @@ -358,6 +340,7 @@ describe('createSourceBufferActor', () => { }, neverAborted ); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); const ids = actor.snapshot.get().context.segments.map((s) => s.id); expect(ids).not.toContain('s1-low'); @@ -380,7 +363,7 @@ describe('createSourceBufferActor', () => { ]); const actor = createSourceBufferActor(sourceBuffer); - await actor.send( + actor.send( { type: 'batch', messages: [ @@ -403,9 +386,11 @@ describe('createSourceBufferActor', () => { }, neverAborted ); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); // Remove at a segment boundary so midpoints cleanly fall inside or outside. - await actor.send({ type: 'remove', start: 0, end: 20 }, neverAborted); + actor.send({ type: 'remove', start: 0, end: 20 }, neverAborted); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(sourceBuffer.remove).toHaveBeenCalledWith(0, 20); const ids = actor.snapshot.get().context.segments.map((s) => s.id); @@ -430,7 +415,8 @@ describe('createSourceBufferActor', () => { stateValues.push(actor.snapshot.get().value); }); - await actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); cleanup(); @@ -471,7 +457,7 @@ describe('createSourceBufferActor', () => { snapshots.push(actor.snapshot.get()); }); - await actor.send( + actor.send( { type: 'append-segment', data: new ArrayBuffer(8), @@ -479,6 +465,7 @@ describe('createSourceBufferActor', () => { }, neverAborted ); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); cleanup(); const hadPartial = snapshots.some((s) => s.context.segments.some((seg) => seg.partial)); @@ -502,10 +489,11 @@ describe('createSourceBufferActor', () => { yield new Uint8Array(4); } - await actor.send( + actor.send( { type: 'append-segment', data: twoChunks(), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' } }, neverAborted ); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); cleanup(); const partialSnapshot = snapshots.find((s) => @@ -524,10 +512,11 @@ describe('createSourceBufferActor', () => { yield new Uint8Array(8); } - await actor.send( + actor.send( { type: 'append-segment', data: oneChunk(), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' } }, neverAborted ); + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); const seg = actor.snapshot.get().context.segments.find((s) => s.id === 's1'); expect(seg).toBeDefined(); @@ -557,7 +546,7 @@ describe('createSourceBufferActor', () => { const actor = createSourceBufferActor(sourceBuffer); const ac = new AbortController(); - const pending = actor.send( + actor.send( { type: 'append-segment', data: pausingStream(), @@ -575,8 +564,8 @@ describe('createSourceBufferActor', () => { ac.abort(); resolveFirst!(); - // Let the task settle (it will reject with AbortError — swallow it) - await pending.catch(() => {}); + // Wait for the actor to settle back to idle after the aborted task + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); // partial: true entry should remain — accurately reflects data in SourceBuffer const seg = actor.snapshot.get().context.segments.find((s) => s.id === 's1'); @@ -596,7 +585,7 @@ describe('createSourceBufferActor', () => { }); // Now fully append the same segment (ArrayBuffer path — atomic, no partial) - await actorWithPartial.send( + actorWithPartial.send( { type: 'append-segment', data: new ArrayBuffer(8), @@ -604,6 +593,7 @@ describe('createSourceBufferActor', () => { }, neverAborted ); + await vi.waitFor(() => expect(actorWithPartial.snapshot.get().value).toBe('idle')); const seg = actorWithPartial.snapshot.get().context.segments.find((s) => s.id === 's1'); expect(seg).toBeDefined(); @@ -613,22 +603,20 @@ describe('createSourceBufferActor', () => { actor.destroy(); }); - it('destroy() aborts the in-progress operation', async () => { + it('destroy() transitions actor to destroyed and silently drops subsequent sends', async () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - const p = actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); await vi.waitFor(() => expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)); actor.destroy(); - // Operation was in-flight — let it complete naturally - await p; + expect(actor.snapshot.get().value).toBe('destroyed'); - // After destroy, send() is rejected - await expect( - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }, neverAborted) - ).rejects.toBeInstanceOf(SourceBufferActorError); + // After destroy, send() is silently dropped — state stays destroyed + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }, neverAborted); + expect(actor.snapshot.get().value).toBe('destroyed'); }); }); From cf9238b5392ed1ee53b76d222bb0fa4db22c143c Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 12:09:01 -0700 Subject: [PATCH 63/79] refactor(spf): migrate SourceBufferActor to createActor; move signal into message SourceBufferActor is now built with createActor<'idle'|'updating', ...>. The 'updating' state uses onSettled:'idle' to auto-transition after tasks settle. Each SourceBufferMessage carries its own AbortSignal as a message field rather than a separate send() argument, making the public API send(message):void and consistent with the rest of the actor taxonomy. Call sites in SegmentLoaderActor and tests updated accordingly. Co-Authored-By: Claude Sonnet 4.6 --- .../src/dom/features/segment-loader-actor.ts | 6 +- .../dom/features/tests/end-of-stream.test.ts | 34 +-- .../spf/src/dom/media/source-buffer-actor.ts | 186 +++++------ .../media/tests/source-buffer-actor.test.ts | 288 +++++++++--------- 4 files changed, 236 insertions(+), 278 deletions(-) diff --git a/packages/spf/src/dom/features/segment-loader-actor.ts b/packages/spf/src/dom/features/segment-loader-actor.ts index 0c4cf0275..447476d6f 100644 --- a/packages/spf/src/dom/features/segment-loader-actor.ts +++ b/packages/spf/src/dom/features/segment-loader-actor.ts @@ -265,7 +265,7 @@ export function createSegmentLoaderActor( const signal = abortController!.signal; try { if (task.type === 'remove') { - sourceBufferActor.send(task, signal); + sourceBufferActor.send({ ...task, signal }); await waitForIdle(sourceBufferActor.snapshot, signal); return; } @@ -287,7 +287,7 @@ export function createSegmentLoaderActor( ); if (!signal.aborted || !isTrackSwitch) { const appendSignal = signal.aborted ? new AbortController().signal : signal; - sourceBufferActor.send({ type: 'append-init', data, meta: task.meta }, appendSignal); + sourceBufferActor.send({ type: 'append-init', data, meta: task.meta, signal: appendSignal }); await waitForIdle(sourceBufferActor.snapshot, appendSignal); } } @@ -301,7 +301,7 @@ export function createSegmentLoaderActor( if (!signal.aborted) { const stream = await fetchBytes(task, { signal }); if (!signal.aborted) { - sourceBufferActor.send({ type: 'append-segment', data: stream, meta: task.meta }, signal); + sourceBufferActor.send({ type: 'append-segment', data: stream, meta: task.meta, signal }); await waitForIdle(sourceBufferActor.snapshot, signal); } } diff --git a/packages/spf/src/dom/features/tests/end-of-stream.test.ts b/packages/spf/src/dom/features/tests/end-of-stream.test.ts index be9fb8ed6..b3bb6b0a1 100644 --- a/packages/spf/src/dom/features/tests/end-of-stream.test.ts +++ b/packages/spf/src/dom/features/tests/end-of-stream.test.ts @@ -543,24 +543,22 @@ describe('endOfStream', () => { // Append the last two segments via the actor — this updates actor context // and triggers the actor subscribers that endOfStream watches. - await actor.send( - { - type: 'batch', - messages: [ - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 'seg-2', startTime: 5, duration: 2.5, trackId: 'video-1' }, - }, - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 'seg-3', startTime: 7.5, duration: 2.5, trackId: 'video-1' }, - }, - ], - }, - neverAborted - ); + actor.send({ + type: 'batch', + messages: [ + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 'seg-2', startTime: 5, duration: 2.5, trackId: 'video-1' }, + }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 'seg-3', startTime: 7.5, duration: 2.5, trackId: 'video-1' }, + }, + ], + signal: neverAborted, + }); await vi.waitFor(() => { expect(mockMs.endOfStream).toHaveBeenCalledTimes(1); diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index 6d6906782..a01d69dbe 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -1,5 +1,4 @@ -import type { ActorSnapshot, SignalActor } from '../../core/actor'; -import { createMachineCore } from '../../core/machine'; +import { createActor, type MessageActor } from '../../core/create-actor'; import { SerialRunner, Task } from '../../core/task'; import type { Segment, Track } from '../../core/types'; import { type AppendData, appendSegment } from './append-segment'; @@ -27,7 +26,13 @@ export type AppendSegmentMessage = { type: 'append-segment'; data: AppendData; m export type RemoveMessage = { type: 'remove'; start: number; end: number }; export type IndividualSourceBufferMessage = AppendInitMessage | AppendSegmentMessage | RemoveMessage; export type BatchMessage = { type: 'batch'; messages: IndividualSourceBufferMessage[] }; -export type SourceBufferMessage = IndividualSourceBufferMessage | BatchMessage; + +/** + * All messages accepted by a SourceBufferActor. + * Each top-level send carries its own AbortSignal — signal is per-message, + * not per-actor, so each call site controls its own cancellation scope. + */ +export type SourceBufferMessage = (IndividualSourceBufferMessage | BatchMessage) & { signal: AbortSignal }; /** Finite states of the actor. */ export type SourceBufferActorState = 'idle' | 'updating' | 'destroyed'; @@ -50,13 +55,8 @@ export interface SourceBufferActorContext { bufferedRanges: BufferedRange[]; } -/** Complete snapshot of a SourceBufferActor. */ -export type SourceBufferActorSnapshot = ActorSnapshot; - /** SourceBuffer actor: queues operations, owns its snapshot. */ -export interface SourceBufferActor extends SignalActor { - send(message: SourceBufferMessage, signal: AbortSignal): void; -} +export type SourceBufferActor = MessageActor; // ============================================================================= // Helpers @@ -225,106 +225,82 @@ export function createSourceBufferActor( sourceBuffer: SourceBuffer, initialContext?: Partial ): SourceBufferActor { - const { snapshotSignal, getState, transition } = createMachineCore( - { - value: 'idle', - context: { segments: [], bufferedRanges: [], initTrackId: undefined, ...initialContext }, - } - ); - - const runner = new SerialRunner(); - - // Applies the completed context atomically with the idle transition. - // If the actor was destroyed while the operation was in flight, preserve - // 'destroyed' — do not regress to 'idle'. - function applyResult(newContext: SourceBufferActorContext): void { - const state = getState() === 'destroyed' ? 'destroyed' : 'idle'; - snapshotSignal.set({ value: state, context: newContext }); - } + type UserState = Exclude; - function handleError(e: unknown): void { - // TODO: QuotaExceededError and other SourceBuffer errors leave the physical - // buffer in an unknown partial state while context goes unchanged. A future - // improvement should detect QuotaExceededError specifically and use total - // bytes-in-buffer as a heuristic to identify the effective buffer capacity, - // enabling targeted flush-and-retry rather than silent model drift. - transition(getState() === 'destroyed' ? 'destroyed' : 'idle'); + const handleError = (e: unknown): void => { if (!(e instanceof Error && e.name === 'AbortError')) { console.error('SourceBuffer operation failed:', e); } - } - - return { - get snapshot() { - return snapshotSignal; - }, - - send(message: SourceBufferMessage, signal: AbortSignal): void { - // Silently drop if not idle — callers observe state via snapshot. - if (getState() !== 'idle') return; - - // Empty batch — guard must pass (actor is idle) but nothing to do. - if (message.type === 'batch' && message.messages.length === 0) return; - - // Transition synchronously so any subsequent send() within the same tick - // is dropped — the actor is now committed to this operation. - transition('updating'); - - const onPartialContext = (ctx: SourceBufferActorContext) => { - snapshotSignal.set({ value: 'updating', context: ctx }); - }; - - if (message.type === 'batch') { - const { messages } = message; - // Each message is its own Task on the runner, executed in submission order. - // workingCtx threads the result of each task into the next without - // writing to the signal between steps — context is only written atomically - // when the last task completes. - // - // workingCtx is captured here (synchronously after state → 'updating'), - // so it reflects the current context at the moment the batch was accepted. - // This is correct: state is now 'updating' so no other sender can modify - // context between this line and the first task executing. - // - // NOTE: if an intermediate task fails (e.g. SourceBuffer error event), - // workingCtx is not updated for that step and subsequent tasks in the - // batch will operate on a stale context. This is an edge case — happy-path - // appends do not fail — but worth revisiting if MSE error recovery lands. - let workingCtx = snapshotSignal.get().context; - - // Partial context updates from streaming appends write to the signal - // directly so external subscribers see in-progress state, but workingCtx - // is only advanced on task completion to preserve batch context threading. - for (const msg of messages.slice(0, -1)) { - const task = messageToTask(msg, { signal, getCtx: () => workingCtx, sourceBuffer, onPartialContext }); - const result = runner.schedule(task); - result.then((newCtx) => { - workingCtx = newCtx; - }); - } - - const lastTask = messageToTask(messages[messages.length - 1]!, { - signal, - getCtx: () => workingCtx, - sourceBuffer, - onPartialContext, - }); - runner.schedule(lastTask).then(applyResult, handleError); - return; - } - - const task = messageToTask(message, { - signal, - getCtx: () => snapshotSignal.get().context, - sourceBuffer, - onPartialContext, - }); - runner.schedule(task).then(applyResult, handleError); - }, + }; - destroy(): void { - transition('destroyed'); - runner.destroy(); + return createActor SerialRunner>({ + runner: () => new SerialRunner(), + initial: 'idle', + context: { segments: [], bufferedRanges: [], initTrackId: undefined, ...initialContext }, + states: { + idle: { + on: { + 'append-init': (msg, { transition, setContext, runner, context }) => { + transition('updating'); + const task = appendInitTask(msg, { + signal: msg.signal, + getCtx: () => context, + sourceBuffer, + onPartialContext: setContext, + }); + runner.schedule(task).then(setContext, handleError); + }, + 'append-segment': (msg, { transition, setContext, runner, context }) => { + transition('updating'); + const task = appendSegmentTask(msg, { + signal: msg.signal, + getCtx: () => context, + sourceBuffer, + onPartialContext: setContext, + }); + runner.schedule(task).then(setContext, handleError); + }, + remove: (msg, { transition, setContext, runner, context }) => { + transition('updating'); + const task = removeTask(msg, { + signal: msg.signal, + getCtx: () => context, + sourceBuffer, + onPartialContext: setContext, + }); + runner.schedule(task).then(setContext, handleError); + }, + batch: (msg, { transition, setContext, runner, context }) => { + const { messages, signal } = msg; + if (messages.length === 0) return; + + transition('updating'); + let workingCtx = context; + + for (const [i, subMsg] of messages.entries()) { + const isLast = i === messages.length - 1; + const task = messageToTask(subMsg, { + signal, + getCtx: () => workingCtx, + sourceBuffer, + onPartialContext: setContext, + }); + const p = runner.schedule(task); + if (isLast) { + p.then(setContext, handleError); + } else { + p.then((ctx) => { + workingCtx = ctx; + }, handleError); + } + } + }, + }, + }, + updating: { + // Automatically return to idle once all scheduled tasks settle. + onSettled: 'idle', + }, }, - }; + }); } diff --git a/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts b/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts index a6eb832c3..214ed46ab 100644 --- a/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts +++ b/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts @@ -83,8 +83,8 @@ describe('createSourceBufferActor', () => { // state transitions to 'updating' synchronously, so the second send() in // the same tick sees 'updating' and is dropped. - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }, neverAborted); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' }, signal: neverAborted }); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' }, signal: neverAborted }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1); @@ -97,10 +97,10 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' }, signal: neverAborted }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }, neverAborted); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' }, signal: neverAborted }); await vi.waitFor(() => expect(actor.snapshot.get().context.initTrackId).toBe('track-2')); actor.destroy(); @@ -114,20 +114,18 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - actor.send( - { - type: 'batch', - messages: [ - { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - }, - ], - }, - neverAborted - ); + actor.send({ + type: 'batch', + messages: [ + { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + }, + ], + signal: neverAborted, + }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(2); @@ -142,24 +140,22 @@ describe('createSourceBufferActor', () => { const actor = createSourceBufferActor(sourceBuffer); // Two segments at the same time range — the second should replace the first - actor.send( - { - type: 'batch', - messages: [ - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's1-low', startTime: 0, duration: 10, trackId: 'track-low' }, - }, - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's1-high', startTime: 0, duration: 10, trackId: 'track-high' }, - }, - ], - }, - neverAborted - ); + actor.send({ + type: 'batch', + messages: [ + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1-low', startTime: 0, duration: 10, trackId: 'track-low' }, + }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1-high', startTime: 0, duration: 10, trackId: 'track-high' }, + }, + ], + signal: neverAborted, + }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); const ids = actor.snapshot.get().context.segments.map((s) => s.id); @@ -179,20 +175,18 @@ describe('createSourceBufferActor', () => { stateValues.push(actor.snapshot.get().value); }); - actor.send( - { - type: 'batch', - messages: [ - { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - }, - ], - }, - neverAborted - ); + actor.send({ + type: 'batch', + messages: [ + { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + }, + ], + signal: neverAborted, + }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); cleanup(); @@ -215,10 +209,12 @@ describe('createSourceBufferActor', () => { const abortedController = new AbortController(); abortedController.abort(); - actor.send( - { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, - abortedController.signal - ); + actor.send({ + type: 'append-init', + data: new ArrayBuffer(4), + meta: { trackId: 'track-1' }, + signal: abortedController.signal, + }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(sourceBuffer.appendBuffer).not.toHaveBeenCalled(); @@ -247,20 +243,18 @@ describe('createSourceBufferActor', () => { return origAppend?.(data); }); - actor.send( - { - type: 'batch', - messages: [ - { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - }, - ], - }, - controller.signal - ); + actor.send({ + type: 'batch', + messages: [ + { type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + }, + ], + signal: controller.signal, + }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1); @@ -276,7 +270,7 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' }, signal: neverAborted }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(actor.snapshot.get().context.initTrackId).toBe('track-1'); @@ -292,14 +286,12 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - actor.send( - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - }, - neverAborted - ); + actor.send({ + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + signal: neverAborted, + }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(actor.snapshot.get().context.segments).toHaveLength(1); @@ -322,24 +314,20 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - actor.send( - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's1-low', startTime: 0, duration: 10, trackId: 'track-low' }, - }, - neverAborted - ); + actor.send({ + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1-low', startTime: 0, duration: 10, trackId: 'track-low' }, + signal: neverAborted, + }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); - actor.send( - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's1-high', startTime: 0, duration: 10, trackId: 'track-high' }, - }, - neverAborted - ); + actor.send({ + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1-high', startTime: 0, duration: 10, trackId: 'track-high' }, + signal: neverAborted, + }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); const ids = actor.snapshot.get().context.segments.map((s) => s.id); @@ -363,33 +351,31 @@ describe('createSourceBufferActor', () => { ]); const actor = createSourceBufferActor(sourceBuffer); - actor.send( - { - type: 'batch', - messages: [ - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - }, - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's2', startTime: 10, duration: 10, trackId: 'track-1' }, - }, - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's3', startTime: 20, duration: 10, trackId: 'track-1' }, - }, - ], - }, - neverAborted - ); + actor.send({ + type: 'batch', + messages: [ + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's2', startTime: 10, duration: 10, trackId: 'track-1' }, + }, + { + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's3', startTime: 20, duration: 10, trackId: 'track-1' }, + }, + ], + signal: neverAborted, + }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); // Remove at a segment boundary so midpoints cleanly fall inside or outside. - actor.send({ type: 'remove', start: 0, end: 20 }, neverAborted); + actor.send({ type: 'remove', start: 0, end: 20, signal: neverAborted }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(sourceBuffer.remove).toHaveBeenCalledWith(0, 20); @@ -415,7 +401,7 @@ describe('createSourceBufferActor', () => { stateValues.push(actor.snapshot.get().value); }); - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' }, signal: neverAborted }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); cleanup(); @@ -457,14 +443,12 @@ describe('createSourceBufferActor', () => { snapshots.push(actor.snapshot.get()); }); - actor.send( - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - }, - neverAborted - ); + actor.send({ + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + signal: neverAborted, + }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); cleanup(); @@ -489,10 +473,12 @@ describe('createSourceBufferActor', () => { yield new Uint8Array(4); } - actor.send( - { type: 'append-segment', data: twoChunks(), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' } }, - neverAborted - ); + actor.send({ + type: 'append-segment', + data: twoChunks(), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + signal: neverAborted, + }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); cleanup(); @@ -512,10 +498,12 @@ describe('createSourceBufferActor', () => { yield new Uint8Array(8); } - actor.send( - { type: 'append-segment', data: oneChunk(), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' } }, - neverAborted - ); + actor.send({ + type: 'append-segment', + data: oneChunk(), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + signal: neverAborted, + }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); const seg = actor.snapshot.get().context.segments.find((s) => s.id === 's1'); @@ -546,14 +534,12 @@ describe('createSourceBufferActor', () => { const actor = createSourceBufferActor(sourceBuffer); const ac = new AbortController(); - actor.send( - { - type: 'append-segment', - data: pausingStream(), - meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - }, - ac.signal - ); + actor.send({ + type: 'append-segment', + data: pausingStream(), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + signal: ac.signal, + }); // Wait until partial state is emitted (first chunk queued) await vi.waitFor(() => { @@ -585,14 +571,12 @@ describe('createSourceBufferActor', () => { }); // Now fully append the same segment (ArrayBuffer path — atomic, no partial) - actorWithPartial.send( - { - type: 'append-segment', - data: new ArrayBuffer(8), - meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - }, - neverAborted - ); + actorWithPartial.send({ + type: 'append-segment', + data: new ArrayBuffer(8), + meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, + signal: neverAborted, + }); await vi.waitFor(() => expect(actorWithPartial.snapshot.get().value).toBe('idle')); const seg = actorWithPartial.snapshot.get().context.segments.find((s) => s.id === 's1'); @@ -607,7 +591,7 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' }, signal: neverAborted }); await vi.waitFor(() => expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)); @@ -616,7 +600,7 @@ describe('createSourceBufferActor', () => { expect(actor.snapshot.get().value).toBe('destroyed'); // After destroy, send() is silently dropped — state stays destroyed - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }, neverAborted); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' }, signal: neverAborted }); expect(actor.snapshot.get().value).toBe('destroyed'); }); }); From 26a39a5d062a70e382dd2d912020703bb8563f10 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 12:38:07 -0700 Subject: [PATCH 64/79] docs(spf): expand actor-reactor-factories with XState async work model comparison Documents the SPF vs XState behavioral divergence around where async work "belongs" (idle handler vs updating-on-entry invoke), consequences for atomicity/lifecycle/partial-updates, tradeoffs of adopting the XState model, and the state-scoped runner as a middle-ground open question. Updates the runner lifetime section to reflect that state-scoped runners are a revisit candidate rather than a firm rejection. Co-Authored-By: Claude Sonnet 4.6 --- .../design/spf/actor-reactor-factories.md | 158 +++++++++++++++++- 1 file changed, 152 insertions(+), 6 deletions(-) diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index 444187790..9ad72fc24 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -333,9 +333,11 @@ when the actor is destroyed. - **Constructor reference** (`runner: SerialRunner`) — `new def.runner()`. Slightly less explicit than a factory; doesn't compose as naturally when construction needs configuration. - **State-lifetime runners** — runner created on state entry, destroyed on state exit. Naturally - eliminates the generation-token problem (`onSettled` always refers to the fresh chain). - Rejected because it prevents runner state from persisting across state transitions — the - current `TextTrackSegmentLoaderActor` intentionally keeps its runner across idle/loading cycles. + eliminates the generation-token problem (`onSettled` always refers to the fresh chain), and + aligns with XState's `invoke` model where async work is tied to the state that started it. + Not adopted as the default because `TextTrackSegmentLoaderActor` intentionally persists runner + state across idle/loading cycles. But this is worth revisiting per-actor — see + [Open Questions](#state-scoped-runner). **Rationale:** Actor-lifetime scope matches the current pattern and is the most flexible default. A factory function (`() => new X(options)`) handles configured runners without changing the @@ -423,7 +425,9 @@ node on every re-run with no memoization. `Computed`s that gate effect re-runs m --- -## XState-style Definition vs. Implementation +## XState Comparison + +### Definition vs. Implementation The current design uses a single definition object that contains both structure (states, runner type, initial state) and behavior (handler functions). XState v5 separates these: @@ -441,7 +445,7 @@ instantiation. SPF's current factory approach is compatible with this future dir `runner: () => new SerialRunner()` today becomes a named reference resolved against a provided implementation map later. The migration path is additive — no existing definitions need to change. -### Handler context API +#### Handler context API The second argument to Actor message handlers is: ```typescript @@ -456,6 +460,148 @@ conditional intersection. --- +### Async Work Model: Where Does Work "Belong"? + +This is the most significant behavioral divergence from XState, with real tradeoffs in both +directions. + +#### The SPF pattern + +In SPF, when an actor like `SourceBufferActor` receives an `append-init` message while `idle`, +the `idle` handler does three things: transitions to `updating`, schedules the work on the +runner, and registers callbacks to update context and settle back to `idle` via `onSettled`. + +```typescript +idle: { + on: { + 'append-init': (msg, { transition, setContext, runner }) => { + transition('updating'); // 1. route + const task = makeTask(msg); + runner.schedule(task).then(setContext); // 2. start work + // 3. onSettled: 'idle' in updating handles the return + } + } +}, +updating: { onSettled: 'idle' } +``` + +The work starts in the `idle` handler and finishes in `updating` via `onSettled`. Two things +happen in separate microtasks: `setContext` (from the task's `.then()`), then `transition('idle')` +(from `onSettled`). Observers see two emissions: `{ value: 'updating', context: newCtx }` followed +by `{ value: 'idle', context: newCtx }`. + +#### The XState pattern + +In XState, `idle` *only routes* — the work belongs to the state that is doing it: + +```typescript +idle: { + on: { 'append-init': { target: 'updating' } } // just routing +}, +updating: { + invoke: { + src: 'executeMessage', + input: ({ event }) => event, // the triggering event travels with the transition + onDone: { + target: 'idle', + actions: assign(({ event }) => event.output) // context + state update, atomically + } + } +} +``` + +`updating` invokes the work on entry, using the event that caused the transition as input. +When the work completes, `onDone` updates context and transitions state in one atomic step — +one emission: `{ value: 'idle', context: newCtx }`. + +#### Consequences + +**Atomicity.** XState's `onDone` updates context and state together; SPF does it in two +microtasks. Currently harmless — all consumers wait for `idle` before reading context — but +it's load-bearing discipline rather than a model guarantee. + +**Lifecycle scoping.** In XState, when the machine leaves `updating` for any reason (a +`cancel` event, `destroy()`, etc.), the invoked service is cancelled automatically. In SPF, the +runner outlives any particular state. Cancellation must be threaded manually via abort signals. +This is non-trivial — `SegmentLoaderActor` has ~50 lines of abort signal management that +XState's state-scoped invocation would eliminate. + +**Partial / streaming updates.** SPF calls `onPartialContext` — a closure callback that writes +context directly from inside the task, bypassing the event system. XState's equivalent is the +invoked service sending intermediate events back to the machine +(`sendBack({ type: 'CHUNK', data })`), which trigger context-updating transitions while the +machine stays in `updating`. More ceremony, but each intermediate state is a proper +event-driven transition — observable, testable, guarded. + +**State graph scalability.** With two states the differences are manageable. If the actor grew +to handle `errored`, `draining`, or `quota-exceeded` states, the XState model scales cleanly — +each state owns its behavior, and leaving any state cancels its work. The SPF model requires +increasingly careful manual management as the graph grows. + +#### Tradeoffs of adopting the XState model + +The XState approach is not strictly better. The costs: + +- **The runner doesn't go away.** `SerialRunner`'s serial queuing and abort semantics don't + exist in XState's `invoke` primitive. The runner would move inside the invoked service rather + than being eliminated. The benefit is lifecycle scoping, not simplification. +- **The dispatch table is the same either way.** Whether messages are routed in the `idle` + handler or via `input: ({ event }) => event` in `updating`'s invoke, the `messageToTask` + dispatch exists in both models. Location changes, not complexity. +- **Partial updates as events adds ceremony for a narrow case.** `onPartialContext` fires once + per streaming segment when the first chunk lands. Modeling it as machine events means the + `updating` state handles task-internal events alongside external messages, with every chunk + going through the full dispatch loop. Heavy machinery for one operation type. +- **TypeScript complexity.** With multiple message types all targeting `updating`, the invoke + `input` type is a union and the service must discriminate on `event.type`. + +#### Middle ground: state-scoped runner + +The most targeted improvement is making the runner *state-scoped* — created on entry to +`updating`, destroyed on exit — without adopting the full `invoke` model: + +```typescript +idle: { + on: { + 'append-init': (msg, { transition, createRunner }) => { + const runner = createRunner(); // fresh runner, owned by updating + transition('updating'); + runner.schedule(makeTask(msg)).then(setContext); + } + } +}, +updating: { + onSettled: 'idle', + onExit: (runner) => runner.destroy() // state exit = cancellation +} +``` + +This buys lifecycle scoping (leaving `updating` for any reason destroys the runner and cancels +in-flight tasks) without requiring `invoke`/`fromCallback` ceremony, chunk events, or +restructuring the dispatch model. The two-phase emission remains, but the cancellation gap is +closed. See [Open Questions](#state-scoped-runner). + +--- + ## Open Questions -_No open questions._ +### State-scoped runner {#state-scoped-runner} + +The current actor-lifetime runner means work scheduled in `updating` completes regardless of +subsequent state transitions. For `SourceBufferActor`, this is intentional (a physical +SourceBuffer write must be reflected in the model even if a signal fires mid-operation). For +other actors, it's accidental — there's no mechanism to say "if the actor leaves this state, +abandon in-flight work." + +A state-scoped runner (created on entry to the active state, destroyed on exit) would close +this gap without requiring the full XState `invoke` model. The main open questions: + +- **API shape.** Does the definition declare a per-state runner factory, or does the framework + provide a `createRunner()` helper in the handler context? +- **`SourceBufferActor` carve-out.** Some actors need the current behavior (context updates + even after leaving the state). A per-actor or per-state opt-in (`keepRunnerOnExit: true`?) + would handle the exception without changing the default. +- **`TextTrackSegmentLoaderActor` compatibility.** This actor's runner intentionally persists + across `idle`/`loading` cycles — tasks from one `loading` entry are still valid in the next. + State-scoped runners would break this unless the runner persists at actor scope (status quo) + or across specific state transitions. From b3478550188aa5f2596914ecf1c39d6388974237 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 12:59:06 -0700 Subject: [PATCH 65/79] refactor(spf): add getContext to HandlerContext; eliminate workingCtx in batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds getContext() — a live untracked read — to HandlerContext alongside the existing context snapshot. Tasks scheduled on the runner use getCtx: getContext so each task reads the context committed by the previous one, rather than the stale dispatch-time snapshot. This eliminates the workingCtx threading pattern in SourceBufferActor's batch handler: every task now calls setContext on completion, publishing its result immediately. Context accurately reflects what has actually been appended as it happens, consistent with the existing onPartialContext model for streaming. Co-Authored-By: Claude Sonnet 4.6 --- .../design/spf/actor-reactor-factories.md | 14 ++++++++- packages/spf/src/core/create-actor.ts | 9 ++++++ .../spf/src/dom/media/source-buffer-actor.ts | 29 +++++++------------ 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index 9ad72fc24..ffcac11ba 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -449,7 +449,12 @@ implementation map later. The migration path is additive — no existing definit The second argument to Actor message handlers is: ```typescript -{ transition: (to: UserState) => void; context: Context; setContext: (next: Context) => void } +{ + transition: (to: UserState) => void; + context: Context; // snapshot at dispatch time — stale after any setContext call + getContext: () => Context; // live untracked read — always current + setContext: (next: Context) => void; +} & (RunnerFactory extends () => infer R ? { runner: R } : {}) ``` @@ -458,6 +463,13 @@ declares a `runner` factory. When no runner is declared, `runner` is absent from entirely (not `undefined` — it simply doesn't exist). This is enforced at the type level via conditional intersection. +`context` vs `getContext`: use `context` for synchronous logic that runs in the handler body +itself (dispatch time). Use `getContext` when passing `getCtx` to tasks scheduled on the +runner — async tasks execute after the handler returns, by which point `context` may be stale +(e.g. a previous task in a batch has already called `setContext`). Passing `getCtx: getContext` +ensures each task reads the context committed by the task before it, making `workingCtx` +threading unnecessary for sequential operations. + --- ### Async Work Model: Where Does Work "Belong"? diff --git a/packages/spf/src/core/create-actor.ts b/packages/spf/src/core/create-actor.ts index 8dfb8f3d0..d4170e0d9 100644 --- a/packages/spf/src/core/create-actor.ts +++ b/packages/spf/src/core/create-actor.ts @@ -32,7 +32,15 @@ export type HandlerContext< RunnerFactory extends (() => RunnerLike) | undefined, > = { transition: (to: UserState) => void; + /** Context snapshot captured at dispatch time. Stale after any `setContext` call. */ context: Context; + /** + * Live untracked read of the current context. Use in async task closures that + * execute after the handler returns — e.g. `getCtx: getContext` passed to tasks + * scheduled on the runner, so each task reads the context committed by the + * previous task rather than the stale snapshot from dispatch time. + */ + getContext: () => Context; setContext: (next: Context) => void; } & (RunnerFactory extends () => infer R ? { runner: R } : object); @@ -182,6 +190,7 @@ export function createActor< if (!handler) return; handler(message, { context: getContext(), + getContext, transition: (to: UserState) => transition(to as FullState), setContext, ...(runner ? { runner } : {}), diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index a01d69dbe..078871c13 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -240,59 +240,50 @@ export function createSourceBufferActor( states: { idle: { on: { - 'append-init': (msg, { transition, setContext, runner, context }) => { + 'append-init': (msg, { transition, setContext, getContext, runner }) => { transition('updating'); const task = appendInitTask(msg, { signal: msg.signal, - getCtx: () => context, + getCtx: getContext, sourceBuffer, onPartialContext: setContext, }); runner.schedule(task).then(setContext, handleError); }, - 'append-segment': (msg, { transition, setContext, runner, context }) => { + 'append-segment': (msg, { transition, setContext, getContext, runner }) => { transition('updating'); const task = appendSegmentTask(msg, { signal: msg.signal, - getCtx: () => context, + getCtx: getContext, sourceBuffer, onPartialContext: setContext, }); runner.schedule(task).then(setContext, handleError); }, - remove: (msg, { transition, setContext, runner, context }) => { + remove: (msg, { transition, setContext, getContext, runner }) => { transition('updating'); const task = removeTask(msg, { signal: msg.signal, - getCtx: () => context, + getCtx: getContext, sourceBuffer, onPartialContext: setContext, }); runner.schedule(task).then(setContext, handleError); }, - batch: (msg, { transition, setContext, runner, context }) => { + batch: (msg, { transition, setContext, getContext, runner }) => { const { messages, signal } = msg; if (messages.length === 0) return; transition('updating'); - let workingCtx = context; - for (const [i, subMsg] of messages.entries()) { - const isLast = i === messages.length - 1; + for (const subMsg of messages) { const task = messageToTask(subMsg, { signal, - getCtx: () => workingCtx, + getCtx: getContext, sourceBuffer, onPartialContext: setContext, }); - const p = runner.schedule(task); - if (isLast) { - p.then(setContext, handleError); - } else { - p.then((ctx) => { - workingCtx = ctx; - }, handleError); - } + runner.schedule(task).then(setContext, handleError); } }, }, From 96ef0a8069addce4b07a1f567c8a46680260b6ef Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 13:05:44 -0700 Subject: [PATCH 66/79] =?UTF-8?q?refactor(spf):=20rename=20getCtx=20?= =?UTF-8?q?=E2=86=92=20getContext=20in=20task=20factories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align task factory parameter names with HandlerContext.getContext for consistency across the actor API. Co-Authored-By: Claude Sonnet 4.6 --- .../spf/src/dom/media/source-buffer-actor.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index 078871c13..8fe167b72 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -74,13 +74,13 @@ function snapshotBuffered(buffered: TimeRanges): BufferedRange[] { // Message task factories // ============================================================================= -// Context is read lazily via getCtx at task execution time — not at creation +// Context is read lazily via getContext at task execution time — not at creation // time — so each task always operates on the most recent context regardless of // when it was scheduled. interface MessageTaskOptions { signal: AbortSignal; - getCtx: () => SourceBufferActorContext; + getContext: () => SourceBufferActorContext; sourceBuffer: SourceBuffer; /** * Called when a streaming append transitions to a partial state — i.e. @@ -93,11 +93,11 @@ interface MessageTaskOptions { function appendInitTask( message: AppendInitMessage, - { signal, getCtx, sourceBuffer }: MessageTaskOptions + { signal, getContext, sourceBuffer }: MessageTaskOptions ): Task { return new Task( async (taskSignal) => { - const ctx = getCtx(); + const ctx = getContext(); if (taskSignal.aborted) return ctx; await appendSegment(sourceBuffer, message.data); // No abort check here: the physical SourceBuffer has been modified, so @@ -110,11 +110,11 @@ function appendInitTask( function appendSegmentTask( message: AppendSegmentMessage, - { signal, getCtx, sourceBuffer, onPartialContext }: MessageTaskOptions + { signal, getContext, sourceBuffer, onPartialContext }: MessageTaskOptions ): Task { return new Task( async (taskSignal) => { - const ctx = getCtx(); + const ctx = getContext(); if (taskSignal.aborted) return ctx; const { meta } = message; @@ -171,11 +171,11 @@ function appendSegmentTask( function removeTask( message: RemoveMessage, - { signal, getCtx, sourceBuffer }: MessageTaskOptions + { signal, getContext, sourceBuffer }: MessageTaskOptions ): Task { return new Task( async (taskSignal) => { - const ctx = getCtx(); + const ctx = getContext(); if (taskSignal.aborted) return ctx; await flushBuffer(sourceBuffer, message.start, message.end); // No abort check here: the physical SourceBuffer has been modified, so @@ -244,7 +244,7 @@ export function createSourceBufferActor( transition('updating'); const task = appendInitTask(msg, { signal: msg.signal, - getCtx: getContext, + getContext, sourceBuffer, onPartialContext: setContext, }); @@ -254,7 +254,7 @@ export function createSourceBufferActor( transition('updating'); const task = appendSegmentTask(msg, { signal: msg.signal, - getCtx: getContext, + getContext, sourceBuffer, onPartialContext: setContext, }); @@ -264,7 +264,7 @@ export function createSourceBufferActor( transition('updating'); const task = removeTask(msg, { signal: msg.signal, - getCtx: getContext, + getContext, sourceBuffer, onPartialContext: setContext, }); @@ -279,7 +279,7 @@ export function createSourceBufferActor( for (const subMsg of messages) { const task = messageToTask(subMsg, { signal, - getCtx: getContext, + getContext, sourceBuffer, onPartialContext: setContext, }); From 8e9a21ff27b237317abb9fa1e5d072fd0e7f541c Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 13:12:35 -0700 Subject: [PATCH 67/79] =?UTF-8?q?refactor(spf):=20rename=20onPartialContex?= =?UTF-8?q?t=20=E2=86=92=20setContext=20in=20task=20factories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns MessageTaskOptions with HandlerContext naming — both now use setContext — removing the partial-specific name that implied a narrower contract than the function actually has. Co-Authored-By: Claude Sonnet 4.6 --- .../spf/src/dom/media/source-buffer-actor.ts | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index 8fe167b72..43b7ef6dc 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -82,13 +82,7 @@ interface MessageTaskOptions { signal: AbortSignal; getContext: () => SourceBufferActorContext; sourceBuffer: SourceBuffer; - /** - * Called when a streaming append transitions to a partial state — i.e. - * the first chunk of an AsyncIterable has been committed and the segment - * now has data in the SourceBuffer but is not yet complete. Not called for - * full ArrayBuffer appends (which are atomic). - */ - onPartialContext: (ctx: SourceBufferActorContext) => void; + setContext: (ctx: SourceBufferActorContext) => void; } function appendInitTask( @@ -110,7 +104,7 @@ function appendInitTask( function appendSegmentTask( message: AppendSegmentMessage, - { signal, getContext, sourceBuffer, onPartialContext }: MessageTaskOptions + { signal, getContext, sourceBuffer, setContext }: MessageTaskOptions ): Task { return new Task( async (taskSignal) => { @@ -130,7 +124,7 @@ function appendSegmentTask( // incomplete. ArrayBuffer appends are atomic so no partial state is // needed — context is updated once at task completion. if (!(message.data instanceof ArrayBuffer)) { - onPartialContext({ + setContext({ ...ctx, segments: [ ...filtered, @@ -246,7 +240,7 @@ export function createSourceBufferActor( signal: msg.signal, getContext, sourceBuffer, - onPartialContext: setContext, + setContext, }); runner.schedule(task).then(setContext, handleError); }, @@ -256,7 +250,7 @@ export function createSourceBufferActor( signal: msg.signal, getContext, sourceBuffer, - onPartialContext: setContext, + setContext, }); runner.schedule(task).then(setContext, handleError); }, @@ -266,7 +260,7 @@ export function createSourceBufferActor( signal: msg.signal, getContext, sourceBuffer, - onPartialContext: setContext, + setContext, }); runner.schedule(task).then(setContext, handleError); }, @@ -281,7 +275,7 @@ export function createSourceBufferActor( signal, getContext, sourceBuffer, - onPartialContext: setContext, + setContext, }); runner.schedule(task).then(setContext, handleError); } From 9e71e6ab5f26a3b5ec304766a8e809e23be4ccbb Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 13:40:59 -0700 Subject: [PATCH 68/79] docs(spf): record state-scoped runner as deferred, not open Replaces the Open Questions entry with a documented decision: actor-lifetime runners stay for now. Explains why state-scoped runners require more than convention (onEnter + context side channel), and the condition under which we'd revisit. Also removes the Option C code example from the XState middle-ground section and cleans up stale getCtx/onPartialContext references. Co-Authored-By: Claude Sonnet 4.6 --- .../design/spf/actor-reactor-factories.md | 72 ++++++++----------- 1 file changed, 30 insertions(+), 42 deletions(-) diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index ffcac11ba..f14143f03 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -464,11 +464,11 @@ entirely (not `undefined` — it simply doesn't exist). This is enforced at the conditional intersection. `context` vs `getContext`: use `context` for synchronous logic that runs in the handler body -itself (dispatch time). Use `getContext` when passing `getCtx` to tasks scheduled on the -runner — async tasks execute after the handler returns, by which point `context` may be stale -(e.g. a previous task in a batch has already called `setContext`). Passing `getCtx: getContext` -ensures each task reads the context committed by the task before it, making `workingCtx` -threading unnecessary for sequential operations. +itself (dispatch time). Use `getContext` when passing it to tasks scheduled on the runner — +async tasks execute after the handler returns, by which point `context` may be stale (e.g. a +previous task in a batch has already called `setContext`). Passing `getContext` ensures each +task reads the context committed by the task before it, making `workingCtx` threading +unnecessary for sequential operations. --- @@ -538,7 +538,7 @@ runner outlives any particular state. Cancellation must be threaded manually via This is non-trivial — `SegmentLoaderActor` has ~50 lines of abort signal management that XState's state-scoped invocation would eliminate. -**Partial / streaming updates.** SPF calls `onPartialContext` — a closure callback that writes +**Partial / streaming updates.** SPF calls `setContext` — a closure callback that writes context directly from inside the task, bypassing the event system. XState's equivalent is the invoked service sending intermediate events back to the machine (`sendBack({ type: 'CHUNK', data })`), which trigger context-updating transitions while the @@ -560,7 +560,7 @@ The XState approach is not strictly better. The costs: - **The dispatch table is the same either way.** Whether messages are routed in the `idle` handler or via `input: ({ event }) => event` in `updating`'s invoke, the `messageToTask` dispatch exists in both models. Location changes, not complexity. -- **Partial updates as events adds ceremony for a narrow case.** `onPartialContext` fires once +- **Partial updates as events adds ceremony for a narrow case.** `setContext` fires once per streaming segment when the first chunk lands. Modeling it as machine events means the `updating` state handles task-internal events alongside external messages, with every chunk going through the full dispatch loop. Heavy machinery for one operation type. @@ -569,29 +569,9 @@ The XState approach is not strictly better. The costs: #### Middle ground: state-scoped runner -The most targeted improvement is making the runner *state-scoped* — created on entry to -`updating`, destroyed on exit — without adopting the full `invoke` model: - -```typescript -idle: { - on: { - 'append-init': (msg, { transition, createRunner }) => { - const runner = createRunner(); // fresh runner, owned by updating - transition('updating'); - runner.schedule(makeTask(msg)).then(setContext); - } - } -}, -updating: { - onSettled: 'idle', - onExit: (runner) => runner.destroy() // state exit = cancellation -} -``` - -This buys lifecycle scoping (leaving `updating` for any reason destroys the runner and cancels -in-flight tasks) without requiring `invoke`/`fromCallback` ceremony, chunk events, or -restructuring the dispatch model. The two-phase emission remains, but the cancellation gap is -closed. See [Open Questions](#state-scoped-runner). +The most targeted improvement would be making the runner *state-scoped* — created on entry to +`updating`, destroyed on exit — without adopting the full `invoke` model. This was explored +and deferred; see [Open Questions](#state-scoped-runner). --- @@ -605,15 +585,23 @@ SourceBuffer write must be reflected in the model even if a signal fires mid-ope other actors, it's accidental — there's no mechanism to say "if the actor leaves this state, abandon in-flight work." -A state-scoped runner (created on entry to the active state, destroyed on exit) would close -this gap without requiring the full XState `invoke` model. The main open questions: - -- **API shape.** Does the definition declare a per-state runner factory, or does the framework - provide a `createRunner()` helper in the handler context? -- **`SourceBufferActor` carve-out.** Some actors need the current behavior (context updates - even after leaving the state). A per-actor or per-state opt-in (`keepRunnerOnExit: true`?) - would handle the exception without changing the default. -- **`TextTrackSegmentLoaderActor` compatibility.** This actor's runner intentionally persists - across `idle`/`loading` cycles — tasks from one `loading` entry are still valid in the next. - State-scoped runners would break this unless the runner persists at actor scope (status quo) - or across specific state transitions. +A state-scoped runner would close this gap, but the investigation concluded it requires more +than convention: + +- **Scheduling happens in `idle`, not `updating`.** `idle` handlers transition to `updating` + and then schedule tasks — in the same function body, on the same runner reference. For the + runner to be state-scoped, either `transition()` must return the new state's runner (magic, + rejected), or task inputs must travel through context so that an `onEnter` hook on `updating` + can drain them and do the scheduling there. +- **`onEnter` is a real API addition.** The "entry hook drains context" model is essentially + a lightweight `invoke` — it requires `onEnter` in `ActorStateDefinition`, a per-state + runner factory, and the framework to wire them up on state entry and exit. That's a meaningful + framework change, not a convention. +- **Context as side channel is awkward.** Task inputs (message payloads) traveling through + observable actor context leaks internal scheduling details into the public snapshot. + +**Decision:** Keep actor-lifetime runners for now. The generation-token problem is already +handled by the framework. The cancellation gap (work outliving its state) is real but not +currently exploited — no actor today has a non-settle path out of its work state. Revisit if +a new actor needs explicit cancellation on state exit, or if the framework grows `onEnter` +support for other reasons (at which point state-scoped runners become straightforward). From 6ee3374d8559605390007d02c5470103de0095b3 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 14:35:36 -0700 Subject: [PATCH 69/79] refactor(spf): replace per-message AbortSignal with cancel message Cancellation is now an explicit actor message rather than an out-of-band signal threaded through every send(). SourceBufferActor gains a `cancel` handler in `updating` that calls runner.abortAll(); SegmentLoaderActor sends cancel only when preempting with a track switch or segment abort, preserving the same-track-seek init-commit optimisation without the appendSignal hack. Co-Authored-By: Claude Sonnet 4.6 --- .../src/dom/features/segment-loader-actor.ts | 30 ++- .../spf/src/dom/media/source-buffer-actor.ts | 192 ++++++++---------- .../media/tests/source-buffer-actor.test.ts | 103 ++++------ 3 files changed, 145 insertions(+), 180 deletions(-) diff --git a/packages/spf/src/dom/features/segment-loader-actor.ts b/packages/spf/src/dom/features/segment-loader-actor.ts index 447476d6f..f09ea33fd 100644 --- a/packages/spf/src/dom/features/segment-loader-actor.ts +++ b/packages/spf/src/dom/features/segment-loader-actor.ts @@ -265,7 +265,7 @@ export function createSegmentLoaderActor( const signal = abortController!.signal; try { if (task.type === 'remove') { - sourceBufferActor.send({ ...task, signal }); + sourceBufferActor.send(task); await waitForIdle(sourceBufferActor.snapshot, signal); return; } @@ -279,16 +279,19 @@ export function createSegmentLoaderActor( // all chunks and yield exactly one — equivalent to arrayBuffer() but // through the same streaming path as media segments. const data = await fetchBytes(task, { signal, minChunkSize: Infinity }); - // For seeks on the same track: commit even if aborted — avoids re-fetching the - // same init next time. For track switches: don't commit the old track's init; - // the new track's init follows in pendingTasks. + // For seeks on the same track: commit even if aborted — avoids re-fetching + // the same init next time. The preempt path in send() only sends cancel to + // the SourceBufferActor for track switches, so for same-track seeks the actor + // is still idle and will accept this append. + // For track switches: cancel was already sent; skip the commit. const isTrackSwitch = pendingTasks?.some( (t) => t.type === 'append-init' && t.meta.trackId !== task.meta.trackId ); if (!signal.aborted || !isTrackSwitch) { - const appendSignal = signal.aborted ? new AbortController().signal : signal; - sourceBufferActor.send({ type: 'append-init', data, meta: task.meta, signal: appendSignal }); - await waitForIdle(sourceBufferActor.snapshot, appendSignal); + sourceBufferActor.send({ type: 'append-init', data, meta: task.meta }); + // Use a fresh signal when committing despite abort so waitForIdle doesn't + // reject before the actor reaches idle. + await waitForIdle(sourceBufferActor.snapshot, signal.aborted ? new AbortController().signal : signal); } } return; @@ -301,7 +304,7 @@ export function createSegmentLoaderActor( if (!signal.aborted) { const stream = await fetchBytes(task, { signal }); if (!signal.aborted) { - sourceBufferActor.send({ type: 'append-segment', data: stream, meta: task.meta, signal }); + sourceBufferActor.send({ type: 'append-segment', data: stream, meta: task.meta }); await waitForIdle(sourceBufferActor.snapshot, signal); } } @@ -385,6 +388,17 @@ export function createSegmentLoaderActor( // Preempt: in-flight work is not needed for the new plan. Abort and replan. pendingTasks = allTasks; abortController?.abort(); + // Cancel SourceBufferActor tasks when there's a segment in-flight (always + // discard) or when a track switch is happening (new track's init follows). + // For same-track seek with an in-flight init, we intentionally skip cancel + // so the init commits — executeLoadTask handles the waitForIdle in that case. + const cancelSourceBuffer = + inFlightSegmentId !== null || + (inFlightInitTrackId !== null && + allTasks.some((t) => t.type === 'append-init' && t.meta.trackId !== inFlightInitTrackId)); + if (cancelSourceBuffer) { + sourceBufferActor.send({ type: 'cancel' }); + } } }, diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index 43b7ef6dc..9026ad0f0 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -26,13 +26,10 @@ export type AppendSegmentMessage = { type: 'append-segment'; data: AppendData; m export type RemoveMessage = { type: 'remove'; start: number; end: number }; export type IndividualSourceBufferMessage = AppendInitMessage | AppendSegmentMessage | RemoveMessage; export type BatchMessage = { type: 'batch'; messages: IndividualSourceBufferMessage[] }; +export type CancelMessage = { type: 'cancel' }; -/** - * All messages accepted by a SourceBufferActor. - * Each top-level send carries its own AbortSignal — signal is per-message, - * not per-actor, so each call site controls its own cancellation scope. - */ -export type SourceBufferMessage = (IndividualSourceBufferMessage | BatchMessage) & { signal: AbortSignal }; +/** All messages accepted by a SourceBufferActor. */ +export type SourceBufferMessage = IndividualSourceBufferMessage | BatchMessage | CancelMessage; /** Finite states of the actor. */ export type SourceBufferActorState = 'idle' | 'updating' | 'destroyed'; @@ -79,7 +76,6 @@ function snapshotBuffered(buffered: TimeRanges): BufferedRange[] { // when it was scheduled. interface MessageTaskOptions { - signal: AbortSignal; getContext: () => SourceBufferActorContext; sourceBuffer: SourceBuffer; setContext: (ctx: SourceBufferActorContext) => void; @@ -87,64 +83,40 @@ interface MessageTaskOptions { function appendInitTask( message: AppendInitMessage, - { signal, getContext, sourceBuffer }: MessageTaskOptions + { getContext, sourceBuffer }: MessageTaskOptions ): Task { - return new Task( - async (taskSignal) => { - const ctx = getContext(); - if (taskSignal.aborted) return ctx; - await appendSegment(sourceBuffer, message.data); - // No abort check here: the physical SourceBuffer has been modified, so - // the model must be updated to match regardless of signal state. - return { ...ctx, initTrackId: message.meta.trackId }; - }, - { signal } - ); + return new Task(async (taskSignal) => { + const ctx = getContext(); + if (taskSignal.aborted) return ctx; + await appendSegment(sourceBuffer, message.data); + // No abort check here: the physical SourceBuffer has been modified, so + // the model must be updated to match regardless of signal state. + return { ...ctx, initTrackId: message.meta.trackId }; + }); } function appendSegmentTask( message: AppendSegmentMessage, - { signal, getContext, sourceBuffer, setContext }: MessageTaskOptions + { getContext, sourceBuffer, setContext }: MessageTaskOptions ): Task { - return new Task( - async (taskSignal) => { - const ctx = getContext(); - if (taskSignal.aborted) return ctx; + return new Task(async (taskSignal) => { + const ctx = getContext(); + if (taskSignal.aborted) return ctx; - const { meta } = message; - // Remove any existing entry at the same start time (same "slot" in the - // timeline), then record the new segment. Assumes time-aligned segments - // across playlists. The epsilon guards against floating-point drift in - // parsed timestamps. - const EPSILON = 0.0001; - const filtered = ctx.segments.filter((s) => Math.abs(s.startTime - meta.startTime) >= EPSILON); + const { meta } = message; + // Remove any existing entry at the same start time (same "slot" in the + // timeline), then record the new segment. Assumes time-aligned segments + // across playlists. The epsilon guards against floating-point drift in + // parsed timestamps. + const EPSILON = 0.0001; + const filtered = ctx.segments.filter((s) => Math.abs(s.startTime - meta.startTime) >= EPSILON); - // For streaming data: emit partial state before the first chunk so - // downstream code can see the in-progress segment and treat it as - // incomplete. ArrayBuffer appends are atomic so no partial state is - // needed — context is updated once at task completion. - if (!(message.data instanceof ArrayBuffer)) { - setContext({ - ...ctx, - segments: [ - ...filtered, - { - id: meta.id, - startTime: meta.startTime, - duration: meta.duration, - trackId: meta.trackId, - ...(meta.trackBandwidth !== undefined && { trackBandwidth: meta.trackBandwidth }), - partial: true, - }, - ], - bufferedRanges: ctx.bufferedRanges, - }); - } - - await appendSegment(sourceBuffer, message.data, taskSignal); - // No abort check here: the physical SourceBuffer has been modified, so - // the model must be updated to match regardless of signal state. - return { + // For streaming data: emit partial state before the first chunk so + // downstream code can see the in-progress segment and treat it as + // incomplete. ArrayBuffer appends are atomic so no partial state is + // needed — context is updated once at task completion. + if (!(message.data instanceof ArrayBuffer)) { + setContext({ ...ctx, segments: [ ...filtered, @@ -154,40 +126,55 @@ function appendSegmentTask( duration: meta.duration, trackId: meta.trackId, ...(meta.trackBandwidth !== undefined && { trackBandwidth: meta.trackBandwidth }), + partial: true, }, ], - bufferedRanges: snapshotBuffered(sourceBuffer.buffered), - }; - }, - { signal } - ); + bufferedRanges: ctx.bufferedRanges, + }); + } + + await appendSegment(sourceBuffer, message.data, taskSignal); + // No abort check here: the physical SourceBuffer has been modified, so + // the model must be updated to match regardless of signal state. + return { + ...ctx, + segments: [ + ...filtered, + { + id: meta.id, + startTime: meta.startTime, + duration: meta.duration, + trackId: meta.trackId, + ...(meta.trackBandwidth !== undefined && { trackBandwidth: meta.trackBandwidth }), + }, + ], + bufferedRanges: snapshotBuffered(sourceBuffer.buffered), + }; + }); } function removeTask( message: RemoveMessage, - { signal, getContext, sourceBuffer }: MessageTaskOptions + { getContext, sourceBuffer }: MessageTaskOptions ): Task { - return new Task( - async (taskSignal) => { - const ctx = getContext(); - if (taskSignal.aborted) return ctx; - await flushBuffer(sourceBuffer, message.start, message.end); - // No abort check here: the physical SourceBuffer has been modified, so - // the model must be updated to match regardless of signal state. - // - // Use the post-flush buffered ranges as ground truth. A segment is kept - // in the model only if its midpoint falls within a buffered range. - // Midpoint-based membership handles flush boundaries that don't align - // exactly with segment edges without over-removing adjacent segments. - const bufferedRanges = snapshotBuffered(sourceBuffer.buffered); - const filtered = ctx.segments.filter((s) => { - const midpoint = s.startTime + s.duration / 2; - return bufferedRanges.some((r) => midpoint >= r.start && midpoint < r.end); - }); - return { ...ctx, segments: filtered, bufferedRanges }; - }, - { signal } - ); + return new Task(async (taskSignal) => { + const ctx = getContext(); + if (taskSignal.aborted) return ctx; + await flushBuffer(sourceBuffer, message.start, message.end); + // No abort check here: the physical SourceBuffer has been modified, so + // the model must be updated to match regardless of signal state. + // + // Use the post-flush buffered ranges as ground truth. A segment is kept + // in the model only if its midpoint falls within a buffered range. + // Midpoint-based membership handles flush boundaries that don't align + // exactly with segment edges without over-removing adjacent segments. + const bufferedRanges = snapshotBuffered(sourceBuffer.buffered); + const filtered = ctx.segments.filter((s) => { + const midpoint = s.startTime + s.duration / 2; + return bufferedRanges.some((r) => midpoint >= r.start && midpoint < r.end); + }); + return { ...ctx, segments: filtered, bufferedRanges }; + }); } type MessageTaskFactory = ( @@ -236,47 +223,27 @@ export function createSourceBufferActor( on: { 'append-init': (msg, { transition, setContext, getContext, runner }) => { transition('updating'); - const task = appendInitTask(msg, { - signal: msg.signal, - getContext, - sourceBuffer, - setContext, - }); + const task = appendInitTask(msg, { getContext, sourceBuffer, setContext }); runner.schedule(task).then(setContext, handleError); }, 'append-segment': (msg, { transition, setContext, getContext, runner }) => { transition('updating'); - const task = appendSegmentTask(msg, { - signal: msg.signal, - getContext, - sourceBuffer, - setContext, - }); + const task = appendSegmentTask(msg, { getContext, sourceBuffer, setContext }); runner.schedule(task).then(setContext, handleError); }, remove: (msg, { transition, setContext, getContext, runner }) => { transition('updating'); - const task = removeTask(msg, { - signal: msg.signal, - getContext, - sourceBuffer, - setContext, - }); + const task = removeTask(msg, { getContext, sourceBuffer, setContext }); runner.schedule(task).then(setContext, handleError); }, batch: (msg, { transition, setContext, getContext, runner }) => { - const { messages, signal } = msg; + const { messages } = msg; if (messages.length === 0) return; transition('updating'); for (const subMsg of messages) { - const task = messageToTask(subMsg, { - signal, - getContext, - sourceBuffer, - setContext, - }); + const task = messageToTask(subMsg, { getContext, sourceBuffer, setContext }); runner.schedule(task).then(setContext, handleError); } }, @@ -285,6 +252,13 @@ export function createSourceBufferActor( updating: { // Automatically return to idle once all scheduled tasks settle. onSettled: 'idle', + on: { + // Abort all in-progress and pending tasks. onSettled handles → 'idle' + // once the aborted tasks drain. + cancel: (_, { runner }) => { + runner.abortAll(); + }, + }, }, }, }); diff --git a/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts b/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts index 214ed46ab..3467cbf1b 100644 --- a/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts +++ b/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts @@ -70,8 +70,6 @@ function makeSourceBuffer(appendRanges: Array<[number, number]> = []): SourceBuf } as unknown as SourceBuffer; } -const neverAborted = new AbortController().signal; - describe('createSourceBufferActor', () => { // --------------------------------------------------------------------------- // State guard — messages rejected when not idle @@ -83,8 +81,8 @@ describe('createSourceBufferActor', () => { // state transitions to 'updating' synchronously, so the second send() in // the same tick sees 'updating' and is dropped. - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' }, signal: neverAborted }); - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' }, signal: neverAborted }); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1); @@ -97,10 +95,10 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' }, signal: neverAborted }); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' }, signal: neverAborted }); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }); await vi.waitFor(() => expect(actor.snapshot.get().context.initTrackId).toBe('track-2')); actor.destroy(); @@ -124,7 +122,6 @@ describe('createSourceBufferActor', () => { meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, }, ], - signal: neverAborted, }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); @@ -154,7 +151,6 @@ describe('createSourceBufferActor', () => { meta: { id: 's1-high', startTime: 0, duration: 10, trackId: 'track-high' }, }, ], - signal: neverAborted, }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); @@ -185,7 +181,6 @@ describe('createSourceBufferActor', () => { meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, }, ], - signal: neverAborted, }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); cleanup(); @@ -202,45 +197,21 @@ describe('createSourceBufferActor', () => { // Abort: before start // --------------------------------------------------------------------------- - it('skips message when signal is aborted before execution starts', async () => { + it('cancel during batch skips tasks not yet started', async () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - const abortedController = new AbortController(); - abortedController.abort(); - - actor.send({ - type: 'append-init', - data: new ArrayBuffer(4), - meta: { trackId: 'track-1' }, - signal: abortedController.signal, - }); - await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); - - expect(sourceBuffer.appendBuffer).not.toHaveBeenCalled(); - - actor.destroy(); - }); - - // --------------------------------------------------------------------------- - // Abort: during batch execution - // --------------------------------------------------------------------------- - - it('batch message: aborts mid-flight; subsequent messages in batch are skipped', async () => { - const sourceBuffer = makeSourceBuffer(); - const actor = createSourceBufferActor(sourceBuffer); - - const controller = new AbortController(); - - const mockedAppend = vi.mocked(sourceBuffer.appendBuffer); - const origAppend = mockedAppend.getMockImplementation(); + // Intercept appendBuffer to send cancel after the first task starts, + // before the second task has a chance to run. + const appendMock = vi.mocked(sourceBuffer.appendBuffer); + const origImpl = appendMock.getMockImplementation(); let firstCall = true; - mockedAppend.mockImplementation((data: BufferSource) => { + appendMock.mockImplementation((data: BufferSource) => { if (firstCall) { firstCall = false; - controller.abort(); + actor.send({ type: 'cancel' }); } - return origAppend?.(data); + return origImpl?.(data); }); actor.send({ @@ -253,7 +224,6 @@ describe('createSourceBufferActor', () => { meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, }, ], - signal: controller.signal, }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); @@ -262,6 +232,23 @@ describe('createSourceBufferActor', () => { actor.destroy(); }); + // --------------------------------------------------------------------------- + // Abort: during batch execution + // --------------------------------------------------------------------------- + + it('cancel while updating returns actor to idle', async () => { + const sourceBuffer = makeSourceBuffer(); + const actor = createSourceBufferActor(sourceBuffer); + + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }); + // Actor is now in 'updating' — cancel should abort tasks and return to idle. + actor.send({ type: 'cancel' }); + + await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); + + actor.destroy(); + }); + // --------------------------------------------------------------------------- // append-init // --------------------------------------------------------------------------- @@ -270,7 +257,7 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' }, signal: neverAborted }); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(actor.snapshot.get().context.initTrackId).toBe('track-1'); @@ -290,7 +277,6 @@ describe('createSourceBufferActor', () => { type: 'append-segment', data: new ArrayBuffer(8), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - signal: neverAborted, }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); @@ -318,7 +304,6 @@ describe('createSourceBufferActor', () => { type: 'append-segment', data: new ArrayBuffer(8), meta: { id: 's1-low', startTime: 0, duration: 10, trackId: 'track-low' }, - signal: neverAborted, }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); @@ -326,7 +311,6 @@ describe('createSourceBufferActor', () => { type: 'append-segment', data: new ArrayBuffer(8), meta: { id: 's1-high', startTime: 0, duration: 10, trackId: 'track-high' }, - signal: neverAborted, }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); @@ -370,12 +354,11 @@ describe('createSourceBufferActor', () => { meta: { id: 's3', startTime: 20, duration: 10, trackId: 'track-1' }, }, ], - signal: neverAborted, }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); // Remove at a segment boundary so midpoints cleanly fall inside or outside. - actor.send({ type: 'remove', start: 0, end: 20, signal: neverAborted }); + actor.send({ type: 'remove', start: 0, end: 20 }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); expect(sourceBuffer.remove).toHaveBeenCalledWith(0, 20); @@ -401,7 +384,7 @@ describe('createSourceBufferActor', () => { stateValues.push(actor.snapshot.get().value); }); - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' }, signal: neverAborted }); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); cleanup(); @@ -447,7 +430,6 @@ describe('createSourceBufferActor', () => { type: 'append-segment', data: new ArrayBuffer(8), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - signal: neverAborted, }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); cleanup(); @@ -477,7 +459,6 @@ describe('createSourceBufferActor', () => { type: 'append-segment', data: twoChunks(), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - signal: neverAborted, }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); cleanup(); @@ -502,7 +483,6 @@ describe('createSourceBufferActor', () => { type: 'append-segment', data: oneChunk(), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - signal: neverAborted, }); await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); @@ -513,8 +493,8 @@ describe('createSourceBufferActor', () => { actor.destroy(); }); - it('leaves partial:true entry in context when streaming append is aborted', async () => { - // Use a controllable iterable that pauses, allowing abort mid-stream + it('leaves partial:true entry in context when streaming append is cancelled', async () => { + // Use a controllable iterable that pauses, allowing cancel mid-stream let resolveFirst: () => void; const firstChunkReady = new Promise((r) => { resolveFirst = r; @@ -522,7 +502,7 @@ describe('createSourceBufferActor', () => { async function* pausingStream() { yield new Uint8Array(4); - // Pause here — abort will fire before the second chunk + // Pause here — cancel will fire before the second chunk await firstChunkReady; yield new Uint8Array(4); } @@ -532,13 +512,11 @@ describe('createSourceBufferActor', () => { [5, 10], ]); const actor = createSourceBufferActor(sourceBuffer); - const ac = new AbortController(); actor.send({ type: 'append-segment', data: pausingStream(), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - signal: ac.signal, }); // Wait until partial state is emitted (first chunk queued) @@ -546,11 +524,11 @@ describe('createSourceBufferActor', () => { expect(actor.snapshot.get().context.segments.some((s) => s.id === 's1' && s.partial === true)).toBe(true); }); - // Abort — the stream is paused waiting for resolveFirst - ac.abort(); + // Cancel — aborts the runner's tasks; stream is paused waiting for resolveFirst + actor.send({ type: 'cancel' }); resolveFirst!(); - // Wait for the actor to settle back to idle after the aborted task + // Wait for the actor to settle back to idle after the cancelled task await vi.waitFor(() => expect(actor.snapshot.get().value).toBe('idle')); // partial: true entry should remain — accurately reflects data in SourceBuffer @@ -575,7 +553,6 @@ describe('createSourceBufferActor', () => { type: 'append-segment', data: new ArrayBuffer(8), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }, - signal: neverAborted, }); await vi.waitFor(() => expect(actorWithPartial.snapshot.get().value).toBe('idle')); @@ -591,7 +568,7 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' }, signal: neverAborted }); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }); await vi.waitFor(() => expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)); @@ -600,7 +577,7 @@ describe('createSourceBufferActor', () => { expect(actor.snapshot.get().value).toBe('destroyed'); // After destroy, send() is silently dropped — state stays destroyed - actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' }, signal: neverAborted }); + actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }); expect(actor.snapshot.get().value).toBe('destroyed'); }); }); From 30deaccc038b647106bb70564c5820c366763c10 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 15:16:46 -0700 Subject: [PATCH 70/79] refactor(spf): extract onMessage helper to deduplicate idle handlers Replaces the three identical append-init/append-segment/remove handler bodies with a shared onMessage closure, using HandlerContext to type the parameters. Co-Authored-By: Claude Sonnet 4.6 --- .../spf/src/dom/media/source-buffer-actor.ts | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index 9026ad0f0..0b6b96df9 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -1,4 +1,4 @@ -import { createActor, type MessageActor } from '../../core/create-actor'; +import { createActor, type HandlerContext, type MessageActor } from '../../core/create-actor'; import { SerialRunner, Task } from '../../core/task'; import type { Segment, Track } from '../../core/types'; import { type AppendData, appendSegment } from './append-segment'; @@ -214,6 +214,14 @@ export function createSourceBufferActor( } }; + type Ctx = HandlerContext SerialRunner>; + + const onMessage = (msg: IndividualSourceBufferMessage, { transition, setContext, getContext, runner }: Ctx): void => { + transition('updating'); + const task = messageToTask(msg, { getContext, sourceBuffer, setContext }); + runner.schedule(task).then(setContext, handleError); + }; + return createActor SerialRunner>({ runner: () => new SerialRunner(), initial: 'idle', @@ -221,21 +229,9 @@ export function createSourceBufferActor( states: { idle: { on: { - 'append-init': (msg, { transition, setContext, getContext, runner }) => { - transition('updating'); - const task = appendInitTask(msg, { getContext, sourceBuffer, setContext }); - runner.schedule(task).then(setContext, handleError); - }, - 'append-segment': (msg, { transition, setContext, getContext, runner }) => { - transition('updating'); - const task = appendSegmentTask(msg, { getContext, sourceBuffer, setContext }); - runner.schedule(task).then(setContext, handleError); - }, - remove: (msg, { transition, setContext, getContext, runner }) => { - transition('updating'); - const task = removeTask(msg, { getContext, sourceBuffer, setContext }); - runner.schedule(task).then(setContext, handleError); - }, + 'append-init': onMessage, + 'append-segment': onMessage, + remove: onMessage, batch: (msg, { transition, setContext, getContext, runner }) => { const { messages } = msg; if (messages.length === 0) return; From 7d8346b40d350b7aa71694d98f3538e291bfdb9b Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 15:20:02 -0700 Subject: [PATCH 71/79] refactor(spf): use forEach in batch handler Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/dom/media/source-buffer-actor.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index 0b6b96df9..0811f1e8a 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -237,11 +237,10 @@ export function createSourceBufferActor( if (messages.length === 0) return; transition('updating'); - - for (const subMsg of messages) { - const task = messageToTask(subMsg, { getContext, sourceBuffer, setContext }); + messages.forEach((msg) => { + const task = messageToTask(msg, { getContext, sourceBuffer, setContext }); runner.schedule(task).then(setContext, handleError); - } + }); }, }, }, From e05cb32e82661277e03546ea6c96aec9ed4fd315 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 16:00:21 -0700 Subject: [PATCH 72/79] refactor(spf): migrate SegmentLoaderActor to createActor + add SerialRunner.abortPending() - Add `abortPending()` to SerialRunner: aborts only queued tasks without touching the in-flight task, enabling the "continue" case in load handlers - Migrate createSegmentLoaderActor from CallbackActor closure to createActor with idle/loading states and SegmentLoaderActorContext tracking in-flight init and segment IDs - Replace executeLoadTask/runScheduled/abortController closure state with makeLoadTask factory + SerialRunner scheduling - loading.on.load: abortPending() for continue (in-flight still needed), abortAll() + optional cancel message for preempt (track switch / seek away) - On unexpected fetch errors, abort pending tasks so a failed init does not allow segment fetches to proceed with no init segment Co-Authored-By: Claude Sonnet 4.6 --- packages/spf/src/core/task.ts | 7 +- packages/spf/src/core/tests/task.test.ts | 72 ++++ .../src/dom/features/segment-loader-actor.ts | 332 +++++++++--------- .../dom/features/tests/end-of-stream.test.ts | 3 - 4 files changed, 243 insertions(+), 171 deletions(-) diff --git a/packages/spf/src/core/task.ts b/packages/spf/src/core/task.ts index 935228e15..cf40cb5cb 100644 --- a/packages/spf/src/core/task.ts +++ b/packages/spf/src/core/task.ts @@ -269,9 +269,14 @@ export class SerialRunner { ); } - abortAll(): void { + /** Aborts and clears queued tasks without touching the in-flight task. */ + abortPending(): void { for (const task of this.#pending) task.abort(); this.#pending.clear(); + } + + abortAll(): void { + this.abortPending(); this.#current?.abort(); } diff --git a/packages/spf/src/core/tests/task.test.ts b/packages/spf/src/core/tests/task.test.ts index cdedbe1bd..48460ac57 100644 --- a/packages/spf/src/core/tests/task.test.ts +++ b/packages/spf/src/core/tests/task.test.ts @@ -481,6 +481,78 @@ describe('SerialRunner', () => { expect(receivedSignal?.aborted).toBe(true); }); + it('abortPending() aborts queued tasks but not the in-flight task', async () => { + const runner = new SerialRunner(); + + let resolveFirst!: () => void; + let firstSignal: AbortSignal | undefined; + const first = new Task( + async (signal) => { + firstSignal = signal; + await new Promise((resolve) => { + resolveFirst = resolve; + }); + }, + { id: '1' } + ); + + let secondSignal: AbortSignal | undefined; + const second = new Task( + async (signal) => { + secondSignal = signal; + }, + { id: '2' } + ); + + runner.schedule(first); + const p2 = runner.schedule(second); + + await vi.waitFor(() => expect(first.status).toBe('running')); + + runner.abortPending(); + + // In-flight task is unaffected + expect(firstSignal?.aborted).toBe(false); + + // Queued task receives aborted signal when it runs + resolveFirst(); + await p2; + expect(secondSignal?.aborted).toBe(true); + }); + + it('abortPending() does not affect the in-flight task — it completes normally', async () => { + const runner = new SerialRunner(); + const results: string[] = []; + + let resolveFirst!: () => void; + const first = new Task( + async () => { + await new Promise((r) => { + resolveFirst = r; + }); + results.push('first-done'); + }, + { id: '1' } + ); + const second = new Task( + async () => { + results.push('second-done'); + }, + { id: '2' } + ); + + runner.schedule(first); + runner.schedule(second); + + await vi.waitFor(() => expect(first.status).toBe('running')); + runner.abortPending(); + resolveFirst(); + + await vi.waitFor(() => expect(first.status).toBe('done')); + // first completed, second ran (with aborted signal) but we only assert first completed + expect(results).toContain('first-done'); + }); + it('abortAll() aborts the in-flight task', async () => { const runner = new SerialRunner(); let taskSignal: AbortSignal | undefined; diff --git a/packages/spf/src/dom/features/segment-loader-actor.ts b/packages/spf/src/dom/features/segment-loader-actor.ts index f09ea33fd..8aea3e712 100644 --- a/packages/spf/src/dom/features/segment-loader-actor.ts +++ b/packages/spf/src/dom/features/segment-loader-actor.ts @@ -1,7 +1,8 @@ -import type { CallbackActor } from '../../core/actor'; import { calculateBackBufferFlushPoint } from '../../core/buffer/back-buffer'; import { calculateForwardFlushPoint, getSegmentsToLoad } from '../../core/buffer/forward-buffer'; +import { createActor, type HandlerContext, type MessageActor } from '../../core/create-actor'; import { effect } from '../../core/signals/effect'; +import { SerialRunner, Task } from '../../core/task'; import type { AddressableObject, AudioTrack, Segment, VideoTrack } from '../../core/types'; import type { AppendInitMessage, @@ -81,12 +82,28 @@ export type LoadTask = // ACTOR INTERFACE // ============================================================================ -export type SegmentLoaderActor = CallbackActor; +/** Finite states of the actor. */ +export type SegmentLoaderActorState = 'idle' | 'loading' | 'destroyed'; + +/** Non-finite (extended) data managed by the actor. */ +export interface SegmentLoaderActorContext { + /** Track ID of the init segment currently being fetched/appended, or null. */ + inFlightInitTrackId: string | null; + /** Segment ID currently being fetched/appended, or null. */ + inFlightSegmentId: string | null; +} + +export type SegmentLoaderActor = MessageActor; // ============================================================================ // HELPERS // ============================================================================ +type FetchBytes = ( + addressable: AddressableObject, + options?: RequestInit & { minChunkSize?: number } +) => Promise>; + /** * Resolves when the SourceBufferActor snapshot reaches 'idle'. * Rejects if the signal is aborted or the actor is destroyed. @@ -130,6 +147,68 @@ function waitForIdle(snapshot: SourceBufferActor['snapshot'], signal: AbortSigna }); } +// ============================================================================ +// LOAD TASK FACTORY +// ============================================================================ + +interface LoadTaskOptions { + getContext: () => SegmentLoaderActorContext; + setContext: (ctx: SegmentLoaderActorContext) => void; + fetchBytes: FetchBytes; + sourceBufferActor: SourceBufferActor; +} + +/** + * Wraps a LoadTask descriptor into a Task that fetches (if needed) and + * forwards to SourceBufferActor. Updates in-flight context around async + * operations so the loading handler can make accurate continue/preempt + * decisions at any point. + */ +function makeLoadTask( + op: LoadTask, + { getContext, setContext, fetchBytes, sourceBufferActor }: LoadTaskOptions +): Task { + return new Task(async (taskSignal) => { + if (taskSignal.aborted) return; + + if (op.type === 'remove') { + sourceBufferActor.send(op); + await waitForIdle(sourceBufferActor.snapshot, taskSignal); + return; + } + + if (op.type === 'append-init') { + setContext({ ...getContext(), inFlightInitTrackId: op.meta.trackId }); + try { + // Init segments are small and need the full body before appending. + // minChunkSize: Infinity accumulates all chunks into one before yielding. + const data = await fetchBytes(op, { signal: taskSignal, minChunkSize: Infinity }); + if (!taskSignal.aborted) { + sourceBufferActor.send({ type: 'append-init', data, meta: op.meta }); + await waitForIdle(sourceBufferActor.snapshot, taskSignal); + } + } finally { + setContext({ ...getContext(), inFlightInitTrackId: null }); + } + return; + } + + // append-segment: await headers eagerly (starts the HTTP connection and + // records the fetch in observers like tests), then pass the body stream + // directly to the actor so chunks are appended as they arrive. + setContext({ ...getContext(), inFlightSegmentId: op.meta.id }); + try { + const stream = await fetchBytes(op, { signal: taskSignal }); + if (!taskSignal.aborted) { + sourceBufferActor.send({ type: 'append-segment', data: stream, meta: op.meta }); + await waitForIdle(sourceBufferActor.snapshot, taskSignal); + } + } finally { + setContext({ ...getContext(), inFlightSegmentId: null }); + } + }); +} + // ============================================================================ // IMPLEMENTATION // ============================================================================ @@ -141,10 +220,12 @@ function waitForIdle(snapshot: SourceBufferActor['snapshot'], signal: AbortSigna * removes, fetches, and appends. Coordinates with the SourceBufferActor for * all physical SourceBuffer operations. * - * Planning (Cases 1–3) happens in `send()` on every incoming message, producing - * an ordered LoadTask list. The runner drains that list sequentially. When a new - * message arrives mid-run, send() replans and either continues the in-flight - * operation (if still needed) or preempts it. + * Planning (Cases 1–3) happens in the `load` handler on every incoming + * message, producing an ordered LoadTask list. The runner drains that list + * sequentially via SerialRunner. When a new message arrives mid-run, the + * handler replans and either continues the in-flight operation (abortPending + * + schedule new remainder) or preempts it (abortAll + cancel SourceBuffer + * if needed + schedule new plan). * * @param sourceBufferActor - Shared SourceBufferActor reference (not owned) * @param fetchBytes - Tracked fetch closure (owns throughput sampling for segments). @@ -153,17 +234,10 @@ function waitForIdle(snapshot: SourceBufferActor['snapshot'], signal: AbortSigna */ export function createSegmentLoaderActor( sourceBufferActor: SourceBufferActor, - fetchBytes: ( - addressable: AddressableObject, - options?: RequestInit & { minChunkSize?: number } - ) => Promise> + fetchBytes: FetchBytes ): SegmentLoaderActor { - let pendingTasks: LoadTask[] | null = null; - let inFlightInitTrackId: string | null = null; - let inFlightSegmentId: string | null = null; - let abortController: AbortController | null = null; - let running = false; - let destroyed = false; + type UserState = Exclude; + type Ctx = HandlerContext SerialRunner>; const getBufferedSegments = (allSegments: readonly Segment[]): Segment[] => { // Exclude partial segments — they are still being streamed and must not be @@ -179,7 +253,7 @@ export function createSegmentLoaderActor( /** * Translate a load message into an ordered LoadTask list based on committed - * actor state. In-flight awareness is handled separately in send(). + * actor state. In-flight awareness is handled separately in the load handler. * * @todo Rename alongside LoadTask (e.g. planOps). * @@ -254,157 +328,81 @@ export function createSegmentLoaderActor( return tasks; }; - /** - * Execute a single LoadTask: fetch (if needed) then forward to SourceBufferActor. - * Sets/clears in-flight tracking around async operations so send() can make - * accurate continue/preempt decisions at any point during execution. - * - * @todo Rename alongside LoadTask (e.g. executeOp). - */ - const executeLoadTask = async (task: LoadTask): Promise => { - const signal = abortController!.signal; - try { - if (task.type === 'remove') { - sourceBufferActor.send(task); - await waitForIdle(sourceBufferActor.snapshot, signal); - return; - } - - if (task.type === 'append-init') { - inFlightInitTrackId = task.meta.trackId; - if (!signal.aborted) { - // Init segments are small and need the full body before the - // same-track-seek vs track-switch commit decision can be made. - // minChunkSize: Infinity causes ChunkedStreamIterable to accumulate - // all chunks and yield exactly one — equivalent to arrayBuffer() but - // through the same streaming path as media segments. - const data = await fetchBytes(task, { signal, minChunkSize: Infinity }); - // For seeks on the same track: commit even if aborted — avoids re-fetching - // the same init next time. The preempt path in send() only sends cancel to - // the SourceBufferActor for track switches, so for same-track seeks the actor - // is still idle and will accept this append. - // For track switches: cancel was already sent; skip the commit. - const isTrackSwitch = pendingTasks?.some( - (t) => t.type === 'append-init' && t.meta.trackId !== task.meta.trackId - ); - if (!signal.aborted || !isTrackSwitch) { - sourceBufferActor.send({ type: 'append-init', data, meta: task.meta }); - // Use a fresh signal when committing despite abort so waitForIdle doesn't - // reject before the actor reaches idle. - await waitForIdle(sourceBufferActor.snapshot, signal.aborted ? new AbortController().signal : signal); - } - } - return; - } - - // append-segment: await headers eagerly (starts the HTTP connection and - // records the fetch in observers like tests), then pass the body stream - // directly to the actor so chunks are appended as they arrive. - inFlightSegmentId = task.meta.id; - if (!signal.aborted) { - const stream = await fetchBytes(task, { signal }); - if (!signal.aborted) { - sourceBufferActor.send({ type: 'append-segment', data: stream, meta: task.meta }); - await waitForIdle(sourceBufferActor.snapshot, signal); - } - } - } finally { - inFlightInitTrackId = null; - inFlightSegmentId = null; - } - }; - - /** - * Drain the scheduled task list sequentially. - * After each task completes, checks for a pending replacement plan from send(). - * If the signal was aborted and no new plan arrived, stops immediately. - */ - const runScheduled = async (initialTasks: LoadTask[]): Promise => { - running = true; - abortController = new AbortController(); - let scheduled = initialTasks; - - while (scheduled.length > 0 && !destroyed) { - const task = scheduled[0]!; - scheduled = scheduled.slice(1); - - try { - await executeLoadTask(task); - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - // Abort is handled in the post-task check below. - } else { - console.error('Unexpected error in segment loader:', error); - // Non-abort error (e.g. fetch failure on init): don't continue with - // remaining tasks in this sequence — they depend on the failed step. - // A pending replacement plan (if any) will still be picked up below. - scheduled = []; - } - } - - if (pendingTasks !== null) { - // A new plan arrived while this task was running — switch to it. - scheduled = pendingTasks; - pendingTasks = null; - abortController = new AbortController(); - } else if (abortController.signal.aborted) { - // Aborted with no replacement plan — stop. - break; - } - } - - abortController = null; - running = false; + const scheduleAll = (tasks: LoadTask[], { getContext, setContext, runner }: Ctx): void => { + tasks.forEach((op) => { + runner + .schedule(makeLoadTask(op, { getContext, setContext, fetchBytes, sourceBufferActor })) + .then(undefined, (e: unknown) => { + if (e instanceof Error && e.name === 'AbortError') return; + // On unexpected fetch/append errors, abort remaining tasks so a failed + // init doesn't cause segment fetches to proceed with no init segment. + console.error('Unexpected error in segment loader:', e); + runner.abortPending(); + }); + }); }; - return { - send(message: SegmentLoaderMessage) { - if (destroyed) return; - - const allTasks = planTasks(message); - - if (!running) { - if (allTasks.length === 0) return; - runScheduled(allTasks); - return; - } - - // Determine whether the in-flight operation is still needed for the new plan. - const inFlightStillNeeded = - (inFlightSegmentId !== null && - allTasks.some((t) => t.type === 'append-segment' && t.meta.id === inFlightSegmentId)) || - (inFlightInitTrackId !== null && - allTasks.some((t) => t.type === 'append-init' && t.meta.trackId === inFlightInitTrackId)); - - if (inFlightStillNeeded) { - // Continue: the in-flight operation covers something the new plan needs. - // Queue everything except the in-flight item — it will complete on its own. - pendingTasks = allTasks.filter( - (t) => - !(t.type === 'append-segment' && t.meta.id === inFlightSegmentId) && - !(t.type === 'append-init' && t.meta.trackId === inFlightInitTrackId) - ); - } else { - // Preempt: in-flight work is not needed for the new plan. Abort and replan. - pendingTasks = allTasks; - abortController?.abort(); - // Cancel SourceBufferActor tasks when there's a segment in-flight (always - // discard) or when a track switch is happening (new track's init follows). - // For same-track seek with an in-flight init, we intentionally skip cancel - // so the init commits — executeLoadTask handles the waitForIdle in that case. - const cancelSourceBuffer = - inFlightSegmentId !== null || - (inFlightInitTrackId !== null && - allTasks.some((t) => t.type === 'append-init' && t.meta.trackId !== inFlightInitTrackId)); - if (cancelSourceBuffer) { - sourceBufferActor.send({ type: 'cancel' }); - } - } - }, - - destroy() { - destroyed = true; - abortController?.abort(); + return createActor SerialRunner>({ + runner: () => new SerialRunner(), + initial: 'idle', + context: { inFlightInitTrackId: null, inFlightSegmentId: null }, + states: { + idle: { + on: { + load: (msg, ctx) => { + const allTasks = planTasks(msg); + if (allTasks.length === 0) return; + ctx.transition('loading'); + scheduleAll(allTasks, ctx); + }, + }, + }, + loading: { + onSettled: 'idle', + on: { + load: (msg, ctx) => { + const { context, runner } = ctx; + const allTasks = planTasks(msg); + + // Determine whether the in-flight operation is still needed. + const inFlightStillNeeded = + (context.inFlightSegmentId !== null && + allTasks.some((t) => t.type === 'append-segment' && t.meta.id === context.inFlightSegmentId)) || + (context.inFlightInitTrackId !== null && + allTasks.some((t) => t.type === 'append-init' && t.meta.trackId === context.inFlightInitTrackId)); + + if (inFlightStillNeeded) { + // Continue: abort only the pending queue, let the in-flight task finish. + // Schedule everything except the in-flight item — it covers that slot. + runner.abortPending(); + scheduleAll( + allTasks.filter( + (t) => + !(t.type === 'append-segment' && t.meta.id === context.inFlightSegmentId) && + !(t.type === 'append-init' && t.meta.trackId === context.inFlightInitTrackId) + ), + ctx + ); + } else { + // Preempt: abort everything and replan. + runner.abortAll(); + // Cancel SourceBufferActor tasks when a segment is in-flight (always + // discard) or when a track switch is happening (new track's init follows). + // For a same-track seek with an in-flight init, skip cancel — the task's + // signal is not aborted (abortAll was called on the runner, but the init + // task already completed or will complete via its own signal path). + const cancelSourceBuffer = + context.inFlightSegmentId !== null || + (context.inFlightInitTrackId !== null && + allTasks.some((t) => t.type === 'append-init' && t.meta.trackId !== context.inFlightInitTrackId)); + if (cancelSourceBuffer) { + sourceBufferActor.send({ type: 'cancel' }); + } + scheduleAll(allTasks, ctx); + } + }, + }, + }, }, - }; + }); } diff --git a/packages/spf/src/dom/features/tests/end-of-stream.test.ts b/packages/spf/src/dom/features/tests/end-of-stream.test.ts index b3bb6b0a1..ee6cb8156 100644 --- a/packages/spf/src/dom/features/tests/end-of-stream.test.ts +++ b/packages/spf/src/dom/features/tests/end-of-stream.test.ts @@ -521,8 +521,6 @@ describe('endOfStream', () => { it('calls endOfStream() when actor context is updated to include the last segment', async () => { const track = makeResolvedVideoTrack(4); const mockMs = makeMediaSource(); - const neverAborted = new AbortController().signal; - // Start with only first two segments loaded const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer, { @@ -557,7 +555,6 @@ describe('endOfStream', () => { meta: { id: 'seg-3', startTime: 7.5, duration: 2.5, trackId: 'video-1' }, }, ], - signal: neverAborted, }); await vi.waitFor(() => { From 99c8b9d91a94fa7da9fd26ea9bd72ecc4cda7df1 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 6 Apr 2026 17:53:14 -0700 Subject: [PATCH 73/79] docs(spf): update lifecycle scoping note to reflect SegmentLoaderActor migration Replace stale "~50 lines of abort signal management" claim with the actual mechanism: explicit runner.abortAll/abortPending in handlers + cancel message. Co-Authored-By: Claude Sonnet 4.6 --- internal/design/spf/actor-reactor-factories.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index f14143f03..7c49879ee 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -534,9 +534,10 @@ it's load-bearing discipline rather than a model guarantee. **Lifecycle scoping.** In XState, when the machine leaves `updating` for any reason (a `cancel` event, `destroy()`, etc.), the invoked service is cancelled automatically. In SPF, the -runner outlives any particular state. Cancellation must be threaded manually via abort signals. -This is non-trivial — `SegmentLoaderActor` has ~50 lines of abort signal management that -XState's state-scoped invocation would eliminate. +runner outlives any particular state. Cancellation is handled explicitly — via +`runner.abortAll()` / `runner.abortPending()` in message handlers and a first-class `cancel` +message on `SourceBufferActor`. `SegmentLoaderActor`'s `loading.on.load` handler encodes the +preempt/continue decision explicitly rather than through automatic state-exit cleanup. **Partial / streaming updates.** SPF calls `setContext` — a closure callback that writes context directly from inside the task, bypassing the event system. XState's equivalent is the From f23bfef1a3d1393be39ccdd46b1a635128ab211c Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 8 Apr 2026 06:42:27 -0700 Subject: [PATCH 74/79] fix(spf): add destroyed guard to TextTrackSegmentLoaderActor Prevents message dispatch and double-destroy after the actor is torn down, matching the destroy pattern used by createMachineActor and createTransitionActor. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../spf/src/dom/features/text-track-segment-loader-actor.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts index 37ee97257..213172ee9 100644 --- a/packages/spf/src/dom/features/text-track-segment-loader-actor.ts +++ b/packages/spf/src/dom/features/text-track-segment-loader-actor.ts @@ -33,9 +33,11 @@ export type TextTrackSegmentLoaderActor = CallbackActor textTracksActor.snapshot.get().context.segments[trackId] ?? []); const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime); @@ -63,6 +65,8 @@ export function createTextTrackSegmentLoaderActor(textTracksActor: TextTracksAct }, destroy(): void { + if (destroyed) return; + destroyed = true; runner.destroy(); }, }; From 44c67397e0f8a46b97b7d309ea6ac7b185ae1201 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 8 Apr 2026 06:42:38 -0700 Subject: [PATCH 75/79] refactor(spf)!: rename createActor/createReactor to createMachineActor/createMachineReactor Makes the FSM nature of these factories explicit, distinguishing them from createTransitionActor (reducer-style, no state machine) and manual CallbackActor implementations. Renames files, exports, all consumers, tests, and design doc references. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/spf/actor-migration-assessment.md | 26 ++++---- .../design/spf/actor-reactor-factories.md | 32 +++++----- internal/design/spf/decisions.md | 6 +- internal/design/spf/index.md | 4 +- internal/design/spf/primitives.md | 16 ++--- internal/design/spf/signals.md | 16 ++--- .../design/spf/text-track-architecture.md | 22 +++---- ...reate-actor.ts => create-machine-actor.ts} | 12 ++-- ...e-reactor.ts => create-machine-reactor.ts} | 8 +-- .../spf/src/core/create-transition-actor.ts | 2 +- .../src/core/features/resolve-presentation.ts | 6 +- packages/spf/src/core/machine.ts | 2 +- ...r.test.ts => create-machine-actor.test.ts} | 55 +++++++++------- ...test.ts => create-machine-reactor.test.ts} | 62 +++++++++---------- .../src/dom/features/load-text-track-cues.ts | 6 +- .../src/dom/features/segment-loader-actor.ts | 4 +- .../spf/src/dom/features/sync-text-tracks.ts | 6 +- .../dom/features/track-playback-initiated.ts | 6 +- .../spf/src/dom/media/source-buffer-actor.ts | 4 +- 19 files changed, 152 insertions(+), 143 deletions(-) rename packages/spf/src/core/{create-actor.ts => create-machine-actor.ts} (96%) rename packages/spf/src/core/{create-reactor.ts => create-machine-reactor.ts} (97%) rename packages/spf/src/core/tests/{create-actor.test.ts => create-machine-actor.test.ts} (89%) rename packages/spf/src/core/tests/{create-reactor.test.ts => create-machine-reactor.test.ts} (87%) diff --git a/.claude/plans/spf/actor-migration-assessment.md b/.claude/plans/spf/actor-migration-assessment.md index 54585c198..5e358512a 100644 --- a/.claude/plans/spf/actor-migration-assessment.md +++ b/.claude/plans/spf/actor-migration-assessment.md @@ -1,13 +1,13 @@ # Actor Migration Assessment -Assessment of migrating SPF's segment-related actors to the `createActor` factory. +Assessment of migrating SPF's segment-related actors to the `createMachineActor` factory. Written after completing the text track Actor/Reactor spike. --- ## Background -The text track spike produced two reference implementations using `createActor`: +The text track spike produced two reference implementations using `createMachineActor`: - `TextTracksActor` — cue management, `idle` → `loading` → `idle` - `TextTrackSegmentLoaderActor` — VTT fetch planning/execution, same FSM shape @@ -24,7 +24,7 @@ The segment-loading layer has three actors to consider: **File:** `packages/spf/src/dom/features/segment-loader-actor.ts` -### Fit with `createActor` +### Fit with `createMachineActor` Mostly a good fit. Status is effectively `idle | loading` (the `running` boolean), messages are fire-and-forget, and the `SerialRunner` pattern is already there @@ -80,26 +80,26 @@ closure-locals into actor context via `setContext`. **File:** `packages/spf/src/dom/media/source-buffer-actor.ts` -### Does not fit `createActor` +### Does not fit `createMachineActor` `SourceBufferActor` is a fundamentally different kind of actor. The mismatches are deep, not surface-level: 1. **Awaitable send** — `send()` returns `Promise`; callers (`SegmentLoaderActor`) - await it. `createActor.send()` returns `void`. Bridging this would require either + await it. `createMachineActor.send()` returns `void`. Bridging this would require either complicating the factory or losing the awaitable API that the loader depends on. -2. **Context as task output** — In `createActor`, context updates happen synchronously +2. **Context as task output** — In `createMachineActor`, context updates happen synchronously inside handlers. In `SourceBufferActor`, context is the *return value* of async tasks: each task computes the new `SourceBufferActorContext` from the physical SourceBuffer state, and that value becomes the next snapshot. This is an inversion of control that - doesn't map to `createActor`'s handler model. + doesn't map to `createMachineActor`'s handler model. 3. **`batch()` method** — A distinct multi-message protocol with its own `workingCtx` - threading between tasks. No `createActor` equivalent. + threading between tasks. No `createMachineActor` equivalent. 4. **`onPartialContext`** — Mid-task side-effect writing to the snapshot signal during - streaming appends (before the task resolves). No hook for this in `createActor`. + streaming appends (before the task resolves). No hook for this in `createMachineActor`. ### Two actor patterns, not one @@ -115,14 +115,14 @@ explicitly recognize and document the two patterns. ### Options for unification -**Option A: `createActor` gains awaitable send** +**Option A: `createMachineActor` gains awaitable send** - `send()` returns `Promise`, resolved when the triggered tasks settle. - Complex: requires tracking which tasks a message schedules and when they complete. - May also need a way to propagate task results back to context. **Option B: A separate `createCommandActor` factory** - Factory for the command-queue pattern: tasks return next context, `send()` is awaitable. -- Keeps `createActor` clean; explicit about the two patterns. +- Keeps `createMachineActor` clean; explicit about the two patterns. **Option C: Leave `SourceBufferActor` as bespoke** - It already has a reactive snapshot, `SerialRunner`, and a sound destroy pattern. @@ -141,7 +141,7 @@ when (if) a second command-queue actor emerges. Currently a function with signals/effects inline — it is the Reactor layer that bridges state → `SegmentLoaderActor` messages. Not itself an actor. -Could be rewritten as a class-based Reactor if a `createReactor` pattern is established +Could be rewritten as a class-based Reactor if a `createMachineReactor` pattern is established (per the primitives.md design). Lower priority than the actor migrations; natural follow-on after `SegmentLoaderActor` is migrated. @@ -150,6 +150,6 @@ follow-on after `SegmentLoaderActor` is migrated. ## Recommended order 1. Add `SerialRunner.replaceQueue()`. -2. Migrate `SegmentLoaderActor` to `createActor`. +2. Migrate `SegmentLoaderActor` to `createMachineActor`. 3. Revisit `loadSegments` as a Reactor class. 4. Decide on command-queue actor unification only if a second such actor appears. diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index 7c49879ee..ee1cbfe0a 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -5,11 +5,11 @@ date: 2026-04-03 # Actor and Reactor Factories -Design for `createActor` and `createReactor` — the declarative factory functions that replace +Design for `createMachineActor` and `createMachineReactor` — the declarative factory functions that replace bespoke Actor classes and function-based Reactors in SPF. Motivated by the text track architecture spike (videojs/v10#1158), which produced the first -`createActor` / `createReactor`-based implementations in SPF and surfaced the need for +`createMachineActor` / `createMachineReactor`-based implementations in SPF and surfaced the need for shared, principled primitives. See [text-track-architecture.md](text-track-architecture.md) for the reference implementation and spike assessment. @@ -25,8 +25,8 @@ mechanics. Two separate factories: ```typescript -const actor = createActor(actorDefinition); -const reactor = createReactor(reactorDefinition); +const actor = createMachineActor(actorDefinition); +const reactor = createMachineReactor(reactorDefinition); ``` Both return instances that implement `SignalActor` and expose `snapshot` and `destroy()`. @@ -44,7 +44,7 @@ type ActorDefinition< Message extends { type: string }, RunnerFactory extends (() => RunnerLike) | undefined = undefined, > = { - runner?: RunnerFactory; // factory — called once at createActor() time + runner?: RunnerFactory; // factory — called once at createMachineActor() time initial: UserState; context: Context; states: Partial({ +const reactor = createMachineReactor<'preconditions-unmet' | 'set-up'>({ initial: 'preconditions-unmet', // monitor returns the target state; framework drives the transition. monitor: () => preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet', @@ -220,7 +220,7 @@ const derivedStateSignal = computed(() => deriveState(state.get(), owners.get()) const currentTimeSignal = computed(() => state.get().currentTime ?? 0); const selectedTrackSignal = computed(() => findSelectedTrack(state.get())); -const reactor = createReactor({ +const reactor = createMachineReactor({ initial: 'preconditions-unmet', monitor: () => derivedStateSignal.get(), states: { @@ -265,7 +265,7 @@ const reactor = createReactor({ ### Factory functions, not base classes -**Decision:** `createActor(def)` and `createReactor(def)` rather than `extends BaseActor` / +**Decision:** `createMachineActor(def)` and `createMachineReactor(def)` rather than `extends BaseActor` / `extends Reactor`. **Alternatives considered:** @@ -282,7 +282,7 @@ door open for a future definition-vs-implementation separation (see below). --- -### Separate `createActor` and `createReactor` +### Separate `createMachineActor` and `createMachineReactor` **Decision:** Two distinct factories with distinct definition shapes. @@ -323,7 +323,7 @@ domain-meaningful states. ### Runner as a factory function, actor-lifetime scope **Decision:** `runner: () => new SerialRunner()` — a factory function called once when -`createActor()` is called. The runner lives for the actor's full lifetime and is destroyed +`createMachineActor()` is called. The runner lives for the actor's full lifetime and is destroyed when the actor is destroyed. **Alternatives considered:** @@ -348,7 +348,7 @@ stale one) is handled by the framework internally rather than by runner scope. ### `monitor`-before-state ordering guarantee -**Decision:** `monitor` effects are registered before per-state effects in `createReactor`. +**Decision:** `monitor` effects are registered before per-state effects in `createMachineReactor`. This ordering is **load-bearing**: per-state effects can rely on invariants established by `monitor` having already run. @@ -356,16 +356,16 @@ This ordering is **load-bearing**: per-state effects can rely on invariants esta `Set` before executing them. Because `monitor` effects are registered first, they are guaranteed to execute before per-state effects in every flush. -**What this enables:** When a `monitor` fn returns a new state, `createReactor` calls +**What this enables:** When a `monitor` fn returns a new state, `createMachineReactor` calls `transition()` immediately and updates the snapshot signal. By the time per-state effects run, the reactor is already in the new state — so a per-state effect gated on `snapshot.value !== state` correctly no-ops without needing to re-check conditions that the `monitor` just resolved. -**Important caveat:** This guarantee is specific to `createReactor`'s registration order. +**Important caveat:** This guarantee is specific to `createMachineReactor`'s registration order. It is not a formal guarantee of the TC39 Signals proposal — it depends on the polyfill's `Watcher` implementation preserving insertion order in `getPending()`. Do not assume this -ordering holds outside of `createReactor`. See [signals.md § Effect Execution Order](signals.md) +ordering holds outside of `createMachineReactor`. See [signals.md § Effect Execution Order](signals.md) for the general principle. --- @@ -421,7 +421,7 @@ Signal reads inside `entry` are automatically untracked — the fn body runs ins **Inline computed anti-pattern:** `computed()` inside an effect body creates a new `Computed` node on every re-run with no memoization. `Computed`s that gate effect re-runs must be hoisted -*outside* the effect body (typically at the factory function scope, before `createReactor()`). +*outside* the effect body (typically at the factory function scope, before `createMachineReactor()`). --- @@ -437,7 +437,7 @@ type, initial state) and behavior (handler functions). XState v5 separates these const def = setup({ actors: { fetcher: fetchActor } }).createMachine({ ... }); // Implementation — runtime wiring -const actor = createActor(def, { input: { ... } }); +const actor = createMachineActor(def, { input: { ... } }); ``` This separation enables serialization, visualization, and testing the definition without diff --git a/internal/design/spf/decisions.md b/internal/design/spf/decisions.md index 49617cab2..105e30e3b 100644 --- a/internal/design/spf/decisions.md +++ b/internal/design/spf/decisions.md @@ -21,15 +21,15 @@ the full reference implementation and assessment. ### `always`-before-state ordering as a load-bearing guarantee -**Decision:** `always` effects in `createReactor` always run before per-state effects. -This ordering guarantee is documented in `createReactor`'s source and must be preserved. +**Decision:** `always` effects in `createMachineReactor` always run before per-state effects. +This ordering guarantee is documented in `createMachineReactor`'s source and must be preserved. **Rationale:** Per-state effects rely on invariants established by `always` monitors. When an `always` monitor calls `transition(newState)`, the snapshot updates before any per-state effect fires — so per-state effects that no-op when `status !== expectedState` do so correctly without needing to re-check conditions themselves. -**Caveat:** The guarantee is specific to `createReactor`'s registration order. It depends +**Caveat:** The guarantee is specific to `createMachineReactor`'s registration order. It depends on the TC39 `signal-polyfill`'s `Watcher` preserving insertion order in `getPending()` — not a formal guarantee of the TC39 Signals proposal. diff --git a/internal/design/spf/index.md b/internal/design/spf/index.md index 2c3ef7fb9..aca125c66 100644 --- a/internal/design/spf/index.md +++ b/internal/design/spf/index.md @@ -7,7 +7,7 @@ date: 2026-03-11 > **This is a living design document for a highly tentative codebase.** The current implementation captures useful early lessons but is expected to undergo significant architectural change in the near term. [architecture.md](architecture.md) and [decisions.md](decisions.md) document the current state; [primitives.md](primitives.md) is the forward-looking design. -A lean, actor-based framework for HLS playback over MSE. Handles manifest parsing, quality selection, segment buffering, and end-of-stream coordination — without a monolithic player. Actors and Reactors are defined via declarative factory functions (`createActor`, `createReactor`) backed by TC39 Signals. +A lean, actor-based framework for HLS playback over MSE. Handles manifest parsing, quality selection, segment buffering, and end-of-stream coordination — without a monolithic player. Actors and Reactors are defined via declarative factory functions (`createMachineActor`, `createMachineReactor`) backed by TC39 Signals. ## Contents @@ -16,7 +16,7 @@ A lean, actor-based framework for HLS playback over MSE. Handles manifest parsin | [index.md](index.md) | Overview, problem, quick start, surface API | | [primitives.md](primitives.md) | Foundational building blocks (Tasks, Actors, Reactors, State) | | [signals.md](signals.md) | Signals as the reactive primitive — decision, tradeoffs, friction | -| [actor-reactor-factories.md](actor-reactor-factories.md) | Decided design for `createActor` / `createReactor` factories | +| [actor-reactor-factories.md](actor-reactor-factories.md) | Decided design for `createMachineActor` / `createMachineReactor` factories | | [text-track-architecture.md](text-track-architecture.md) | Reference Actor/Reactor implementation + spike assessment | | [architecture.md](architecture.md) | Current implementation: layers, components, data flow | | [decisions.md](decisions.md) | Decided and open design decisions | diff --git a/internal/design/spf/primitives.md b/internal/design/spf/primitives.md index 9e257b974..95ef41de2 100644 --- a/internal/design/spf/primitives.md +++ b/internal/design/spf/primitives.md @@ -45,7 +45,7 @@ A Task is the unit of work *inside* an Actor or Reactor. Actors plan and execute `core/task.ts` — thin wrapper around a function with an `AbortController`. The shape is approximately right; the question is how much structure to add. -> **See also:** [actor-reactor-factories.md](actor-reactor-factories.md) — decided design for `createActor` / `createReactor`, including how runners are declared and lifecycle-managed. +> **See also:** [actor-reactor-factories.md](actor-reactor-factories.md) — decided design for `createMachineActor` / `createMachineReactor`, including how runners are declared and lifecycle-managed. ### Open questions @@ -107,19 +107,19 @@ An Actor does not know about state outside itself. It receives messages and prod ### Current approach -`createActor` in `core/create-actor.ts` — a declarative factory replacing bespoke closures. +`createMachineActor` in `core/create-machine-actor.ts` — a declarative factory replacing bespoke closures. Actors define state, context, message handlers per state, and an optional runner factory in a definition object. The factory manages the snapshot signal, runner lifecycle, and `'destroyed'` terminal state. See [actor-reactor-factories.md](actor-reactor-factories.md). -The existing `SourceBufferActor` predates `createActor` and has not been migrated — the +The existing `SourceBufferActor` predates `createMachineActor` and has not been migrated — the behavioral contract is equivalent, but the factory pattern is not yet used there. ### Decided - **Snapshot as signal** — Actors expose `snapshot` as a `ReadonlySignal`, making current state synchronously readable and tracked in reactive contexts without polling. - **Message validity per state** — Actors define valid messages per state via a per-state `on` map in the definition. Messages sent in a state with no handler for that type are silently dropped. `'destroyed'` always drops all messages. -- **Factory function, not base class** — `createActor(definition)` rather than `extends BaseActor`. See [actor-reactor-factories.md](actor-reactor-factories.md). +- **Factory function, not base class** — `createMachineActor(definition)` rather than `extends BaseActor`. See [actor-reactor-factories.md](actor-reactor-factories.md). - **`'destroyed'` is always implicit** — the framework adds it as the terminal state; user status types never include it. - **Actor dependencies are explicit** — Actors receive dependencies at construction time (via the factory call site) and interact with peer Actors via `send()`. No global state access. @@ -152,7 +152,7 @@ A Reactor is typically the bridge between observable state and one or more Actor ### Current approach -`createReactor` in `core/create-reactor.ts` — a declarative factory. The first Reactor +`createMachineReactor` in `core/create-machine-reactor.ts` — a declarative factory. The first Reactor implementations are in `dom/features/` as part of the text track spike (videojs/v10#1158): `syncTextTracks` and `loadTextTrackCues`. See [text-track-architecture.md](text-track-architecture.md) for the reference implementation. @@ -163,10 +163,10 @@ function-based with no formal status or snapshot — they remain to be migrated. ### Decided - **Snapshot as signal** — same decision as Actors. `snapshot` is a `ReadonlySignal<{ status, context }>`. -- **Factory function, not base class** — `createReactor(definition)`. Per-state effect arrays; each element becomes one independent `effect()` call. See [actor-reactor-factories.md](actor-reactor-factories.md). +- **Factory function, not base class** — `createMachineReactor(definition)`. Per-state effect arrays; each element becomes one independent `effect()` call. See [actor-reactor-factories.md](actor-reactor-factories.md). - **Reactors do not send to other Reactors** — coordination flows through state or via `actor.send()`. - **`always` effects for cross-cutting monitors** — a dedicated `always` array runs before per-state effects in every flush. The primary use case is condition monitoring that drives transitions from one place. See the ordering guarantee in [actor-reactor-factories.md](actor-reactor-factories.md). -- **Context via closure (tested approach)** — the text track spike used closure variables for Reactor non-finite state throughout. A formal `context` field in `createReactor` has been prototyped but is tracked as a future improvement, not current practice. +- **Context via closure (tested approach)** — the text track spike used closure variables for Reactor non-finite state throughout. A formal `context` field in `createMachineReactor` has been prototyped but is tracked as a future improvement, not current practice. ### Open questions @@ -273,7 +273,7 @@ points of friction, and future directions. with `AbortController` or signal-scoped lifetimes could reduce boilerplate. - **Reading outside reactive context** — is this a discipline problem or a design problem? Currently discipline (`untrack()` conventions). The `entry`/`reactive` split in - `createReactor` would address the most common case structurally. + `createMachineReactor` would address the most common case structurally. --- diff --git a/internal/design/spf/signals.md b/internal/design/spf/signals.md index 23e00a23f..dc2b4a396 100644 --- a/internal/design/spf/signals.md +++ b/internal/design/spf/signals.md @@ -67,7 +67,7 @@ via the `Signal.subtle.Watcher` API, leaving scheduling entirely to the caller. microtask checkpoint, batching all synchronous writes made in a single turn. This scheduling control is what makes the `monitor`-before-state ordering guarantee in -`createReactor` possible. The effect scheduler drains pending computeds in an +`createMachineReactor` possible. The effect scheduler drains pending computeds in an insertion-ordered `Set`, so registration order determines execution order. ### Lower barrier to entry @@ -163,11 +163,11 @@ core/signals/ signal(), computed(), effect(), untrack(), update() PlaybackEngineOwners — signal (mediaElement, actors, buffers, ...) 2. Reactor execution model - createReactor() — always[] and states[][] each become effect() calls + createMachineReactor() — always[] and states[][] each become effect() calls Transitions fire when a computed signal changes and an always monitor detects it 3. Actor observability - createActor() — snapshot is a signal<{ status, context }> + createMachineActor() — snapshot is a signal<{ status, context }> Reactors and the engine observe Actor state without polling or callbacks ``` @@ -223,7 +223,7 @@ distinguishes "run once on entry" from "re-run reactively" in its definition sha 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 -`createReactor` specifically. +`createMachineReactor` specifically. --- @@ -258,7 +258,7 @@ creates extra upstream subscriptions — a more visible correctness failure, eas in review. Abstractions built on signals compound this subtly. When an abstraction hides the effect -boundary — as `createReactor` does, where each array entry in `states[S]` becomes an +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. @@ -457,9 +457,9 @@ a predictable execution order: effects registered first run first. But that is a of the polyfill's `Watcher` implementation and the scheduler's use of `Set` — not something the TC39 proposal guarantees. -`createReactor` takes a load-bearing dependency on this property. The `always`-before-state +`createMachineReactor` takes a load-bearing dependency on this property. The `always`-before-state ordering guarantee — that `always` effects always run before per-state effects — is an -explicit guarantee of `createReactor`'s own API, documented in source and tested in +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. @@ -494,7 +494,7 @@ states: { } ``` -For `createReactor`, this would make `untrack()` unnecessary in the common case of +For `createMachineReactor`, this would make `untrack()` unnecessary in the common case of enter-once effects, eliminate the class of bugs where accidental tracking causes unexpected re-runs, and make the author's intent visible in the definition rather than in `untrack()` calls buried inside effect bodies. diff --git a/internal/design/spf/text-track-architecture.md b/internal/design/spf/text-track-architecture.md index e6bd1b2bb..ded6cf854 100644 --- a/internal/design/spf/text-track-architecture.md +++ b/internal/design/spf/text-track-architecture.md @@ -6,7 +6,7 @@ date: 2026-04-02 # Text Track Architecture The text track implementation is the **reference implementation** for the -`createActor` / `createReactor` factories in SPF. It was built as part of a +`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](primitives.md) and [actor-reactor-factories.md](actor-reactor-factories.md). @@ -139,7 +139,7 @@ the cue record snapshot. ``` No runner — all message handling is synchronous. `'destroyed'` is the only -other state (implicit, added by `createActor`). +other state (implicit, added by `createMachineActor`). --- @@ -156,7 +156,7 @@ the signal and drives the transition: // on every re-run with no memoization. const derivedStatusSignal = computed(() => deriveStatus(state.get(), owners.get())); -createReactor({ +createMachineReactor({ always: [ ({ status, transition }) => { const target = derivedStatusSignal.get(); @@ -320,10 +320,10 @@ Evaluated against the goals from videojs/v10#1158: | Goal | Result | Notes | |------|--------|-------| -| **Finite state machine** | ✓ | Both `createReactor` and `createActor` produce explicit FSMs with named states | +| **Finite state machine** | ✓ | Both `createMachineReactor` and `createMachineActor` produce explicit FSMs with named states | | **Non-finite context** | ✓ | `TextTracksActor.context` holds unbounded `loaded` + `segments` maps; observable via snapshot | | **Teardown / abort propagation** | ✓ | `destroy()` fires effect cleanups; `SerialRunner.abortAll()` aborts in-flight Tasks; actors in owners destroyed by engine | -| **Message → task IoC** | ✓ | `createActor` decouples message dispatch from task execution; `SerialRunner` handles scheduling | +| **Message → task IoC** | ✓ | `createMachineActor` decouples message dispatch from task execution; `SerialRunner` handles scheduling | | **Observable snapshots** | ✓ | Both factories expose `snapshot: ReadonlySignal<{ status, context }>` | | **Bidirectional sync** | ✓ | `syncTextTracks` Effect 2 bridges `TextTrackList` `'change'` events back to state | @@ -333,8 +333,8 @@ Evaluated against the goals from videojs/v10#1158: destruction depends on the engine's generic loop. Callers using these reactors outside the engine must manage actor destruction explicitly. - **The `always`-before-state ordering guarantee** requires care — it's an implementation - guarantee of `createReactor`, not a formal TC39 Signals guarantee. It cannot be assumed - outside `createReactor`. + guarantee of `createMachineReactor`, not a formal TC39 Signals guarantee. It cannot be assumed + outside `createMachineReactor`. - **Entry vs. reactive effect intent is invisible in the definition shape.** `untrack()` is a convention, not API enforcement. An enter-once effect that accidentally tracks a signal produces no error — just unexpected re-runs. @@ -359,9 +359,9 @@ states: { }] } -// CORRECT — hoist outside createReactor() +// CORRECT — hoist outside createMachineReactor() const trackSignal = computed(() => findSelectedTrack(state.get())); -createReactor({ states: { 'monitoring-for-loads': [() => { +createMachineReactor({ states: { 'monitoring-for-loads': [() => { const track = trackSignal.get(); segmentLoaderActor.send({ type: 'load', track, currentTime }); }] } }); @@ -444,7 +444,7 @@ ownership is manageable. Revisit if the pattern spreads to video/audio. ### Formal `context` field usage on Reactor -`createReactor` accepts `context` + `setContext`, but `loadTextTrackCues` and +`createMachineReactor` accepts `context` + `setContext`, but `loadTextTrackCues` and `syncTextTracks` both use `context: {}` throughout — reactor non-finite state is held in closure variables and the `owners` signal instead. @@ -517,7 +517,7 @@ equivalent. The equality function's conditions map to the FSM's state conditions re-entering a state IS the "previous state" signal — state entry is the transition event. **`SourceBufferActor`**: Already a proper actor with observable snapshot, `SerialRunner`, -and a well-defined message interface. It predates `createActor` and has not been migrated +and a well-defined message interface. It predates `createMachineActor` and has not been migrated to the factory, but the behavioral contract is equivalent. Migration would be additive. **Actors in owners**: The video/audio actors should follow the same actors-in-owners diff --git a/packages/spf/src/core/create-actor.ts b/packages/spf/src/core/create-machine-actor.ts similarity index 96% rename from packages/spf/src/core/create-actor.ts rename to packages/spf/src/core/create-machine-actor.ts index d4170e0d9..cfab8d764 100644 --- a/packages/spf/src/core/create-actor.ts +++ b/packages/spf/src/core/create-machine-actor.ts @@ -8,7 +8,7 @@ import type { TaskLike } from './task'; // ============================================================================= /** - * Minimal interface for any runner that can be used with createActor. + * Minimal interface for any runner that can be used with createMachineActor. */ export interface RunnerLike { schedule(task: TaskLike): Promise; @@ -70,7 +70,7 @@ export type ActorStateDefinition< }; /** - * Full actor definition passed to `createActor`. + * Full actor definition passed to `createMachineActor`. * * `UserState` is the set of domain-meaningful states. `'destroyed'` is always * added by the framework as the implicit terminal state — do not include it here. @@ -82,7 +82,7 @@ export type ActorDefinition< RunnerFactory extends (() => RunnerLike) | undefined = undefined, > = { /** - * Runner factory — called once at `createActor()` time. + * Runner factory — called once at `createMachineActor()` time. * The runner lives for the full actor lifetime and is destroyed with it. * * @example @@ -104,7 +104,7 @@ export type ActorDefinition< // Live actor interface // ============================================================================= -/** Live actor instance returned by `createActor`. */ +/** Live actor instance returned by `createMachineActor`. */ export interface MessageActor extends SignalActor { send(message: Message): void; @@ -128,7 +128,7 @@ export interface MessageActor new SerialRunner(), * initial: 'idle', * context: {}, @@ -153,7 +153,7 @@ export interface MessageActor = { // Live reactor interface // ============================================================================= -/** Live reactor instance returned by `createReactor`. */ +/** Live reactor instance returned by `createMachineReactor`. */ export type Reactor = Machine>; // ============================================================================= @@ -97,7 +97,7 @@ const toArray = (x: T | T[] | undefined): T[] => (x === undefined ? [] : Arra * for the synchronous base case. Active effect cleanups fire via disposal. * * @example - * const reactor = createReactor({ + * const reactor = createMachineReactor({ * initial: 'waiting', * monitor: () => srcSignal.get() ? 'active' : 'waiting', * states: { @@ -111,7 +111,7 @@ const toArray = (x: T | T[] | undefined): T[] => (x === undefined ? [] : Arra * } * }); */ -export function createReactor( +export function createMachineReactor( def: ReactorDefinition ): Reactor { type FullState = State | 'destroying' | 'destroyed'; diff --git a/packages/spf/src/core/create-transition-actor.ts b/packages/spf/src/core/create-transition-actor.ts index 0477d0428..78edc85b7 100644 --- a/packages/spf/src/core/create-transition-actor.ts +++ b/packages/spf/src/core/create-transition-actor.ts @@ -16,7 +16,7 @@ import { untrack, update } from './signals/primitives'; * * Use this when the actor has context that needs to be observable but no * meaningful state machine (e.g., a message-driven model with DOM side - * effects). For actors that need per-state behavior, use `createActor`. + * effects). For actors that need per-state behavior, use `createMachineActor`. */ export interface TransitionActor extends Machine> { diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index bf13435c6..4b83fd254 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -1,6 +1,6 @@ import { fetchResolvable, getResponseText } from '../../dom/network/fetch'; -import type { Reactor } from '../create-reactor'; -import { createReactor } from '../create-reactor'; +import type { Reactor } from '../create-machine-reactor'; +import { createMachineReactor } from '../create-machine-reactor'; import { parseMultivariantPlaylist } from '../hls/parse-multivariant'; import { computed, type Signal, update } from '../signals/primitives'; import type { AddressableObject, Presentation } from '../types'; @@ -93,7 +93,7 @@ export function resolvePresentation({ }): Reactor { const derivedStateSignal = computed(() => deriveState(state.get())); - return createReactor({ + return createMachineReactor({ initial: 'preconditions-unmet', monitor: () => derivedStateSignal.get(), states: { diff --git a/packages/spf/src/core/machine.ts b/packages/spf/src/core/machine.ts index c482c13cf..388629148 100644 --- a/packages/spf/src/core/machine.ts +++ b/packages/spf/src/core/machine.ts @@ -34,7 +34,7 @@ export interface Machine> { * Provisions the shared mechanics for all machine-like primitives: a snapshot * signal, an untracked state reader, and a transition function. * - * Internal — consumed by `createActor` and `createReactor`. Not part of the + * Internal — consumed by `createMachineActor` and `createMachineReactor`. Not part of the * public API. */ export function createMachineCore>( diff --git a/packages/spf/src/core/tests/create-actor.test.ts b/packages/spf/src/core/tests/create-machine-actor.test.ts similarity index 89% rename from packages/spf/src/core/tests/create-actor.test.ts rename to packages/spf/src/core/tests/create-machine-actor.test.ts index f730f426d..372c2ab2a 100644 --- a/packages/spf/src/core/tests/create-actor.test.ts +++ b/packages/spf/src/core/tests/create-machine-actor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createActor } from '../create-actor'; +import { createMachineActor } from '../create-machine-actor'; import { SerialRunner, Task } from '../task'; // ============================================================================= @@ -7,8 +7,12 @@ import { SerialRunner, Task } from '../task'; // ============================================================================= function makeCounter() { - return createActor({ - initial: 'idle' as const, + return createMachineActor< + 'idle' | 'running', + { count: number }, + { type: 'increment' } | { type: 'start' } | { type: 'stop' } + >({ + initial: 'idle', context: { count: 0 }, states: { idle: { @@ -27,10 +31,10 @@ function makeCounter() { } // ============================================================================= -// createActor — core behavior +// createMachineActor — core behavior // ============================================================================= -describe('createActor', () => { +describe('createMachineActor', () => { it('starts with the initial status and context', () => { const actor = makeCounter(); @@ -42,7 +46,7 @@ describe('createActor', () => { it('dispatches messages to the correct state handler', () => { const handler = vi.fn(); - const actor = createActor({ + const actor = createMachineActor({ initial: 'idle' as const, context: {}, states: { @@ -58,7 +62,7 @@ describe('createActor', () => { it('passes message, context, transition, and setContext to handlers', () => { let captured: { msg: unknown; ctx: unknown } | undefined; - const actor = createActor({ + const actor = createMachineActor({ initial: 'idle' as const, context: { value: 42 }, states: { @@ -106,7 +110,7 @@ describe('createActor', () => { it('handler receives context value at dispatch time', () => { const observed: number[] = []; - const actor = createActor({ + const actor = createMachineActor({ initial: 'idle' as const, context: { count: 0 }, states: { @@ -142,7 +146,7 @@ describe('createActor', () => { }); it('drops messages when the state has no on map', () => { - const actor = createActor({ + const actor = createMachineActor({ initial: 'idle' as const, context: {}, states: { @@ -173,10 +177,10 @@ describe('createActor', () => { }); // ============================================================================= -// createActor — destroy +// createMachineActor — destroy // ============================================================================= -describe('createActor — destroy', () => { +describe('createMachineActor — destroy', () => { it('transitions to destroyed on destroy()', () => { const actor = makeCounter(); @@ -195,7 +199,7 @@ describe('createActor — destroy', () => { it('drops send() after destroy()', () => { const handler = vi.fn(); - const actor = createActor({ + const actor = createMachineActor({ initial: 'idle' as const, context: {}, states: { idle: { on: { ping: handler } } }, @@ -211,7 +215,7 @@ describe('createActor — destroy', () => { const runner = new SerialRunner(); const destroySpy = vi.spyOn(runner, 'destroy'); - const actor = createActor({ + const actor = createMachineActor({ runner: () => runner, initial: 'idle' as const, context: {}, @@ -225,13 +229,13 @@ describe('createActor — destroy', () => { }); // ============================================================================= -// createActor — runner and onSettled +// createMachineActor — runner and onSettled // ============================================================================= -describe('createActor — runner', () => { +describe('createMachineActor — runner', () => { it('provides the runner to handlers when a runner factory is given', () => { let capturedRunner: unknown; - const actor = createActor({ + const actor = createMachineActor({ runner: () => new SerialRunner(), initial: 'idle' as const, context: {}, @@ -257,7 +261,7 @@ describe('createActor — runner', () => { it('omits runner from handler context when no runner factory is given', () => { let capturedCtx: Record | undefined; - const actor = createActor({ + const actor = createMachineActor({ initial: 'idle' as const, context: {}, states: { @@ -279,9 +283,9 @@ describe('createActor — runner', () => { }); it('transitions to onSettled state when the runner settles', async () => { - const actor = createActor({ + const actor = createMachineActor<'idle' | 'loading', Record, { type: 'load' }, () => SerialRunner>({ runner: () => new SerialRunner(), - initial: 'idle' as const, + initial: 'idle', context: {}, states: { idle: { @@ -310,9 +314,14 @@ describe('createActor — runner', () => { it('onSettled is a no-op when the state changes before the runner settles', async () => { let resolveTask!: () => void; - const actor = createActor({ + const actor = createMachineActor< + 'idle' | 'loading' | 'cancelled', + Record, + { type: 'load' } | { type: 'cancel' }, + () => SerialRunner + >({ runner: () => new SerialRunner(), - initial: 'idle' as const, + initial: 'idle', context: {}, states: { idle: { @@ -358,9 +367,9 @@ describe('createActor — runner', () => { it('onSettled generation-token: rescheduling supersedes the stale callback', async () => { let resolveFirst!: () => void; - const actor = createActor({ + const actor = createMachineActor<'idle' | 'loading', Record, { type: 'load' }, () => SerialRunner>({ runner: () => new SerialRunner(), - initial: 'idle' as const, + initial: 'idle', context: {}, states: { idle: { diff --git a/packages/spf/src/core/tests/create-reactor.test.ts b/packages/spf/src/core/tests/create-machine-reactor.test.ts similarity index 87% rename from packages/spf/src/core/tests/create-reactor.test.ts rename to packages/spf/src/core/tests/create-machine-reactor.test.ts index 35eedafeb..0aee9f326 100644 --- a/packages/spf/src/core/tests/create-reactor.test.ts +++ b/packages/spf/src/core/tests/create-machine-reactor.test.ts @@ -1,17 +1,17 @@ import { describe, expect, it, vi } from 'vitest'; -import { createReactor } from '../create-reactor'; +import { createMachineReactor } from '../create-machine-reactor'; import { signal } from '../signals/primitives'; // One microtask tick — enough for the signal-polyfill watcher to flush pending effects. const tick = () => new Promise((resolve) => queueMicrotask(resolve)); // ============================================================================= -// createReactor — core behavior +// createMachineReactor — core behavior // ============================================================================= -describe('createReactor', () => { +describe('createMachineReactor', () => { it('starts with the initial status', () => { - const reactor = createReactor({ + const reactor = createMachineReactor({ initial: 'idle' as const, states: { idle: {} }, }); @@ -23,7 +23,7 @@ describe('createReactor', () => { it('runs the entry effect for the initial state on creation', () => { const fn = vi.fn(); - createReactor({ + createMachineReactor({ initial: 'idle' as const, states: { idle: { entry: [fn] } }, }).destroy(); @@ -33,7 +33,7 @@ describe('createReactor', () => { it('runs the reaction effect for the initial state on creation', () => { const fn = vi.fn(); - createReactor({ + createMachineReactor({ initial: 'idle' as const, states: { idle: { reactions: [fn] } }, }).destroy(); @@ -43,7 +43,7 @@ describe('createReactor', () => { it('does not run effects for states other than the initial state', () => { const otherFn = vi.fn(); - createReactor<'idle' | 'other'>({ + createMachineReactor<'idle' | 'other'>({ initial: 'idle', states: { idle: {}, @@ -56,7 +56,7 @@ describe('createReactor', () => { it('transitions status via derive', async () => { const src = signal(false); - const reactor = createReactor<'waiting' | 'active'>({ + const reactor = createMachineReactor<'waiting' | 'active'>({ initial: 'waiting', monitor: () => (src.get() ? 'active' : 'waiting'), states: { @@ -79,7 +79,7 @@ describe('createReactor', () => { const src = signal(false); const activeFn = vi.fn(); - const reactor = createReactor<'waiting' | 'active'>({ + const reactor = createMachineReactor<'waiting' | 'active'>({ initial: 'waiting', monitor: () => (src.get() ? 'active' : 'waiting'), states: { @@ -104,7 +104,7 @@ describe('createReactor', () => { const fn2 = vi.fn(); const fn3 = vi.fn(); - createReactor({ + createMachineReactor({ initial: 'idle' as const, states: { idle: { entry: [fn1, fn2, fn3] } }, }).destroy(); @@ -124,7 +124,7 @@ describe('createReactor', () => { src2.get(); }); - const reactor = createReactor({ + const reactor = createMachineReactor({ initial: 'idle' as const, states: { idle: { reactions: [fn1, fn2] } }, }); @@ -147,7 +147,7 @@ describe('createReactor', () => { src.get(); // read inside entry — should NOT create a reactive dep }); - const reactor = createReactor({ + const reactor = createMachineReactor({ initial: 'idle' as const, states: { idle: { entry: [fn] } }, }); @@ -164,7 +164,7 @@ describe('createReactor', () => { it('snapshot is reactive', async () => { const src = signal(false); - const reactor = createReactor<'waiting' | 'active'>({ + const reactor = createMachineReactor<'waiting' | 'active'>({ initial: 'waiting', monitor: () => (src.get() ? 'active' : 'waiting'), states: { @@ -186,15 +186,15 @@ describe('createReactor', () => { }); // ============================================================================= -// createReactor — cleanup +// createMachineReactor — cleanup // ============================================================================= -describe('createReactor — cleanup', () => { +describe('createMachineReactor — cleanup', () => { it('calls the entry effect cleanup on state exit', async () => { const src = signal(false); const cleanup = vi.fn(); - const reactor = createReactor<'active' | 'done'>({ + const reactor = createMachineReactor<'active' | 'done'>({ initial: 'active', monitor: () => (src.get() ? 'done' : 'active'), states: { @@ -224,7 +224,7 @@ describe('createReactor — cleanup', () => { const src = signal(0); const cleanup = vi.fn(); - const reactor = createReactor({ + const reactor = createMachineReactor({ initial: 'idle' as const, states: { idle: { @@ -252,7 +252,7 @@ describe('createReactor — cleanup', () => { const entryCleanup = vi.fn(); const reactionCleanup = vi.fn(); - const reactor = createReactor({ + const reactor = createMachineReactor({ initial: 'idle' as const, states: { idle: { @@ -272,7 +272,7 @@ describe('createReactor — cleanup', () => { const activeCleanup = vi.fn(); const inactiveCleanup = vi.fn(); - createReactor<'idle' | 'other'>({ + createMachineReactor<'idle' | 'other'>({ initial: 'idle', states: { idle: { entry: [() => activeCleanup] }, @@ -286,13 +286,13 @@ describe('createReactor — cleanup', () => { }); // ============================================================================= -// createReactor — derive +// createMachineReactor — derive // ============================================================================= -describe('createReactor — derive', () => { +describe('createMachineReactor — derive', () => { it('transitions to the status returned by the derive fn', async () => { const src = signal<'waiting' | 'active'>('waiting'); - const reactor = createReactor<'waiting' | 'active'>({ + const reactor = createMachineReactor<'waiting' | 'active'>({ initial: 'waiting', monitor: () => src.get(), states: { waiting: {}, active: {} }, @@ -310,7 +310,7 @@ describe('createReactor — derive', () => { it('does not transition when the derive fn returns the current status', async () => { const activeFn = vi.fn(); - const reactor = createReactor<'idle' | 'active'>({ + const reactor = createMachineReactor<'idle' | 'active'>({ initial: 'idle', monitor: () => 'idle', states: { idle: {}, active: { entry: activeFn } }, @@ -328,7 +328,7 @@ describe('createReactor — derive', () => { const src = signal<'waiting' | 'active'>('waiting'); const deriveFn = vi.fn(() => src.get()); - const reactor = createReactor<'waiting' | 'active'>({ + const reactor = createMachineReactor<'waiting' | 'active'>({ initial: 'waiting', monitor: deriveFn, states: { waiting: {}, active: {} }, @@ -346,7 +346,7 @@ describe('createReactor — derive', () => { it('does not run during destroying or destroyed', async () => { const deriveFn = vi.fn(() => 'idle' as const); - const reactor = createReactor({ + const reactor = createMachineReactor({ initial: 'idle' as const, monitor: deriveFn, states: { idle: {} }, @@ -363,7 +363,7 @@ describe('createReactor — derive', () => { const src = signal<'waiting' | 'active'>('waiting'); const order: string[] = []; - const reactor = createReactor<'waiting' | 'active'>({ + const reactor = createMachineReactor<'waiting' | 'active'>({ initial: 'waiting', monitor: () => { order.push('derive'); @@ -397,12 +397,12 @@ describe('createReactor — derive', () => { }); // ============================================================================= -// createReactor — destroy +// createMachineReactor — destroy // ============================================================================= -describe('createReactor — destroy', () => { +describe('createMachineReactor — destroy', () => { it('transitions to destroyed on destroy()', () => { - const reactor = createReactor({ + const reactor = createMachineReactor({ initial: 'idle' as const, states: { idle: {} }, }); @@ -413,7 +413,7 @@ describe('createReactor — destroy', () => { }); it('destroy() is idempotent', () => { - const reactor = createReactor({ + const reactor = createMachineReactor({ initial: 'idle' as const, states: { idle: {} }, }); @@ -429,7 +429,7 @@ describe('createReactor — destroy', () => { src.get(); }); - const reactor = createReactor({ + const reactor = createMachineReactor({ initial: 'idle' as const, states: { idle: { reactions: [fn] } }, }); diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index fbc4a25dd..22fc8b443 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -1,5 +1,5 @@ -import type { Reactor } from '../../core/create-reactor'; -import { createReactor } from '../../core/create-reactor'; +import type { Reactor } from '../../core/create-machine-reactor'; +import { createMachineReactor } from '../../core/create-machine-reactor'; import { computed, type Signal, untrack, update } from '../../core/signals/primitives'; import type { Presentation, TextTrack } from '../../core/types'; import { isResolvedTrack } from '../../core/types'; @@ -141,7 +141,7 @@ export function loadTextTrackCues state.get().currentTime ?? 0); const selectedTrackSignal = computed(() => findSelectedTrack(state.get())); - return createReactor({ + return createMachineReactor({ initial: 'preconditions-unmet', monitor: () => derivedStateSignal.get(), states: { diff --git a/packages/spf/src/dom/features/segment-loader-actor.ts b/packages/spf/src/dom/features/segment-loader-actor.ts index 8aea3e712..d8ee1a388 100644 --- a/packages/spf/src/dom/features/segment-loader-actor.ts +++ b/packages/spf/src/dom/features/segment-loader-actor.ts @@ -1,6 +1,6 @@ import { calculateBackBufferFlushPoint } from '../../core/buffer/back-buffer'; import { calculateForwardFlushPoint, getSegmentsToLoad } from '../../core/buffer/forward-buffer'; -import { createActor, type HandlerContext, type MessageActor } from '../../core/create-actor'; +import { createMachineActor, type HandlerContext, type MessageActor } from '../../core/create-machine-actor'; import { effect } from '../../core/signals/effect'; import { SerialRunner, Task } from '../../core/task'; import type { AddressableObject, AudioTrack, Segment, VideoTrack } from '../../core/types'; @@ -342,7 +342,7 @@ export function createSegmentLoaderActor( }); }; - return createActor SerialRunner>({ + return createMachineActor SerialRunner>({ runner: () => new SerialRunner(), initial: 'idle', context: { inFlightInitTrackId: null, inFlightSegmentId: null }, diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index 1635c7e80..463d73db0 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -1,6 +1,6 @@ import { listen } from '@videojs/utils/dom'; -import type { Reactor } from '../../core/create-reactor'; -import { createReactor } from '../../core/create-reactor'; +import type { Reactor } from '../../core/create-machine-reactor'; +import { createMachineReactor } from '../../core/create-machine-reactor'; import { computed, type Signal, untrack, update } from '../../core/signals/primitives'; import type { PartiallyResolvedTextTrack, Presentation, TextTrack } from '../../core/types'; @@ -106,7 +106,7 @@ export function syncTextTracks state.get().selectedTextTrackId); const preconditionsMetSignal = computed(() => !!mediaElementSignal.get() && !!modelTextTracksSignal.get()?.length); - return createReactor<'preconditions-unmet' | 'set-up'>({ + return createMachineReactor<'preconditions-unmet' | 'set-up'>({ initial: 'preconditions-unmet', monitor: () => (preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'), states: { diff --git a/packages/spf/src/dom/features/track-playback-initiated.ts b/packages/spf/src/dom/features/track-playback-initiated.ts index e769fa1f6..d7a4df7aa 100644 --- a/packages/spf/src/dom/features/track-playback-initiated.ts +++ b/packages/spf/src/dom/features/track-playback-initiated.ts @@ -1,6 +1,6 @@ import { listen } from '@videojs/utils/dom'; -import type { Reactor } from '../../core/create-reactor'; -import { createReactor } from '../../core/create-reactor'; +import type { Reactor } from '../../core/create-machine-reactor'; +import { createMachineReactor } from '../../core/create-machine-reactor'; import { computed, type Signal, update } from '../../core/signals/primitives'; /** @@ -69,7 +69,7 @@ export function trackPlaybackInitiated owners.get().mediaElement); const urlSignal = computed(() => state.get().presentation?.url); - return createReactor<'preconditions-unmet' | 'monitoring' | 'playback-initiated'>({ + return createMachineReactor<'preconditions-unmet' | 'monitoring' | 'playback-initiated'>({ initial: 'preconditions-unmet', monitor: () => derivedStateSignal.get(), states: { diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index 0811f1e8a..9c3f1efc3 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -1,4 +1,4 @@ -import { createActor, type HandlerContext, type MessageActor } from '../../core/create-actor'; +import { createMachineActor, type HandlerContext, type MessageActor } from '../../core/create-machine-actor'; import { SerialRunner, Task } from '../../core/task'; import type { Segment, Track } from '../../core/types'; import { type AppendData, appendSegment } from './append-segment'; @@ -222,7 +222,7 @@ export function createSourceBufferActor( runner.schedule(task).then(setContext, handleError); }; - return createActor SerialRunner>({ + return createMachineActor SerialRunner>({ runner: () => new SerialRunner(), initial: 'idle', context: { segments: [], bufferedRanges: [], initTrackId: undefined, ...initialContext }, From 0f2ee39eed076f2aa7399e1c7b689f058ecdcfe7 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 8 Apr 2026 07:29:43 -0700 Subject: [PATCH 76/79] docs(spf): update design docs for current actor/reactor API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace stale actor examples in actor-reactor-factories.md with SourceBufferActor and SegmentLoaderActor (the actual consumers) - Add actor/reactor type taxonomy table documenting all four patterns - Fix HandlerContext shape (was missing getContext) - Update primitives.md: SourceBufferActor migrated, factory not class, monitor/entry/reactions terminology, entry/reactions now decided - Update text-track-architecture.md: fix actor descriptions, code examples to use monitor/entry/reactions, mark implemented futures - Update decisions.md and signals.md: always → monitor terminology - Update actor-migration-assessment.md: mark completed migrations Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/spf/actor-migration-assessment.md | 141 +++---------- .../design/spf/actor-reactor-factories.md | 149 +++++++++---- internal/design/spf/decisions.md | 28 +-- internal/design/spf/primitives.md | 16 +- internal/design/spf/signals.md | 29 ++- .../design/spf/text-track-architecture.md | 197 ++++++++---------- 6 files changed, 261 insertions(+), 299 deletions(-) diff --git a/.claude/plans/spf/actor-migration-assessment.md b/.claude/plans/spf/actor-migration-assessment.md index 5e358512a..6cad806ef 100644 --- a/.claude/plans/spf/actor-migration-assessment.md +++ b/.claude/plans/spf/actor-migration-assessment.md @@ -1,136 +1,50 @@ # Actor Migration Assessment Assessment of migrating SPF's segment-related actors to the `createMachineActor` factory. -Written after completing the text track Actor/Reactor spike. +Written after completing the text track Actor/Reactor spike. **Partially stale** — see +inline notes for what has been completed. --- ## Background -The text track spike produced two reference implementations using `createMachineActor`: +The text track spike produced reference implementations using three actor patterns: -- `TextTracksActor` — cue management, `idle` → `loading` → `idle` -- `TextTrackSegmentLoaderActor` — VTT fetch planning/execution, same FSM shape +- `TextTracksActor` — uses `createTransitionActor` (reducer-style, no FSM) +- `TextTrackSegmentLoaderActor` — manual `CallbackActor` (lightweight, no framework) +- Reactors (`syncTextTracks`, `loadTextTrackCues`) — use `createMachineReactor` -Both were clean fits: fire-and-forget message sending, `SerialRunner` as an internal -detail, status transitions driven by `onSettled`. - -The segment-loading layer has three actors to consider: +The segment-loading layer had three actors to consider: `SegmentLoaderActor`, `SourceBufferActor`, and `loadSegments` (Reactor). --- -## SegmentLoaderActor +## SegmentLoaderActor ✅ (Completed) **File:** `packages/spf/src/dom/features/segment-loader-actor.ts` -### Fit with `createMachineActor` - -Mostly a good fit. Status is effectively `idle | loading` (the `running` boolean), -messages are fire-and-forget, and the `SerialRunner` pattern is already there -conceptually. The FSM would look like: - -``` -idle → load message → loading (schedule tasks) -loading → load message → loading (continue or preempt) -loading → runner settles → idle (via onSettled) -``` - -### Key blocker: continue/preempt logic - -When a new `load` arrives mid-run, the actor decides: - -- **Preempt** — in-flight work is not needed for the new plan: `abortAll()` + reschedule. - This maps cleanly to `SerialRunner`. - -- **Continue** — in-flight work IS needed (e.g. currently fetching segment X, new plan - also needs segment X): let it finish, replace the queued remainder only. - `SerialRunner` has no concept of "replace queued tasks without aborting the running one." - -The continue case exists to avoid re-fetching partially-streamed video/audio segments — -a real bandwidth cost, not just an edge case. Simplifying to always-preempt would be a -regression. - -### Recommended path - -Add `SerialRunner.replaceQueue(tasks: TaskLike[])` — drops queued (not in-flight) tasks -and enqueues the new list. This is a small, well-scoped addition that maps directly to -the continue case and is independently useful. - -With that in place, the `loading` state handler becomes: - -```ts -load: (msg, { runner, context, setContext }) => { - const allTasks = planTasks(msg, context); - if (inFlightStillNeeded(allTasks, context)) { - runner.replaceQueue(allTasks.filter(/* exclude in-flight */)); - } else { - runner.abortAll(); - allTasks.forEach(t => runner.schedule(t)); - } -} -``` - -In-flight tracking (`inFlightInitTrackId`, `inFlightSegmentId`) would move from -closure-locals into actor context via `setContext`. +Migrated to `createMachineActor` with `idle`/`loading` states, `onSettled: 'idle'`, +and continue/preempt logic. The continue case uses `SerialRunner.abortPending()` (added +during migration) — drops queued tasks without touching the in-flight task. In-flight +tracking (`inFlightInitTrackId`, `inFlightSegmentId`) lives in actor context via +`setContext`/`getContext`. --- -## SourceBufferActor +## SourceBufferActor ✅ (Completed) **File:** `packages/spf/src/dom/media/source-buffer-actor.ts` -### Does not fit `createMachineActor` - -`SourceBufferActor` is a fundamentally different kind of actor. The mismatches are -deep, not surface-level: - -1. **Awaitable send** — `send()` returns `Promise`; callers (`SegmentLoaderActor`) - await it. `createMachineActor.send()` returns `void`. Bridging this would require either - complicating the factory or losing the awaitable API that the loader depends on. - -2. **Context as task output** — In `createMachineActor`, context updates happen synchronously - inside handlers. In `SourceBufferActor`, context is the *return value* of async tasks: - each task computes the new `SourceBufferActorContext` from the physical SourceBuffer - state, and that value becomes the next snapshot. This is an inversion of control that - doesn't map to `createMachineActor`'s handler model. - -3. **`batch()` method** — A distinct multi-message protocol with its own `workingCtx` - threading between tasks. No `createMachineActor` equivalent. - -4. **`onPartialContext`** — Mid-task side-effect writing to the snapshot signal during - streaming appends (before the task resolves). No hook for this in `createMachineActor`. - -### Two actor patterns, not one - -This reveals that the codebase has two distinct actor patterns: - -| Pattern | Example | `send()` | Context updates | Runner | -|---|---|---|---|---| -| **Command-queue actor** | `SourceBufferActor` | `Promise` (awaitable) | Derived from async task results | Exposed to callers indirectly | -| **Message actor** | `TextTracksActor`, `TextTrackSegmentLoaderActor` | `void` (fire-and-forget) | Set synchronously in handlers | Hidden internal detail | - -Both are valid. The question is whether to unify them under a single factory, or -explicitly recognize and document the two patterns. - -### Options for unification - -**Option A: `createMachineActor` gains awaitable send** -- `send()` returns `Promise`, resolved when the triggered tasks settle. -- Complex: requires tracking which tasks a message schedules and when they complete. -- May also need a way to propagate task results back to context. - -**Option B: A separate `createCommandActor` factory** -- Factory for the command-queue pattern: tasks return next context, `send()` is awaitable. -- Keeps `createMachineActor` clean; explicit about the two patterns. - -**Option C: Leave `SourceBufferActor` as bespoke** -- It already has a reactive snapshot, `SerialRunner`, and a sound destroy pattern. -- Unifying under a factory would be refactoring for its own sake. -- Revisit only if a second command-queue actor appears and the pattern is worth naming. +Migrated to `createMachineActor` with `idle`/`updating` states, `onSettled: 'idle'`, +and a `cancel` message in the `updating` state. The four originally-identified blockers +were resolved: -**Recommended:** Option C for now. `SourceBufferActor` is well-structured. Revisit -when (if) a second command-queue actor emerges. +1. **Awaitable send** — `SegmentLoaderActor` now observes completion via + `waitForIdle(snapshot, signal)` rather than awaiting `send()` directly. +2. **Context as task output** — tasks return new context; `.then(setContext)` commits it. + `getContext` threading ensures each task reads the context committed by the previous task. +3. **`batch()`** — implemented as a message type; handler iterates and schedules all tasks. +4. **Partial updates** — `setContext()` called mid-task for streaming segment progress. --- @@ -147,9 +61,8 @@ follow-on after `SegmentLoaderActor` is migrated. --- -## Recommended order +## Status -1. Add `SerialRunner.replaceQueue()`. -2. Migrate `SegmentLoaderActor` to `createMachineActor`. -3. Revisit `loadSegments` as a Reactor class. -4. Decide on command-queue actor unification only if a second such actor appears. +- ✅ `SegmentLoaderActor` — migrated to `createMachineActor` (with `abortPending()` instead of `replaceQueue()`) +- ✅ `SourceBufferActor` — migrated to `createMachineActor` +- ⬜ `loadSegments` — still function-based; natural follow-on as a `createMachineReactor` migration diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index ee1cbfe0a..0b7d77894 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -31,6 +31,36 @@ const reactor = createMachineReactor(reactorDefinition); Both return instances that implement `SignalActor` and expose `snapshot` and `destroy()`. +A third factory, `createTransitionActor`, handles actors with observable context but no +FSM. Lightweight callback actors implement the `CallbackActor` interface directly. + +### Actor and Reactor Types + +| Factory | States | Observable? | Runner | Use when | +|---|---|---|---|---| +| `createMachineActor` | User-defined FSM | Yes (`snapshot`) | Optional | Per-state message dispatch, `onSettled`, async work | +| `createTransitionActor` | `active` / `destroyed` | Yes (`snapshot`) | No | Observable context via reducer, no FSM needed | +| `CallbackActor` (manual) | None | No | Manual | Fire-and-forget messages, minimal overhead | +| `createMachineReactor` | User-defined FSM | Yes (`snapshot`) | No | Signal-driven transitions, per-state effects | + +**Actors** (message-driven): +- **`MessageActor`** — returned by `createMachineActor`. Has finite states, per-state + handlers, optional runner, and observable `snapshot` with `value` + `context`. + Used by: `SourceBufferActor`, `SegmentLoaderActor`. +- **`TransitionActor`** — returned by `createTransitionActor`. Pure reducer model: + `(context, message) => context`. No finite states — `snapshot.value` is always + `'active' | 'destroyed'`. Observable context for downstream consumers. + Used by: `TextTracksActor`. +- **`CallbackActor`** — manual implementation. `send()` + `destroy()`, no snapshot. + Used when the actor needs no observable state and the overhead of a factory isn't + warranted. Used by: `TextTrackSegmentLoaderActor`. + +**Reactors** (signal-driven): +- **`Reactor`** — returned by `createMachineReactor`. Has finite states, `monitor` for + state derivation, and `entry`/`reactions` per-state effects. No `send()` — driven + entirely by signal observation. + Used by: `syncTextTracks`, `loadTextTrackCues`, `resolvePresentation`, `trackPlaybackInitiated`. + --- ## Actor Definition @@ -62,66 +92,99 @@ type ActorDefinition< // When omitted, runner is absent from the type entirely (not undefined). type HandlerContext = { transition: (to: UserState) => void; - context: Context; + context: Context; // snapshot at dispatch time — stale after any setContext call + getContext: () => Context; // live untracked read — always current setContext: (next: Context) => void; } & (RunnerFactory extends () => infer R ? { runner: R } : object); ``` -### Example — `TextTrackSegmentLoaderActor` +### Example — `SourceBufferActor` + +Serializes SourceBuffer operations. Shows `onSettled` for auto-return, an `onMessage` +helper to deduplicate handlers, `batch` for atomic multi-message dispatch, and `cancel` +in the work state. Tasks return the next context — `getContext` threading ensures each +task reads the context committed by the previous task. ```typescript -import { SerialRunner, Task } from '../../core/task'; -import { parseVttSegment } from '../text/parse-vtt-segment'; +const onMessage = (msg: IndividualSourceBufferMessage, { transition, setContext, getContext, runner }: Ctx): void => { + transition('updating'); + const task = messageToTask(msg, { getContext, sourceBuffer, setContext }); + runner.schedule(task).then(setContext, handleError); +}; -const textTrackSegmentLoaderDef = { +return createMachineActor SerialRunner>({ runner: () => new SerialRunner(), - initial: 'idle' as const, - context: {} as Record, + initial: 'idle', + context: { segments: [], bufferedRanges: [], initTrackId: undefined }, states: { idle: { on: { - load: (msg, { transition, runner }) => { - const segments = plan(msg); - if (!segments.length) return; - segments.forEach(s => runner.schedule(new Task(async (signal) => { - const cues = await parseVttSegment(s.url); - if (!signal.aborted) textTracksActor.send({ type: 'add-cues', ... }); - }))); - transition('loading'); - } - } + 'append-init': onMessage, + 'append-segment': onMessage, + remove: onMessage, + batch: (msg, { transition, setContext, getContext, runner }) => { + if (msg.messages.length === 0) return; + transition('updating'); + msg.messages.forEach((m) => { + const task = messageToTask(m, { getContext, sourceBuffer, setContext }); + runner.schedule(task).then(setContext, handleError); + }); + }, + }, }, - loading: { + updating: { onSettled: 'idle', on: { - load: (msg, { runner }) => { - runner.abortAll(); - plan(msg).forEach(s => runner.schedule(new Task(...))); - // stays 'loading' — onSettled handles → 'idle' - } - } - } - } -}; + cancel: (_, { runner }) => { runner.abortAll(); }, + }, + }, + }, +}); ``` -### Example — `TextTracksActor` (no runner, synchronous) +### Example — `SegmentLoaderActor` + +Plans and executes segment fetches. Shows context threading (`inFlightInitTrackId`, +`inFlightSegmentId`), continue/preempt decision in the `loading` handler, and +`abortPending()` vs `abortAll()` for fine-grained runner control. ```typescript -const textTracksActorDef = { - // runner: omitted — no async work - initial: 'idle' as const, - context: { loaded: {}, segments: {} } as TextTracksActorContext, +return createMachineActor SerialRunner>({ + runner: () => new SerialRunner(), + initial: 'idle', + context: { inFlightInitTrackId: null, inFlightSegmentId: null }, states: { idle: { on: { - 'add-cues': (msg, { context, setContext }) => { - setContext(applyAddCues(context, msg)); - } - } - } - } -}; + load: (msg, ctx) => { + const allTasks = planTasks(msg); + if (allTasks.length === 0) return; + ctx.transition('loading'); + scheduleAll(allTasks, ctx); + }, + }, + }, + loading: { + onSettled: 'idle', + on: { + load: (msg, ctx) => { + const { context, runner } = ctx; + const allTasks = planTasks(msg); + const inFlightStillNeeded = /* check context against new plan */; + + if (inFlightStillNeeded) { + runner.abortPending(); // continue in-flight + scheduleAll(excludeInFlight(allTasks), ctx); // schedule remainder + } else { + runner.abortAll(); // preempt everything + sourceBufferActor.send({ type: 'cancel' }); + scheduleAll(allTasks, ctx); + } + }, + }, + }, + }, +}); ``` --- @@ -398,10 +461,10 @@ explicit and inspectable from the definition alone — no need to trace imperati (all scheduled tasks have completed) while the actor is in that state, the framework automatically transitions to `targetState`. -**Rationale:** This replaces the manual `runner.settled` reference-equality pattern in -`TextTrackSegmentLoaderActor`. The framework owns the generation-token logic — re-subscribing to -`runner.settled` each time tasks are scheduled so that `abortAll()` + reschedule correctly -cancels the previous settled callback. +**Rationale:** The framework owns the generation-token logic — re-subscribing to +`runner.whenSettled()` each time the handler returns so that `abortAll()` + reschedule +correctly supersedes the previous settled callback. Both `SourceBufferActor` and +`SegmentLoaderActor` use `onSettled: 'idle'` to auto-return from their work states. --- diff --git a/internal/design/spf/decisions.md b/internal/design/spf/decisions.md index 105e30e3b..6cb6123ca 100644 --- a/internal/design/spf/decisions.md +++ b/internal/design/spf/decisions.md @@ -19,15 +19,16 @@ the full reference implementation and assessment. --- -### `always`-before-state ordering as a load-bearing guarantee +### `monitor`-before-state ordering as a load-bearing guarantee -**Decision:** `always` effects in `createMachineReactor` always run before per-state effects. +**Decision:** `monitor` effects in `createMachineReactor` always run before per-state effects. This ordering guarantee is documented in `createMachineReactor`'s source and must be preserved. -**Rationale:** Per-state effects rely on invariants established by `always` monitors. -When an `always` monitor calls `transition(newState)`, the snapshot updates before any -per-state effect fires — so per-state effects that no-op when `status !== expectedState` -do so correctly without needing to re-check conditions themselves. +**Rationale:** Per-state effects rely on invariants established by `monitor` functions. +When a `monitor` function returns a new state, the framework calls `transition()` and the +snapshot updates before any per-state effect fires — so per-state effects that no-op when +`snapshot.value !== expectedState` do so correctly without needing to re-check conditions +themselves. **Caveat:** The guarantee is specific to `createMachineReactor`'s registration order. It depends on the TC39 `signal-polyfill`'s `Watcher` preserving insertion order in `getPending()` — @@ -35,15 +36,16 @@ not a formal guarantee of the TC39 Signals proposal. --- -### `deriveStatus` pattern for transition logic +### `deriveState` pattern for transition logic -**Decision:** Transition conditions live in a pure `deriveStatus` function, wrapped in a -`computed()` signal outside any effect body, consumed by the `always` monitor to drive -transitions. The `always` effect contains only: read the computed, compare, call `transition`. +**Decision:** Transition conditions live in a pure `deriveState` function, wrapped in a +`computed()` signal outside any effect body, consumed by the `monitor` field to drive +transitions. The `monitor` function returns the target state; the framework handles the +comparison and transition. -**Rationale:** Keeps `always` minimal and machine-readable; makes transition conditions -independently testable as a plain function; prevents the inline computed anti-pattern -(see [actor-reactor-factories.md](actor-reactor-factories.md)). +**Rationale:** Keeps the `monitor` function minimal and machine-readable; makes transition +conditions independently testable as a plain function; prevents the inline computed +anti-pattern (see [actor-reactor-factories.md](actor-reactor-factories.md)). --- diff --git a/internal/design/spf/primitives.md b/internal/design/spf/primitives.md index 95ef41de2..e0e179ece 100644 --- a/internal/design/spf/primitives.md +++ b/internal/design/spf/primitives.md @@ -99,7 +99,7 @@ An Actor: The snapshot is observable: other things (Reactors, `endOfStream`, the engine) can subscribe to Actor state changes without polling. -Actors should be **classes**. The current bespoke-closure approach makes it difficult to test, subclass, or inspect Actors in isolation. A class with a defined interface makes the contract explicit. +Actors are created via **factory functions** (`createMachineActor`, `createTransitionActor`) that take a declarative definition object. The factory owns all mechanics (snapshot signal, runner lifecycle, `'destroyed'` guard); the definition owns behavior. ### Relationship to Reactors @@ -112,8 +112,10 @@ Actors define state, context, message handlers per state, and an optional runner a definition object. The factory manages the snapshot signal, runner lifecycle, and `'destroyed'` terminal state. See [actor-reactor-factories.md](actor-reactor-factories.md). -The existing `SourceBufferActor` predates `createMachineActor` and has not been migrated — the -behavioral contract is equivalent, but the factory pattern is not yet used there. +`SourceBufferActor` and `SegmentLoaderActor` both use `createMachineActor`. Actors without +FSM states (e.g., `TextTracksActor`) use `createTransitionActor` — a reducer-style factory +with observable context but no per-state behavior. Lightweight callback actors (e.g., +`TextTrackSegmentLoaderActor`) implement the `CallbackActor` interface directly. ### Decided @@ -165,15 +167,15 @@ function-based with no formal status or snapshot — they remain to be migrated. - **Snapshot as signal** — same decision as Actors. `snapshot` is a `ReadonlySignal<{ status, context }>`. - **Factory function, not base class** — `createMachineReactor(definition)`. Per-state effect arrays; each element becomes one independent `effect()` call. See [actor-reactor-factories.md](actor-reactor-factories.md). - **Reactors do not send to other Reactors** — coordination flows through state or via `actor.send()`. -- **`always` effects for cross-cutting monitors** — a dedicated `always` array runs before per-state effects in every flush. The primary use case is condition monitoring that drives transitions from one place. See the ordering guarantee in [actor-reactor-factories.md](actor-reactor-factories.md). -- **Context via closure (tested approach)** — the text track spike used closure variables for Reactor non-finite state throughout. A formal `context` field in `createMachineReactor` has been prototyped but is tracked as a future improvement, not current practice. +- **`monitor` for cross-cutting state derivation** — a `monitor` function (or array) returns the target state; the framework drives the transition. Registered before per-state effects — the ordering guarantee ensures transitions fire before per-state effects re-evaluate. See [actor-reactor-factories.md](actor-reactor-factories.md). +- **`entry` / `reactions` per-state effect split** — `entry` effects run once on state entry, automatically untracked. `reactions` effects re-run when tracked signals change. This makes reactive intent explicit in the definition shape rather than relying on `untrack()` conventions. +- **Context via closure (tested approach)** — the text track spike used closure variables for Reactor non-finite state throughout. Reactors do not have a formal `context` field — non-finite state is held in closures and the `owners` signal. ### Open questions - **Effect scheduling** — when observed state changes, does a Reactor's response fire synchronously within the same update batch, or always deferred? The current implementation defers via `queueMicrotask`; the exact semantics under compound state changes are not fully characterized. - **Lifecycle ownership** — who creates and destroys Reactors? Currently the engine owns this explicitly. With a signal-based state primitive, Reactors could self-scope to a signal context and auto-dispose. -- **Reactor context — what belongs where** — see the "Reactor `context`" open question in [actor-reactor-factories.md](actor-reactor-factories.md). -- **Entry vs. reactive per-state effect distinction** — currently a `untrack()` convention rather than an API distinction. A future `entry` / `reactive` split in the definition shape would make intent explicit. See [actor-reactor-factories.md](actor-reactor-factories.md). +- **Reactor context — what belongs where** — non-finite state is held in closures and `owners`, not in a formal Reactor `context` field. The right answer depends on what debugging and testing patterns emerge. --- diff --git a/internal/design/spf/signals.md b/internal/design/spf/signals.md index dc2b4a396..19eae4982 100644 --- a/internal/design/spf/signals.md +++ b/internal/design/spf/signals.md @@ -66,7 +66,7 @@ via the `Signal.subtle.Watcher` API, leaving scheduling entirely to the caller. `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 +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. @@ -163,11 +163,11 @@ core/signals/ signal(), computed(), effect(), untrack(), update() PlaybackEngineOwners — signal (mediaElement, actors, buffers, ...) 2. Reactor execution model - createMachineReactor() — always[] and states[][] each become effect() calls - Transitions fire when a computed signal changes and an always monitor detects it + createMachineReactor() — monitor, entry, and reactions 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<{ status, context }> + createMachineActor() — snapshot is a signal<{ value, context }> Reactors and the engine observe Actor state without polling or callbacks ``` @@ -457,8 +457,8 @@ a predictable execution order: effects registered first run first. But that is a 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 `always`-before-state -ordering guarantee — that `always` effects always run before per-state effects — is an +`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 @@ -480,24 +480,21 @@ The ambient reactive context concern — and the `untrack()` discipline it requi 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. -An abstraction that distinguishes "run once on entry" from "re-run reactively" in its -definition shape makes reactive participation a declaration rather than a runtime property -of call-site context: +`createMachineReactor` implements this principle via `entry` and `reactions` per-state +effect keys: ```typescript -// Hypothetical: intent encoded in definition shape states: { 'set-up': { - entry: [/* automatically untracked — run once */], - reactive: [/* tracked — re-run when dependencies change */], + entry: [/* automatically untracked — run once on state entry */], + reactions: [/* tracked — re-run when dependencies change */], } } ``` -For `createMachineReactor`, this would make `untrack()` unnecessary in the common case of -enter-once effects, eliminate the class of bugs where accidental tracking causes unexpected -re-runs, and make the author's intent visible in the definition rather than in `untrack()` -calls buried inside effect bodies. +`entry` effects are automatically untracked — no `untrack()` needed for reads that are +setup-only. `reactions` effects re-run when tracked signals change; `untrack()` is only +needed for reads within `reactions` 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 diff --git a/internal/design/spf/text-track-architecture.md b/internal/design/spf/text-track-architecture.md index ded6cf854..99c41ee0b 100644 --- a/internal/design/spf/text-track-architecture.md +++ b/internal/design/spf/text-track-architecture.md @@ -73,7 +73,7 @@ any state ──── destroy() ────→ 'destroying' ────→ 'd - 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 `always` monitor handles the +**`'preconditions-unmet'`** has no effects — the `monitor` handles the exit transition. --- @@ -103,7 +103,7 @@ State effects: - **`'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 `always` monitor that evaluates a `deriveStatus()` +All transitions are driven by a single `monitor` that evaluates a `deriveState()` computed signal. --- @@ -112,18 +112,10 @@ computed signal. Fetches VTT segments and delegates cue management to `TextTracksActor`. -``` -'idle' ──── load (segments to fetch) ────→ 'loading' - ↑ │ - └──── onSettled (runner chain empties) ───────┘ - ↑ - └──── load (nothing to fetch) — stays idle -``` - -Both states handle `load`. The `idle` handler transitions to `'loading'`; -the `loading` handler stays `loading` (re-plans in place by aborting + rescheduling). -`onSettled: 'idle'` in the `loading` state definition handles the auto-return once -all tasks complete. +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. @@ -135,40 +127,37 @@ Wraps a `HTMLMediaElement`'s `textTracks`, owns cue deduplication and the cue record snapshot. ``` -'idle' ──── add-cues ────→ 'idle' (single state; all messages synchronous) +'active' ──── add-cues ────→ 'active' (reducer; context updated per message) ``` -No runner — all message handling is synchronous. `'destroyed'` is the only -other state (implicit, added by `createMachineActor`). +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. `deriveStatus` + `always` monitor +### 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 `always` monitor reads -the signal and drives the transition: +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: ```typescript // Hoist outside the reactor — computed() inside an effect creates a new node // on every re-run with no memoization. -const derivedStatusSignal = computed(() => deriveStatus(state.get(), owners.get())); +const derivedStateSignal = computed(() => deriveState(state.get(), owners.get())); createMachineReactor({ - always: [ - ({ status, transition }) => { - const target = derivedStatusSignal.get(); - if (target !== status) transition(target); - } - ], - // ... + monitor: () => derivedStateSignal.get(), + states: { ... }, }); ``` -`deriveStatus` is a plain function, independently testable. The `always` effect is -kept to one comparison and one transition call — no logic lives there. +`deriveState` is a plain function, independently testable. The `monitor` returns the +target state — the framework handles the comparison and transition call. --- @@ -188,11 +177,13 @@ function teardownActors(owners: Signal) { } // Called in BOTH reset states: -'preconditions-unmet': [() => { teardownActors(owners); }], -'setting-up': [() => { - teardownActors(owners); // defensive — same guard - // ... create fresh actors -}], +'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 @@ -233,32 +224,38 @@ reactor.destroy(); When an effect must read a signal value *without* creating a reactive dependency, wrap the read with `untrack()`. The two main cases: -**Enter-once setup** — reading `owners` or `state` in an enter-once effect. Without -`untrack()`, a change to `owners.mediaElement` would re-run an effect that was only -meant to run once on state entry: +**Entry effects are automatically untracked** — reading `owners` or `state` in an +`entry` effect does not create reactive dependencies. No `untrack()` wrapper needed: ```typescript -'setting-up': [() => { - // untrack: mediaElement might change later; we only need it at setup time. - const mediaElement = untrack(() => owners.get().mediaElement!); - const textTracksActor = createTextTracksActor(mediaElement); - // ... -}], +'setting-up': { + // entry is automatically untracked — no need for untrack() here. + entry: () => { + const mediaElement = owners.get().mediaElement!; + const textTracksActor = createTextTracksActor(mediaElement); + // ... + }, +}, ``` -**Preventing feedback loops** — reading actor snapshot in a monitoring effect. -`segmentLoaderActor.snapshot` changes every time the actor processes a message. -Without `untrack()`, the monitoring effect would re-run on every snapshot change, +**Preventing feedback loops in `reactions`** — 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: ```typescript -'monitoring-for-loads': [() => { - const currentTime = currentTimeSignal.get(); // tracked intentionally - const track = selectedTrackSignal.get()!; // tracked intentionally - // untrack: actor snapshot changes must not re-trigger this effect. - const { segmentLoaderActor } = untrack(() => owners.get()); - segmentLoaderActor!.send({ type: 'load', track, currentTime }); -}], +'monitoring-for-loads': { + // reactions: 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. + reactions: () => { + const currentTime = currentTimeSignal.get(); // tracked + const track = selectedTrackSignal.get()!; // tracked + const { segmentLoaderActor } = untrack(() => owners.get()); + segmentLoaderActor!.send({ type: 'load', track, currentTime }); + }, +}, ``` --- @@ -332,12 +329,11 @@ Evaluated against the goals from videojs/v10#1158: - **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 `always`-before-state ordering guarantee** requires care — it's an implementation +- **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 is invisible in the definition shape.** `untrack()` is - a convention, not API enforcement. An enter-once effect that accidentally tracks a signal - produces no error — just unexpected re-runs. +- **Entry vs. reactive effect intent** was initially invisible in the definition shape — + addressed by the `entry` / `reactions` split adopted after the spike. --- @@ -352,27 +348,32 @@ effect body. This is easy to miss because the code looks correct: ```typescript // WRONG — new Computed on every re-run, no memoization states: { - 'monitoring-for-loads': [() => { - const trackSignal = computed(() => findSelectedTrack(state.get())); // new node each time! - const track = trackSignal.get(); - segmentLoaderActor.send({ type: 'load', track, currentTime }); - }] + 'monitoring-for-loads': { + reactions: () => { + 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': [() => { - const track = trackSignal.get(); - segmentLoaderActor.send({ type: 'load', track, currentTime }); -}] } }); +createMachineReactor({ states: { 'monitoring-for-loads': { + reactions: () => { + const track = trackSignal.get(); + segmentLoaderActor.send({ type: 'load', track, currentTime }); + }, +} } }); ``` -### `untrack()` — convention without enforcement +### `untrack()` in `reactions` effects -Nothing in the API prevents an enter-once effect from tracking signals it shouldn't. -The author must know to use `untrack()` for reads that are setup-only. Missing it -produces unexpected re-runs when the read signal changes, which can cause duplicate -DOM mutations or redundant actor messages. +The `entry` / `reactions` split eliminated the most common footgun (accidental tracking +in enter-once effects). However, `reactions` 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 `reactions`, not +in all effects — but it remains a convention rather than API enforcement. ### Actor lifecycle ownership split @@ -401,24 +402,11 @@ not explored during the spike. ## Possible Future Improvements -### `entry` vs. `reactive` distinction in the definition shape - -The `untrack()` convention for enter-once effects is a footgun. A future definition shape -might distinguish: - -```typescript -states: { - 'set-up': { - entry: [/* automatically untracked, run once */], - reactive: [/* re-run when tracked signals change */] - } -} -``` +### ~~`entry` vs. `reactive` distinction in the definition shape~~ (Implemented) -This would make intent explicit and eliminate the class of bugs where an enter-once effect -accidentally tracks a signal. The `always` array already provides the primary reactive -mechanism for cross-cutting monitors; `reactive` within-state effects are a secondary but -real use case. Worth revisiting as more examples accumulate. +Adopted as `entry` / `reactions` in the `createMachineReactor` definition shape. `entry` +effects are automatically untracked; `reactions` effects re-run when tracked signals change. +See [actor-reactor-factories.md](actor-reactor-factories.md) for the decided design. ### Self-contained actor lifecycle in Reactor @@ -442,16 +430,13 @@ One way to express this: state `exit` callbacks alongside effect cleanup: 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 usage on Reactor - -`createMachineReactor` accepts `context` + `setContext`, but `loadTextTrackCues` and -`syncTextTracks` both use `context: {}` throughout — reactor non-finite state is held in -closure variables and the `owners` signal instead. +### Formal `context` field on Reactor -The tradeoff: `owners` is externally visible (other features can observe actor state); -closure variables are not inspectable from outside; Reactor `context` would be observable -via `snapshot` but adds API surface. The right answer likely depends on what debugging -and testing patterns emerge as more Reactors are written. +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` +(observable 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` @@ -471,17 +456,17 @@ that constitutes a recoverable error or a programming bug. ## Still Open Questions -### `always`-before-state ordering: guarantee or implementation detail? +### 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 `always`-before-state +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., `always` effects check -`status` and no-op if already transitioning), or (c) redesign to not rely on ordering +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? @@ -509,16 +494,16 @@ closed). No general policy has been established. 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 `deriveStatus` + `always` pattern is the direct +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`**: Already a proper actor with observable snapshot, `SerialRunner`, -and a well-defined message interface. It predates `createMachineActor` and has not been migrated -to the factory, but the behavioral contract is equivalent. Migration would be additive. +**`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 From 98c7b244b709919bda271ad8a5aa281b6a39b650 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 8 Apr 2026 07:36:26 -0700 Subject: [PATCH 77/79] refactor(spf): rename reactor `reactions` to `effects` The `effects` name directly maps to the underlying signal effect() primitive, making it immediately clear how tracked re-runs work. The `entry` / `effects` pairing is natural: entry is the special untracked one-time thing, effects are normal reactive signal effects. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../design/spf/actor-reactor-factories.md | 20 ++++++++-------- internal/design/spf/primitives.md | 2 +- internal/design/spf/signals.md | 10 ++++---- .../design/spf/text-track-architecture.md | 24 +++++++++---------- .../spf/src/core/create-machine-reactor.ts | 18 +++++++------- .../core/tests/create-machine-reactor.test.ts | 12 +++++----- .../src/dom/features/load-text-track-cues.ts | 2 +- .../spf/src/dom/features/sync-text-tracks.ts | 2 +- .../dom/features/track-playback-initiated.ts | 2 +- 9 files changed, 46 insertions(+), 46 deletions(-) diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index 0b7d77894..29a1a9d23 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -57,7 +57,7 @@ FSM. Lightweight callback actors implement the `CallbackActor` interface directl **Reactors** (signal-driven): - **`Reactor`** — returned by `createMachineReactor`. Has finite states, `monitor` for - state derivation, and `entry`/`reactions` per-state effects. No `send()` — driven + state derivation, and `entry`/`effects` per-state effects. No `send()` — driven entirely by signal observation. Used by: `syncTextTracks`, `loadTextTrackCues`, `resolvePresentation`, `trackPlaybackInitiated`. @@ -225,7 +225,7 @@ type ReactorStateDefinition = { * this state is active. Return a cleanup to run before each re-run * and on state exit. */ - reactions?: ReactorEffectFn | ReactorEffectFn[]; + effects?: ReactorEffectFn | ReactorEffectFn[]; }; type ReactorEffectFn = () => (() => void) | { abort(): void } | void; @@ -234,7 +234,7 @@ type ReactorEffectFn = () => (() => void) | { abort(): void } | void; ### Example — `syncTextTracks` Two states (`preconditions-unmet` ↔ `set-up`), one `monitor`, and one `entry` + one -`reactions` effect in `set-up` with independent tracking and cleanup. +`effects` effect in `set-up` with independent tracking and cleanup. ```typescript const reactor = createMachineReactor<'preconditions-unmet' | 'set-up'>({ @@ -257,9 +257,9 @@ const reactor = createMachineReactor<'preconditions-unmet' | 'set-up'>({ }; }, - // reactions: re-runs when selectedId changes. el is read with untrack() + // effects: re-runs when selectedId changes. el is read with untrack() // since element changes go through the monitor (preconditions-unmet path). - reactions: () => { + effects: () => { const el = untrack(() => mediaElementSignal.get() as HTMLMediaElement); const selectedId = selectedIdSignal.get(); // tracked — re-run on change syncModes(el.textTracks, selectedId); @@ -307,11 +307,11 @@ const reactor = createMachineReactor({ pending: {}, // neutral waiting state — no effects 'monitoring-for-loads': { - // reactions: re-runs whenever currentTime or selectedTrack changes. + // 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. - reactions: () => { + effects: () => { const currentTime = currentTimeSignal.get(); // tracked const track = selectedTrackSignal.get()!; // tracked const { segmentLoaderActor } = untrack(() => owners.get()); @@ -468,7 +468,7 @@ correctly supersedes the previous settled callback. Both `SourceBufferActor` and --- -### `entry` vs `reactions` per-state effects +### `entry` vs `effects` per-state effects Per-state effects fall into two distinct categories, each with its own key in the state definition: @@ -476,10 +476,10 @@ Per-state effects fall into two distinct categories, each with its own key in th the fn body. Use for one-time setup: creating DOM elements, reading `owners`, starting a fetch. Return a cleanup function or `AbortController` to run on state exit (or re-entry if the effect runs again). -- **`reactions`** — intentionally re-run when a tracked signal changes while the state is active. +- **`effects`** — intentionally re-run when a tracked signal changes while the state is active. Use for effects that must stay in sync with reactive data: mode sync, message dispatch. -Signals that should not trigger re-runs in a `reactions` effect must be wrapped with `untrack()`. +Signals that should not trigger re-runs in a `effects` effect must be wrapped with `untrack()`. Signal reads inside `entry` are automatically untracked — the fn body runs inside `untrack()`. **Inline computed anti-pattern:** `computed()` inside an effect body creates a new `Computed` diff --git a/internal/design/spf/primitives.md b/internal/design/spf/primitives.md index e0e179ece..e23c0e964 100644 --- a/internal/design/spf/primitives.md +++ b/internal/design/spf/primitives.md @@ -168,7 +168,7 @@ function-based with no formal status or snapshot — they remain to be migrated. - **Factory function, not base class** — `createMachineReactor(definition)`. Per-state effect arrays; each element becomes one independent `effect()` call. See [actor-reactor-factories.md](actor-reactor-factories.md). - **Reactors do not send to other Reactors** — coordination flows through state or via `actor.send()`. - **`monitor` for cross-cutting state derivation** — a `monitor` function (or array) returns the target state; the framework drives the transition. Registered before per-state effects — the ordering guarantee ensures transitions fire before per-state effects re-evaluate. See [actor-reactor-factories.md](actor-reactor-factories.md). -- **`entry` / `reactions` per-state effect split** — `entry` effects run once on state entry, automatically untracked. `reactions` effects re-run when tracked signals change. This makes reactive intent explicit in the definition shape rather than relying on `untrack()` conventions. +- **`entry` / `effects` per-state split** — `entry` runs once on state entry, automatically untracked. `effects` re-run when tracked signals change. This makes reactive intent explicit in the definition shape rather than relying on `untrack()` conventions. - **Context via closure (tested approach)** — the text track spike used closure variables for Reactor non-finite state throughout. Reactors do not have a formal `context` field — non-finite state is held in closures and the `owners` signal. ### Open questions diff --git a/internal/design/spf/signals.md b/internal/design/spf/signals.md index 19eae4982..cfb267d44 100644 --- a/internal/design/spf/signals.md +++ b/internal/design/spf/signals.md @@ -163,7 +163,7 @@ core/signals/ signal(), computed(), effect(), untrack(), update() PlaybackEngineOwners — signal (mediaElement, actors, buffers, ...) 2. Reactor execution model - createMachineReactor() — monitor, entry, and reactions each become effect() calls + 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 @@ -480,21 +480,21 @@ The ambient reactive context concern — and the `untrack()` discipline it requi 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 `reactions` per-state +`createMachineReactor` implements this principle via `entry` and `effects` per-state effect keys: ```typescript states: { 'set-up': { entry: [/* automatically untracked — run once on state entry */], - reactions: [/* tracked — re-run when dependencies change */], + effects: [/* tracked — re-run when dependencies change */], } } ``` `entry` effects are automatically untracked — no `untrack()` needed for reads that are -setup-only. `reactions` effects re-run when tracked signals change; `untrack()` is only -needed for reads within `reactions` that should not create dependencies. +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 diff --git a/internal/design/spf/text-track-architecture.md b/internal/design/spf/text-track-architecture.md index 99c41ee0b..5457659dd 100644 --- a/internal/design/spf/text-track-architecture.md +++ b/internal/design/spf/text-track-architecture.md @@ -238,18 +238,18 @@ wrap the read with `untrack()`. The two main cases: }, ``` -**Preventing feedback loops in `reactions`** — reading actor snapshot in a reactive +**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: ```typescript 'monitoring-for-loads': { - // reactions: re-runs whenever currentTime or selectedTrack changes. + // 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. - reactions: () => { + effects: () => { const currentTime = currentTimeSignal.get(); // tracked const track = selectedTrackSignal.get()!; // tracked const { segmentLoaderActor } = untrack(() => owners.get()); @@ -333,7 +333,7 @@ Evaluated against the goals from videojs/v10#1158: 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` / `reactions` split adopted after the spike. + addressed by the `entry` / `effects` split adopted after the spike. --- @@ -349,7 +349,7 @@ 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': { - reactions: () => { + effects: () => { const trackSignal = computed(() => findSelectedTrack(state.get())); // new node each time! const track = trackSignal.get(); segmentLoaderActor.send({ type: 'load', track, currentTime }); @@ -360,19 +360,19 @@ states: { // CORRECT — hoist outside createMachineReactor() const trackSignal = computed(() => findSelectedTrack(state.get())); createMachineReactor({ states: { 'monitoring-for-loads': { - reactions: () => { + effects: () => { const track = trackSignal.get(); segmentLoaderActor.send({ type: 'load', track, currentTime }); }, } } }); ``` -### `untrack()` in `reactions` effects +### `untrack()` in `effects` -The `entry` / `reactions` split eliminated the most common footgun (accidental tracking -in enter-once effects). However, `reactions` effects still require `untrack()` for reads +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 `reactions`, not +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 @@ -404,8 +404,8 @@ not explored during the spike. ### ~~`entry` vs. `reactive` distinction in the definition shape~~ (Implemented) -Adopted as `entry` / `reactions` in the `createMachineReactor` definition shape. `entry` -effects are automatically untracked; `reactions` effects re-run when tracked signals change. +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](actor-reactor-factories.md) for the decided design. ### Self-contained actor lifecycle in Reactor diff --git a/packages/spf/src/core/create-machine-reactor.ts b/packages/spf/src/core/create-machine-reactor.ts index bb4febc42..db0522017 100644 --- a/packages/spf/src/core/create-machine-reactor.ts +++ b/packages/spf/src/core/create-machine-reactor.ts @@ -18,7 +18,7 @@ import { untrack } from './signals/primitives'; export type ReactorDeriveFn = () => State; /** - * An effect function used in reactor `entry` and `reactions` blocks. + * An effect function used in reactor `entry` and `effects` blocks. * * May return a cleanup function that runs before each re-evaluation and on * state exit (including destroy). @@ -31,15 +31,15 @@ export type ReactorEffectFn = () => (() => void) | { abort(): void } | void; * - `entry` effects run once on state entry. The fn body is automatically * untracked — no `untrack()` calls are needed inside. Use this for * one-time setup: reading current values, attaching event listeners, etc. - * - `reactions` effects run on state entry and re-run whenever a signal read - * inside the fn body changes. Use `untrack()` for reads you do not want to - * track. Use this for work that must stay in sync with reactive state. + * - `effects` run on state entry and re-run whenever a signal read inside + * the fn body changes. Use `untrack()` for reads you do not want to track. + * Use this for work that must stay in sync with reactive state. * * Both are optional; pass `{}` for states with no effects. */ export type ReactorStateDefinition = { entry?: ReactorEffectFn | ReactorEffectFn[]; - reactions?: ReactorEffectFn | ReactorEffectFn[]; + effects?: ReactorEffectFn | ReactorEffectFn[]; }; /** @@ -60,7 +60,7 @@ export type ReactorDefinition = { monitor?: ReactorDeriveFn | ReactorDeriveFn[]; /** * Per-state effect groupings. Every valid state must be declared — pass `{}` - * for states with no effects. `entry` and `reactions` each become independent + * for states with no effects. `entry` and `effects` each become independent * `effect()` calls gated on that state, with their own cleanup lifecycles. */ states: Record; @@ -104,8 +104,8 @@ const toArray = (x: T | T[] | undefined): T[] => (x === undefined ? [] : Arra * active: { * // entry: runs once on state entry; fn body is automatically untracked. * entry: () => listen(el, 'play', handler), - * // reactions: re-runs whenever tracked signals change. - * reactions: () => { currentTimeSignal.get(); return cleanup; }, + * // effects: re-runs whenever tracked signals change. + * effects: () => { currentTimeSignal.get(); return cleanup; }, * }, * waiting: {}, * } @@ -157,7 +157,7 @@ export function createMachineReactor( const isNotState = (snapshot: { value: FullState }) => snapshot.value !== state; return [ ...toArray(stateDef.entry).map((fn) => ({ fn, shouldSkip: isNotState, toFnCall: untracked })), - ...toArray(stateDef.reactions).map((fn) => ({ fn, shouldSkip: isNotState })), + ...toArray(stateDef.effects).map((fn) => ({ fn, shouldSkip: isNotState })), ]; }), ]; diff --git a/packages/spf/src/core/tests/create-machine-reactor.test.ts b/packages/spf/src/core/tests/create-machine-reactor.test.ts index 0aee9f326..97c82f5a7 100644 --- a/packages/spf/src/core/tests/create-machine-reactor.test.ts +++ b/packages/spf/src/core/tests/create-machine-reactor.test.ts @@ -35,7 +35,7 @@ describe('createMachineReactor', () => { const fn = vi.fn(); createMachineReactor({ initial: 'idle' as const, - states: { idle: { reactions: [fn] } }, + states: { idle: { effects: [fn] } }, }).destroy(); expect(fn).toHaveBeenCalledOnce(); @@ -126,7 +126,7 @@ describe('createMachineReactor', () => { const reactor = createMachineReactor({ initial: 'idle' as const, - states: { idle: { reactions: [fn1, fn2] } }, + states: { idle: { effects: [fn1, fn2] } }, }); expect(fn1).toHaveBeenCalledOnce(); @@ -228,7 +228,7 @@ describe('createMachineReactor — cleanup', () => { initial: 'idle' as const, states: { idle: { - reactions: [ + effects: [ () => { src.get(); return cleanup; @@ -257,7 +257,7 @@ describe('createMachineReactor — cleanup', () => { states: { idle: { entry: [() => entryCleanup], - reactions: [() => reactionCleanup], + effects: [() => reactionCleanup], }, }, }); @@ -423,7 +423,7 @@ describe('createMachineReactor — destroy', () => { expect(reactor.snapshot.get().value).toBe('destroyed'); }); - it('does not run reactions after destroy()', async () => { + it('does not run effects after destroy()', async () => { const src = signal(0); const fn = vi.fn(() => { src.get(); @@ -431,7 +431,7 @@ describe('createMachineReactor — destroy', () => { const reactor = createMachineReactor({ initial: 'idle' as const, - states: { idle: { reactions: [fn] } }, + states: { idle: { effects: [fn] } }, }); reactor.destroy(); diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 22fc8b443..f45087a69 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -170,7 +170,7 @@ export function loadTextTrackCues { + effects: () => { const currentTime = currentTimeSignal.get(); const track = selectedTrackSignal.get()!; // deriveState guarantees segmentLoaderActor is in owners and findSelectedTrack diff --git a/packages/spf/src/dom/features/sync-text-tracks.ts b/packages/spf/src/dom/features/sync-text-tracks.ts index 463d73db0..d5dff9015 100644 --- a/packages/spf/src/dom/features/sync-text-tracks.ts +++ b/packages/spf/src/dom/features/sync-text-tracks.ts @@ -131,7 +131,7 @@ export function syncTextTracks { + effects: () => { const mediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement); const selectedId = selectedTextTrackIdSignal.get(); diff --git a/packages/spf/src/dom/features/track-playback-initiated.ts b/packages/spf/src/dom/features/track-playback-initiated.ts index d7a4df7aa..eb590c86e 100644 --- a/packages/spf/src/dom/features/track-playback-initiated.ts +++ b/packages/spf/src/dom/features/track-playback-initiated.ts @@ -97,7 +97,7 @@ export function trackPlaybackInitiated { + effects: () => { mediaElementSignal.get(); // tracked — re-run on element change urlSignal.get(); // tracked — re-run on URL change return () => update(state, { playbackInitiated: false } as Partial); From 26e9f80ea5584270dfbb9d0672c7aef55ca294a3 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 8 Apr 2026 11:49:36 -0700 Subject: [PATCH 78/79] docs(spf): disambiguate "observable" terminology across docs and code Use "reactive" for signals-based snapshots/context/state (the SPF primitive) and reserve "observable"/"Observable" for the RxJS/TC39 Observable pattern discussed as an alternative. Adds glossary and structure note to spf/index.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../design/spf/actor-reactor-factories.md | 14 ++++----- internal/design/spf/decisions.md | 4 +-- internal/design/spf/index.md | 16 ++++++++++ internal/design/spf/primitives.md | 30 +++++++++---------- internal/design/spf/signals.md | 2 +- .../design/spf/text-track-architecture.md | 6 ++-- packages/spf/src/core/actor.ts | 2 +- .../spf/src/core/create-transition-actor.ts | 4 +-- .../core/tests/create-machine-actor.test.ts | 2 +- .../features/tests/text-tracks-actor.test.ts | 2 +- 10 files changed, 49 insertions(+), 33 deletions(-) diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md index 29a1a9d23..291535a42 100644 --- a/internal/design/spf/actor-reactor-factories.md +++ b/internal/design/spf/actor-reactor-factories.md @@ -31,28 +31,28 @@ const reactor = createMachineReactor(reactorDefinition); Both return instances that implement `SignalActor` and expose `snapshot` and `destroy()`. -A third factory, `createTransitionActor`, handles actors with observable context but no +A third factory, `createTransitionActor`, handles actors with reactive context but no FSM. Lightweight callback actors implement the `CallbackActor` interface directly. ### Actor and Reactor Types -| Factory | States | Observable? | Runner | Use when | +| Factory | States | Reactive? | Runner | Use when | |---|---|---|---|---| | `createMachineActor` | User-defined FSM | Yes (`snapshot`) | Optional | Per-state message dispatch, `onSettled`, async work | -| `createTransitionActor` | `active` / `destroyed` | Yes (`snapshot`) | No | Observable context via reducer, no FSM needed | +| `createTransitionActor` | `active` / `destroyed` | Yes (`snapshot`) | No | Reactive context via reducer, no FSM needed | | `CallbackActor` (manual) | None | No | Manual | Fire-and-forget messages, minimal overhead | | `createMachineReactor` | User-defined FSM | Yes (`snapshot`) | No | Signal-driven transitions, per-state effects | **Actors** (message-driven): - **`MessageActor`** — returned by `createMachineActor`. Has finite states, per-state - handlers, optional runner, and observable `snapshot` with `value` + `context`. + handlers, optional runner, and reactive `snapshot` with `value` + `context`. Used by: `SourceBufferActor`, `SegmentLoaderActor`. - **`TransitionActor`** — returned by `createTransitionActor`. Pure reducer model: `(context, message) => context`. No finite states — `snapshot.value` is always - `'active' | 'destroyed'`. Observable context for downstream consumers. + `'active' | 'destroyed'`. Reactive context for downstream consumers. Used by: `TextTracksActor`. - **`CallbackActor`** — manual implementation. `send()` + `destroy()`, no snapshot. - Used when the actor needs no observable state and the overhead of a factory isn't + Used when the actor needs no reactive state and the overhead of a factory isn't warranted. Used by: `TextTrackSegmentLoaderActor`. **Reactors** (signal-driven): @@ -662,7 +662,7 @@ than convention: runner factory, and the framework to wire them up on state entry and exit. That's a meaningful framework change, not a convention. - **Context as side channel is awkward.** Task inputs (message payloads) traveling through - observable actor context leaks internal scheduling details into the public snapshot. + reactive actor context leaks internal scheduling details into the public snapshot. **Decision:** Keep actor-lifetime runners for now. The generation-token problem is already handled by the framework. The cancellation gap (work outliving its state) is real but not diff --git a/internal/design/spf/decisions.md b/internal/design/spf/decisions.md index 6cb6123ca..e6b82521c 100644 --- a/internal/design/spf/decisions.md +++ b/internal/design/spf/decisions.md @@ -290,7 +290,7 @@ an actor-alive state?" — the entry effect is always safe to run. **Decision:** `loadSegments` maintains local `throughput` state per track and syncs it to `state.bandwidthState` after each sample. -**Context:** This is a migration artifact. The long-term design has ABR read directly from a throughput observable rather than going through the global state. The bridge exists to decouple the refactor from the feature work. +**Context:** This is a migration artifact. The long-term design has ABR read directly from a reactive throughput source rather than going through the global state. The bridge exists to decouple the refactor from the feature work. **Status: temporary.** Remove once ABR reads from `throughput` directly. See [Open Questions](#abr-throughput). @@ -325,7 +325,7 @@ This lets the UI show "currently manual at 720p, ABR would choose 1080p" without ### ABR Throughput Direct Read {#abr-throughput} -The bandwidth bridge (`loadSegments` → `state.bandwidthState` → `switchQuality`) introduces a round-trip through global state. ABR should eventually read from a throughput observable owned by the network layer, removing the bridge. +The bandwidth bridge (`loadSegments` → `state.bandwidthState` → `switchQuality`) introduces a round-trip through global state. ABR should eventually read from a reactive throughput source owned by the network layer, removing the bridge. **Open:** Requires defining the throughput API in `core/` and wiring it through `dom/`. diff --git a/internal/design/spf/index.md b/internal/design/spf/index.md index aca125c66..b21888293 100644 --- a/internal/design/spf/index.md +++ b/internal/design/spf/index.md @@ -6,6 +6,8 @@ date: 2026-03-11 # SPF — Streaming Playback Framework > **This is a living design document for a highly tentative codebase.** The current implementation captures useful early lessons but is expected to undergo significant architectural change in the near term. [architecture.md](architecture.md) and [decisions.md](decisions.md) document the current state; [primitives.md](primitives.md) is the forward-looking design. +> +> **Structure note:** These docs don't follow the standard [design doc template](../README.md) (`Decision → Context → Alternatives → Rationale`). SPF's scope — a multi-layered streaming framework with several interacting primitives — warrants a different structure: an index with glossary, per-primitive deep dives, and explicit "Open questions" sections for areas still in flux. A lean, actor-based framework for HLS playback over MSE. Handles manifest parsing, quality selection, segment buffering, and end-of-stream coordination — without a monolithic player. Actors and Reactors are defined via declarative factory functions (`createMachineActor`, `createMachineReactor`) backed by TC39 Signals. @@ -21,6 +23,20 @@ A lean, actor-based framework for HLS playback over MSE. Handles manifest parsin | [architecture.md](architecture.md) | Current implementation: layers, components, data flow | | [decisions.md](decisions.md) | Decided and open design decisions | +## Glossary + +| Term | Definition | +| ---- | ---------- | +| **Actor** | Long-lived stateful worker that processes messages serially via a queue. Owns a context snapshot and a Runner. Key examples: `SourceBufferActor`, `SegmentLoaderActor`. | +| **Reactor** | Thin subscriber that observes state changes and translates them into actor messages. Contains no business logic beyond "should I send a message, and what should it say?" | +| **Task** | Ephemeral async work unit with status tracking (`pending`, `active`, `complete`, `error`) and abort support. | +| **Runner** | Task scheduler that controls execution ordering. `SerialRunner` runs one task at a time; `ConcurrentRunner` runs tasks in parallel. | +| **Snapshot** | Reactive read-only state of an Actor or Reactor, exposed for external consumption. | +| **Signal** | Reactive primitive from the [TC39 Signals proposal](https://github.com/tc39/proposal-signals). The layer underneath Actors and Reactors — see [signals.md](signals.md). | +| **MSE** | [Media Source Extensions](https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API) — browser API for programmatically feeding media data to a `