@rohal12/spindle 0.37.1 → 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.1",
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
@@ -2,7 +2,7 @@ import { render } from 'preact';
2
2
  import { App } from './components/App';
3
3
  import { parseStoryData } from './parser';
4
4
  import { useStoryStore, fireStoryInit } from './store';
5
- import { installStoryAPI } from './story-api';
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';
@@ -200,7 +200,14 @@ function boot() {
200
200
 
201
201
  render(<App />, root);
202
202
 
203
- 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
+ }
204
211
  }
205
212
 
206
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',
@@ -233,6 +234,7 @@ export interface StoryState {
233
234
  transitionConfig: TransitionConfig | null;
234
235
  nextTransition: TransitionConfig | null;
235
236
  nobr: boolean;
237
+ renderDeferred: boolean;
236
238
 
237
239
  setMaxHistory: (limit: number) => void;
238
240
  init: (
@@ -263,6 +265,8 @@ export interface StoryState {
263
265
  setTransition: (config: TransitionConfig | null) => void;
264
266
  setNextTransition: (config: TransitionConfig | null) => void;
265
267
  consumeNextTransition: () => TransitionConfig | null;
268
+ deferRender: () => void;
269
+ clearDeferredRender: () => void;
266
270
  }
267
271
 
268
272
  export const useStoryStore = create<StoryState>()(
@@ -284,6 +288,7 @@ export const useStoryStore = create<StoryState>()(
284
288
  transitionConfig: null,
285
289
  nextTransition: null,
286
290
  nobr: false,
291
+ renderDeferred: false,
287
292
 
288
293
  setMaxHistory: (limit: number) => {
289
294
  set((state) => {
@@ -510,6 +515,7 @@ export const useStoryStore = create<StoryState>()(
510
515
  state.historyIndex = 0;
511
516
  state.visitCounts = { [startPassage.name]: 1 };
512
517
  state.renderCounts = { [startPassage.name]: 1 };
518
+ state.renderDeferred = false;
513
519
  });
514
520
 
515
521
  lastNavigationVars = get().variables;
@@ -776,5 +782,17 @@ export const useStoryStore = create<StoryState>()(
776
782
  }
777
783
  return current;
778
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
+ },
779
797
  })),
780
798
  );
package/src/story-api.ts CHANGED
@@ -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: {
@@ -452,6 +472,21 @@ function createStoryAPI(): StoryAPI {
452
472
  useStoryStore.getState().setNextTransition(config);
453
473
  },
454
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
+
455
490
  random(): number {
456
491
  return random();
457
492
  },