@rohal12/spindle 0.59.9 → 0.59.10

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.
@@ -9735,13 +9735,24 @@ function tokenizeMarkup(source, options = {}) {
9735
9735
  //#endregion
9736
9736
  //#region src/hooks/use-story-fields.ts
9737
9737
  /**
9738
+ * Tells the components below when to keep the state they last rendered with,
9739
+ * given the store's current state: an owner (a click body, see
9740
+ * useDetachedBody; a passage on its way out, see PassageDisplay) provides it
9741
+ * so that a branch skipped earlier cannot run against later state (#351,
9742
+ * #352). It runs inside the store's own notification, before the owner could
9743
+ * re-render, which is why it is a predicate and not a flag.
9744
+ */
9745
+ var FrozenStateContext = createContext(null);
9746
+ /**
9738
9747
  * Story state fields `keys`, re-rendering when any of them changes (as one
9739
9748
  * useStoryStore() selector per field would). The result keeps its identity
9740
9749
  * while the fields don't change, as the store's snapshot must.
9741
9750
  */
9742
9751
  function useStoryFields(...keys) {
9743
9752
  const prev = useRef();
9753
+ const freeze = useContext(FrozenStateContext);
9744
9754
  return useStoryStore((s) => {
9755
+ if (prev.current && freeze?.(s)) return prev.current;
9745
9756
  const next = {};
9746
9757
  for (const key of keys) next[key] = s[key];
9747
9758
  return shallow(prev.current, next) ? prev.current : prev.current = next;
@@ -9754,7 +9765,7 @@ function useStoryFields(...keys) {
9754
9765
  * All four dicts use unprefixed keys suitable for evaluate/execute.
9755
9766
  */
9756
9767
  function useMergedLocals() {
9757
- const { variables, temporary, transient } = useStoryFields("variables", "temporary", "transient");
9768
+ const { variables, temporary, transient, renderCounts } = useStoryFields("variables", "temporary", "transient", "renderCounts");
9758
9769
  const localsValues = useContext(LocalsValuesContext);
9759
9770
  return useMemo(() => {
9760
9771
  return [
@@ -9767,7 +9778,8 @@ function useMergedLocals() {
9767
9778
  variables,
9768
9779
  temporary,
9769
9780
  localsValues,
9770
- transient
9781
+ transient,
9782
+ renderCounts
9771
9783
  ]);
9772
9784
  }
9773
9785
  //#endregion
@@ -10887,7 +10899,17 @@ function LocalsScope({ body, parentValues, ownKeys }) {
10887
10899
  }
10888
10900
  //#endregion
10889
10901
  //#region src/components/macros/WidgetInvocation.tsx
10890
- function WidgetInvocation({ body, params, rawArgs, invocationChildren }) {
10902
+ function WidgetInvocation(props) {
10903
+ const { className, id } = props;
10904
+ const content = /* @__PURE__ */ jsx(WidgetContent, { ...props });
10905
+ if (!className && !id) return content;
10906
+ return /* @__PURE__ */ jsx("span", {
10907
+ class: className,
10908
+ id,
10909
+ children: content
10910
+ });
10911
+ }
10912
+ function WidgetContent({ body, params, rawArgs, invocationChildren, className, id }) {
10891
10913
  const renderOptions = useRenderOptions();
10892
10914
  const parentValues = renderOptions.locals;
10893
10915
  const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = useMergedLocals();
@@ -11643,18 +11665,21 @@ function nodeKey(node) {
11643
11665
  }
11644
11666
  function renderMacro(node, key) {
11645
11667
  if (isSubMacro(node.name)) return null;
11668
+ const invocation = {
11669
+ rawArgs: node.rawArgs,
11670
+ className: node.className,
11671
+ id: node.id
11672
+ };
11646
11673
  const widget = getWidget(node.name);
11647
11674
  if (widget) return /* @__PURE__ */ jsx(WidgetInvocation, {
11675
+ ...invocation,
11648
11676
  body: widget.body,
11649
11677
  params: widget.params,
11650
- rawArgs: node.rawArgs,
11651
11678
  invocationChildren: node.children
11652
11679
  }, key);
11653
11680
  const Component = getMacro(node.name);
11654
11681
  if (Component) return /* @__PURE__ */ jsx(Component, {
11655
- rawArgs: node.rawArgs,
11656
- className: node.className,
11657
- id: node.id,
11682
+ ...invocation,
11658
11683
  children: node.children,
11659
11684
  branches: node.branches
11660
11685
  }, key);
@@ -14177,6 +14202,7 @@ async function startNewPlaythroughNow(ifid, id = randomUUID()) {
14177
14202
  await backend.setMeta(playthroughCountKey(ifid), num);
14178
14203
  });
14179
14204
  await backend.setMeta(currentPlaythroughKey(ifid), id);
14205
+ saveSessionPlaythrough(ifid, id);
14180
14206
  return id;
14181
14207
  }
14182
14208
  var PLAYTHROUGH_LABEL = /^Playthrough (\d+)$/;
@@ -14216,6 +14242,7 @@ async function adoptPlaythroughNow(ifid, id) {
14216
14242
  await ensurePlaythrough(ifid, id, (/* @__PURE__ */ new Date()).toISOString());
14217
14243
  const key = currentPlaythroughKey(ifid);
14218
14244
  if (await backend.getMeta(key) !== id) await backend.setMeta(key, id);
14245
+ saveSessionPlaythrough(ifid, id);
14219
14246
  }
14220
14247
  /**
14221
14248
  * Record the playthrough `id` of the story as "Imported", created at
@@ -14238,8 +14265,12 @@ async function ensurePlaythrough(ifid, id, createdAt) {
14238
14265
  */
14239
14266
  function establishPlaythrough(ifid) {
14240
14267
  return inOrder(async () => {
14268
+ const backend = await getBackend();
14269
+ const own = loadSessionPlaythrough(ifid);
14270
+ const id = own !== void 0 && (await backend.getPlaythroughsByIfid(ifid)).some((p) => p.id === own) ? own : await backend.getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid);
14271
+ saveSessionPlaythrough(ifid, id);
14241
14272
  return {
14242
- id: await (await getBackend()).getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid),
14273
+ id,
14243
14274
  knownSaves: await populateKnownSavesNow(ifid)
14244
14275
  };
14245
14276
  });
@@ -14542,12 +14573,16 @@ readingSlot(livePayload);
14542
14573
  * (see adoptPlaythrough) in the same operation: operations issued after the
14543
14574
  * load take effect in the loaded playthrough. Resolves to the live payload
14544
14575
  * and its playthrough, or undefined if the slot is empty.
14576
+ *
14577
+ * `check` is run on the payload first: when it throws, the load rejects and
14578
+ * the game stays in its playthrough (#357).
14545
14579
  */
14546
- function loadSlotSave(ifid, slot) {
14580
+ function loadSlotSave(ifid, slot, check) {
14547
14581
  return inOrder(async () => {
14548
14582
  const record = await slotRecord(ifid, slot);
14549
14583
  if (!record) return void 0;
14550
14584
  const payload = decodePayload(record.payload);
14585
+ check?.(payload);
14551
14586
  const { playthroughId } = record.meta;
14552
14587
  if (playthroughId) await adoptPlaythroughNow(ifid, playthroughId);
14553
14588
  return {
@@ -14637,6 +14672,26 @@ function clearSession(ifid) {
14637
14672
  sessionStorage.removeItem(`${SESSION_KEY_PREFIX}${ifid}`);
14638
14673
  } catch {}
14639
14674
  }
14675
+ /**
14676
+ * The playthrough this tab's game is in, kept with its session: the shared
14677
+ * current playthrough (a stored meta value) can be changed by another tab.
14678
+ * Written where this tab's storage operations set the current playthrough,
14679
+ * so it follows them in call order. Under the session prefix, so clearing
14680
+ * all data clears it.
14681
+ */
14682
+ var SESSION_PLAYTHROUGH_PREFIX = `${SESSION_KEY_PREFIX}playthrough.`;
14683
+ function saveSessionPlaythrough(ifid, id) {
14684
+ try {
14685
+ sessionStorage.setItem(`${SESSION_PLAYTHROUGH_PREFIX}${ifid}`, id);
14686
+ } catch {}
14687
+ }
14688
+ function loadSessionPlaythrough(ifid) {
14689
+ try {
14690
+ return sessionStorage.getItem(`${SESSION_PLAYTHROUGH_PREFIX}${ifid}`) || void 0;
14691
+ } catch {
14692
+ return;
14693
+ }
14694
+ }
14640
14695
  /** The export of a stored save, if there is one. */
14641
14696
  function toExport(record) {
14642
14697
  if (!record) return void 0;
@@ -16078,6 +16133,11 @@ function missingPassage(storyData, payload) {
16078
16133
  if (!storyData) return void 0;
16079
16134
  return [payload.passage, ...payload.history.map((m) => m.passage)].find((name) => !storyData.passages.has(name));
16080
16135
  }
16136
+ /** Throw if `payload` refers to a passage `storyData` does not have. */
16137
+ function assertPassagesExist(storyData, payload) {
16138
+ const missing = missingPassage(storyData, payload);
16139
+ if (missing !== void 0) throw new Error(`The save refers to the passage "${missing}", which this version of the story does not have`);
16140
+ }
16081
16141
  var NAMESPACE_KEYS = [
16082
16142
  "variables",
16083
16143
  "temporary",
@@ -16420,7 +16480,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
16420
16480
  state.loadError = null;
16421
16481
  });
16422
16482
  const previous = resolvePlaythroughId();
16423
- const read = loadSlotSave(storyData.ifid, slot);
16483
+ const read = loadSlotSave(storyData.ifid, slot, (payload) => assertPassagesExist(storyData, payload));
16424
16484
  const switched = switchToLookedUpPlaythrough(Promise.all([previous, read.catch(() => void 0)]).then(([prev, loaded]) => loaded?.playthroughId || prev));
16425
16485
  const replacement = ++stateReplacementsIssued;
16426
16486
  return reported(read.then(async (loaded) => {
@@ -16525,8 +16585,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
16525
16585
  console.warn("loadFromPayload: rejecting payload with empty history");
16526
16586
  return;
16527
16587
  }
16528
- const missing = missingPassage(get().storyData, payload);
16529
- if (missing !== void 0) throw new Error(`The save refers to the passage "${missing}", which this version of the story does not have`);
16588
+ assertPassagesExist(get().storyData, payload);
16530
16589
  latestStateApplied = replacement;
16531
16590
  emit("beforeload", slot);
16532
16591
  const ifid = get().storyData?.ifid;
@@ -19354,11 +19413,13 @@ defineMacro({
19354
19413
  });
19355
19414
  //#endregion
19356
19415
  //#region src/components/macros/detached-body.tsx
19416
+ var frozenAlways = () => true;
19357
19417
  /**
19358
19418
  * Return a function that runs a macro body once, outside the passage tree:
19359
19419
  * it renders `children` into a detached node, so the body's macros ({set},
19360
19420
  * {if}, {goto}, ...) fire their side effects through the normal Preact
19361
- * pipeline. Used by {button} and {link} on click. The body stays mounted
19421
+ * pipeline. Used by {button} and {link} on click. The body's state is frozen
19422
+ * once it has rendered, so it runs once per click. It stays mounted
19362
19423
  * until the owning macro unmounts (its passage changes, say), so work it
19363
19424
  * starts in an effect, such as the timer of a {timed} or {repeat}, can
19364
19425
  * finish; the passage leaving cancels it.
@@ -19389,24 +19450,27 @@ function useDetachedBody() {
19389
19450
  const run = (children) => {
19390
19451
  const locals = liveLocalsView(updater.getValues);
19391
19452
  const container = document.createElement("div");
19392
- render(/* @__PURE__ */ jsx(LocalsUpdateContext.Provider, {
19393
- value: updater,
19394
- children: /* @__PURE__ */ jsx(LocalsValuesContext.Provider, {
19395
- value: locals,
19396
- children: /* @__PURE__ */ jsx(NobrContext.Provider, {
19397
- value: nobr,
19398
- children: /* @__PURE__ */ jsx(InlineContext.Provider, {
19399
- value: inline,
19400
- children: /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
19401
- value: widgetChildren,
19402
- children: /* @__PURE__ */ jsx(RepeatContext.Provider, {
19403
- value: repeat,
19404
- children: /* @__PURE__ */ jsx(DialogCloseContext.Provider, {
19405
- value: closeDialog,
19406
- children: renderNodes(children, {
19407
- nobr,
19408
- inline,
19409
- locals
19453
+ render(/* @__PURE__ */ jsx(FrozenStateContext.Provider, {
19454
+ value: frozenAlways,
19455
+ children: /* @__PURE__ */ jsx(LocalsUpdateContext.Provider, {
19456
+ value: updater,
19457
+ children: /* @__PURE__ */ jsx(LocalsValuesContext.Provider, {
19458
+ value: locals,
19459
+ children: /* @__PURE__ */ jsx(NobrContext.Provider, {
19460
+ value: nobr,
19461
+ children: /* @__PURE__ */ jsx(InlineContext.Provider, {
19462
+ value: inline,
19463
+ children: /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
19464
+ value: widgetChildren,
19465
+ children: /* @__PURE__ */ jsx(RepeatContext.Provider, {
19466
+ value: repeat,
19467
+ children: /* @__PURE__ */ jsx(DialogCloseContext.Provider, {
19468
+ value: closeDialog,
19469
+ children: renderNodes(children, {
19470
+ nobr,
19471
+ inline,
19472
+ locals
19473
+ })
19410
19474
  })
19411
19475
  })
19412
19476
  })
@@ -20433,13 +20497,19 @@ function defineInputMacro(name, type, toValue = (input) => input !== void 0 ? St
20433
20497
  label: placeholder || ctx.varName,
20434
20498
  perform: (v) => ctx.setValue(toValue(v))
20435
20499
  });
20500
+ const typed = ctx.hooks.useRef(null);
20501
+ const shown = typed.current !== null && toValue(typed.current) === ctx.value && type === "number" ? typed.current : display(ctx.value);
20436
20502
  return h(type ? "input" : "textarea", {
20437
20503
  type,
20438
20504
  id: ctx.id,
20439
20505
  class: ctx.cls,
20440
- value: display(ctx.value),
20506
+ value: shown,
20441
20507
  placeholder,
20442
- onInput: (e) => ctx.setValue(toValue(e.target.value))
20508
+ onInput: (e) => {
20509
+ const text = e.target.value;
20510
+ typed.current = text;
20511
+ ctx.setValue(toValue(text));
20512
+ }
20443
20513
  });
20444
20514
  }
20445
20515
  });
@@ -20831,6 +20901,9 @@ defineMacro({
20831
20901
  });
20832
20902
  const resolvedTypeRef = useRef("fade");
20833
20903
  const prevHistoryLenRef = useRef(0);
20904
+ const displayedId = displayed.id;
20905
+ const isOutgoing = useCallback((state) => state.navigationId !== displayedId, [displayedId]);
20906
+ const lastElement = useRef(null);
20834
20907
  const containerRef = useRef(null);
20835
20908
  const timeoutsRef = useRef([]);
20836
20909
  /** Cancel any in-progress transition. */
@@ -20899,6 +20972,16 @@ defineMacro({
20899
20972
  cleanupSnapshots(containerEl);
20900
20973
  }, config.duration)];
20901
20974
  }, [navigationId]);
20975
+ const shownId = displayedPassage && !renderDeferred ? displayed.id : null;
20976
+ const lastShownId = useRef(shownId);
20977
+ useLayoutEffect(() => {
20978
+ if (shownId === null) return;
20979
+ const previous = lastShownId.current;
20980
+ lastShownId.current = shownId;
20981
+ if (previous === null || previous === shownId) return;
20982
+ const el = containerRef.current?.querySelector(".passage");
20983
+ if (el && el.getBoundingClientRect().top < 0) el.scrollIntoView?.({ block: "start" });
20984
+ }, [shownId]);
20902
20985
  const effectivePassage = renderDeferred ? storyData?.passages.get("StoryLoading") ?? null : displayedPassage ? storyData?.passages.get(displayedPassage) : null;
20903
20986
  if (!effectivePassage && displayedPassage && !renderDeferred) return /* @__PURE__ */ jsxs("div", {
20904
20987
  class: "error",
@@ -20909,6 +20992,12 @@ defineMacro({
20909
20992
  ]
20910
20993
  });
20911
20994
  const readyPassage = storyData?.passages.get("PassageReady");
20995
+ const passageElement = !renderDeferred && displayed.id !== navigationId && lastElement.current ? lastElement.current : effectivePassage && /* @__PURE__ */ jsx(Passage, {
20996
+ passage: effectivePassage,
20997
+ dataTransition: renderDeferred ? "none" : resolvedTypeRef.current,
20998
+ navigationId: renderDeferred ? void 0 : displayed.id
20999
+ }, renderDeferred ? "loading" : `nav-${displayed.id}`);
21000
+ lastElement.current = passageElement || null;
20912
21001
  return /* @__PURE__ */ jsxs("div", {
20913
21002
  id: ctx.id ?? "story",
20914
21003
  class: ctx.className ?? "story",
@@ -20918,11 +21007,10 @@ defineMacro({
20918
21007
  }, `ready-${navigationId}`), /* @__PURE__ */ jsx("div", {
20919
21008
  class: "passage-container",
20920
21009
  ref: containerRef,
20921
- children: effectivePassage && /* @__PURE__ */ jsx(Passage, {
20922
- passage: effectivePassage,
20923
- dataTransition: renderDeferred ? "none" : resolvedTypeRef.current,
20924
- navigationId: renderDeferred ? void 0 : displayed.id
20925
- }, renderDeferred ? "loading" : `nav-${displayed.id}`)
21010
+ children: passageElement && /* @__PURE__ */ jsx(FrozenStateContext.Provider, {
21011
+ value: isOutgoing,
21012
+ children: passageElement
21013
+ })
20926
21014
  })]
20927
21015
  });
20928
21016
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.9",
3
+ "version": "0.59.10",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,6 +1,15 @@
1
- import { useRef, useEffect, useCallback, useState } from 'preact/hooks';
2
- import { useStoryStore } from '../../store';
3
- import { useStoryFields } from '../../hooks/use-story-fields';
1
+ import {
2
+ useRef,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useCallback,
6
+ useState,
7
+ } from 'preact/hooks';
8
+ import { useStoryStore, type StoryState } from '../../store';
9
+ import {
10
+ useStoryFields,
11
+ FrozenStateContext,
12
+ } from '../../hooks/use-story-fields';
4
13
  import { Passage, renderPassageContent } from '../Passage';
5
14
  import { defineMacro } from '../../define-macro';
6
15
  import { resolveTransition, type ResolvedTransition } from '../../transition';
@@ -126,6 +135,13 @@ defineMacro({
126
135
  // Track previous history length to detect restart/load
127
136
  const prevHistoryLenRef = useRef(0);
128
137
 
138
+ const displayedId = displayed.id;
139
+ const isOutgoing = useCallback(
140
+ (state: StoryState) => state.navigationId !== displayedId,
141
+ [displayedId],
142
+ );
143
+ const lastElement = useRef<preact.VNode | null>(null);
144
+
129
145
  // Container ref for snapshot insertion
130
146
  const containerRef = useRef<HTMLDivElement>(null);
131
147
 
@@ -265,6 +281,22 @@ defineMacro({
265
281
  // eslint-disable-next-line react-hooks/exhaustive-deps
266
282
  }, [navigationId]);
267
283
 
284
+ // A new passage opens at its beginning, not where the previous one was
285
+ // scrolled to (#354). The page keeps its position on first display
286
+ // (a refresh restores it) and when the beginning is already in view.
287
+ const shownId = displayedPassage && !renderDeferred ? displayed.id : null;
288
+ const lastShownId = useRef(shownId);
289
+ useLayoutEffect(() => {
290
+ if (shownId === null) return; // between passages of a transition
291
+ const previous = lastShownId.current;
292
+ lastShownId.current = shownId;
293
+ if (previous === null || previous === shownId) return;
294
+ const el = containerRef.current?.querySelector('.passage');
295
+ if (el && el.getBoundingClientRect().top < 0) {
296
+ el.scrollIntoView?.({ block: 'start' });
297
+ }
298
+ }, [shownId]);
299
+
268
300
  // When render is deferred, show StoryLoading passage or nothing
269
301
  const effectivePassage = renderDeferred
270
302
  ? (storyData?.passages.get('StoryLoading') ?? null)
@@ -282,6 +314,23 @@ defineMacro({
282
314
 
283
315
  const readyPassage = storyData?.passages.get('PassageReady');
284
316
 
317
+ // Between a navigation and the commit of its passage, the old passage is
318
+ // still mounted. It must not execute against the destination's state, so
319
+ // it keeps the element and the state it last rendered with (#352).
320
+ const outgoing = !renderDeferred && displayed.id !== navigationId;
321
+ const passageElement =
322
+ outgoing && lastElement.current
323
+ ? lastElement.current
324
+ : effectivePassage && (
325
+ <Passage
326
+ passage={effectivePassage}
327
+ key={renderDeferred ? 'loading' : `nav-${displayed.id}`}
328
+ dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
329
+ navigationId={renderDeferred ? undefined : displayed.id}
330
+ />
331
+ );
332
+ lastElement.current = passageElement || null;
333
+
285
334
  return (
286
335
  <div
287
336
  id={ctx.id ?? 'story'}
@@ -299,13 +348,10 @@ defineMacro({
299
348
  class="passage-container"
300
349
  ref={containerRef}
301
350
  >
302
- {effectivePassage && (
303
- <Passage
304
- passage={effectivePassage}
305
- key={renderDeferred ? 'loading' : `nav-${displayed.id}`}
306
- dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
307
- navigationId={renderDeferred ? undefined : displayed.id}
308
- />
351
+ {passageElement && (
352
+ <FrozenStateContext.Provider value={isOutgoing}>
353
+ {passageElement}
354
+ </FrozenStateContext.Provider>
309
355
  )}
310
356
  </div>
311
357
  </div>
@@ -15,13 +15,32 @@ interface WidgetInvocationProps {
15
15
  params: string[];
16
16
  rawArgs?: string;
17
17
  invocationChildren?: ASTNode[];
18
+ className?: string;
19
+ id?: string;
18
20
  }
19
21
 
20
- export function WidgetInvocation({
22
+ export function WidgetInvocation(props: WidgetInvocationProps) {
23
+ const { className, id } = props;
24
+ const content = <WidgetContent {...props} />;
25
+ // Invocation selectors (`{.badge#hero Stats}`) need an element to land on.
26
+ if (!className && !id) return content;
27
+ return (
28
+ <span
29
+ class={className}
30
+ id={id}
31
+ >
32
+ {content}
33
+ </span>
34
+ );
35
+ }
36
+
37
+ function WidgetContent({
21
38
  body,
22
39
  params,
23
40
  rawArgs,
24
41
  invocationChildren,
42
+ className,
43
+ id,
25
44
  }: WidgetInvocationProps) {
26
45
  const renderOptions = useRenderOptions();
27
46
  const parentValues = renderOptions.locals;
@@ -10,16 +10,20 @@ 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
14
  import { liveLocalsView } from '../../utils/live-locals';
14
15
  import { runWithCommittedMutations } from '../../execute-mutation';
15
16
  import { RepeatContext } from './Repeat';
16
17
  import { DialogCloseContext } from '../PassageDialog';
17
18
 
19
+ const frozenAlways = () => true;
20
+
18
21
  /**
19
22
  * Return a function that runs a macro body once, outside the passage tree:
20
23
  * it renders `children` into a detached node, so the body's macros ({set},
21
24
  * {if}, {goto}, ...) fire their side effects through the normal Preact
22
- * pipeline. Used by {button} and {link} on click. The body stays mounted
25
+ * pipeline. Used by {button} and {link} on click. The body's state is frozen
26
+ * once it has rendered, so it runs once per click. It stays mounted
23
27
  * until the owning macro unmounts (its passage changes, say), so work it
24
28
  * starts in an effect, such as the timer of a {timed} or {repeat}, can
25
29
  * finish; the passage leaving cancels it.
@@ -58,21 +62,23 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
58
62
  const locals = liveLocalsView(updater.getValues);
59
63
  const container = document.createElement('div');
60
64
  render(
61
- <LocalsUpdateContext.Provider value={updater}>
62
- <LocalsValuesContext.Provider value={locals}>
63
- <NobrContext.Provider value={nobr}>
64
- <InlineContext.Provider value={inline}>
65
- <WidgetChildrenContext.Provider value={widgetChildren}>
66
- <RepeatContext.Provider value={repeat}>
67
- <DialogCloseContext.Provider value={closeDialog}>
68
- {renderNodes(children, { nobr, inline, locals })}
69
- </DialogCloseContext.Provider>
70
- </RepeatContext.Provider>
71
- </WidgetChildrenContext.Provider>
72
- </InlineContext.Provider>
73
- </NobrContext.Provider>
74
- </LocalsValuesContext.Provider>
75
- </LocalsUpdateContext.Provider>,
65
+ <FrozenStateContext.Provider value={frozenAlways}>
66
+ <LocalsUpdateContext.Provider value={updater}>
67
+ <LocalsValuesContext.Provider value={locals}>
68
+ <NobrContext.Provider value={nobr}>
69
+ <InlineContext.Provider value={inline}>
70
+ <WidgetChildrenContext.Provider value={widgetChildren}>
71
+ <RepeatContext.Provider value={repeat}>
72
+ <DialogCloseContext.Provider value={closeDialog}>
73
+ {renderNodes(children, { nobr, inline, locals })}
74
+ </DialogCloseContext.Provider>
75
+ </RepeatContext.Provider>
76
+ </WidgetChildrenContext.Provider>
77
+ </InlineContext.Provider>
78
+ </NobrContext.Provider>
79
+ </LocalsValuesContext.Provider>
80
+ </LocalsUpdateContext.Provider>
81
+ </FrozenStateContext.Provider>,
76
82
  container,
77
83
  );
78
84
  mounted.current.push(container);
@@ -59,14 +59,29 @@ export function defineInputMacro(
59
59
  perform: (v) => ctx.setValue!(toValue(v)),
60
60
  });
61
61
 
62
+ // What the reader last typed. A number input reports an unfinished
63
+ // number ("-", "2e") as an empty value, so the text is kept for as
64
+ // long as it still converts to the variable's value, instead of being
65
+ // replaced by that value on every keystroke (#353).
66
+ const typed = ctx.hooks.useRef<string | null>(null);
67
+ const shown =
68
+ typed.current !== null &&
69
+ toValue(typed.current) === ctx.value &&
70
+ type === 'number'
71
+ ? typed.current
72
+ : display(ctx.value);
73
+
62
74
  return h(type ? 'input' : 'textarea', {
63
75
  type,
64
76
  id: ctx.id,
65
77
  class: ctx.cls,
66
- value: display(ctx.value),
78
+ value: shown,
67
79
  placeholder,
68
- onInput: (e: Event) =>
69
- ctx.setValue!(toValue((e.target as HTMLInputElement).value)),
80
+ onInput: (e: Event) => {
81
+ const text = (e.target as HTMLInputElement).value;
82
+ typed.current = text;
83
+ ctx.setValue!(toValue(text));
84
+ },
70
85
  });
71
86
  },
72
87
  });
@@ -12,14 +12,17 @@ export function useMergedLocals(): readonly [
12
12
  Record<string, unknown>,
13
13
  Record<string, unknown>,
14
14
  ] {
15
- const { variables, temporary, transient } = useStoryFields(
15
+ // renderCounts is read by rendered()/hasRendered() inside expressions, so
16
+ // it re-renders them (and renews the tuple) when an inclusion mounts.
17
+ const { variables, temporary, transient, renderCounts } = useStoryFields(
16
18
  'variables',
17
19
  'temporary',
18
20
  'transient',
21
+ 'renderCounts',
19
22
  );
20
23
  const localsValues = useContext(LocalsValuesContext);
21
24
 
22
25
  return useMemo(() => {
23
26
  return [variables, temporary, localsValues, transient] as const;
24
- }, [variables, temporary, localsValues, transient]);
27
+ }, [variables, temporary, localsValues, transient, renderCounts]);
25
28
  }
@@ -1,7 +1,20 @@
1
- import { useRef } from 'preact/hooks';
1
+ import { createContext } from 'preact';
2
+ import { useContext, useRef } from 'preact/hooks';
2
3
  import { shallow } from 'zustand/vanilla/shallow';
3
4
  import { useStoryStore, type StoryState } from '../store';
4
5
 
6
+ /**
7
+ * Tells the components below when to keep the state they last rendered with,
8
+ * given the store's current state: an owner (a click body, see
9
+ * useDetachedBody; a passage on its way out, see PassageDisplay) provides it
10
+ * so that a branch skipped earlier cannot run against later state (#351,
11
+ * #352). It runs inside the store's own notification, before the owner could
12
+ * re-render, which is why it is a predicate and not a flag.
13
+ */
14
+ export const FrozenStateContext = createContext<
15
+ ((state: StoryState) => boolean) | null
16
+ >(null);
17
+
5
18
  /**
6
19
  * Story state fields `keys`, re-rendering when any of them changes (as one
7
20
  * useStoryStore() selector per field would). The result keeps its identity
@@ -11,7 +24,9 @@ export function useStoryFields<K extends keyof StoryState>(
11
24
  ...keys: K[]
12
25
  ): Pick<StoryState, K> {
13
26
  const prev = useRef<Pick<StoryState, K>>();
27
+ const freeze = useContext(FrozenStateContext);
14
28
  return useStoryStore((s) => {
29
+ if (prev.current && freeze?.(s)) return prev.current;
15
30
  const next = {} as Pick<StoryState, K>;
16
31
  for (const key of keys) next[key] = s[key];
17
32
  return shallow(prev.current, next) ? prev.current! : (prev.current = next);
@@ -679,14 +679,21 @@ function nodeKey(node: ASTNode): string {
679
679
  function renderMacro(node: MacroNode, key: string) {
680
680
  if (isSubMacro(node.name)) return null;
681
681
 
682
+ // What an invocation passes on to a widget and to a built-in macro alike
683
+ const invocation = {
684
+ rawArgs: node.rawArgs,
685
+ className: node.className,
686
+ id: node.id,
687
+ };
688
+
682
689
  const widget = getWidget(node.name);
683
690
  if (widget) {
684
691
  return (
685
692
  <WidgetInvocation
686
693
  key={key}
694
+ {...invocation}
687
695
  body={widget.body}
688
696
  params={widget.params}
689
- rawArgs={node.rawArgs}
690
697
  invocationChildren={node.children}
691
698
  />
692
699
  );
@@ -697,9 +704,7 @@ function renderMacro(node: MacroNode, key: string) {
697
704
  return (
698
705
  <Component
699
706
  key={key}
700
- rawArgs={node.rawArgs}
701
- className={node.className}
702
- id={node.id}
707
+ {...invocation}
703
708
  children={node.children}
704
709
  branches={node.branches}
705
710
  />