@rohal12/spindle 0.37.0 → 0.38.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.37.0",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -71,6 +71,7 @@ defineMacro({
71
71
  render(_props, ctx) {
72
72
  const currentPassage = useStoryStore((s) => s.currentPassage);
73
73
  const storyData = useStoryStore((s) => s.storyData);
74
+ const renderDeferred = useStoryStore((s) => s.renderDeferred);
74
75
 
75
76
  // Render-gating: displayedPassage controls which passage is visually shown.
76
77
  // The store updates immediately, but the visual swap is deferred by the
@@ -245,12 +246,14 @@ defineMacro({
245
246
  // eslint-disable-next-line react-hooks/exhaustive-deps
246
247
  }, [currentPassage]);
247
248
 
248
- // Resolve the passage to display (or show nothing during outgoing phase)
249
- const passage = displayedPassage
250
- ? storyData?.passages.get(displayedPassage)
251
- : null;
249
+ // When render is deferred, show StoryLoading passage or nothing
250
+ const effectivePassage = renderDeferred
251
+ ? (storyData?.passages.get('StoryLoading') ?? null)
252
+ : displayedPassage
253
+ ? storyData?.passages.get(displayedPassage)
254
+ : null;
252
255
 
253
- if (!passage && displayedPassage) {
256
+ if (!effectivePassage && displayedPassage && !renderDeferred) {
254
257
  return (
255
258
  <div class="error">
256
259
  Error: Passage &ldquo;{displayedPassage}&rdquo; not found.
@@ -265,7 +268,7 @@ defineMacro({
265
268
  id={ctx.id ?? 'story'}
266
269
  class={ctx.className ?? 'story'}
267
270
  >
268
- {readyPassage && (
271
+ {!renderDeferred && readyPassage && (
269
272
  <div
270
273
  key={`ready-${currentPassage}`}
271
274
  hidden
@@ -277,11 +280,11 @@ defineMacro({
277
280
  class="passage-container"
278
281
  ref={containerRef}
279
282
  >
280
- {passage && (
283
+ {effectivePassage && (
281
284
  <Passage
282
- passage={passage}
283
- key={displayedPassage}
284
- dataTransition={resolvedTypeRef.current}
285
+ passage={effectivePassage}
286
+ key={renderDeferred ? 'loading' : displayedPassage}
287
+ dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
285
288
  />
286
289
  )}
287
290
  </div>
package/src/index.tsx CHANGED
@@ -1,8 +1,8 @@
1
1
  import { render } from 'preact';
2
2
  import { App } from './components/App';
3
3
  import { parseStoryData } from './parser';
4
- import { useStoryStore } from './store';
5
- import { installStoryAPI } from './story-api';
4
+ import { useStoryStore, fireStoryInit } from './store';
5
+ import { installStoryAPI, getReadyPromise } from './story-api';
6
6
  import { resetIdCounters } from './action-registry';
7
7
  import { executeStoryInit } from './story-init';
8
8
  import { checkTriggers, reinitTriggerState } from './triggers';
@@ -101,7 +101,6 @@ function boot() {
101
101
 
102
102
  // Execute StoryInit passage if it exists
103
103
  executeStoryInit();
104
- useStoryStore.getState().bumpInitCount();
105
104
 
106
105
  // Restore session state if the page was refreshed
107
106
  const sessionPayload = loadSession(storyData.ifid);
@@ -109,6 +108,9 @@ function boot() {
109
108
  useStoryStore.getState().loadFromPayload(sessionPayload);
110
109
  }
111
110
 
111
+ // Fire storyinit after all state is settled (defaults + StoryInit + session)
112
+ fireStoryInit();
113
+
112
114
  // Pass 1: Pre-scan all widget passages to discover block widgets.
113
115
  // Register them as block macros BEFORE any tokenize/buildAST calls,
114
116
  // so that widget bodies using other block widgets parse correctly
@@ -198,7 +200,14 @@ function boot() {
198
200
 
199
201
  render(<App />, root);
200
202
 
201
- document.dispatchEvent(new CustomEvent(':storyready'));
203
+ const pending = getReadyPromise();
204
+ if (pending) {
205
+ pending.then(() => {
206
+ document.dispatchEvent(new CustomEvent(':storyready'));
207
+ });
208
+ } else {
209
+ document.dispatchEvent(new CustomEvent(':storyready'));
210
+ }
202
211
  }
203
212
 
204
213
  if (document.readyState === 'loading') {
package/src/store.ts CHANGED
@@ -41,6 +41,7 @@ const SPECIAL_PASSAGES = new Set([
41
41
  'StoryInit',
42
42
  'StoryInterface',
43
43
  'StoryVariables',
44
+ 'StoryLoading',
44
45
  'SaveTitle',
45
46
  'PassageReady',
46
47
  'PassageHeader',
@@ -176,6 +177,26 @@ function resetModuleState(base: Record<string, unknown>): void {
176
177
  serializedHistory = [];
177
178
  }
178
179
 
180
+ // ---------------------------------------------------------------------------
181
+ // storyinit callbacks (direct invocation — avoids Zustand subscription issues)
182
+ // ---------------------------------------------------------------------------
183
+
184
+ type StoryInitListener = () => void;
185
+ let storyInitListeners: StoryInitListener[] = [];
186
+
187
+ /** Register a callback to run after StoryInit completes (boot + every restart). */
188
+ export function onStoryInit(cb: StoryInitListener): () => void {
189
+ storyInitListeners.push(cb);
190
+ return () => {
191
+ storyInitListeners = storyInitListeners.filter((l) => l !== cb);
192
+ };
193
+ }
194
+
195
+ /** Fire all storyinit listeners. Called after all state resets are complete. */
196
+ export function fireStoryInit(): void {
197
+ for (const cb of storyInitListeners) cb();
198
+ }
199
+
179
200
  // ---------------------------------------------------------------------------
180
201
  // Store
181
202
  // ---------------------------------------------------------------------------
@@ -207,15 +228,14 @@ export interface StoryState {
207
228
  renderCounts: Record<string, number>;
208
229
  knownSaves: Record<string, true>;
209
230
  playthroughId: string;
210
- initCount: number;
211
231
  maxHistory: number;
212
232
  saveError: string | null;
213
233
  loadError: string | null;
214
234
  transitionConfig: TransitionConfig | null;
215
235
  nextTransition: TransitionConfig | null;
216
236
  nobr: boolean;
237
+ renderDeferred: boolean;
217
238
 
218
- bumpInitCount: () => void;
219
239
  setMaxHistory: (limit: number) => void;
220
240
  init: (
221
241
  storyData: StoryData,
@@ -245,6 +265,8 @@ export interface StoryState {
245
265
  setTransition: (config: TransitionConfig | null) => void;
246
266
  setNextTransition: (config: TransitionConfig | null) => void;
247
267
  consumeNextTransition: () => TransitionConfig | null;
268
+ deferRender: () => void;
269
+ clearDeferredRender: () => void;
248
270
  }
249
271
 
250
272
  export const useStoryStore = create<StoryState>()(
@@ -260,19 +282,13 @@ export const useStoryStore = create<StoryState>()(
260
282
  renderCounts: {},
261
283
  knownSaves: {},
262
284
  playthroughId: '',
263
- initCount: 0,
264
285
  maxHistory: 40,
265
286
  saveError: null,
266
287
  loadError: null,
267
288
  transitionConfig: null,
268
289
  nextTransition: null,
269
290
  nobr: false,
270
-
271
- bumpInitCount: () => {
272
- set((state) => {
273
- state.initCount++;
274
- });
275
- },
291
+ renderDeferred: false,
276
292
 
277
293
  setMaxHistory: (limit: number) => {
278
294
  set((state) => {
@@ -499,12 +515,13 @@ export const useStoryStore = create<StoryState>()(
499
515
  state.historyIndex = 0;
500
516
  state.visitCounts = { [startPassage.name]: 1 };
501
517
  state.renderCounts = { [startPassage.name]: 1 };
518
+ state.renderDeferred = false;
502
519
  });
503
520
 
504
521
  lastNavigationVars = get().variables;
505
522
  executeStoryInit();
506
- get().bumpInitCount();
507
523
  clearSession(storyData.ifid);
524
+ fireStoryInit();
508
525
 
509
526
  // Start a new playthrough on restart
510
527
  startNewPlaythrough(storyData.ifid)
@@ -765,5 +782,17 @@ export const useStoryStore = create<StoryState>()(
765
782
  }
766
783
  return current;
767
784
  },
785
+
786
+ deferRender: () => {
787
+ set((state) => {
788
+ state.renderDeferred = true;
789
+ });
790
+ },
791
+
792
+ clearDeferredRender: () => {
793
+ set((state) => {
794
+ state.renderDeferred = false;
795
+ });
796
+ },
768
797
  })),
769
798
  );
package/src/story-api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { useStoryStore } from './store';
1
+ import { useStoryStore, onStoryInit } from './store';
2
2
  import type { Passage } from './parser';
3
3
  import { settings } from './settings';
4
4
  import type {
@@ -50,6 +50,24 @@ import type { TransitionConfig } from './transition';
50
50
  export type { StoryAction };
51
51
  export type { MacroMetadata };
52
52
 
53
+ // Deferred-render promise lifecycle.
54
+ // deferRender() creates a promise; ready() resolves it.
55
+ // index.tsx reads getReadyPromise() AFTER render(), which is after both
56
+ // author JS and storyinit have run, so it always gets the final promise.
57
+ let readyResolve: (() => void) | null = null;
58
+ let readyPromise: Promise<void> | null = null;
59
+
60
+ /** Returns the current deferred-render promise, or null if not deferred. */
61
+ export function getReadyPromise(): Promise<void> | null {
62
+ return readyPromise;
63
+ }
64
+
65
+ /** Test-only: reset module-level promise state. */
66
+ export function _resetReadyState(): void {
67
+ readyResolve = null;
68
+ readyPromise = null;
69
+ }
70
+
53
71
  type NavigateCallback = (to: string, from: string) => void;
54
72
  type StoryInitCallback = () => void;
55
73
  type ActionsChangedCallback = () => void;
@@ -121,6 +139,8 @@ export interface StoryAPI {
121
139
  setCSS(enabled: boolean): void;
122
140
  setTransition(config: TransitionConfig | null): void;
123
141
  setNextTransition(config: TransitionConfig | null): void;
142
+ deferRender(): void;
143
+ ready(): void;
124
144
  random(): number;
125
145
  randomInt(min: number, max: number): number;
126
146
  readonly config: {
@@ -359,13 +379,7 @@ function createStoryAPI(): StoryAPI {
359
379
  }
360
380
 
361
381
  if (event === 'storyinit') {
362
- let prevCount = useStoryStore.getState().initCount;
363
- return useStoryStore.subscribe((state) => {
364
- if (state.initCount !== prevCount) {
365
- prevCount = state.initCount;
366
- (callback as StoryInitCallback)();
367
- }
368
- });
382
+ return onStoryInit(callback as StoryInitCallback);
369
383
  }
370
384
 
371
385
  if (event === 'actionsChanged') {
@@ -458,6 +472,21 @@ function createStoryAPI(): StoryAPI {
458
472
  useStoryStore.getState().setNextTransition(config);
459
473
  },
460
474
 
475
+ deferRender(): void {
476
+ useStoryStore.getState().deferRender();
477
+ readyPromise = new Promise<void>((resolve) => {
478
+ readyResolve = resolve;
479
+ });
480
+ },
481
+
482
+ ready(): void {
483
+ if (!readyResolve) return;
484
+ useStoryStore.getState().clearDeferredRender();
485
+ readyResolve();
486
+ readyResolve = null;
487
+ readyPromise = null;
488
+ },
489
+
461
490
  random(): number {
462
491
  return random();
463
492
  },