@rohal12/spindle 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
package/src/index.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { render } from 'preact';
2
2
  import { App } from './components/App';
3
3
  import { parseStoryData } from './parser';
4
- import { useStoryStore, fireStoryInit } from './store';
4
+ import { useStoryStore, fireStoryInit, enterRuntimePhase } from './store';
5
5
  import { installStoryAPI, getReadyPromise } from './story-api';
6
6
  import { resetIdCounters } from './action-registry';
7
7
  import { executeStoryInit } from './story-init';
@@ -99,6 +99,9 @@ function boot() {
99
99
 
100
100
  useStoryStore.getState().init(storyData, defaults);
101
101
 
102
+ // Enter runtime phase — handlers registered from here on are cleaned on restart
103
+ enterRuntimePhase();
104
+
102
105
  // Execute StoryInit passage if it exists
103
106
  executeStoryInit();
104
107
 
package/src/store.ts CHANGED
@@ -177,6 +177,41 @@ function resetModuleState(base: Record<string, unknown>): void {
177
177
  serializedHistory = [];
178
178
  }
179
179
 
180
+ // ---------------------------------------------------------------------------
181
+ // Runtime handler cleanup (auto-unsub on restart)
182
+ // ---------------------------------------------------------------------------
183
+
184
+ let runtimeUnsubs: Array<() => void> = [];
185
+ let inRuntimePhase = false;
186
+
187
+ /**
188
+ * Track an unsubscribe function for automatic cleanup on restart.
189
+ * No-op if called during the startup phase (before enterRuntimePhase).
190
+ */
191
+ export function trackRuntimeUnsub(unsub: () => void): void {
192
+ if (inRuntimePhase) {
193
+ runtimeUnsubs.push(unsub);
194
+ }
195
+ }
196
+
197
+ /** Mark the start of the runtime phase. Called before executeStoryInit(). */
198
+ export function enterRuntimePhase(): void {
199
+ inRuntimePhase = true;
200
+ }
201
+
202
+ /** Call all tracked unsubs and reset the runtime phase. */
203
+ function cleanupRuntimeHandlers(): void {
204
+ for (const unsub of runtimeUnsubs) unsub();
205
+ runtimeUnsubs = [];
206
+ inRuntimePhase = false;
207
+ }
208
+
209
+ /** Test-only: reset runtime phase state between tests. */
210
+ export function _resetRuntimePhase(): void {
211
+ runtimeUnsubs = [];
212
+ inRuntimePhase = false;
213
+ }
214
+
180
215
  // ---------------------------------------------------------------------------
181
216
  // storyinit callbacks (direct invocation — avoids Zustand subscription issues)
182
217
  // ---------------------------------------------------------------------------
@@ -516,7 +551,19 @@ export const useStoryStore = create<StoryState>()(
516
551
  const startPassage = storyData.passagesById.get(storyData.startNode);
517
552
  if (!startPassage) return;
518
553
 
554
+ // Reset renderDeferred before firing beforerestart so we can detect
555
+ // if a handler called deferRender() during the callback.
556
+ set((state) => {
557
+ state.renderDeferred = false;
558
+ });
559
+
519
560
  fireBeforeRestart();
561
+
562
+ const keepDeferred = get().renderDeferred;
563
+
564
+ // Clean up all runtime-phase handlers (after beforerestart has fired)
565
+ cleanupRuntimeHandlers();
566
+
520
567
  resetPRNG();
521
568
  resetTriggers();
522
569
  const initialVars = deepClone(variableDefaults);
@@ -535,10 +582,16 @@ export const useStoryStore = create<StoryState>()(
535
582
  state.historyIndex = 0;
536
583
  state.visitCounts = { [startPassage.name]: 1 };
537
584
  state.renderCounts = { [startPassage.name]: 1 };
538
- state.renderDeferred = false;
585
+ if (!keepDeferred) {
586
+ state.renderDeferred = false;
587
+ }
539
588
  });
540
589
 
541
590
  lastNavigationVars = get().variables;
591
+
592
+ // Re-enter runtime phase before StoryInit so new handlers are tracked
593
+ enterRuntimePhase();
594
+
542
595
  executeStoryInit();
543
596
  clearSession(storyData.ifid);
544
597
  fireStoryInit();
package/src/story-api.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { useStoryStore, onStoryInit, onBeforeRestart } from './store';
1
+ import {
2
+ useStoryStore,
3
+ onStoryInit,
4
+ onBeforeRestart,
5
+ trackRuntimeUnsub,
6
+ } from './store';
2
7
  import type { Passage } from './parser';
3
8
  import { settings } from './settings';
4
9
  import type {
@@ -371,30 +376,38 @@ function createStoryAPI(): StoryAPI {
371
376
  on(event: string, callback: (...args: any[]) => void): () => void {
372
377
  if (event === 'navigate') {
373
378
  let prev = useStoryStore.getState().currentPassage;
374
- return useStoryStore.subscribe((state) => {
379
+ const unsub = useStoryStore.subscribe((state) => {
375
380
  if (state.currentPassage !== prev) {
376
381
  const from = prev;
377
382
  prev = state.currentPassage;
378
383
  (callback as NavigateCallback)(state.currentPassage, from);
379
384
  }
380
385
  });
386
+ trackRuntimeUnsub(unsub);
387
+ return unsub;
381
388
  }
382
389
 
383
390
  if (event === 'beforerestart') {
384
- return onBeforeRestart(callback as BeforeRestartCallback);
391
+ const unsub = onBeforeRestart(callback as BeforeRestartCallback);
392
+ trackRuntimeUnsub(unsub);
393
+ return unsub;
385
394
  }
386
395
 
387
396
  if (event === 'storyinit') {
388
- return onStoryInit(callback as StoryInitCallback);
397
+ const unsub = onStoryInit(callback as StoryInitCallback);
398
+ trackRuntimeUnsub(unsub);
399
+ return unsub;
389
400
  }
390
401
 
391
402
  if (event === 'actionsChanged') {
392
- return onActionsChanged(callback as ActionsChangedCallback);
403
+ const unsub = onActionsChanged(callback as ActionsChangedCallback);
404
+ trackRuntimeUnsub(unsub);
405
+ return unsub;
393
406
  }
394
407
 
395
408
  if (event === 'variableChanged') {
396
409
  let prevVars = { ...useStoryStore.getState().variables };
397
- return useStoryStore.subscribe((state) => {
410
+ const unsub = useStoryStore.subscribe((state) => {
398
411
  const changed: Record<string, { from: unknown; to: unknown }> = {};
399
412
  let hasChanges = false;
400
413
  const allKeys = new Set([
@@ -412,6 +425,8 @@ function createStoryAPI(): StoryAPI {
412
425
  (callback as VariableChangedCallback)(changed);
413
426
  }
414
427
  });
428
+ trackRuntimeUnsub(unsub);
429
+ return unsub;
415
430
  }
416
431
 
417
432
  throw new Error(`spindle: Unknown event "${event}".`);