@rohal12/spindle 0.59.15 → 0.59.17

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.
@@ -10125,7 +10125,30 @@ function errorMessage(err, fallback) {
10125
10125
  return fallback ?? String(err);
10126
10126
  }
10127
10127
  //#endregion
10128
+ //#region src/utils/listeners.ts
10129
+ /**
10130
+ * A set of listeners to call when something happens: `subscribe` adds one and
10131
+ * returns the function that removes it, `notify` calls each one. A listener
10132
+ * removed or added while they are called is called or not as the set was
10133
+ * when `notify` started.
10134
+ */
10135
+ function createListeners() {
10136
+ const listeners = /* @__PURE__ */ new Set();
10137
+ return {
10138
+ subscribe(listener) {
10139
+ listeners.add(listener);
10140
+ return () => {
10141
+ listeners.delete(listener);
10142
+ };
10143
+ },
10144
+ notify() {
10145
+ for (const listener of [...listeners]) listener();
10146
+ }
10147
+ };
10148
+ }
10149
+ //#endregion
10128
10150
  //#region src/runtime-errors.ts
10151
+ var listeners = createListeners();
10129
10152
  /**
10130
10153
  * The error for `name`, a passage name that names no passage, used in the
10131
10154
  * passage `from`.
@@ -10133,6 +10156,7 @@ function errorMessage(err, fallback) {
10133
10156
  function noPassageError(name, from) {
10134
10157
  return /* @__PURE__ */ new Error(`No passage named ${JSON.stringify(name)} (in passage ${JSON.stringify(from)})`);
10135
10158
  }
10159
+ listeners.subscribe;
10136
10160
  //#endregion
10137
10161
  //#region src/components/macros/arg-utils.ts
10138
10162
  /**
@@ -11250,16 +11274,25 @@ var VALID_VAR_TYPES = new Set([
11250
11274
  "string",
11251
11275
  "boolean"
11252
11276
  ]);
11253
- function inferSchema(value) {
11277
+ /**
11278
+ * The schema of a default value. An object met again (a backreference, a
11279
+ * shared subtree) has the schema made for it the first time, so a cyclic
11280
+ * default gives a schema that refers to itself instead of recursing forever.
11281
+ */
11282
+ function inferSchema(value, seen = /* @__PURE__ */ new Map()) {
11254
11283
  if (Array.isArray(value)) return { type: "array" };
11255
11284
  if (value === null) return { type: "null" };
11256
11285
  if (typeof value === "object") {
11286
+ const known = seen.get(value);
11287
+ if (known) return known;
11257
11288
  const fields = /* @__PURE__ */ new Map();
11258
- for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
11259
- return {
11289
+ const schema = {
11260
11290
  type: "object",
11261
11291
  fields
11262
11292
  };
11293
+ seen.set(value, schema);
11294
+ for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val, seen));
11295
+ return schema;
11263
11296
  }
11264
11297
  const jsType = typeof value;
