From 97db2975a81536011375f44da9125f8156de0253 Mon Sep 17 00:00:00 2001 From: clem Date: Thu, 26 Mar 2026 23:17:03 +0800 Subject: [PATCH 1/3] feat: add runtime phase tracking and cleanup on restart (#128) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/store.ts | 42 +++++++++++++++++++ test/unit/store.test.ts | 89 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/store.ts b/src/store.ts index 6d77e3b..a2eaa90 100644 --- a/src/store.ts +++ b/src/store.ts @@ -177,6 +177,41 @@ function resetModuleState(base: Record): void { serializedHistory = []; } +// --------------------------------------------------------------------------- +// Runtime handler cleanup (auto-unsub on restart) +// --------------------------------------------------------------------------- + +let runtimeUnsubs: Array<() => void> = []; +let inRuntimePhase = false; + +/** + * Track an unsubscribe function for automatic cleanup on restart. + * No-op if called during the startup phase (before enterRuntimePhase). + */ +export function trackRuntimeUnsub(unsub: () => void): void { + if (inRuntimePhase) { + runtimeUnsubs.push(unsub); + } +} + +/** Mark the start of the runtime phase. Called before executeStoryInit(). */ +export function enterRuntimePhase(): void { + inRuntimePhase = true; +} + +/** Call all tracked unsubs and reset the runtime phase. */ +function cleanupRuntimeHandlers(): void { + for (const unsub of runtimeUnsubs) unsub(); + runtimeUnsubs = []; + inRuntimePhase = false; +} + +/** Test-only: reset runtime phase state between tests. */ +export function _resetRuntimePhase(): void { + runtimeUnsubs = []; + inRuntimePhase = false; +} + // --------------------------------------------------------------------------- // storyinit callbacks (direct invocation — avoids Zustand subscription issues) // --------------------------------------------------------------------------- @@ -526,6 +561,9 @@ export const useStoryStore = create()( const keepDeferred = get().renderDeferred; + // Clean up all runtime-phase handlers (after beforerestart has fired) + cleanupRuntimeHandlers(); + resetPRNG(); resetTriggers(); const initialVars = deepClone(variableDefaults); @@ -550,6 +588,10 @@ export const useStoryStore = create()( }); lastNavigationVars = get().variables; + + // Re-enter runtime phase before StoryInit so new handlers are tracked + enterRuntimePhase(); + executeStoryInit(); clearSession(storyData.ifid); fireStoryInit(); diff --git a/test/unit/store.test.ts b/test/unit/store.test.ts index 72b08c5..84de05c 100644 --- a/test/unit/store.test.ts +++ b/test/unit/store.test.ts @@ -1,6 +1,13 @@ // @vitest-environment happy-dom -import { describe, it, expect, beforeEach } from 'vitest'; -import { useStoryStore, onBeforeRestart } from '../../src/store'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + useStoryStore, + onBeforeRestart, + onStoryInit, + trackRuntimeUnsub, + enterRuntimePhase, + _resetRuntimePhase, +} from '../../src/store'; import { executeStoryInit } from '../../src/story-init'; import type { StoryData, Passage } from '../../src/parser'; import { registerClass, clearRegistry } from '../../src/class-registry'; @@ -882,4 +889,82 @@ describe('useStoryStore', () => { unsub(); }); }); + + describe('runtime handler cleanup', () => { + beforeEach(() => { + _resetRuntimePhase(); + useStoryStore.setState({ + storyData: null, + currentPassage: '', + variables: {}, + variableDefaults: {}, + temporary: {}, + history: [], + historyIndex: -1, + visitCounts: {}, + renderCounts: {}, + transitionConfig: null, + nextTransition: null, + renderDeferred: false, + }); + }); + + it('trackRuntimeUnsub is a no-op before enterRuntimePhase', () => { + const unsub = vi.fn(); + trackRuntimeUnsub(unsub); + + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + useStoryStore.getState().restart(); + + expect(unsub).not.toHaveBeenCalled(); + }); + + it('calls tracked unsubs on restart after enterRuntimePhase', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + + enterRuntimePhase(); + + const unsub = vi.fn(); + trackRuntimeUnsub(unsub); + + useStoryStore.getState().restart(); + + expect(unsub).toHaveBeenCalledOnce(); + }); + + it('does not call startup-phase unsubs on restart', () => { + const startupUnsub = vi.fn(); + trackRuntimeUnsub(startupUnsub); + + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + + enterRuntimePhase(); + + const runtimeUnsub = vi.fn(); + trackRuntimeUnsub(runtimeUnsub); + + useStoryStore.getState().restart(); + + expect(startupUnsub).not.toHaveBeenCalled(); + expect(runtimeUnsub).toHaveBeenCalledOnce(); + }); + + it('clears tracked unsubs after restart so they are not called again', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + + enterRuntimePhase(); + const unsub = vi.fn(); + trackRuntimeUnsub(unsub); + + useStoryStore.getState().restart(); + expect(unsub).toHaveBeenCalledOnce(); + + useStoryStore.getState().restart(); + expect(unsub).toHaveBeenCalledOnce(); + }); + }); }); From 53888f658d74e213ad2dc6df3a64be04ddbfeb56 Mon Sep 17 00:00:00 2001 From: clem Date: Thu, 26 Mar 2026 23:23:36 +0800 Subject: [PATCH 2/3] feat: wire Story.on() to trackRuntimeUnsub for auto-cleanup (#128) Every event branch in Story.on() now captures the unsub function and passes it to trackRuntimeUnsub() so handlers registered during the runtime phase are automatically cleaned on restart. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/story-api.ts | 27 ++++++++--- test/unit/story-api.test.ts | 94 ++++++++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/src/story-api.ts b/src/story-api.ts index c31126d..dcafdb0 100644 --- a/src/story-api.ts +++ b/src/story-api.ts @@ -1,4 +1,9 @@ -import { useStoryStore, onStoryInit, onBeforeRestart } from './store'; +import { + useStoryStore, + onStoryInit, + onBeforeRestart, + trackRuntimeUnsub, +} from './store'; import type { Passage } from './parser'; import { settings } from './settings'; import type { @@ -371,30 +376,38 @@ function createStoryAPI(): StoryAPI { on(event: string, callback: (...args: any[]) => void): () => void { if (event === 'navigate') { let prev = useStoryStore.getState().currentPassage; - return useStoryStore.subscribe((state) => { + const unsub = useStoryStore.subscribe((state) => { if (state.currentPassage !== prev) { const from = prev; prev = state.currentPassage; (callback as NavigateCallback)(state.currentPassage, from); } }); + trackRuntimeUnsub(unsub); + return unsub; } if (event === 'beforerestart') { - return onBeforeRestart(callback as BeforeRestartCallback); + const unsub = onBeforeRestart(callback as BeforeRestartCallback); + trackRuntimeUnsub(unsub); + return unsub; } if (event === 'storyinit') { - return onStoryInit(callback as StoryInitCallback); + const unsub = onStoryInit(callback as StoryInitCallback); + trackRuntimeUnsub(unsub); + return unsub; } if (event === 'actionsChanged') { - return onActionsChanged(callback as ActionsChangedCallback); + const unsub = onActionsChanged(callback as ActionsChangedCallback); + trackRuntimeUnsub(unsub); + return unsub; } if (event === 'variableChanged') { let prevVars = { ...useStoryStore.getState().variables }; - return useStoryStore.subscribe((state) => { + const unsub = useStoryStore.subscribe((state) => { const changed: Record = {}; let hasChanges = false; const allKeys = new Set([ @@ -412,6 +425,8 @@ function createStoryAPI(): StoryAPI { (callback as VariableChangedCallback)(changed); } }); + trackRuntimeUnsub(unsub); + return unsub; } throw new Error(`spindle: Unknown event "${event}".`); diff --git a/test/unit/story-api.test.ts b/test/unit/story-api.test.ts index 6c2eecb..2416cc9 100644 --- a/test/unit/story-api.test.ts +++ b/test/unit/story-api.test.ts @@ -1,6 +1,10 @@ // @vitest-environment happy-dom import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { useStoryStore } from '../../src/store'; +import { + useStoryStore, + enterRuntimePhase, + _resetRuntimePhase, +} from '../../src/store'; import type { StoryData, Passage } from '../../src/parser'; import { clearActions, @@ -34,6 +38,7 @@ let Story: any; describe('StoryAPI', () => { beforeEach(async () => { + _resetRuntimePhase(); clearActions(); resetIdCounters(); const storyData = makeStoryData([ @@ -612,4 +617,91 @@ describe('StoryAPI', () => { await second; }); }); + + describe('runtime handler auto-cleanup', () => { + beforeEach(() => { + _resetRuntimePhase(); + }); + + it('navigate handler registered during runtime is cleaned on restart', () => { + enterRuntimePhase(); + + const cb = vi.fn(); + Story.on('navigate', cb); + + // Navigate to verify handler works + Story.goto('Room'); + expect(cb).toHaveBeenCalledWith('Room', 'Start'); + + cb.mockClear(); + + // Restart cleans the handler + Story.restart(); + + // Navigate again — handler should NOT fire + Story.goto('Room'); + expect(cb).not.toHaveBeenCalled(); + }); + + it('beforerestart handler fires during the restart that cleans it', () => { + enterRuntimePhase(); + + const cb = vi.fn(); + Story.on('beforerestart', cb); + + Story.restart(); + // Should have fired once during this restart + expect(cb).toHaveBeenCalledOnce(); + + cb.mockClear(); + + // Second restart — handler was cleaned, should NOT fire + Story.restart(); + expect(cb).not.toHaveBeenCalled(); + }); + + it('no duplicate handlers after multiple restart cycles', () => { + const calls: string[] = []; + + // Simulate what a host boot() does: register on storyinit + // BEFORE entering runtime phase (host boot runs during startup) + Story.on('storyinit', () => { + // Inside storyinit, we're in runtime phase (restart calls enterRuntimePhase) + Story.on('navigate', () => { + calls.push('nav'); + }); + }); + + enterRuntimePhase(); + + // First restart: storyinit fires, registers one navigate handler + Story.restart(); + Story.goto('Room'); + expect(calls).toEqual(['nav']); + + calls.length = 0; + + // Second restart: old navigate handler cleaned, storyinit registers a new one + Story.restart(); + Story.goto('Room'); + expect(calls).toEqual(['nav']); // still exactly one, not two + }); + + it('manual unsub still works and double-call is safe', () => { + enterRuntimePhase(); + + const cb = vi.fn(); + const unsub = Story.on('navigate', cb); + + // Manually unsub + unsub(); + + // Navigate — should not fire + Story.goto('Room'); + expect(cb).not.toHaveBeenCalled(); + + // Restart — the stale entry in runtimeUnsubs is a no-op + expect(() => Story.restart()).not.toThrow(); + }); + }); }); From f3ea2000b78df0815761efb15c0b1083107a926f Mon Sep 17 00:00:00 2001 From: clem Date: Thu, 26 Mar 2026 23:28:32 +0800 Subject: [PATCH 3/3] feat: enter runtime phase before executeStoryInit during boot (#128) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 459dc39..c42485c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,7 @@ import { render } from 'preact'; import { App } from './components/App'; import { parseStoryData } from './parser'; -import { useStoryStore, fireStoryInit } from './store'; +import { useStoryStore, fireStoryInit, enterRuntimePhase } from './store'; import { installStoryAPI, getReadyPromise } from './story-api'; import { resetIdCounters } from './action-registry'; import { executeStoryInit } from './story-init'; @@ -99,6 +99,9 @@ function boot() { useStoryStore.getState().init(storyData, defaults); + // Enter runtime phase — handlers registered from here on are cleaned on restart + enterRuntimePhase(); + // Execute StoryInit passage if it exists executeStoryInit();