Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();

Expand Down
42 changes: 42 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,41 @@ function resetModuleState(base: Record<string, unknown>): 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)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -526,6 +561,9 @@ export const useStoryStore = create<StoryState>()(

const keepDeferred = get().renderDeferred;

// Clean up all runtime-phase handlers (after beforerestart has fired)
cleanupRuntimeHandlers();

resetPRNG();
resetTriggers();
const initialVars = deepClone(variableDefaults);
Expand All @@ -550,6 +588,10 @@ export const useStoryStore = create<StoryState>()(
});

lastNavigationVars = get().variables;

// Re-enter runtime phase before StoryInit so new handlers are tracked
enterRuntimePhase();

executeStoryInit();
clearSession(storyData.ifid);
fireStoryInit();
Expand Down
27 changes: 21 additions & 6 deletions src/story-api.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string, { from: unknown; to: unknown }> = {};
let hasChanges = false;
const allKeys = new Set([
Expand All @@ -412,6 +425,8 @@ function createStoryAPI(): StoryAPI {
(callback as VariableChangedCallback)(changed);
}
});
trackRuntimeUnsub(unsub);
return unsub;
}

throw new Error(`spindle: Unknown event "${event}".`);
Expand Down
89 changes: 87 additions & 2 deletions test/unit/store.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
});
});
94 changes: 93 additions & 1 deletion test/unit/story-api.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -34,6 +38,7 @@ let Story: any;

describe('StoryAPI', () => {
beforeEach(async () => {
_resetRuntimePhase();
clearActions();
resetIdCounters();
const storyData = makeStoryData([
Expand Down Expand Up @@ -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();
});
});
});
Loading