@rohal12/spindle 0.49.1 → 0.50.1

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/src/settings.ts CHANGED
@@ -27,6 +27,7 @@ export type SettingDef =
27
27
  const definitions = new Map<string, SettingDef>();
28
28
  let values: Record<string, unknown> = {};
29
29
  let storageLoaded = false;
30
+ let unsubscribeStoryData: (() => void) | null = null;
30
31
 
31
32
  function storageKey(): string {
32
33
  const storyData = useStoryStore.getState().storyData;
@@ -40,6 +41,20 @@ function persist(): void {
40
41
 
41
42
  function loadFromStorage(): void {
42
43
  if (storageLoaded) return;
44
+ // Settings registered from Story JavaScript run before store.init(), when
45
+ // the IFID (and thus the storage key) is not known yet. Defer the load
46
+ // until story data arrives instead of reading `spindle.unknown.settings`.
47
+ if (!useStoryStore.getState().storyData?.ifid) {
48
+ if (!unsubscribeStoryData) {
49
+ unsubscribeStoryData = useStoryStore.subscribe((state) => {
50
+ if (!state.storyData?.ifid) return;
51
+ unsubscribeStoryData?.();
52
+ unsubscribeStoryData = null;
53
+ loadFromStorage();
54
+ });
55
+ }
56
+ return;
57
+ }
43
58
  storageLoaded = true;
44
59
  try {
45
60
  const raw = localStorage.getItem(storageKey());
package/src/store.ts CHANGED
@@ -17,12 +17,17 @@ import type {
17
17
  import { isSaveExport } from './saves/types';
18
18
  import { executeStoryInit } from './story-init';
19
19
  import { emit } from './event-emitter';
20
- import { resetTriggers } from './triggers';
20
+ import {
21
+ resetTriggers,
22
+ checkTriggersOnNavigation,
23
+ reinitTriggerState,
24
+ } from './triggers';
21
25
  import {
22
26
  initSaveSystem,
23
27
  startNewPlaythrough,
24
28
  getCurrentPlaythroughId,
25
29
  quickSave,
30
+ saveWithHooks,
26
31
  loadQuickSave,
27
32
  populateKnownSaves,
28
33
  getSlotSaveInfo,
@@ -36,7 +41,7 @@ import {
36
41
  clearAllData as smClearAllData,
37
42
  deletePlaythroughData as smDeletePlaythroughData,
38
43
  } from './saves/save-manager';
39
- import { deepClone, serialize, deserialize } from './class-registry';
44
+ import { deepClone, serialize } from './class-registry';
40
45
  import {
41
46
  snapshotPRNG,
42
47
  restorePRNG,
@@ -106,6 +111,19 @@ function computeVarPatches(
106
111
  return { forward: clonePatches(forward), inverse: clonePatches(inverse) };
107
112
  }
108
113
 
114
+ /**
115
+ * Replace the variable snapshot recorded for the newest history moment
116
+ * (e.g. with changes made by watchers reacting to the navigation into it).
117
+ */
118
+ function rerecordNewestMoment(vars: Record<string, unknown>): void {
119
+ const last = patchEntries.length - 1;
120
+ if (last < 0) {
121
+ variableBase = vars;
122
+ } else {
123
+ patchEntries[last] = computeVarPatches(reconstructVarsAt(last), vars);
124
+ }
125
+ }
126
+
109
127
  /** Reconstruct variables at a given history moment by replaying patches. */
110
128
  function reconstructVarsAt(index: number): Record<string, unknown> {
111
129
  let vars: Record<string, unknown> = variableBase;
@@ -133,7 +151,8 @@ function persistSession(get: () => StoryState): void {
133
151
  } = get();
134
152
  if (!storyData) return;
135
153
 
136
- // Trim cache when history was truncated (goBack then navigate)
154
+ // Trim cache when history shrank (navigate() drops discarded forward
155
+ // moments itself, since a replacement branch may keep the same length)
137
156
  if (serializedHistory.length > history.length) {
138
157
  serializedHistory.length = history.length;
139
158
  }
@@ -156,10 +175,9 @@ function persistSession(get: () => StoryState): void {
156
175
  for (let i = 0; i < history.length; i++) {
157
176
  if (i > 0) vars = applyPatches(vars, patchEntries[i - 1]!.forward);
158
177
  if (i >= serializedHistory.length) {
159
- const v = i === history.length - 1 ? variables : vars;
160
178
  serializedHistory[i] = {
161
179
  passage: history[i]!.passage,
162
- variables: serialize(v),
180
+ variables: serialize(vars),
163
181
  timestamp: history[i]!.timestamp,
164
182
  prng: history[i]!.prng,
165
183
  };
@@ -179,6 +197,12 @@ function persistSession(get: () => StoryState): void {
179
197
  });
180
198
  }
181
199
 
200
+ /** True while navigate() lets watchers react to the moment it entered. */
201
+ let navigationTriggerPhase = false;
202
+
203
+ /** Navigations requested during the trigger phase, run once it is over. */
204
+ let deferredNavigations: string[] = [];
205
+
182
206
  /** Reset all module-level state (called on init, restart, loadFromPayload). */
183
207
  function resetModuleState(base: Record<string, unknown>): void {
184
208
  variableBase = base;
@@ -187,6 +211,27 @@ function resetModuleState(base: Record<string, unknown>): void {
187
211
  serializedHistory = [];
188
212
  }
189
213
 
214
+ /**
215
+ * Record the state StoryInit left behind as the start moment: its variable
216
+ * snapshot (the history base) and PRNG state. Called by executeStoryInit()
217
+ * after the StoryInit passage has rendered; init()/restart() record the
218
+ * start moment before StoryInit runs. A no-op once the story has moved on.
219
+ */
220
+ export function recordStoryInitState(): void {
221
+ const { history, historyIndex, variables } = useStoryStore.getState();
222
+ if (history.length !== 1 || historyIndex !== 0) return;
223
+
224
+ variableBase = variables;
225
+ patchEntries = [];
226
+ lastNavigationVars = variables;
227
+ serializedHistory = [];
228
+
229
+ const prng = snapshotPRNG();
230
+ useStoryStore.setState((state) => {
231
+ state.history[0]!.prng = prng;
232
+ });
233
+ }
234
+
190
235
  // ---------------------------------------------------------------------------
191
236
  // Runtime handler cleanup (auto-unsub on restart)
192
237
  // ---------------------------------------------------------------------------
@@ -231,6 +276,25 @@ function normalizeHotkey(key: unknown): string | null {
231
276
  return typeof key === 'string' && key !== '' ? key : null;
232
277
  }
233
278
 
279
+ /**
280
+ * The history moment whose entry snapshot a load restores: the moment at
281
+ * `historyIndex`, provided it is in range and belongs to the saved passage.
282
+ * Otherwise (malformed or foreign payloads) the load falls back to the
283
+ * payload's live variables.
284
+ */
285
+ function loadedEntryMoment(
286
+ payload: SavePayload,
287
+ ): SaveHistoryMoment | undefined {
288
+ const { history, historyIndex } = payload;
289
+ if (!Number.isInteger(historyIndex)) return undefined;
290
+ const moment = history[historyIndex];
291
+ if (!moment || moment.passage !== payload.passage) return undefined;
292
+ if (typeof moment.variables !== 'object' || moment.variables === null) {
293
+ return undefined;
294
+ }
295
+ return moment;
296
+ }
297
+
234
298
  /** Restore or reset PRNG from a history moment's snapshot. */
235
299
  function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
236
300
  if (moment?.prng) {
@@ -246,9 +310,21 @@ export interface HistoryMoment {
246
310
  prng?: PRNGSnapshot | null;
247
311
  }
248
312
 
313
+ /** The variable namespaces watch conditions and mutations can touch. */
314
+ export interface VariableNamespaces {
315
+ variables: Record<string, unknown>;
316
+ temporary: Record<string, unknown>;
317
+ transient: Record<string, unknown>;
318
+ }
319
+
249
320
  export interface StoryState {
250
321
  storyData: StoryData | null;
251
322
  currentPassage: string;
323
+ /**
324
+ * Incremented on every navigation (navigate, back, forward, restart, load)
325
+ * so the passage display can remount even when the passage name is unchanged.
326
+ */
327
+ navigationId: number;
252
328
  variables: Record<string, unknown>;
253
329
  variableDefaults: Record<string, unknown>;
254
330
  transient: Record<string, unknown>;
@@ -287,6 +363,11 @@ export interface StoryState {
287
363
  deleteTemporary: (name: string) => void;
288
364
  setTransient: (name: string, value: unknown) => void;
289
365
  deleteTransient: (name: string) => void;
366
+ /**
367
+ * Apply changes to several variables as one store update, so subscribers
368
+ * (watchers, components) see them together instead of half-applied.
369
+ */
370
+ updateVariables: (recipe: (draft: VariableNamespaces) => void) => void;
290
371
  trackRender: (passageName: string) => void;
291
372
  restart: () => void;
292
373
  save: (slot?: string, custom?: Record<string, unknown>) => void;
@@ -301,7 +382,11 @@ export interface StoryState {
301
382
  clearAllData: () => void;
302
383
  deletePlaythrough: (playthroughId: string) => void;
303
384
  getSavePayload: () => SavePayload;
304
- loadFromPayload: (payload: SavePayload) => void;
385
+ /**
386
+ * Replace the game state with a live (deserialized) payload. `slot` is
387
+ * passed to the `beforeload`/`afterload` events.
388
+ */
389
+ loadFromPayload: (payload: SavePayload, slot?: string) => void;
305
390
  getHistoryVariables: (index: number) => Record<string, unknown>;
306
391
  setTransition: (config: TransitionConfig | null) => void;
307
392
  setNextTransition: (config: TransitionConfig | null) => void;
@@ -314,6 +399,7 @@ export const useStoryStore = create<StoryState>()(
314
399
  immer((set, get) => ({
315
400
  storyData: null,
316
401
  currentPassage: '',
402
+ navigationId: 0,
317
403
  variables: {},
318
404
  variableDefaults: {},
319
405
  transient: {},
@@ -371,6 +457,7 @@ export const useStoryStore = create<StoryState>()(
371
457
  set((state) => {
372
458
  state.storyData = storyData as StoryData;
373
459
  state.currentPassage = startPassage.name;
460
+ state.navigationId++;
374
461
  state.variables = initialVars;
375
462
  state.variableDefaults = variableDefaults;
376
463
  state.transient = deepClone(transientDefaults);
@@ -420,7 +507,14 @@ export const useStoryStore = create<StoryState>()(
420
507
  },
421
508
 
422
509
  navigate: (passageName: string) => {
423
- const { storyData, variables: currVars } = get();
510
+ // A watcher (goto action or callback) reacting to a navigation must not
511
+ // start another one before the first has recorded its moment.
512
+ if (navigationTriggerPhase) {
513
+ deferredNavigations.push(passageName);
514
+ return;
515
+ }
516
+
517
+ const { storyData } = get();
424
518
  if (!storyData) return;
425
519
 
426
520
  if (SPECIAL_PASSAGES.has(passageName)) {
@@ -438,16 +532,21 @@ export const useStoryStore = create<StoryState>()(
438
532
  const previousPassage = get().currentPassage;
439
533
  emit('beforenavigate', passageName);
440
534
 
441
- // Compute variable delta before Immer set()
442
- const patchEntry = computeVarPatches(lastNavigationVars, currVars);
535
+ // Compute variable delta before Immer set(). Read the variables after
536
+ // beforenavigate so changes made by its handlers are recorded.
537
+ const patchEntry = computeVarPatches(lastNavigationVars, get().variables);
443
538
 
444
539
  set((state) => {
445
540
  state.temporary = {};
446
541
  state.currentPassage = passageName;
542
+ state.navigationId++;
447
543
 
448
544
  // Truncate forward history if we navigated back then chose a new path
449
545
  state.history = state.history.slice(0, state.historyIndex + 1);
450
546
  patchEntries.length = state.historyIndex;
547
+ if (serializedHistory.length > state.historyIndex + 1) {
548
+ serializedHistory.length = state.historyIndex + 1;
549
+ }
451
550
 
452
551
  // Push new transition and moment
453
552
  patchEntries.push(patchEntry);
@@ -476,32 +575,59 @@ export const useStoryStore = create<StoryState>()(
476
575
  (state.renderCounts[passageName] ?? 0) + 1;
477
576
  });
478
577
 
578
+ // Watchers react to the completed transition (visit counts, cleared
579
+ // temporaries). Like beforenavigate changes, their run actions belong
580
+ // to the entered moment; navigations they request run afterwards.
581
+ const enteredVars = get().variables;
582
+ navigationTriggerPhase = true;
583
+ try {
584
+ checkTriggersOnNavigation();
585
+ } finally {
586
+ navigationTriggerPhase = false;
587
+ }
588
+ const deferred = deferredNavigations;
589
+ deferredNavigations = [];
590
+ if (get().variables !== enteredVars) {
591
+ rerecordNewestMoment(get().variables);
592
+ }
593
+ const prng = snapshotPRNG();
594
+ const recorded = get().history[get().historyIndex]!.prng;
595
+ if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) {
596
+ set((state) => {
597
+ state.history[state.historyIndex]!.prng = prng;
598
+ });
599
+ }
600
+
479
601
  lastNavigationVars = get().variables;
480
602
  persistSession(get);
481
603
 
482
604
  emit('afternavigate', passageName, previousPassage);
605
+
606
+ for (const next of deferred) get().navigate(next);
483
607
  },
484
608
 
485
609
  goBack: () => {
486
- const { historyIndex, variables } = get();
610
+ const { historyIndex } = get();
487
611
  if (historyIndex <= 0) return;
488
612
 
489
613
  const previousPassage = get().currentPassage;
490
614
  const targetPassage = get().history[historyIndex - 1]!.passage;
491
615
  emit('beforenavigate', targetPassage);
492
616
 
493
- // Apply inverse transition: moment historyIndex → historyIndex−1
494
- const restoredVars = deepClone(
495
- applyPatches(variables, patchEntries[historyIndex - 1]!.inverse),
496
- );
617
+ // Restore the recorded snapshot; live variables may hold edits made
618
+ // since the current moment was recorded.
619
+ const restoredVars = deepClone(reconstructVarsAt(historyIndex - 1));
497
620
 
498
621
  set((state) => {
499
622
  state.historyIndex--;
500
623
  state.currentPassage = state.history[state.historyIndex]!.passage;
624
+ state.navigationId++;
501
625
  state.variables = restoredVars;
502
626
  state.temporary = {};
503
627
  });
504
628
 
629
+ // Restored state is not a change watchers react to
630
+ reinitTriggerState();
505
631
  lastNavigationVars = get().variables;
506
632
  restorePRNGFromMoment(get().history[get().historyIndex]);
507
633
  persistSession(get);
@@ -510,25 +636,27 @@ export const useStoryStore = create<StoryState>()(
510
636
  },
511
637
 
512
638
  goForward: () => {
513
- const { historyIndex, history: hist, variables } = get();
639
+ const { historyIndex, history: hist } = get();
514
640
  if (historyIndex >= hist.length - 1) return;
515
641
 
516
642
  const previousPassage = get().currentPassage;
517
643
  const targetPassage = hist[historyIndex + 1]!.passage;
518
644
  emit('beforenavigate', targetPassage);
519
645
 
520
- // Apply forward transition: moment historyIndex → historyIndex+1
521
- const restoredVars = deepClone(
522
- applyPatches(variables, patchEntries[historyIndex]!.forward),
523
- );
646
+ // Restore the recorded snapshot; live variables may hold edits made
647
+ // since the current moment was recorded.
648
+ const restoredVars = deepClone(reconstructVarsAt(historyIndex + 1));
524
649
 
525
650
  set((state) => {
526
651
  state.historyIndex++;
527
652
  state.currentPassage = state.history[state.historyIndex]!.passage;
653
+ state.navigationId++;
528
654
  state.variables = restoredVars;
529
655
  state.temporary = {};
530
656
  });
531
657
 
658
+ // Restored state is not a change watchers react to
659
+ reinitTriggerState();
532
660
  lastNavigationVars = get().variables;
533
661
  restorePRNGFromMoment(get().history[get().historyIndex]);
534
662
  persistSession(get);
@@ -572,6 +700,12 @@ export const useStoryStore = create<StoryState>()(
572
700
  });
573
701
  },
574
702
 
703
+ updateVariables: (recipe: (draft: VariableNamespaces) => void) => {
704
+ set((state) => {
705
+ recipe(state);
706
+ });
707
+ },
708
+
575
709
  trackRender: (passageName: string) => {
576
710
  set((state) => {
577
711
  state.renderCounts[passageName] =
@@ -606,6 +740,7 @@ export const useStoryStore = create<StoryState>()(
606
740
 
607
741
  set((state) => {
608
742
  state.currentPassage = startPassage.name;
743
+ state.navigationId++;
609
744
  state.variables = initialVars;
610
745
  state.transient = deepClone(transientDefaults);
611
746
  state.temporary = {};
@@ -648,30 +783,29 @@ export const useStoryStore = create<StoryState>()(
648
783
  const { storyData, playthroughId } = get();
649
784
  if (!storyData) return;
650
785
 
651
- emit('beforesave', slot, custom);
652
-
653
- const payload = get().getSavePayload();
654
-
655
- set((state) => {
656
- state.saveError = null;
657
- });
658
- quickSave(storyData.ifid, playthroughId, payload, slot, custom)
659
- .then(() => {
786
+ saveWithHooks(
787
+ slot,
788
+ custom,
789
+ () => get().getSavePayload(),
790
+ async (payload) => {
791
+ set((state) => {
792
+ state.saveError = null;
793
+ });
794
+ await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
660
795
  set((state) => {
661
796
  state.knownSaves = {
662
797
  ...state.knownSaves,
663
798
  [slot ?? '']: true,
664
799
  };
665
800
  });
666
- emit('aftersave', slot);
667
- })
668
- .catch((err) => {
669
- console.error('spindle: failed to save', err);
670
- set((state) => {
671
- state.saveError =
672
- err instanceof Error ? err.message : 'Failed to save';
673
- });
801
+ },
802
+ ).catch((err) => {
803
+ console.error('spindle: failed to save', err);
804
+ set((state) => {
805
+ state.saveError =
806
+ err instanceof Error ? err.message : 'Failed to save';
674
807
  });
808
+ });
675
809
  },
676
810
 
677
811
  load: (slot?: string) => {
@@ -684,7 +818,7 @@ export const useStoryStore = create<StoryState>()(
684
818
  loadQuickSave(storyData.ifid, slot)
685
819
  .then((payload) => {
686
820
  if (!payload) return;
687
- get().loadFromPayload(payload);
821
+ get().loadFromPayload(payload, slot);
688
822
  })
689
823
  .catch((err) => {
690
824
  console.error('spindle: failed to load save', err);
@@ -832,41 +966,49 @@ export const useStoryStore = create<StoryState>()(
832
966
  };
833
967
  },
834
968
 
835
- loadFromPayload: (payload: SavePayload) => {
969
+ loadFromPayload: (payload: SavePayload, slot?: string) => {
836
970
  if (payload.history.length === 0) {
837
971
  console.warn('loadFromPayload: rejecting payload with empty history');
838
972
  return;
839
973
  }
840
974
 
841
- emit('beforeload', undefined);
975
+ emit('beforeload', slot);
842
976
 
977
+ // Restore the state on entering the saved passage, not the payload's
978
+ // live variables: the passage remounts and runs its {set}/{do} again,
979
+ // so restoring their results as well would apply them twice. Changes
980
+ // made after entering it (input, clicks) are not restored, as with
981
+ // back/forward.
982
+ const entry = loadedEntryMoment(payload);
983
+
984
+ // The payload is already live (deserialized at the storage boundary by
985
+ // loadSave/loadSession); deserializing again would corrupt built-ins.
843
986
  // Convert full snapshots to patch entries
844
- const base = deserialize(payload.history[0]?.variables ?? {}) as Record<
845
- string,
846
- unknown
847
- >;
987
+ const base = deepClone(payload.history[0]?.variables ?? {});
848
988
  const newPatchEntries: PatchEntry[] = [];
849
989
 
850
990
  let prevVars: Record<string, unknown> = base;
851
991
  for (let i = 1; i < payload.history.length; i++) {
852
- const currVars = deserialize(payload.history[i]!.variables) as Record<
853
- string,
854
- unknown
855
- >;
992
+ const currVars = deepClone(payload.history[i]!.variables);
856
993
  newPatchEntries.push(computeVarPatches(prevVars, currVars));
857
994
  prevVars = currVars;
858
995
  }
859
996
 
860
997
  variableBase = deepClone(base);
861
998
  patchEntries = newPatchEntries;
862
- serializedHistory = [];
999
+ // Seed the session cache from the payload's own snapshots, so
1000
+ // persistSession does not rebuild them from the live variables.
1001
+ serializedHistory = payload.history.map((m) => ({
1002
+ passage: m.passage,
1003
+ variables: serialize(m.variables),
1004
+ timestamp: m.timestamp,
1005
+ prng: m.prng,
1006
+ }));
863
1007
 
864
1008
  set((state) => {
865
1009
  state.currentPassage = payload.passage;
866
- state.variables = deserialize(payload.variables) as Record<
867
- string,
868
- unknown
869
- >;
1010
+ state.navigationId++;
1011
+ state.variables = deepClone(entry?.variables ?? payload.variables);
870
1012
  state.history = payload.history.map((m) => ({
871
1013
  passage: m.passage,
872
1014
  timestamp: m.timestamp,
@@ -882,15 +1024,26 @@ export const useStoryStore = create<StoryState>()(
882
1024
  state.transient = deepClone(get().transientDefaults);
883
1025
  });
884
1026
 
885
- lastNavigationVars = get().variables;
1027
+ // Loaded state is not a change watchers react to
1028
+ reinitTriggerState();
886
1029
 
887
- if (payload.prng) {
888
- restorePRNG(payload.prng.seed, payload.prng.pull);
1030
+ // The next navigate() diffs from the snapshot recorded for the current
1031
+ // moment (the live variables, unless the load fell back to them)
1032
+ lastNavigationVars = reconstructVarsAt(get().historyIndex);
1033
+
1034
+ // Replay the passage's random rolls from its entry PRNG state; saves
1035
+ // whose moments predate PRNG snapshots use the payload's.
1036
+ const prng = entry?.prng !== undefined ? entry.prng : payload.prng;
1037
+ if (prng) {
1038
+ restorePRNG(prng.seed, prng.pull);
889
1039
  } else {
890
1040
  resetPRNG();
891
1041
  }
892
1042
 
893
- emit('afterload', undefined);
1043
+ // Write the loaded game to the session so a refresh restores it
1044
+ persistSession(get);
1045
+
1046
+ emit('afterload', slot);
894
1047
  },
895
1048
 
896
1049
  getHistoryVariables: (index: number): Record<string, unknown> => {
package/src/story-api.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore, trackRuntimeUnsub } from './store';
2
+ import type { VariableNamespaces } from './store';
2
3
  import {
3
4
  on as emitterOn,
4
5
  emit,
@@ -29,6 +30,7 @@ import type { MacroDefinition } from './define-macro';
29
30
  import { getMacroRegistry as _getMacroRegistry } from './registry';
30
31
  import type { MacroMetadata } from './registry';
31
32
  import { getActions, getAction, type StoryAction } from './action-registry';
33
+ import { getRenderedPassage } from './passage-render-state';
32
34
  import {
33
35
  initPRNG,
34
36
  isPRNGEnabled,
@@ -284,23 +286,15 @@ function warnIfUndeclared(isTransient: boolean, key: string): void {
284
286
  );
285
287
  }
286
288
 
287
- /** Set a single variable, resolving dot-paths if present. */
288
- function setOne(name: string, value: unknown): void {
289
+ /** Set a single variable on an Immer draft, resolving dot-paths if present. */
290
+ function setOne(draft: VariableNamespaces, name: string, value: unknown): void {
289
291
  const { isTransient, key } = parseName(name);
290
- const namespace = isTransient ? 'transient' : 'variables';
291
- warnIfUndeclared(isTransient, key);
292
+ const namespace = isTransient ? draft.transient : draft.variables;
292
293
 
293
294
  if (key.includes('.')) {
294
- useStoryStore.setState((state) => {
295
- setByPath(state[namespace] as Record<string, unknown>, key, value);
296
- });
295
+ setByPath(namespace, key, value);
297
296
  } else {
298
- const state = useStoryStore.getState();
299
- if (isTransient) {
300
- state.setTransient(key, value);
301
- } else {
302
- state.setVariable(key, value);
303
- }
297
+ namespace[key] = value;
304
298
  }
305
299
  }
306
300
 
@@ -315,13 +309,22 @@ function createStoryAPI(): StoryAPI {
315
309
  },
316
310
 
317
311
  set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
318
- if (typeof nameOrVars === 'string') {
319
- setOne(nameOrVars, value);
320
- } else {
321
- for (const [k, v] of Object.entries(nameOrVars)) {
322
- setOne(k, v);
323
- }
312
+ const names =
313
+ typeof nameOrVars === 'string' ? [nameOrVars] : Object.keys(nameOrVars);
314
+ for (const name of names) {
315
+ const { isTransient, key } = parseName(name);
316
+ warnIfUndeclared(isTransient, key);
324
317
  }
318
+ // One store update for all keys, so watchers see them together
319
+ useStoryStore.getState().updateVariables((draft) => {
320
+ if (typeof nameOrVars === 'string') {
321
+ setOne(draft, nameOrVars, value);
322
+ } else {
323
+ for (const [k, v] of Object.entries(nameOrVars)) {
324
+ setOne(draft, k, v);
325
+ }
326
+ }
327
+ });
325
328
  },
326
329
 
327
330
  goto(passageName: string): void {
@@ -539,14 +542,26 @@ function createStoryAPI(): StoryAPI {
539
542
  return unsub;
540
543
  },
541
544
 
542
- waitForActions(): Promise<StoryAction[]> {
543
- return new Promise((resolve) => {
544
- requestAnimationFrame(() => {
545
- requestAnimationFrame(() => {
546
- resolve(getActions());
545
+ async waitForActions(): Promise<StoryAction[]> {
546
+ await new Promise<void>((resolve) => {
547
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
548
+ });
549
+ // A navigation can still be rendering: the passage display mounts the
550
+ // new passage from an effect, after a fade-through's outgoing phase.
551
+ // Its actions exist only once it is mounted, so wait for that.
552
+ const { currentPassage, renderDeferred } = useStoryStore.getState();
553
+ const rendered = getRenderedPassage();
554
+ if (rendered !== null && rendered !== currentPassage && !renderDeferred) {
555
+ await new Promise<void>((resolve) => {
556
+ const off = emitterOn('passagerender', (name) => {
557
+ if (name === useStoryStore.getState().currentPassage) {
558
+ off();
559
+ resolve();
560
+ }
547
561
  });
548
562
  });
549
- });
563
+ }
564
+ return getActions();
550
565
  },
551
566
 
552
567
  watch(