11265
11298
  if (!VALID_VAR_TYPES.has(jsType)) throw new Error(`Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.15",
3
+ "version": "0.59.17",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -3,7 +3,11 @@ import { LocalsValuesContext } from '../../markup/render';
3
3
  import { defineMacro } from '../../define-macro';
4
4
  import { MacroError } from './MacroError';
5
5
  import { stableKey } from '../../utils/stable-key';
6
- import { controlEditVersion, editedItem } from '../../utils/control-edits';
6
+ import {
7
+ ItemEditContext,
8
+ controlEditVersion,
9
+ editedItems,
10
+ } from '../../utils/control-edits';
7
11
  import type { ASTNode } from '../../markup/ast';
8
12
  import { checkVariableName } from '../../utils/namespace';
9
13
  import { readState } from '../../execute-mutation';
@@ -129,18 +133,17 @@ defineMacro({
129
133
 
130
134
  const { itemVar, indexVar } = loop;
131
135
  // An iteration is remounted when its item's contents change, so mount-only
132
- // macros run again for the new item (#45) -- unless the change is a
133
- // reader editing the item through a control: remounting would drop the
134
- // control's focus after the first keystroke.
135
- const since = keys.edits;
136
- const { variables } = readState();
136
+ // macros run again for the new item (#45) -- unless the change is an edit
137
+ // of the item (see control-edits): a reader typing into a control, which
138
+ // a remount would take the focus from, or code the iterations ran, which
139
+ // a remount would run again (#400).
140
+ const edited = editedItems(keys.edits, readState());
137
141
  keys.edits = controlEditVersion();
138
142
  const generations = list.map((item, i) => {
139
143
  const contents = stableKey(item);
140
144
  if (
141
145
  keys.generations[i] === undefined ||
142
- (contents !== keys.contents[i] &&
143
- !editedItem(since, variables, list, item, i))
146
+ (contents !== keys.contents[i] && !edited(list, item, i))
144
147
  ) {
145
148
  keys.generations[i] = keys.next++;
146
149
  }
@@ -160,7 +163,11 @@ defineMacro({
160
163
  />
161
164
  ));
162
165
 
163
- return ctx.wrap(content);
166
+ return ctx.wrap(
167
+ <ItemEditContext.Provider value={true}>
168
+ {content}
169
+ </ItemEditContext.Provider>,
170
+ );
164
171
  },
165
172
  text({ rawArgs, children = [] }, ctx) {
166
173
  const { itemVar, indexVar, listExpr } = loopArgs(rawArgs, ctx.args);
@@ -9,6 +9,7 @@ import { useStoryStore, type StoryState } from '../../store';
9
9
  import {
10
10
  useStoryFields,
11
11
  FrozenStateContext,
12
+ OutgoingContext,
12
13
  } from '../../hooks/use-story-fields';
13
14
  import { Passage, renderPassageContent } from '../Passage';
14
15
  import { defineMacro } from '../../define-macro';
@@ -140,6 +141,11 @@ defineMacro({
140
141
  (state: StoryState) => state.navigationId !== displayedId,
141
142
  [displayedId],
142
143
  );
144
+ // Read live: a timer asks it before the outgoing tree has unmounted
145
+ const hasLeft = useCallback(
146
+ () => isOutgoing(useStoryStore.getState()),
147
+ [isOutgoing],
148
+ );
143
149
  const lastElement = useRef<preact.VNode | null>(null);
144
150
 
145
151
  // Container ref for snapshot insertion
@@ -350,7 +356,9 @@ defineMacro({
350
356
  >
351
357
  {passageElement && (
352
358
  <FrozenStateContext.Provider value={isOutgoing}>
353
- {passageElement}
359
+ <OutgoingContext.Provider value={hasLeft}>
360
+ {passageElement}
361
+ </OutgoingContext.Provider>
354
362
  </FrozenStateContext.Provider>
355
363
  )}
356
364
  </div>
@@ -1,6 +1,7 @@
1
1
  import { createContext } from 'preact';
2
2
  import { defineMacro } from '../../define-macro';
3
3
  import { DELAY_PARAMETER } from './macro-args';
4
+ import { OutgoingContext } from '../../hooks/use-story-fields';
4
5
 
5
6
  export const RepeatContext = createContext<{ stop: () => void }>({
6
7
  stop: () => {},
@@ -12,17 +13,21 @@ defineMacro({
12
13
  interpolate: true,
13
14
  parameters: [DELAY_PARAMETER],
14
15
  render({ children = [] }, ctx) {
15
- const { useState, useEffect, useCallback } = ctx.hooks;
16
+ const { useState, useEffect, useCallback, useContext } = ctx.hooks;
16
17
 
17
18
  const delay = ctx.args.delay ?? 0;
18
19
  const [count, setCount] = useState(0);
19
20
  const [stopped, setStopped] = useState(false);
20
21
 
22
+ const hasLeft = useContext(OutgoingContext);
23
+
21
24
  const stop = useCallback(() => setStopped(true), []);
22
25
 
23
26
  useEffect(() => {
24
27
  if (stopped) return;
25
28
  const interval = setInterval(() => {
29
+ // The passage was left: its body must not run again (#410)
30
+ if (hasLeft?.()) return;
26
31
  setCount((c) => c + 1);
27
32
  }, delay);
28
33
  return () => clearInterval(interval);
@@ -1,6 +1,7 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { DELAY_PARAMETER, parseMacroArgs } from './macro-args';
3
3
  import { wrapContent } from './display';
4
+ import { OutgoingContext } from '../../hooks/use-story-fields';
4
5
 
5
6
  /** The parameters of {timed} and of each {next}: the delay before it shows. */
6
7
  const TIMED_PARAMETERS = [DELAY_PARAMETER] as const;
@@ -11,7 +12,8 @@ defineMacro({
11
12
  interpolate: true,
12
13
  parameters: TIMED_PARAMETERS,
13
14
  render({ branches = [] }, ctx) {
14
- const { useState, useEffect, useMemo } = ctx.hooks;
15
+ const { useState, useEffect, useMemo, useContext } = ctx.hooks;
16
+ const hasLeft = useContext(OutgoingContext);
15
17
 
16
18
  const sections = useMemo(() => {
17
19
  return branches.map((branch) => ({
@@ -31,6 +33,8 @@ defineMacro({
31
33
  const delay = sections[nextIndex]!.delay;
32
34
 
33
35
  const timer = setTimeout(() => {
36
+ // The passage was left: its body must not run (#410)
37
+ if (hasLeft?.()) return;
34
38
  setVisibleIndex(nextIndex);
35
39
  }, delay);
36
40
 
@@ -1,5 +1,12 @@
1
1
  import { defineMacro } from '../../define-macro';
2
- import { addMacroTrigger, removeTrigger } from '../../triggers';
2
+ import { FrozenStateContext } from '../../hooks/use-story-fields';
3
+ import { useStoryStore } from '../../store';
4
+ import {
5
+ addMacroTrigger,
6
+ removeTrigger,
7
+ subscribeTriggerResets,
8
+ triggerResets,
9
+ } from '../../triggers';
3
10
 
4
11
  defineMacro({
5
12
  name: 'watch',
@@ -34,9 +41,22 @@ defineMacro({
34
41
  // Register during render (like {set}) — triggers survive navigation
35
42
  // and are cleaned up via resetTriggers() on restart or {unwatch}.
36
43
  // Remounts (revisits, re-rendered branches) keep the registered watcher.
37
- const registered = hooks.useRef(false);
38
- if (!registered.current) {
39
- registered.current = true;
44
+ // A {watch} still mounted when a restart forgets the watchers (in the
45
+ // story interface) registers it again for the new game (#402); one in a
46
+ // passage on its way out or a finished click body does not.
47
+ const frozen = hooks.useContext(FrozenStateContext);
48
+ const [, rerender] = hooks.useState(0);
49
+ hooks.useEffect(
50
+ () => subscribeTriggerResets(() => rerender((n) => n + 1)),
51
+ [],
52
+ );
53
+ const registered = hooks.useRef<number | null>(null);
54
+ if (
55
+ registered.current === null ||
56
+ (registered.current !== triggerResets() &&
57
+ !frozen?.(useStoryStore.getState()))
58
+ ) {
59
+ registered.current = triggerResets();
40
60
  addMacroTrigger(condition, options);
41
61
  }
42
62
 
@@ -10,11 +10,16 @@ import {
10
10
  } from '../../markup/render';
11
11
  import type { ASTNode } from '../../markup/ast';
12
12
  import { useRenderOptions } from '../../hooks/use-render-options';
13
- import { FrozenStateContext } from '../../hooks/use-story-fields';
13
+ import {
14
+ FrozenStateContext,
15
+ OutgoingContext,
16
+ } from '../../hooks/use-story-fields';
14
17
  import { liveLocalsView } from '../../utils/live-locals';
15
18
  import { runWithCommittedMutations } from '../../execute-mutation';
19
+ import { subscribeStateReplacement } from '../../store';
16
20
  import { RepeatContext } from './Repeat';
17
21
  import { DialogCloseContext } from '../PassageDialog';
22
+ import { ItemEditContext } from '../../utils/control-edits';
18
23
 
19
24
  const frozenAlways = () => true;
20
25
 
@@ -26,13 +31,16 @@ const frozenAlways = () => true;
26
31
  * once it has rendered, so it runs once per click. It stays mounted
27
32
  * until the owning macro unmounts (its passage changes, say), so work it
28
33
  * starts in an effect, such as the timer of a {timed} or {repeat}, can
29
- * finish; the passage leaving cancels it.
34
+ * finish; the passage leaving cancels it, and so does a restart or a load
35
+ * replacing the game, which a control of the story interface outlives
36
+ * (#401).
30
37
  *
31
38
  * A detached render starts with no contexts, so the hook captures the ones
32
39
  * the owning macro sees and provides them again: the locals scope (read
33
40
  * through a live view, so a local assigned earlier in the body is visible),
34
41
  * nobr/inline rendering, the enclosing block widget's {@children}, the
35
- * enclosing {repeat}'s {stop} and the enclosing dialog's close callback.
42
+ * enclosing {repeat}'s {stop} and the enclosing dialog's close callback and whether it runs in a {for} iteration
43
+ * (#411) and the owning passage's departure (#410).
36
44
  * The body then behaves as if it rendered in place.
37
45
  *
38
46
  * The body's macros read story state from the store. Run from mutation code
@@ -47,16 +55,26 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
47
55
  const widgetChildren = useContext(WidgetChildrenContext);
48
56
  const repeat = useContext(RepeatContext);
49
57
  const closeDialog = useContext(DialogCloseContext);
58
+ const inIteration = useContext(ItemEditContext);
59
+ const hasLeft = useContext(OutgoingContext);
50
60
 
51
61
  const mounted = useRef<HTMLElement[]>([]);
52
- useEffect(
53
- () => () => {
62
+ useEffect(() => {
63
+ const unsubscribe = subscribeStateReplacement(() => {
64
+ // After the code that replaced the game, which may be running in one
65
+ // of the bodies (`{do}Story.restart(){/do}`), has finished rendering
66
+ const replaced = mounted.current.splice(0);
67
+ queueMicrotask(() => {
68
+ for (const container of replaced) render(null, container);
69
+ });
70
+ });
71
+ return () => {
72
+ unsubscribe();
54
73
  for (const container of mounted.current.splice(0)) {
55
74
  render(null, container);
56
75
  }
57
- },
58
- [],
59
- );
76
+ };
77
+ }, []);
60
78
 
61
79
  const run = (children: ASTNode[]) => {
62
80
  const locals = liveLocalsView(updater.getValues);
@@ -70,7 +88,11 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
70
88
  <WidgetChildrenContext.Provider value={widgetChildren}>
71
89
  <RepeatContext.Provider value={repeat}>
72
90
  <DialogCloseContext.Provider value={closeDialog}>
73
- {renderNodes(children, { nobr, inline, locals })}
91
+ <ItemEditContext.Provider value={inIteration}>
92
+ <OutgoingContext.Provider value={hasLeft}>
93
+ {renderNodes(children, { nobr, inline, locals })}
94
+ </OutgoingContext.Provider>
95
+ </ItemEditContext.Provider>
74
96
  </DialogCloseContext.Provider>
75
97
  </RepeatContext.Provider>
76
98
  </WidgetChildrenContext.Provider>
@@ -10,7 +10,11 @@ import {
10
10
  useMemo,
11
11
  } from 'preact/hooks';
12
12
  import { useInterpolate } from './hooks/use-interpolate';
13
- import { noteControlEdit } from './utils/control-edits';
13
+ import {
14
+ ItemEditContext,
15
+ noteControlEdit,
16
+ runAsItemEdit,
17
+ } from './utils/control-edits';
14
18
  import { useMergedLocals } from './hooks/use-merged-locals';
15
19
  import { useRenderOptions } from './hooks/use-render-options';
16
20
  import {
@@ -155,6 +159,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
155
159
 
156
160
  // Always-on: cssClass + mutation
157
161
  const { update, getValues } = useContext(LocalsUpdateContext);
162
+ const inIteration = useContext(ItemEditContext);
158
163
  const renderOptions = useRenderOptions();
159
164
  const renderNodes = (
160
165
  nodes: ASTNode[],
@@ -190,7 +195,11 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
190
195
  id,
191
196
  resolve,
192
197
  cls: macroClass(config.name, className),
193
- mutate: (code: string) => executeMutation(code, getValues(), update),
198
+ // Code run inside a {for} iteration edits the loop's items (#400)
199
+ mutate: inIteration
200
+ ? (code: string) =>
201
+ runAsItemEdit(() => executeMutation(code, getValues(), update))
202
+ : (code: string) => executeMutation(code, getValues(), update),
194
203
  update,
195
204
  getValues,
196
205
  wrap: (content: ComponentChildren): VNode<any> =>
@@ -207,11 +216,19 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
207
216
  if (config.storeVar) {
208
217
  const firstToken = readBoundVariable(props.rawArgs).replace(/["']/g, '');
209
218
 
210
- if (firstToken.startsWith('%')) {
219
+ // Only story variables ($name) can be bound: another sigil would be
220
+ // read as a story key spelled with it (#414)
221
+ const unbindable: Record<string, string> = {
222
+ '%': 'transient variables',
223
+ _: 'temporary variables',
224
+ '@': 'locals',
225
+ };
226
+ const kind = unbindable[firstToken.charAt(0)];
227
+ if (kind) {
211
228
  return h(
212
229
  'span',
213
230
  { class: 'error' },
214
- `{${config.name}}: transient variables (%${firstToken.slice(1)}) cannot be bound to input macros`,
231
+ `{${config.name}}: ${kind} (${firstToken}) cannot be bound to input macros`,
215
232
  );
216
233
  }
217
234
 
@@ -9,6 +9,8 @@ const FOCUSABLE = [
9
9
  'select:not([disabled])',
10
10
  'textarea:not([disabled])',
11
11
  'iframe',
12
+ 'audio[controls]',
13
+ 'video[controls]',
12
14
  'summary',
13
15
  '[contenteditable]',
14
16
  '[tabindex]',
@@ -15,6 +15,14 @@ export const FrozenStateContext = createContext<
15
15
  ((state: StoryState) => boolean) | null
16
16
  >(null);
17
17
 
18
+ /**
19
+ * Tells a timer ({repeat}, {timed}) whether the passage it belongs to has
20
+ * been left: it must not run a new body then, for that would write into the
21
+ * destination's state until the outgoing tree unmounts (#410). Null outside
22
+ * a passage.
23
+ */
24
+ export const OutgoingContext = createContext<(() => boolean) | null>(null);
25
+
18
26
  /**
19
27
  * Story state fields `keys`, re-rendering when any of them changes (as one
20
28
  * useStoryStore() selector per field would). The result keeps its identity
@@ -2,6 +2,7 @@
2
2
  // errors that would otherwise only reach the browser console.
3
3
 
4
4
  import { errorMessage } from './utils/error-message';
5
+ import { createListeners } from './utils/listeners';
5
6
 
6
7
  /** A runtime error shown on the page until the player dismisses it. */
7
8
  export interface RuntimeError {
@@ -17,11 +18,11 @@ export interface RuntimeError {
17
18
 
18
19
  let errors: readonly RuntimeError[] = [];
19
20
  let nextId = 1;
20
- const listeners = new Set<() => void>();
21
+ const listeners = createListeners();
21
22
 
22
23
  function update(next: readonly RuntimeError[]): void {
23
24
  errors = next;
24
- for (const listener of listeners) listener();
25
+ listeners.notify();
25
26
  }
26
27
 
27
28
  /**
@@ -67,9 +68,4 @@ export function getRuntimeErrors(): readonly RuntimeError[] {
67
68
  }
68
69
 
69
70
  /** Call `listener` whenever the shown errors change; returns unsubscribe. */
70
- export function subscribeRuntimeErrors(listener: () => void): () => void {
71
- listeners.add(listener);
72
- return () => {
73
- listeners.delete(listener);
74
- };
75
- }
71
+ export const subscribeRuntimeErrors = listeners.subscribe;
@@ -608,6 +608,7 @@ async function fillSlot(
608
608
  slots.includes(slot) ? slots : [...slots, slot],
609
609
  );
610
610
  }
611
+ announceSlots(ifid);
611
612
  }
612
613
 
613
614
  /** Clear a slot: its pointer, and its entry in the slot index. */
@@ -616,6 +617,7 @@ async function emptySlot(ifid: string, slot?: string): Promise<void> {
616
617
  if (isNamedSlot(slot)) {
617
618
  await updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
618
619
  }
620
+ announceSlots(ifid);
619
621
  }
620
622
 
621
623
  /** Clear every slot holding a save whose ID passes `test`. */
@@ -629,6 +631,65 @@ async function emptySlotsHolding(
629
631
  }
630
632
  }
631
633
 
634
+ // --- Other tabs ---
635
+
636
+ // The tabs of a story share its storage, but each keeps which slots hold a
637
+ // save (the store's knownSaves, behind hasSave() and QuickLoad). A tab that
638
+ // fills or empties slots tells the others, which look them up again (#404).
639
+
640
+ const SLOTS_CHANNEL = 'spindle.slots';
641
+ /** Every story's slots: what clearing all data announces. */
642
+ const ALL_STORIES = '*';
643
+ let slotsChannel: BroadcastChannel | null | undefined;
644
+
645
+ function getSlotsChannel(): BroadcastChannel | null {
646
+ if (slotsChannel === undefined) {
647
+ try {
648
+ slotsChannel = new BroadcastChannel(SLOTS_CHANNEL);
649
+ // Node (headless runs) would not exit while it is open
650
+ (slotsChannel as { unref?: () => void }).unref?.();
651
+ } catch {
652
+ // No BroadcastChannel: tabs catch up when shown (see watchSlotChanges)
653
+ slotsChannel = null;
654
+ }
655
+ }
656
+ return slotsChannel;
657
+ }
658
+
659
+ /** Tell the story's other tabs that its slots changed. */
660
+ function announceSlots(ifid: string): void {
661
+ try {
662
+ getSlotsChannel()?.postMessage(ifid);
663
+ } catch {
664
+ // A tab that cannot tell the others leaves them to catch up when shown
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Call `onChange` when another tab may have filled or emptied the story's
670
+ * slots: when it says so, and when this tab is shown again. Returns the
671
+ * function that stops it.
672
+ */
673
+ export function watchSlotChanges(
674
+ ifid: string,
675
+ onChange: () => void,
676
+ ): () => void {
677
+ const channel = getSlotsChannel();
678
+ const onMessage = (event: MessageEvent) => {
679
+ if (event.data === ifid || event.data === ALL_STORIES) onChange();
680
+ };
681
+ const onShown = () => {
682
+ if (document.visibilityState === 'visible') onChange();
683
+ };
684
+ const doc = typeof document === 'undefined' ? undefined : document;
685
+ channel?.addEventListener('message', onMessage);
686
+ doc?.addEventListener('visibilitychange', onShown);
687
+ return () => {
688
+ channel?.removeEventListener('message', onMessage);
689
+ doc?.removeEventListener('visibilitychange', onShown);
690
+ };
691
+ }
692
+
632
693
  /** The `custom` keys that say which slot a save lives in. */
633
694
  const slotCustom = (slot: string | undefined) => ({
634
695
  isAutosave: !isNamedSlot(slot),
@@ -976,6 +1037,7 @@ export function clearGameData(ifid: string): Promise<void> {
976
1037
  await backend.deletePlaythroughsByIfid(ifid);
977
1038
  await backend.deleteMetaByIfid(ifid);
978
1039
  clearSession(ifid);
1040
+ announceSlots(ifid);
979
1041
  });
980
1042
  }
981
1043
 
@@ -997,6 +1059,7 @@ async function clearAllDataNow(): Promise<void> {
997
1059
  } catch {
998
1060
  // no sessionStorage (or access denied): nothing to clear
999
1061
  }
1062
+ announceSlots(ALL_STORIES);
1000
1063
  }
1001
1064
 
1002
1065
  /**
package/src/store.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  loadSlotSave,
35
35
  adoptPlaythrough,
36
36
  populateKnownSaves,
37
+ watchSlotChanges,
37
38
  getSlotSaveInfo,
38
39
  listSlotSaves,
39
40
  deleteSlotSave,
@@ -65,6 +66,7 @@ import {
65
66
  type PRNGSnapshot,
66
67
  } from './prng';
67
68
  import { errorMessage } from './utils/error-message';
69
+ import { createListeners } from './utils/listeners';
68
70
  import {
69
71
  routeStoreUpdate,
70
72
  runWithCommittedMutations,
@@ -662,9 +664,25 @@ let slotLoadApplying: number | null = null;
662
664
 
663
665
  /** A replacement of the game state applied at its call. */
664
666
  function replaceStateNow(): void {
665
- latestStateApplied = ++stateReplacementsIssued;
667
+ applyReplacement(++stateReplacementsIssued);
666
668
  }
667
669
 
670
+ const replacementListeners = createListeners();
671
+
672
+ /** Record that replacement `n` is applied, and tell the listeners. */
673
+ function applyReplacement(n: number): void {
674
+ latestStateApplied = n;
675
+ replacementListeners.notify();
676
+ }
677
+
678
+ /**
679
+ * Call `listener` whenever a boot, restart or load replaces the game state:
680
+ * work the replaced game started that would write into the new one later (a
681
+ * click body's timer in the story interface, which stays mounted) is to
682
+ * stop (#401).
683
+ */
684
+ export const subscribeStateReplacement = replacementListeners.subscribe;
685
+
668
686
  // ---------------------------------------------------------------------------
669
687
  // Runtime handler cleanup (auto-unsub on restart)
670
688
  // ---------------------------------------------------------------------------
@@ -1122,6 +1140,18 @@ function recordKnownSave(
1122
1140
  });
1123
1141
  }
1124
1142
 
1143
+ /** Look up which slots hold a save again, as an operation queued now. */
1144
+ function refreshKnownSaves(set: StoreSet, ifid: string): Promise<void> {
1145
+ return populateKnownSaves(ifid).then((known) => {
1146
+ set((state) => {
1147
+ state.knownSaves = known;
1148
+ });
1149
+ });
1150
+ }
1151
+
1152
+ /** Stops following the slot changes of other tabs (see init). */
1153
+ let stopWatchingSlots: (() => void) | undefined;
1154
+
1125
1155
  /**
1126
1156
  * Queue the clearing of saved data, then restart now: the new playthrough
1127
1157
  * is stored after it, and operations issued from here on belong to the new
@@ -1242,6 +1272,15 @@ export const useStoryStore = create<StoryState>()(
1242
1272
  return '';
1243
1273
  }),
1244
1274
  );
1275
+ // Another tab of the story saving or deleting changes the slots too
1276
+ // (#404). Looked up in an operation queued then, so after those issued
1277
+ // before; operations issued later update the cache after it.
1278
+ stopWatchingSlots?.();
1279
+ stopWatchingSlots = watchSlotChanges(storyData.ifid, () => {
1280
+ refreshKnownSaves(set, storyData.ifid).catch(() => {
1281
+ // The cache keeps what it had; the next change tries again
1282
+ });
1283
+ });
1245
1284
  },
1246
1285
 
1247
1286
  navigate: (passageName: string) => {
@@ -1596,12 +1635,7 @@ export const useStoryStore = create<StoryState>()(
1596
1635
  }
1597
1636
 
1598
1637
  return reported(
1599
- deletion.then(async () => {
1600
- const known = await populateKnownSaves(storyData.ifid);
1601
- set((state) => {
1602
- state.knownSaves = known;
1603
- });
1604
- }),
1638
+ deletion.then(() => refreshKnownSaves(set, storyData.ifid)),
1605
1639
  'spindle: failed to delete playthrough',
1606
1640
  );
1607
1641
  },
@@ -1672,7 +1706,7 @@ export const useStoryStore = create<StoryState>()(
1672
1706
  // Before anything is replaced: an incompatible payload leaves the
1673
1707
  // running game as it is
1674
1708
  assertPassagesExist(get().storyData, payload);
1675
- latestStateApplied = replacement;
1709
+ applyReplacement(replacement);
1676
1710
 
1677
1711
  emit('beforeload', slot);
1678
1712
 
package/src/story-api.ts CHANGED
@@ -60,9 +60,10 @@ export type { StoryAction };
60
60
  export type { MacroMetadata };
61
61
 
62
62
  // Deferred-render promise lifecycle.
63
- // deferRender() creates a promise; ready() resolves it.
63
+ // deferRender() creates a promise; ready() resolves it. A deferRender() while
64
+ // one is pending keeps that promise: boot may already wait on it (#403).
64
65
  // index.tsx reads getReadyPromise() AFTER render(), which is after both
65
- // author JS and storyinit have run, so it always gets the final promise.
66
+ // author JS and storyinit have run.
66
67
  let readyResolve: (() => void) | null = null;
67
68
  let readyPromise: Promise<void> | null = null;
68
69
 
@@ -518,7 +519,7 @@ function createStoryAPI(): StoryAPI {
518
519
 
519
520
  deferRender(): void {
520
521
  useStoryStore.getState().deferRender();
521
- readyPromise = new Promise<void>((resolve) => {
522
+ readyPromise ??= new Promise<void>((resolve) => {
522
523
  readyResolve = resolve;
523
524
  });
524
525
  },