@rohal12/spindle 0.59.7 → 0.59.9

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.
@@ -10269,7 +10269,13 @@ function widgetDef({ name = "", parameters = "" }) {
10269
10269
  function parseWidgetDef(rawArgs) {
10270
10270
  return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
10271
10271
  }
10272
- var BLOCK_WIDGET = /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
10272
+ /**
10273
+ * A definition: its arguments (read like a {widget} macro's, see
10274
+ * parseWidgetDef) and its body. Macro names are not case-sensitive.
10275
+ */
10276
+ var WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
10277
+ /** `{@children}`, also with selectors such as `{.highlight @children}`. */
10278
+ var CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
10273
10279
  /**
10274
10280
  * The names of the block widgets (those whose body renders `{@children}`)
10275
10281
  * that StoryInit and the passages tagged `widget` define. They must be
@@ -10280,7 +10286,12 @@ function blockWidgetNames(passages) {
10280
10286
  const names = [];
10281
10287
  for (const passage of passages) {
10282
10288
  if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
10283
- for (const match of passage.content.matchAll(BLOCK_WIDGET)) if (/\{@children\}/.test(match[2])) names.push(match[1]);
10289
+ for (const match of passage.content.matchAll(WIDGET_DEFINITION)) {
10290
+ if (!CHILDREN_PLACEHOLDER.test(match[2])) continue;
10291
+ try {
10292
+ names.push(parseWidgetDef(match[1]).name);
10293
+ } catch {}
10294
+ }
10284
10295
  }
10285
10296
  return names;
10286
10297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.7",
3
+ "version": "0.59.9",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -51,7 +51,9 @@ export function clearRegistry(): void {
51
51
  // with its message, cause, errors and own enumerable keys (not its stack);
52
52
  // - `S`: a symbol from the global registry (Symbol.for);
53
53
  // - `K`: a plain object holding a one-character key that JSON escapes (see
54
- // GUARD_V8_KEYS).
54
+ // GUARD_V8_KEYS);
55
+ // - `R`: a RegExp whose lastIndex is not zero (the scanning cursor of a
56
+ // global or sticky pattern), with its source, flags and lastIndex.
55
57
  //
56
58
  // Everything else is refused when saving, with the path to the value:
57
59
  // functions, unique symbols, symbol keys, instances of unregistered classes,
@@ -61,6 +63,7 @@ const CLASS_PREFIX = 'c:';
61
63
  const ERROR_PREFIX = 'E:';
62
64
  const SYMBOL_TAG = 'S';
63
65
  const KEYS_TAG = 'K';
66
+ const REGEXP_TAG = 'R';
64
67
 
65
68
  /**
66
69
  * Work around Chromium issue 521080746 (V8 in Chrome/Chromium 14x-153+,
@@ -167,6 +170,9 @@ function reducers(): Record<string, (value: unknown) => unknown> {
167
170
  const key = Symbol.keyFor(v);
168
171
  return key !== undefined && [key];
169
172
  };
173
+ out[REGEXP_TAG] = (v) =>
174
+ v instanceof RegExp &&
175
+ v.lastIndex !== 0 && [v.source, v.flags, v.lastIndex];
170
176
  if (GUARD_V8_KEYS) {
171
177
  out[KEYS_TAG] = (v) => {
172
178
  if (!isObject(v) || Object.getPrototypeOf(v) !== Object.prototype) {
@@ -264,6 +270,21 @@ function reviverFor(key: string | symbol) {
264
270
  };
265
271
  }
266
272
  if (key === KEYS_TAG) return reviveKeyed;
273
+ if (key === REGEXP_TAG) {
274
+ return (data: unknown) => {
275
+ if (
276
+ !Array.isArray(data) ||
277
+ typeof data[0] !== 'string' ||
278
+ typeof data[1] !== 'string' ||
279
+ !Number.isSafeInteger(data[2])
280
+ ) {
281
+ throw new TypeError('spindle: Malformed regular expression');
282
+ }
283
+ const re = new RegExp(data[0], data[1]);
284
+ re.lastIndex = data[2] as number;
285
+ return re;
286
+ };
287
+ }
267
288
  if (key.startsWith(CLASS_PREFIX)) {
268
289
  return reviveClass(key.slice(CLASS_PREFIX.length));
269
290
  }
@@ -5,6 +5,7 @@ import { NobrContext } from '../markup/render';
5
5
  import { StoryInterface } from './StoryInterface';
6
6
  import { TriggerDialogHost } from './TriggerDialogHost';
7
7
  import { RuntimeErrors } from './RuntimeErrors';
8
+ import { quickLoad, quickSave } from '../quick-actions';
8
9
 
9
10
  export function App() {
10
11
  const { storyData, currentPassage, nobr } = useStoryFields(
@@ -15,14 +16,13 @@ export function App() {
15
16
 
16
17
  useEffect(() => {
17
18
  const onKeyDown = (e: KeyboardEvent) => {
18
- const { quickSaveKey, quickLoadKey, save, load } =
19
- useStoryStore.getState();
19
+ const { quickSaveKey, quickLoadKey } = useStoryStore.getState();
20
20
  if (quickSaveKey !== null && e.key === quickSaveKey) {
21
21
  e.preventDefault();
22
- save();
22
+ quickSave();
23
23
  } else if (quickLoadKey !== null && e.key === quickLoadKey) {
24
24
  e.preventDefault();
25
- load();
25
+ quickLoad();
26
26
  }
27
27
  };
28
28
  document.addEventListener('keydown', onKeyDown);
@@ -83,9 +83,9 @@ export function PassageDialog({
83
83
 
84
84
  const handleBackdrop = (e: MouseEvent) => {
85
85
  if (!dismissible) return;
86
- if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
87
- stableOnClose();
88
- }
86
+ // Only a click on this overlay itself: a nested dialog's backdrop click
87
+ // bubbles through the outer overlay, whose target is not its own.
88
+ if (e.target === e.currentTarget) stableOnClose();
89
89
  };
90
90
 
91
91
  const cls = panelClass ? `dialog-panel ${panelClass}` : 'dialog-panel';
@@ -105,6 +105,7 @@ export function PassageDialog({
105
105
  >
106
106
  {showCloseButton && (
107
107
  <button
108
+ type="button"
108
109
  class="dialog-close"
109
110
  aria-label="Close"
110
111
  onClick={stableOnClose}
@@ -26,6 +26,7 @@ defineMacro({
26
26
 
27
27
  return (
28
28
  <button
29
+ type="button"
29
30
  id={ctx.id}
30
31
  class={ctx.cls}
31
32
  onClick={handleClick}
@@ -32,6 +32,7 @@ defineMacro({
32
32
 
33
33
  return (
34
34
  <button
35
+ type="button"
35
36
  id={ctx.id}
36
37
  class={ctx.cls}
37
38
  onClick={handleClick}
@@ -22,6 +22,7 @@ defineMacro({
22
22
  return (
23
23
  <>
24
24
  <button
25
+ type="button"
25
26
  id={ctx.id}
26
27
  class={ctx.cls}
27
28
  onClick={() => setOpen(true)}
@@ -1,5 +1,5 @@
1
1
  import { defineMacro } from '../../define-macro';
2
- import { logMacroError } from './MacroError';
2
+ import { useRunOnce } from './MacroError';
3
3
 
4
4
  defineMacro({
5
5
  name: 'do',
@@ -8,16 +8,8 @@ defineMacro({
8
8
  // The parser keeps the body verbatim as a single text node
9
9
  const code = ctx.collectText(children);
10
10
 
11
- ctx.hooks.useLayoutEffect(() => {
12
- // Before the code runs: it may navigate away from this passage
13
- const location = ctx.sourceLocation();
14
- try {
15
- ctx.mutate(code);
16
- } catch (err) {
17
- logMacroError('do', err, location);
18
- }
19
- }, []);
20
-
21
- return null;
11
+ // During the first render, like {set}: the macros after it in the
12
+ // passage run later and see its writes (a layout effect ran after them).
13
+ return useRunOnce('do', '', () => ctx.mutate(code));
22
14
  },
23
15
  });
@@ -49,7 +49,7 @@ export function useRunOnce(macro: string, rawArgs: string, effect: () => void) {
49
49
  effect();
50
50
  } catch (error) {
51
51
  failure.current = { error };
52
- logMacroError(`${macro} ${rawArgs}`, error, location);
52
+ logMacroError(`${macro} ${rawArgs}`.trim(), error, location);
53
53
  }
54
54
  }
55
55
 
@@ -2,6 +2,7 @@ import { useState } from 'preact/hooks';
2
2
  import { useStoryStore } from '../../store';
3
3
  import { useStoryFields } from '../../hooks/use-story-fields';
4
4
  import { settings } from '../../settings';
5
+ import { quickLoad, quickSave } from '../../quick-actions';
5
6
  import { defineMacro } from '../../define-macro';
6
7
  import { PassageDialog } from '../PassageDialog';
7
8
  import type { ActionType } from '../../action-registry';
@@ -62,6 +63,7 @@ export function defineMenubarAction(config: MenubarActionConfig) {
62
63
  return (
63
64
  <>
64
65
  <button
66
+ type="button"
65
67
  id={ctx.id}
66
68
  class={cls}
67
69
  onClick={perform}
@@ -110,8 +112,11 @@ defineMenubarAction({
110
112
  label: 'QuickSave',
111
113
  actionType: 'save',
112
114
  setup: () => {
113
- const { save, quickSaveKey } = useStoryFields('save', 'quickSaveKey');
114
- return { perform: save, title: hotkeyTitle('Quick Save', quickSaveKey) };
115
+ const { quickSaveKey } = useStoryFields('quickSaveKey');
116
+ return {
117
+ perform: quickSave,
118
+ title: hotkeyTitle('Quick Save', quickSaveKey),
119
+ };
115
120
  },
116
121
  });
117
122
 
@@ -122,14 +127,13 @@ defineMenubarAction({
122
127
  confirm: 'Load saved game? Current progress will be lost.',
123
128
  setup: () => {
124
129
  // knownSaves: re-render when the saves change, as hasSave() reads them
125
- const { load, hasSave, quickLoadKey } = useStoryFields(
126
- 'load',
130
+ const { hasSave, quickLoadKey } = useStoryFields(
127
131
  'hasSave',
128
132
  'quickLoadKey',
129
133
  'knownSaves',
130
134
  );
131
135
  return {
132
- perform: () => load(),
136
+ perform: quickLoad,
133
137
  disabled: !hasSave(),
134
138
  title: hotkeyTitle('Quick Load', quickLoadKey),
135
139
  };
@@ -8,7 +8,7 @@ import { resolveTransition, type ResolvedTransition } from '../../transition';
8
8
  /**
9
9
  * Create a snapshot clone of a .passage element for outgoing transitions.
10
10
  * - Strips `id` attributes from the clone tree
11
- * - Disables interaction (pointer-events, user-select)
11
+ * - Disables interaction (pointer-events, user-select, inert, aria-hidden)
12
12
  * - Pauses/mutes any <audio>/<video>
13
13
  * - Adds `.passage-snapshot` class and `data-transition` attribute
14
14
  */
@@ -28,6 +28,10 @@ function createSnapshot(
28
28
  // Disable interaction
29
29
  clone.style.pointerEvents = 'none';
30
30
  clone.style.userSelect = 'none';
31
+ // Visual only: out of the tab order and the accessibility tree, and its
32
+ // controls (copied with their inline handlers) can not be activated
33
+ clone.setAttribute('inert', '');
34
+ clone.setAttribute('aria-hidden', 'true');
31
35
 
32
36
  // Pause/mute media
33
37
  const mediaEls = clone.querySelectorAll('audio, video');
@@ -80,6 +84,18 @@ function cleanupSnapshots(containerEl: Element | null): void {
80
84
  }
81
85
  }
82
86
 
87
+ /**
88
+ * `config` for players who prefer reduced motion: the CSS shortens the
89
+ * animations (see styles.css), so the waits that follow them shrink too,
90
+ * leaving no empty passage area before the next passage.
91
+ */
92
+ function reduceMotion(config: ResolvedTransition): ResolvedTransition {
93
+ if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
94
+ return config;
95
+ }
96
+ return { ...config, duration: Math.min(config.duration, 10), pause: 0 };
97
+ }
98
+
83
99
  defineMacro({
84
100
  name: 'passage',
85
101
  interpolate: true,
@@ -173,10 +189,8 @@ defineMacro({
173
189
  }
174
190
 
175
191
  // Resolve transition from tags → nextTransition → store default → built-in
176
- const config = resolveTransition(
177
- targetPassage.tags,
178
- next,
179
- transitionConfig,
192
+ const config = reduceMotion(
193
+ resolveTransition(targetPassage.tags, next, transitionConfig),
180
194
  );
181
195
 
182
196
  resolvedTypeRef.current = config.type;
@@ -21,6 +21,7 @@ import {
21
21
  saveWithHooks,
22
22
  type PlaythroughGroup,
23
23
  } from '../../saves/save-manager';
24
+ import { getBackendType } from '../../saves/storage';
24
25
  import { DialogCloseContext } from '../PassageDialog';
25
26
  import { defineMacro } from '../../define-macro';
26
27
  import { errorMessage } from '../../utils/error-message';
@@ -56,6 +57,7 @@ export function SaveManagerContent() {
56
57
  } | null>(null);
57
58
  const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
58
59
  const [renamingId, setRenamingId] = useState<string | null>(null);
60
+ const renameCancelled = useRef(false);
59
61
  const [renameValue, setRenameValue] = useState('');
60
62
  const renameInputRef = useRef<HTMLInputElement>(null);
61
63
  const fileInputRef = useRef<HTMLInputElement>(null);
@@ -70,10 +72,15 @@ export function SaveManagerContent() {
70
72
  'loadFromPayload',
71
73
  );
72
74
  const ifid = storyData?.ifid ?? '';
75
+ // Saves held in memory only (no browser storage is available) are lost
76
+ // when the page is reloaded or closed: told to the player, who can export
77
+ const [temporary, setTemporary] = useState(false);
73
78
 
74
79
  const refresh = useCallback(async () => {
75
80
  if (!ifid) return;
76
81
  const data = await getSavesGrouped(ifid);
82
+ // After the read: the storage in use is known by now
83
+ setTemporary(getBackendType() === 'memory');
77
84
  setGroups(data);
78
85
  setLoading(false);
79
86
  }, [ifid]);
@@ -130,7 +137,7 @@ export function SaveManagerContent() {
130
137
  // Queued now, in call order with other storage operations
131
138
  return createSave(ifid, playthrough, payload);
132
139
  });
133
- showStatus('Save created');
140
+ showStatus(temporary ? 'Save created (temporary)' : 'Save created');
134
141
  await refresh();
135
142
  } catch {
136
143
  showStatus('Failed to create save', 'error');
@@ -191,6 +198,7 @@ export function SaveManagerContent() {
191
198
  };
192
199
 
193
200
  const handleRenameStart = (save: SaveRecord) => {
201
+ renameCancelled.current = false;
194
202
  setRenamingId(save.meta.id);
195
203
  setRenameValue(save.meta.title);
196
204
  };
@@ -209,7 +217,18 @@ export function SaveManagerContent() {
209
217
 
210
218
  const handleRenameKeyDown = (e: KeyboardEvent) => {
211
219
  if (e.key === 'Enter') handleRenameConfirm();
212
- else if (e.key === 'Escape') setRenamingId(null);
220
+ else if (e.key === 'Escape') {
221
+ // Cancel the edit only: the dialog stays open, and the blur that
222
+ // follows removing the input must not confirm it
223
+ e.preventDefault();
224
+ e.stopPropagation();
225
+ renameCancelled.current = true;
226
+ setRenamingId(null);
227
+ }
228
+ };
229
+
230
+ const handleRenameBlur = () => {
231
+ if (!renameCancelled.current) handleRenameConfirm();
213
232
  };
214
233
 
215
234
  const handleExport = async (saveId: string) => {
@@ -263,12 +282,14 @@ export function SaveManagerContent() {
263
282
  <div class="saves-header-left">
264
283
  <div class="saves-mode-toggle">
265
284
  <button
285
+ type="button"
266
286
  class={mode === 'save' ? 'active' : ''}
267
287
  onClick={() => setMode('save')}
268
288
  >
269
289
  Save
270
290
  </button>
271
291
  <button
292
+ type="button"
272
293
  class={mode === 'load' ? 'active' : ''}
273
294
  onClick={() => setMode('load')}
274
295
  >
@@ -278,8 +299,19 @@ export function SaveManagerContent() {
278
299
  </div>
279
300
  </div>
280
301
 
302
+ {temporary && (
303
+ <div
304
+ class="saves-notice"
305
+ role="status"
306
+ >
307
+ Saves can not be kept in this browser: they are lost when the page is
308
+ reloaded or closed. Export a save to keep it.
309
+ </div>
310
+ )}
311
+
281
312
  <div class="saves-toolbar">
282
313
  <button
314
+ type="button"
283
315
  class="saves-toolbar-button"
284
316
  onClick={handleImport}
285
317
  >
@@ -350,7 +382,7 @@ export function SaveManagerContent() {
350
382
  )
351
383
  }
352
384
  onKeyDown={handleRenameKeyDown}
353
- onBlur={handleRenameConfirm}
385
+ onBlur={handleRenameBlur}
354
386
  />
355
387
  ) : (
356
388
  <div class="save-slot-title">{save.meta.title}</div>
@@ -363,6 +395,7 @@ export function SaveManagerContent() {
363
395
  <div class="save-slot-actions">
364
396
  {mode === 'save' ? (
365
397
  <button
398
+ type="button"
366
399
  class="save-slot-action primary"
367
400
  onClick={() => handleOverwrite(save.meta.id)}
368
401
  >
@@ -370,6 +403,7 @@ export function SaveManagerContent() {
370
403
  </button>
371
404
  ) : (
372
405
  <button
406
+ type="button"
373
407
  class="save-slot-action primary"
374
408
  onClick={() => handleLoad(save)}
375
409
  >
@@ -377,18 +411,21 @@ export function SaveManagerContent() {
377
411
  </button>
378
412
  )}
379
413
  <button
414
+ type="button"
380
415
  class="save-slot-action"
381
416
  onClick={() => handleRenameStart(save)}
382
417
  >
383
418
  Rename
384
419
  </button>
385
420
  <button
421
+ type="button"
386
422
  class="save-slot-action"
387
423
  onClick={() => handleExport(save.meta.id)}
388
424
  >
389
425
  Export
390
426
  </button>
391
427
  <button
428
+ type="button"
392
429
  class="save-slot-action danger"
393
430
  onClick={() => handleDelete(save.meta.id)}
394
431
  >
@@ -400,6 +437,7 @@ export function SaveManagerContent() {
400
437
 
401
438
  {mode === 'save' && isCurrentPt && (
402
439
  <button
440
+ type="button"
403
441
  class="save-slot-new"
404
442
  onClick={handleNewSave}
405
443
  >
@@ -419,6 +457,7 @@ export function SaveManagerContent() {
419
457
  <div class="playthrough-group">
420
458
  <div class="playthrough-saves">
421
459
  <button
460
+ type="button"
422
461
  class="save-slot-new"
423
462
  onClick={handleNewSave}
424
463
  >
@@ -1,15 +1,54 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { DELAY_PARAMETER } from './macro-args';
3
3
 
4
+ /**
5
+ * The clip-path that shows the first `count` characters of `root`'s text in
6
+ * reading order: the boxes the browser laid out for them, each wrapped line
7
+ * as far as its last revealed character, as one path in the coordinates of
8
+ * `inner`'s box. Undefined where there is no layout to measure.
9
+ */
10
+ function revealedPath(
11
+ root: HTMLElement,
12
+ inner: HTMLElement,
13
+ count: number,
14
+ ): string | undefined {
15
+ const range = document.createRange();
16
+ if (!range.getClientRects) return undefined;
17
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
18
+ let left = count;
19
+ let end: { node: Node; offset: number } | undefined;
20
+ for (let n = walker.nextNode(); n && left > 0; n = walker.nextNode()) {
21
+ const length = n.nodeValue?.length ?? 0;
22
+ end = { node: n, offset: Math.min(left, length) };
23
+ left -= length;
24
+ }
25
+ if (!end) return 'inset(0 100% 0 0)';
26
+ range.setStart(root, 0);
27
+ range.setEnd(end.node, end.offset);
28
+ const rects = range.getClientRects();
29
+ if (rects.length === 0) return undefined;
30
+ const origin = inner.getBoundingClientRect();
31
+ let path = '';
32
+ for (const r of Array.from(rects)) {
33
+ if (r.width === 0 && r.height === 0) continue;
34
+ const x = (r.left - origin.left).toFixed(2);
35
+ const y = (r.top - origin.top).toFixed(2);
36
+ path += `M${x} ${y}h${r.width.toFixed(2)}v${r.height.toFixed(2)}h-${r.width.toFixed(2)}z`;
37
+ }
38
+ return path ? `path("${path}")` : undefined;
39
+ }
40
+
4
41
  defineMacro({
5
42
  name: 'type',
6
43
  block: true,
7
44
  interpolate: true,
8
45
  parameters: [DELAY_PARAMETER],
9
46
  render({ children = [] }, ctx) {
10
- const { useState, useEffect, useRef } = ctx.hooks;
47
+ const { useState, useEffect, useLayoutEffect, useRef } = ctx.hooks;
11
48
  const speed = ctx.args.delay ?? 0;
12
49
  const containerRef = useRef<HTMLSpanElement>(null);
50
+ const innerRef = useRef<HTMLSpanElement>(null);
51
+ const [clip, setClip] = useState<string | undefined>();
13
52
  const [totalChars, setTotalChars] = useState(0);
14
53
  const [visibleChars, setVisibleChars] = useState(0);
15
54
  const visibleCharsRef = useRef(0);
@@ -44,6 +83,21 @@ defineMacro({
44
83
 
45
84
  const done = visibleChars >= totalChars && totalChars > 0;
46
85
 
86
+ // Clip to the characters revealed so far, as laid out: remeasured when
87
+ // the count changes and when the width the text wraps in does.
88
+ useLayoutEffect(() => {
89
+ const root = containerRef.current;
90
+ const inner = innerRef.current;
91
+ if (!root || !inner || totalChars === 0 || done) {
92
+ setClip(undefined);
93
+ return;
94
+ }
95
+ const measure = () => setClip(revealedPath(root, inner, visibleChars));
96
+ measure();
97
+ window.addEventListener('resize', measure);
98
+ return () => window.removeEventListener('resize', measure);
99
+ }, [visibleChars, totalChars, done]);
100
+
47
101
  const cls = [
48
102
  'macro-type',
49
103
  done ? 'macro-type-done' : '',
@@ -57,19 +111,14 @@ defineMacro({
57
111
  id={ctx.id}
58
112
  class={cls}
59
113
  ref={containerRef}
60
- style={{
61
- clipPath: totalChars > 0 && !done ? undefined : undefined,
62
- }}
63
114
  >
64
115
  <span
116
+ ref={innerRef}
65
117
  class="macro-type-inner"
66
118
  style={{
67
119
  display: 'inline',
68
120
  visibility: totalChars === 0 ? 'hidden' : 'visible',
69
- clipPath:
70
- totalChars > 0 && !done
71
- ? `inset(0 ${((totalChars - visibleChars) / totalChars) * 100}% 0 0)`
72
- : undefined,
121
+ clipPath: clip,
73
122
  }}
74
123
  >
75
124
  {ctx.renderInlineNodes(children)}
@@ -1,5 +1,5 @@
1
1
  import { render } from 'preact';
2
- import { useContext } from 'preact/hooks';
2
+ import { useContext, useEffect, useRef } from 'preact/hooks';
3
3
  import {
4
4
  renderNodes,
5
5
  LocalsUpdateContext,
@@ -19,7 +19,10 @@ import { DialogCloseContext } from '../PassageDialog';
19
19
  * Return a function that runs a macro body once, outside the passage tree:
20
20
  * it renders `children` into a detached node, so the body's macros ({set},
21
21
  * {if}, {goto}, ...) fire their side effects through the normal Preact
22
- * pipeline, then unmounts it. Used by {button} and {link} on click.
22
+ * pipeline. Used by {button} and {link} on click. The body stays mounted
23
+ * until the owning macro unmounts (its passage changes, say), so work it
24
+ * starts in an effect, such as the timer of a {timed} or {repeat}, can
25
+ * finish; the passage leaving cancels it.
23
26
  *
24
27
  * A detached render starts with no contexts, so the hook captures the ones
25
28
  * the owning macro sees and provides them again: the locals scope (read
@@ -41,6 +44,16 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
41
44
  const repeat = useContext(RepeatContext);
42
45
  const closeDialog = useContext(DialogCloseContext);
43
46
 
47
+ const mounted = useRef<HTMLElement[]>([]);
48
+ useEffect(
49
+ () => () => {
50
+ for (const container of mounted.current.splice(0)) {
51
+ render(null, container);
52
+ }
53
+ },
54
+ [],
55
+ );
56
+
44
57
  const run = (children: ASTNode[]) => {
45
58
  const locals = liveLocalsView(updater.getValues);
46
59
  const container = document.createElement('div');
@@ -62,7 +75,7 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
62
75
  </LocalsUpdateContext.Provider>,
63
76
  container,
64
77
  );
65
- render(null, container);
78
+ mounted.current.push(container);
66
79
  };
67
80
  return (children) => runWithCommittedMutations(() => run(children));
68
81
  }
package/src/expression.ts CHANGED
@@ -123,32 +123,22 @@ export function historyQueries(
123
123
  }
124
124
 
125
125
  let cachedFns: ExpressionFns | null = null;
126
- let cachedVisitCounts: Counts | null = null;
127
- let cachedRenderCounts: Counts | null = null;
128
126
 
127
+ /**
128
+ * The functions compiled code calls. The history queries read the counters
129
+ * when called, not when the functions were built: mutation code that
130
+ * navigates (`Story.goto()`) goes on with the counters of the new state,
131
+ * as the Story API does.
132
+ */
129
133
  export function buildExpressionFns() {
130
- const state = useStoryStore.getState();
131
- const { visitCounts, renderCounts } = state;
132
-
133
- if (
134
- cachedFns &&
135
- cachedVisitCounts === visitCounts &&
136
- cachedRenderCounts === renderCounts
137
- ) {
138
- return cachedFns;
139
- }
140
-
141
- cachedFns = {
134
+ cachedFns ??= {
142
135
  ...historyQueries(
143
- () => visitCounts,
144
- () => renderCounts,
136
+ () => useStoryStore.getState().visitCounts,
137
+ () => useStoryStore.getState().renderCounts,
145
138
  ),
146
139
  random,
147
140
  randomInt,
148
141
  };
149
- cachedVisitCounts = visitCounts;
150
- cachedRenderCounts = renderCounts;
151
-
152
142
  return cachedFns;
153
143
  }
154
144
 
@@ -221,8 +211,6 @@ export function clearExpressionCache(): void {
221
211
  fnCache.clear();
222
212
  clearTransformCache();
223
213
  cachedFns = null;
224
- cachedVisitCounts = null;
225
- cachedRenderCounts = null;
226
214
  }
227
215
 
228
216
  export function evaluateWithState(expr: string, state: StoryState): unknown {