@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.
@@ -3,7 +3,7 @@ import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, u
3
3
  import { useSyncExternalStore } from "preact/compat";
4
4
  import { createStore } from "zustand/vanilla";
5
5
  import { immer } from "zustand/middleware/immer";
6
- import { Immer, current, enableMapSet, enablePatches, freeze, isDraft } from "immer";
6
+ import { Immer, current, enableMapSet, enablePatches, freeze, isDraft, original } from "immer";
7
7
  import { shallow } from "zustand/vanilla/shallow";
8
8
  import { Fragment as Fragment$1, jsx, jsxs } from "preact/jsx-runtime";
9
9
  import { micromark } from "micromark";
@@ -10286,13 +10286,35 @@ function parseDelay(str) {
10286
10286
  return parseFloat(trimmed);
10287
10287
  }
10288
10288
  //#endregion
10289
+ //#region src/utils/listeners.ts
10290
+ /**
10291
+ * A set of listeners to call when something happens: `subscribe` adds one and
10292
+ * returns the function that removes it, `notify` calls each one. A listener
10293
+ * removed or added while they are called is called or not as the set was
10294
+ * when `notify` started.
10295
+ */
10296
+ function createListeners() {
10297
+ const listeners = /* @__PURE__ */ new Set();
10298
+ return {
10299
+ subscribe(listener) {
10300
+ listeners.add(listener);
10301
+ return () => {
10302
+ listeners.delete(listener);
10303
+ };
10304
+ },
10305
+ notify() {
10306
+ for (const listener of [...listeners]) listener();
10307
+ }
10308
+ };
10309
+ }
10310
+ //#endregion
10289
10311
  //#region src/runtime-errors.ts
10290
10312
  var errors = [];
10291
10313
  var nextId = 1;
10292
- var listeners$1 = /* @__PURE__ */ new Set();
10314
+ var listeners$1 = createListeners();
10293
10315
  function update(next) {
10294
10316
  errors = next;
10295
- for (const listener of listeners$1) listener();
10317
+ listeners$1.notify();
10296
10318
  }
10297
10319
  /**
10298
10320
  * Show `error` on the page, after `context` (what failed). The same error
@@ -10328,12 +10350,7 @@ function getRuntimeErrors() {
10328
10350
  return errors;
10329
10351
  }
10330
10352
  /** Call `listener` whenever the shown errors change; returns unsubscribe. */
10331
- function subscribeRuntimeErrors(listener) {
10332
- listeners$1.add(listener);
10333
- return () => {
10334
- listeners$1.delete(listener);
10335
- };
10336
- }
10353
+ var subscribeRuntimeErrors = listeners$1.subscribe;
10337
10354
  //#endregion
10338
10355
  //#region src/components/macros/arg-utils.ts
10339
10356
  /**
@@ -10883,6 +10900,13 @@ function registerWidgetDefinitions(nodes, passageName) {
10883
10900
  */
10884
10901
  var FrozenStateContext = createContext(null);
10885
10902
  /**
10903
+ * Tells a timer ({repeat}, {timed}) whether the passage it belongs to has
10904
+ * been left: it must not run a new body then, for that would write into the
10905
+ * destination's state until the outgoing tree unmounts (#410). Null outside
10906
+ * a passage.
10907
+ */
10908
+ var OutgoingContext = createContext(null);
10909
+ /**
10886
10910
  * Story state fields `keys`, re-rendering when any of them changes (as one
10887
10911
  * useStoryStore() selector per field would). The result keeps its identity
10888
10912
  * while the fields don't change, as the store's snapshot must.
@@ -11054,7 +11078,7 @@ function getPRNGPull() {
11054
11078
  * (see src/tooling.ts).
11055
11079
  */
11056
11080
  var TRANSFORM_CACHE_MAX = 500;
11057
- var NAMESPACES$1 = {
11081
+ var NAMESPACES$2 = {
11058
11082
  $: "variables",
11059
11083
  _: "temporary",
11060
11084
  "@": "locals",
@@ -11087,7 +11111,7 @@ function transform(expr, goal = "expression") {
11087
11111
  result += expr.slice(at, ref.start);
11088
11112
  if (ref.shorthand) result += `${ref.name}: `;
11089
11113
  else if (IDENT_END_RE.test(result.slice(-2))) result += " ";
11090
- result += `${NAMESPACES$1[ref.sigil]}["${ref.name}"]`;
11114
+ result += `${NAMESPACES$2[ref.sigil]}["${ref.name}"]`;
11091
11115
  at = ref.end;
11092
11116
  }
11093
11117
  result += expr.slice(at);
@@ -14400,7 +14424,12 @@ function getByPath(obj, segments) {
14400
14424
  for (const seg of segments) {
14401
14425
  if (current == null) return void 0;
14402
14426
  if (seg in Object.prototype && !hasOwn(Object(current), seg)) return;
14403
- current = current[seg];
14427
+ const holder = current;
14428
+ current = holder[seg];
14429
+ if (current === void 0 && isDraft(holder) && (holder instanceof Map || holder instanceof Set)) {
14430
+ const base = original(holder);
14431
+ if (base && hasOwn(base, seg)) current = base[seg];
14432
+ }
14404
14433
  }
14405
14434
  return current;
14406
14435
  }
@@ -14437,10 +14466,12 @@ function shallowCopy(value) {
14437
14466
  * written in place.
14438
14467
  *
14439
14468
  * The path goes through own properties of plain objects, class instances
14440
- * and arrays (by index); anything else throws a TypeError rather than
14441
- * writing where no clone or save would see it: an inherited property such
14442
- * as a method counts as missing, and Map, Set, Date and RegExp values and
14443
- * other keys of arrays are refused, as is a "__proto__" segment.
14469
+ * and arrays (by index), and, outside a draft, any key of an instance of a
14470
+ * registered subclass of a built-in (#408); anything else throws a
14471
+ * TypeError rather than writing where no clone or save would see it: an
14472
+ * inherited property such as a method counts as missing, and Map, Set,
14473
+ * Date and RegExp values and other keys of arrays are refused, as is a
14474
+ * "__proto__" segment.
14444
14475
  */
14445
14476
  function setByPath(root, segments, value, options = {}) {
14446
14477
  const parent = walkToParent(root, segments, options.createMissing ?? false);
@@ -14475,8 +14506,15 @@ function deleteByPath(root, segments) {
14475
14506
  function checkSegments(segments) {
14476
14507
  if (segments.includes("__proto__")) throw new TypeError(`spindle: Cannot use "__proto__" in a variable path ("${segments.join(".")}")`);
14477
14508
  }
14478
- /** Throw unless `holder` can take `key` as story state (see setByPath). */
14479
- function checkHolder(holder, key, segments, depth) {
14509
+ /**
14510
+ * Throw unless `holder` can take `key` as story state (see setByPath). An
14511
+ * instance of a registered subclass of a built-in keeps its own fields
14512
+ * through clones and saves, so it takes any key (#408) when written in
14513
+ * place: a draft of one, or one a draft shares, is refused as other
14514
+ * built-ins are, and the caller writes the whole value instead.
14515
+ */
14516
+ function checkHolder(holder, key, segments, depth, inPlace) {
14517
+ if (inPlace && registeredClassName(holder) !== void 0) return;
14480
14518
  const kind = builtinName(holder) ?? (Array.isArray(holder) && !isArrayKey(key) ? "an array" : "");
14481
14519
  if (kind) throw new TypeError(`spindle: Cannot set property "${key}" on ${kind} (at "${segments.slice(0, depth).join(".")}")`);
14482
14520
  }
@@ -14492,7 +14530,7 @@ function walkToParent(root, segments, createMissing) {
14492
14530
  let current = root;
14493
14531
  for (let i = 0; i < segments.length - 1; i++) {
14494
14532
  const seg = segments[i];
14495
- checkHolder(current, seg, segments, i);
14533
+ checkHolder(current, seg, segments, i, !copyOnWrite);
14496
14534
  let next = hasOwn(current, seg) ? current[seg] : void 0;
14497
14535
  if (next == null || typeof next !== "object") {
14498
14536
  if (!createMissing) throw new TypeError(`spindle: Cannot set property "${segments[i + 1]}" on ${next === null ? "null" : typeof next} (at "${segments.slice(0, i + 1).join(".")}")`);
@@ -14504,7 +14542,7 @@ function walkToParent(root, segments, createMissing) {
14504
14542
  }
14505
14543
  current = next;
14506
14544
  }
14507
- checkHolder(current, segments[segments.length - 1], segments, segments.length - 1);
14545
+ checkHolder(current, segments[segments.length - 1], segments, segments.length - 1, !copyOnWrite);
14508
14546
  return current;
14509
14547
  }
14510
14548
  //#endregion
@@ -15022,6 +15060,18 @@ function setEntryValue(root, path, value) {
15022
15060
  return true;
15023
15061
  }
15024
15062
  /**
15063
+ * The own enumerable fields of a Map, Set, Date or RegExp (an instance of a
15064
+ * registered subclass may hold some besides its built-in content, #412);
15065
+ * none for any other value.
15066
+ */
15067
+ function builtinFields(value) {
15068
+ return value instanceof Map || value instanceof Set || value instanceof Date || value instanceof RegExp ? extraKeys(value) : [];
15069
+ }
15070
+ /** Whether `x` and `y` are built-ins of one class that may hold fields. */
15071
+ function sameBuiltin(x, y) {
15072
+ return Object.getPrototypeOf(x) === Object.getPrototypeOf(y) && (builtinFields(x).length > 0 || builtinFields(y).length > 0);
15073
+ }
15074
+ /**
15025
15075
  * Every path each object (or array, or other value) of `root` is at, the
15026
15076
  * first appearance first, in depth-first order. Plain objects and arrays are
15027
15077
  * entered, where first met only (an object held in an array is one object,
@@ -15045,10 +15095,13 @@ function objectPaths(root, skip = /* @__PURE__ */ new Set(), entries = false) {
15045
15095
  }
15046
15096
  all.set(value, [at]);
15047
15097
  if (isMergeable(value) || Array.isArray(value)) walk(value, at);
15048
- else if (entries && entryChildren(value)) collections.push([value, at]);
15098
+ else {
15099
+ walk(value, at, builtinFields(value));
15100
+ if (entries && entryChildren(value)) collections.push([value, at]);
15101
+ }
15049
15102
  }
15050
- function walk(node, path) {
15051
- for (const key of Object.keys(node)) {
15103
+ function walk(node, path, keys = Object.keys(node)) {
15104
+ for (const key of keys) {
15052
15105
  const value = node[key];
15053
15106
  if (!isObjectValue(value)) continue;
15054
15107
  const at = [...path, key];
@@ -15096,13 +15149,13 @@ function aliasMoved(x, y, at, earlier, later) {
15096
15149
  a,
15097
15150
  held.get(segment)
15098
15151
  ]);
15099
- } else if ((isMergeable(x) || Array.isArray(x)) && mergesWith(x, y, true)) {
15100
- for (const key of Object.keys(y)) if (hasOwn(x, key)) pairs.push([
15101
- key,
15102
- x[key],
15103
- y[key]
15104
- ]);
15105
15152
  }
15153
+ const keys = mergesWith(x, y, true) ? Object.keys(y) : sameBuiltin(x, y) ? builtinFields(y) : [];
15154
+ for (const key of keys) if (hasOwn(x, key)) pairs.push([
15155
+ key,
15156
+ x[key],
15157
+ y[key]
15158
+ ]);
15106
15159
  return pairs.some(([segment, a, b]) => {
15107
15160
  if (!isObjectValue(a) || !isObjectValue(b)) return false;
15108
15161
  const here = [...at, segment];
@@ -15123,7 +15176,7 @@ function aliasChanges(before, after) {
15123
15176
  const later = firstPaths(after, void 0, true);
15124
15177
  const changes = [];
15125
15178
  (function walk(b, a, path) {
15126
- for (const key of Object.keys(a)) {
15179
+ for (const key of Array.isArray(a) || isMergeable(a) ? Object.keys(a) : builtinFields(a)) {
15127
15180
  if (!hasOwn(b, key)) continue;
15128
15181
  const x = b[key];
15129
15182
  const y = a[key];
@@ -15144,7 +15197,7 @@ function aliasChanges(before, after) {
15144
15197
  value: y,
15145
15198
  alias: true
15146
15199
  });
15147
- if ((isMergeable(x) || Array.isArray(x)) && mergesWith(x, y, true) && pathKey(isAt) === k && pathKey(wasAt) === k) walk(x, y, at);
15200
+ if (((isMergeable(x) || Array.isArray(x)) && mergesWith(x, y, true) || sameBuiltin(x, y)) && pathKey(isAt) === k && pathKey(wasAt) === k) walk(x, y, at);
15148
15201
  }
15149
15202
  })(before, after, []);
15150
15203
  return changes;
@@ -15659,11 +15712,13 @@ async function slotRecord(ifid, slot) {
15659
15712
  async function fillSlot(ifid, slot, saveId) {
15660
15713
  await (await getBackend()).setMeta(slotMetaKey(ifid, slot), saveId);
15661
15714
  if (isNamedSlot(slot)) await updateSlotIndex(ifid, (slots) => slots.includes(slot) ? slots : [...slots, slot]);
15715
+ announceSlots(ifid);
15662
15716
  }
15663
15717
  /** Clear a slot: its pointer, and its entry in the slot index. */
15664
15718
  async function emptySlot(ifid, slot) {
15665
15719
  await (await getBackend()).deleteMeta(slotMetaKey(ifid, slot));
15666
15720
  if (isNamedSlot(slot)) await updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
15721
+ announceSlots(ifid);
15667
15722
  }
15668
15723
  /** Clear every slot holding a save whose ID passes `test`. */
15669
15724
  async function emptySlotsHolding(ifid, test) {
@@ -15672,6 +15727,46 @@ async function emptySlotsHolding(ifid, test) {
15672
15727
  if (id && test(id)) await emptySlot(ifid, slot);
15673
15728
  }
15674
15729
  }
15730
+ var SLOTS_CHANNEL = "spindle.slots";
15731
+ /** Every story's slots: what clearing all data announces. */
15732
+ var ALL_STORIES = "*";
15733
+ var slotsChannel;
15734
+ function getSlotsChannel() {
15735
+ if (slotsChannel === void 0) try {
15736
+ slotsChannel = new BroadcastChannel(SLOTS_CHANNEL);
15737
+ slotsChannel.unref?.();
15738
+ } catch {
15739
+ slotsChannel = null;
15740
+ }
15741
+ return slotsChannel;
15742
+ }
15743
+ /** Tell the story's other tabs that its slots changed. */
15744
+ function announceSlots(ifid) {
15745
+ try {
15746
+ getSlotsChannel()?.postMessage(ifid);
15747
+ } catch {}
15748
+ }
15749
+ /**
15750
+ * Call `onChange` when another tab may have filled or emptied the story's
15751
+ * slots: when it says so, and when this tab is shown again. Returns the
15752
+ * function that stops it.
15753
+ */
15754
+ function watchSlotChanges(ifid, onChange) {
15755
+ const channel = getSlotsChannel();
15756
+ const onMessage = (event) => {
15757
+ if (event.data === ifid || event.data === ALL_STORIES) onChange();
15758
+ };
15759
+ const onShown = () => {
15760
+ if (document.visibilityState === "visible") onChange();
15761
+ };
15762
+ const doc = typeof document === "undefined" ? void 0 : document;
15763
+ channel?.addEventListener("message", onMessage);
15764
+ doc?.addEventListener("visibilitychange", onShown);
15765
+ return () => {
15766
+ channel?.removeEventListener("message", onMessage);
15767
+ doc?.removeEventListener("visibilitychange", onShown);
15768
+ };
15769
+ }
15675
15770
  /** The `custom` keys that say which slot a save lives in. */
15676
15771
  var slotCustom = (slot) => ({
15677
15772
  isAutosave: !isNamedSlot(slot),
@@ -15925,6 +16020,7 @@ function clearGameData(ifid) {
15925
16020
  await backend.deletePlaythroughsByIfid(ifid);
15926
16021
  await backend.deleteMetaByIfid(ifid);
15927
16022
  clearSession(ifid);
16023
+ announceSlots(ifid);
15928
16024
  });
15929
16025
  }
15930
16026
  var clearAllData = queued(clearAllDataNow);
@@ -15939,6 +16035,7 @@ async function clearAllDataNow() {
15939
16035
  }
15940
16036
  for (const key of toRemove) sessionStorage.removeItem(key);
15941
16037
  } catch {}
16038
+ announceSlots(ALL_STORIES);
15942
16039
  }
15943
16040
  /**
15944
16041
  * Delete a playthrough with its saves, emptying the slots that held them.
@@ -16023,7 +16120,7 @@ function initializeStory(session) {
16023
16120
  }
16024
16121
  //#endregion
16025
16122
  //#region src/execute-mutation.ts
16026
- var NAMESPACES = [
16123
+ var NAMESPACES$1 = [
16027
16124
  "variables",
16028
16125
  "temporary",
16029
16126
  "transient"
@@ -16146,7 +16243,7 @@ function mirror(draft, change, scopes = activeScopes, cloner = clonerOf) {
16146
16243
  }
16147
16244
  /** A cloner of a copy of its own (no references shared with others). */
16148
16245
  var clonerOf = (target) => clonerFor(target);
16149
- var NAMESPACE_KEYS$1 = new Set(NAMESPACES);
16246
+ var NAMESPACE_KEYS$1 = new Set(NAMESPACES$1);
16150
16247
  /**
16151
16248
  * Runs store updates on the running code's working copies. They stay the
16152
16249
  * code's own, so nothing may freeze them.
@@ -16241,7 +16338,7 @@ function routeStoreUpdate(recipe) {
16241
16338
  transient: inner.work.transient
16242
16339
  };
16243
16340
  const [next, patches] = scratch.produceWithPatches(view, recipe);
16244
- const changes = NAMESPACES.flatMap((ns) => {
16341
+ const changes = NAMESPACES$1.flatMap((ns) => {
16245
16342
  const own = patches.filter((p) => p.path[0] === ns);
16246
16343
  return own.length ? writtenPaths(view[ns], next[ns], own).map((change) => ({
16247
16344
  ...change,
@@ -16659,8 +16756,21 @@ function reinitTriggerState() {
16659
16756
  */
16660
16757
  function resetTriggers() {
16661
16758
  triggers = [];
16759
+ resets++;
16662
16760
  closeAllOpenDialogs();
16761
+ resetListeners.notify();
16762
+ }
16763
+ var resets = 0;
16764
+ var resetListeners = createListeners();
16765
+ /** How many times resetTriggers has forgotten every watcher. */
16766
+ function triggerResets() {
16767
+ return resets;
16663
16768
  }
16769
+ /**
16770
+ * Call `listener` after each resetTriggers (a restart): a {watch} that stays
16771
+ * mounted, in the story interface, registers its watcher again (#402).
16772
+ */
16773
+ var subscribeTriggerResets = resetListeners.subscribe;
16664
16774
  function subscribeTriggerDialogs(cb) {
16665
16775
  dialogNotify = cb;
16666
16776
  if (dialogQueue.length > 0) cb();
@@ -17148,8 +17258,21 @@ var latestStateApplied = 0;
17148
17258
  var slotLoadApplying = null;
17149
17259
  /** A replacement of the game state applied at its call. */
17150
17260
  function replaceStateNow() {
17151
- latestStateApplied = ++stateReplacementsIssued;
17261
+ applyReplacement(++stateReplacementsIssued);
17262
+ }
17263
+ var replacementListeners = createListeners();
17264
+ /** Record that replacement `n` is applied, and tell the listeners. */
17265
+ function applyReplacement(n) {
17266
+ latestStateApplied = n;
17267
+ replacementListeners.notify();
17152
17268
  }
17269
+ /**
17270
+ * Call `listener` whenever a boot, restart or load replaces the game state:
17271
+ * work the replaced game started that would write into the new one later (a
17272
+ * click body's timer in the story interface, which stays mounted) is to
17273
+ * stop (#401).
17274
+ */
17275
+ var subscribeStateReplacement = replacementListeners.subscribe;
17153
17276
  var runtimeUnsubs = [];
17154
17277
  var inRuntimePhase = false;
17155
17278
  /**
@@ -17381,6 +17504,16 @@ function recordKnownSave(set, slot, known) {
17381
17504
  }
17382
17505
  });
17383
17506
  }
17507
+ /** Look up which slots hold a save again, as an operation queued now. */
17508
+ function refreshKnownSaves(set, ifid) {
17509
+ return populateKnownSaves(ifid).then((known) => {
17510
+ set((state) => {
17511
+ state.knownSaves = known;
17512
+ });
17513
+ });
17514
+ }
17515
+ /** Stops following the slot changes of other tabs (see init). */
17516
+ var stopWatchingSlots;
17384
17517
  /**
17385
17518
  * Queue the clearing of saved data, then restart now: the new playthrough
17386
17519
  * is stored after it, and operations issued from here on belong to the new
@@ -17462,6 +17595,10 @@ var useStoryStore = create()(storyStore((set, get) => ({
17462
17595
  console.error("spindle: failed to init save system", err);
17463
17596
  return "";
17464
17597
  }));
17598
+ stopWatchingSlots?.();
17599
+ stopWatchingSlots = watchSlotChanges(storyData.ifid, () => {
17600
+ refreshKnownSaves(set, storyData.ifid).catch(() => {});
17601
+ });
17465
17602
  },
17466
17603
  navigate: (passageName) => {
17467
17604
  if (navigationTriggerPhase) {
@@ -17681,12 +17818,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
17681
17818
  });
17682
17819
  if (playthroughId !== "" && playthroughId === current) switchToPlaythrough(replacementId, deletion);
17683
17820
  else if (current === "") switchToLookedUpPlaythrough(deletion.then((replaced) => replaced ? replacementId : established, () => established));
17684
- return reported(deletion.then(async () => {
17685
- const known = await populateKnownSaves(storyData.ifid);
17686
- set((state) => {
17687
- state.knownSaves = known;
17688
- });
17689
- }), "spindle: failed to delete playthrough");
17821
+ return reported(deletion.then(() => refreshKnownSaves(set, storyData.ifid)), "spindle: failed to delete playthrough");
17690
17822
  },
17691
17823
  getSavePayload: () => {
17692
17824
  const { currentPassage, variables, history, historyIndex, visitCounts, renderCounts } = get();
@@ -17723,7 +17855,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
17723
17855
  return;
17724
17856
  }
17725
17857
  assertPassagesExist(get().storyData, payload);
17726
- latestStateApplied = replacement;
17858
+ applyReplacement(replacement);
17727
17859
  emit("beforeload", slot);
17728
17860
  const ifid = get().storyData?.ifid;
17729
17861
  if (playthroughId && ifid) switchToLoadedPlaythrough(ifid, playthroughId);
@@ -17818,6 +17950,8 @@ var FOCUSABLE = [
17818
17950
  "select:not([disabled])",
17819
17951
  "textarea:not([disabled])",
17820
17952
  "iframe",
17953
+ "audio[controls]",
17954
+ "video[controls]",
17821
17955
  "summary",
17822
17956
  "[contenteditable]",
17823
17957
  "[tabindex]"
@@ -18399,37 +18533,85 @@ var settings = {
18399
18533
  //#endregion
18400
18534
  //#region src/utils/control-edits.ts
18401
18535
  /**
18402
- * Records the edits a reader made through a control bound to a story
18403
- * variable (`{textbox}`, `{checkbox}`, ...). {for} tells these apart from a
18404
- * list that is replaced: typing into a control inside an iteration changes
18405
- * its item, but must not remount the iteration and drop the control's focus.
18536
+ * Records the edits made to the items of a list from inside a {for}
18537
+ * iteration, which {for} tells apart from a list that is replaced:
18538
+ *
18539
+ * - a reader editing a variable through a control (`{textbox}`,
18540
+ * `{checkbox}`, ...): typing into a control inside an iteration changes
18541
+ * its item, but must not remount the iteration and drop the control's
18542
+ * focus (#369);
18543
+ * - mutation code an iteration runs as it renders (`{set $party[@i].hp +=
18544
+ * 1}`): remounting would run the iteration's mount-only macros, and so the
18545
+ * write, again for every write it makes (#400).
18546
+ *
18406
18547
  * An edit is attributed to the variable path it wrote, so a replacement made
18407
- * elsewhere (a watcher reacting to the edit) still remounts.
18548
+ * elsewhere (a watcher reacting to the edit, a button outside the loop)
18549
+ * still remounts.
18408
18550
  */
18409
18551
  var version = 0;
18410
- var MAX_LOG = 256;
18552
+ var MAX_LOG = 4096;
18553
+ /** Edits in the order made: paths below the namespaces (`['variables', 'a']`). */
18411
18554
  var log = [];
18412
- function noteControlEdit(segments) {
18555
+ function note(path) {
18413
18556
  log.push({
18414
18557
  version: ++version,
18415
- segments
18558
+ path
18416
18559
  });
18417
18560
  if (log.length > MAX_LOG) log.shift();
18418
18561
  }
18562
+ /** Note an edit a control made to the story variable at `segments`. */
18563
+ function noteControlEdit(segments) {
18564
+ note(["variables", ...segments]);
18565
+ }
18566
+ /**
18567
+ * Whether mutation code runs inside a {for} iteration: {for} provides true
18568
+ * around its iterations, and the writes of such code are noted as edits of
18569
+ * the items they reach (see runAsItemEdit).
18570
+ */
18571
+ var ItemEditContext = createContext(false);
18572
+ var NAMESPACES = [
18573
+ "variables",
18574
+ "temporary",
18575
+ "transient"
18576
+ ];
18577
+ /** Run `run`, noting each variable path it changes as an edit. */
18578
+ function runAsItemEdit(run) {
18579
+ const before = readState();
18580
+ try {
18581
+ run();
18582
+ } finally {
18583
+ const after = readState();
18584
+ for (const ns of NAMESPACES) {
18585
+ if (before[ns] === after[ns]) continue;
18586
+ for (const { path } of diffPaths(before[ns], after[ns], true)) note([ns, ...path]);
18587
+ }
18588
+ }
18589
+ }
18419
18590
  function controlEditVersion() {
18420
18591
  return version;
18421
18592
  }
18422
18593
  /**
18423
- * Whether an edit since `since` wrote inside `item`, element `index` of
18424
- * `list`: the path leads through the item (compared by reference in
18425
- * `variables`), or, for a primitive item, ends at its slot in the list.
18426
- */
18427
- function editedItem(since, variables, list, item, index) {
18428
- return log.some(({ version: v, segments }) => {
18429
- if (v <= since) return false;
18430
- if (typeof item === "object" && item !== null) return segments.some((_, n) => getByPath(variables, segments.slice(0, n + 1)) === item);
18431
- return segments[segments.length - 1] === String(index) && getByPath(variables, segments.slice(0, -1)) === list;
18432
- });
18594
+ * A test of whether an edit made since `since` wrote inside `item`, element
18595
+ * `index` of `list`: the path leads through the item (compared by reference
18596
+ * in `state`), or, for a primitive item, ends at its slot in the list.
18597
+ */
18598
+ function editedItems(since, state) {
18599
+ const through = /* @__PURE__ */ new Set();
18600
+ const ends = /* @__PURE__ */ new Map();
18601
+ for (let i = log.length - 1; i >= 0 && log[i].version > since; i--) {
18602
+ const { path } = log[i];
18603
+ let node = state;
18604
+ path.forEach((seg, n) => {
18605
+ if (n === path.length - 1 && node !== null && typeof node === "object") {
18606
+ const keys = ends.get(node) ?? /* @__PURE__ */ new Set();
18607
+ keys.add(seg);
18608
+ ends.set(node, keys);
18609
+ }
18610
+ node = node !== null && typeof node === "object" ? getByPath(node, [seg]) : void 0;
18611
+ if (node !== null && typeof node === "object") through.add(node);
18612
+ });
18613
+ }
18614
+ return (list, item, index) => typeof item === "object" && item !== null ? through.has(item) : ends.get(list)?.has(String(index)) === true;
18433
18615
  }
18434
18616
  //#endregion
18435
18617
  //#region src/action-registry.ts
@@ -18626,6 +18808,7 @@ function defineMacro(config, source = "builtin") {
18626
18808
  id = resolve(id);
18627
18809
  }
18628
18810
  const { update, getValues } = useContext(LocalsUpdateContext);
18811
+ const inIteration = useContext(ItemEditContext);
18629
18812
  const renderOptions = useRenderOptions();
18630
18813
  const renderNodes$1 = (nodes, options) => renderNodes(nodes, {
18631
18814
  ...renderOptions,
@@ -18656,7 +18839,7 @@ function defineMacro(config, source = "builtin") {
18656
18839
  id,
18657
18840
  resolve,
18658
18841
  cls: macroClass(config.name, className),
18659
- mutate: (code) => executeMutation(code, getValues(), update),
18842
+ mutate: inIteration ? (code) => runAsItemEdit(() => executeMutation(code, getValues(), update)) : (code) => executeMutation(code, getValues(), update),
18660
18843
  update,
18661
18844
  getValues,
18662
18845
  wrap: (content) => wrapContent(className, id, content)
@@ -18668,7 +18851,12 @@ function defineMacro(config, source = "builtin") {
18668
18851
  }
18669
18852
  if (config.storeVar) {
18670
18853
  const firstToken = readBoundVariable(props.rawArgs).replace(/["']/g, "");
18671
- if (firstToken.startsWith("%")) return h("span", { class: "error" }, `{${config.name}}: transient variables (%${firstToken.slice(1)}) cannot be bound to input macros`);
18854
+ const kind = {
18855
+ "%": "transient variables",
18856
+ _: "temporary variables",
18857
+ "@": "locals"
18858
+ }[firstToken.charAt(0)];
18859
+ if (kind) return h("span", { class: "error" }, `{${config.name}}: ${kind} (${firstToken}) cannot be bound to input macros`);
18672
18860
  const varExpr = firstToken.replace(/["']/g, "").replace(/^\$/, "");
18673
18861
  const segments = varExpr.split(".");
18674
18862
  if (segments.includes("__proto__")) return h("span", { class: "error" }, `{${config.name}}: "$${varExpr}" cannot be bound: ${RESERVED_NAME} is reserved`);
@@ -18962,7 +19150,7 @@ function createStoryAPI() {
18962
19150
  },
18963
19151
  deferRender() {
18964
19152
  useStoryStore.getState().deferRender();
18965
- readyPromise = new Promise((resolve) => {
19153
+ readyPromise ??= new Promise((resolve) => {
18966
19154
  readyResolve = resolve;
18967
19155
  });
18968
19156
  },
@@ -19521,16 +19709,25 @@ var PRIMITIVE_SAMPLES = {
19521
19709
  number: Object(0),
19522
19710
  boolean: Object(false)
19523
19711
  };
19524
- function inferSchema(value) {
19712
+ /**
19713
+ * The schema of a default value. An object met again (a backreference, a
19714
+ * shared subtree) has the schema made for it the first time, so a cyclic
19715
+ * default gives a schema that refers to itself instead of recursing forever.
19716
+ */
19717
+ function inferSchema(value, seen = /* @__PURE__ */ new Map()) {
19525
19718
  if (Array.isArray(value)) return { type: "array" };
19526
19719
  if (value === null) return { type: "null" };
19527
19720
  if (typeof value === "object") {
19721
+ const known = seen.get(value);
19722
+ if (known) return known;
19528
19723
  const fields = /* @__PURE__ */ new Map();
19529
- for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
19530
- return {
19724
+ const schema = {
19531
19725
  type: "object",
19532
19726
  fields
19533
19727
  };
19728
+ seen.set(value, schema);
19729
+ for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val, seen));
19730
+ return schema;
19534
19731
  }
19535
19732
  const jsType = typeof value;
19536
19733
  if (!VALID_VAR_TYPES.has(jsType)) throw new Error(`Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`);
@@ -20028,23 +20225,32 @@ defineMacro({
20028
20225
  * Content-derived string key for a value, used to remount components when
20029
20226
  * the value's contents change (e.g. {for} iterations, #45).
20030
20227
  *
20031
- * JSON-compatible values (strings, finite numbers, booleans, null, arrays and
20032
- * plain objects of those) produce exactly their `JSON.stringify` output. Other
20033
- * values get distinct, unambiguous markers so their contents are reflected
20034
- * too: Map and Set entries (nested at any depth), Date, RegExp, BigInt,
20035
- * undefined, NaN/±Infinity, symbols (by description), functions (by name)
20036
- * and registered class instances. A hole in an array reads as undefined.
20037
- * Cyclic references become a back-reference marker instead of throwing,
20038
- * and the function never throws.
20228
+ * A tree of JSON-compatible values (strings, finite numbers, booleans, null,
20229
+ * arrays and plain objects of those) produces exactly its `JSON.stringify`
20230
+ * output. Other values get distinct, unambiguous markers so their contents
20231
+ * are reflected too: Map and Set entries (nested at any depth), Date,
20232
+ * RegExp, BigInt, undefined, NaN/±Infinity, symbols (by description),
20233
+ * functions (by name) and registered class instances, with the class name
20234
+ * and, for a subclass of a built-in, its own fields (#407). A hole in an
20235
+ * array reads as undefined.
20236
+ *
20237
+ * An object met again, through a cycle or a second reference to it, is a
20238
+ * marker naming where it was first met rather than another copy of its
20239
+ * contents, so the key grows with the objects a value holds, not with the
20240
+ * paths to them (#406). The function never throws.
20039
20241
  */
20040
20242
  function stableKey(value) {
20041
20243
  try {
20042
- return keyOf(value, []);
20244
+ return keyOf(value, /* @__PURE__ */ new Map());
20043
20245
  } catch {
20044
20246
  return "<unkeyable>";
20045
20247
  }
20046
20248
  }
20047
- function keyOf(val, ancestors) {
20249
+ /**
20250
+ * The key of `val`. `seen` numbers the objects keyed so far, in the order
20251
+ * they were first met.
20252
+ */
20253
+ function keyOf(val, seen) {
20048
20254
  switch (typeof val) {
20049
20255
  case "string": return JSON.stringify(val);
20050
20256
  case "number": return Number.isFinite(val) ? JSON.stringify(val) : String(val);
@@ -20056,22 +20262,28 @@ function keyOf(val, ancestors) {
20056
20262
  }
20057
20263
  if (val === null) return "null";
20058
20264
  const obj = val;
20059
- const depth = ancestors.indexOf(obj);
20060
- if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
20061
- if (val instanceof Date) return `Date(${Date.prototype.getTime.call(val)})`;
20062
- if (val instanceof RegExp) return `RegExp(${RegExp.prototype.toString.call(val)}@${val.lastIndex})`;
20063
- ancestors.push(obj);
20064
- try {
20065
- if (Array.isArray(val)) return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(",")}]`;
20066
- if (val instanceof Map) return `Map[${[...mapEntries(val)].map(([k, v]) => `[${keyOf(k, ancestors)},${keyOf(v, ancestors)}]`).join(",")}]`;
20067
- if (val instanceof Set) return `Set[${[...setMembers(val)].map((v) => keyOf(v, ancestors)).join(",")}]`;
20068
- const record = obj;
20069
- const body = Object.keys(record).map((k) => `${JSON.stringify(k)}:${keyOf(record[k], ancestors)}`).join(",");
20070
- const className = registeredClassName(obj);
20071
- return className === void 0 ? `{${body}}` : `Class(${JSON.stringify(className)}){${body}}`;
20072
- } finally {
20073
- ancestors.pop();
20265
+ const first = seen.get(obj);
20266
+ if (first !== void 0) return `<ref ${first}>`;
20267
+ const className = registeredClassName(obj);
20268
+ const named = (key) => className === void 0 ? key : `Class(${JSON.stringify(className)})${key}`;
20269
+ const withFields = (key) => {
20270
+ const keys = className === void 0 ? [] : extraKeys(obj);
20271
+ return named(keys.length ? `${key}${fieldsOf(obj, keys, seen)}` : key);
20272
+ };
20273
+ if (val instanceof Date || val instanceof RegExp) {
20274
+ if (className !== void 0 && extraKeys(obj).length) seen.set(obj, seen.size);
20275
+ return withFields(val instanceof Date ? `Date(${Date.prototype.getTime.call(val)})` : `RegExp(${RegExp.prototype.toString.call(val)}@${val.lastIndex})`);
20074
20276
  }
20277
+ seen.set(obj, seen.size);
20278
+ if (Array.isArray(val)) return withFields(`[${Array.from(val, (v) => keyOf(v, seen)).join(",")}]`);
20279
+ if (val instanceof Map) return withFields(`Map[${Array.from(mapEntries(val), ([k, v]) => `[${keyOf(k, seen)},${keyOf(v, seen)}]`).join(",")}]`);
20280
+ if (val instanceof Set) return withFields(`Set[${Array.from(setMembers(val), (v) => keyOf(v, seen)).join(",")}]`);
20281
+ return named(fieldsOf(obj, Object.keys(obj), seen));
20282
+ }
20283
+ /** `{"key":value,...}` for the `keys` of `obj`. */
20284
+ function fieldsOf(obj, keys, seen) {
20285
+ const record = obj;
20286
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${keyOf(record[k], seen)}`).join(",")}}`;
20075
20287
  }
20076
20288
  //#endregion
20077
20289
  //#region src/components/macros/For.tsx
@@ -20153,12 +20365,11 @@ defineMacro({
20153
20365
  });
20154
20366
  }
20155
20367
  const { itemVar, indexVar } = loop;
20156
- const since = keys.edits;
20157
- const { variables } = readState();
20368
+ const edited = editedItems(keys.edits, readState());
20158
20369
  keys.edits = controlEditVersion();
20159
20370
  const generations = list.map((item, i) => {
20160
20371
  const contents = stableKey(item);
20161
- if (keys.generations[i] === void 0 || contents !== keys.contents[i] && !editedItem(since, variables, list, item, i)) keys.generations[i] = keys.next++;
20372
+ if (keys.generations[i] === void 0 || contents !== keys.contents[i] && !edited(list, item, i)) keys.generations[i] = keys.next++;
20162
20373
  keys.contents[i] = contents;
20163
20374
  return keys.generations[i];
20164
20375
  });
@@ -20171,7 +20382,10 @@ defineMacro({
20171
20382
  indexValue: i,
20172
20383
  children
20173
20384
  }, `${i}-${generations[i]}`));
20174
- return ctx.wrap(content);
20385
+ return ctx.wrap(/* @__PURE__ */ jsx(ItemEditContext.Provider, {
20386
+ value: true,
20387
+ children: content
20388
+ }));
20175
20389
  },
20176
20390
  text({ rawArgs, children = [] }, ctx) {
20177
20391
  const { itemVar, indexVar, listExpr } = loopArgs(rawArgs, ctx.args);
@@ -20226,14 +20440,16 @@ defineMacro({
20226
20440
  interpolate: true,
20227
20441
  parameters: [DELAY_PARAMETER],
20228
20442
  render({ children = [] }, ctx) {
20229
- const { useState, useEffect, useCallback } = ctx.hooks;
20443
+ const { useState, useEffect, useCallback, useContext } = ctx.hooks;
20230
20444
  const delay = ctx.args.delay ?? 0;
20231
20445
  const [count, setCount] = useState(0);
20232
20446
  const [stopped, setStopped] = useState(false);
20447
+ const hasLeft = useContext(OutgoingContext);
20233
20448
  const stop = useCallback(() => setStopped(true), []);
20234
20449
  useEffect(() => {
20235
20450
  if (stopped) return;
20236
20451
  const interval = setInterval(() => {
20452
+ if (hasLeft?.()) return;
20237
20453
  setCount((c) => c + 1);
20238
20454
  }, delay);
20239
20455
  return () => clearInterval(interval);
@@ -20262,13 +20478,16 @@ var frozenAlways = () => true;
20262
20478
  * once it has rendered, so it runs once per click. It stays mounted
20263
20479
  * until the owning macro unmounts (its passage changes, say), so work it
20264
20480
  * starts in an effect, such as the timer of a {timed} or {repeat}, can
20265
- * finish; the passage leaving cancels it.
20481
+ * finish; the passage leaving cancels it, and so does a restart or a load
20482
+ * replacing the game, which a control of the story interface outlives
20483
+ * (#401).
20266
20484
  *
20267
20485
  * A detached render starts with no contexts, so the hook captures the ones
20268
20486
  * the owning macro sees and provides them again: the locals scope (read
20269
20487
  * through a live view, so a local assigned earlier in the body is visible),
20270
20488
  * nobr/inline rendering, the enclosing block widget's {@children}, the
20271
- * enclosing {repeat}'s {stop} and the enclosing dialog's close callback.
20489
+ * enclosing {repeat}'s {stop} and the enclosing dialog's close callback and whether it runs in a {for} iteration
20490
+ * (#411) and the owning passage's departure (#410).
20272
20491
  * The body then behaves as if it rendered in place.
20273
20492
  *
20274
20493
  * The body's macros read story state from the store. Run from mutation code
@@ -20283,9 +20502,20 @@ function useDetachedBody() {
20283
20502
  const widgetChildren = useContext(WidgetChildrenContext);
20284
20503
  const repeat = useContext(RepeatContext);
20285
20504
  const closeDialog = useContext(DialogCloseContext);
20505
+ const inIteration = useContext(ItemEditContext);
20506
+ const hasLeft = useContext(OutgoingContext);
20286
20507
  const mounted = useRef([]);
20287
- useEffect(() => () => {
20288
- for (const container of mounted.current.splice(0)) render(null, container);
20508
+ useEffect(() => {
20509
+ const unsubscribe = subscribeStateReplacement(() => {
20510
+ const replaced = mounted.current.splice(0);
20511
+ queueMicrotask(() => {
20512
+ for (const container of replaced) render(null, container);
20513
+ });
20514
+ });
20515
+ return () => {
20516
+ unsubscribe();
20517
+ for (const container of mounted.current.splice(0)) render(null, container);
20518
+ };
20289
20519
  }, []);
20290
20520
  const run = (children) => {
20291
20521
  const locals = liveLocalsView(updater.getValues);
@@ -20306,10 +20536,16 @@ function useDetachedBody() {
20306
20536
  value: repeat,
20307
20537
  children: /* @__PURE__ */ jsx(DialogCloseContext.Provider, {
20308
20538
  value: closeDialog,
20309
- children: renderNodes(children, {
20310
- nobr,
20311
- inline,
20312
- locals
20539
+ children: /* @__PURE__ */ jsx(ItemEditContext.Provider, {
20540
+ value: inIteration,
20541
+ children: /* @__PURE__ */ jsx(OutgoingContext.Provider, {
20542
+ value: hasLeft,
20543
+ children: renderNodes(children, {
20544
+ nobr,
20545
+ inline,
20546
+ locals
20547
+ })
20548
+ })
20313
20549
  })
20314
20550
  })
20315
20551
  })
@@ -20468,7 +20704,8 @@ defineMacro({
20468
20704
  interpolate: true,
20469
20705
  parameters: TIMED_PARAMETERS,
20470
20706
  render({ branches = [] }, ctx) {
20471
- const { useState, useEffect, useMemo } = ctx.hooks;
20707
+ const { useState, useEffect, useMemo, useContext } = ctx.hooks;
20708
+ const hasLeft = useContext(OutgoingContext);
20472
20709
  const sections = useMemo(() => {
20473
20710
  return branches.map((branch) => ({
20474
20711
  delay: parseMacroArgs(branch.rawArgs, TIMED_PARAMETERS).delay ?? 0,
@@ -20483,6 +20720,7 @@ defineMacro({
20483
20720
  const nextIndex = visibleIndex + 1;
20484
20721
  const delay = sections[nextIndex].delay;
20485
20722
  const timer = setTimeout(() => {
20723
+ if (hasLeft?.()) return;
20486
20724
  setVisibleIndex(nextIndex);
20487
20725
  }, delay);
20488
20726
  return () => clearTimeout(timer);
@@ -21790,6 +22028,7 @@ defineMacro({
21790
22028
  const prevHistoryLenRef = useRef(0);
21791
22029
  const displayedId = displayed.id;
21792
22030
  const isOutgoing = useCallback((state) => state.navigationId !== displayedId, [displayedId]);
22031
+ const hasLeft = useCallback(() => isOutgoing(useStoryStore.getState()), [isOutgoing]);
21793
22032
  const lastElement = useRef(null);
21794
22033
  const containerRef = useRef(null);
21795
22034
  const timeoutsRef = useRef([]);
@@ -21896,7 +22135,10 @@ defineMacro({
21896
22135
  ref: containerRef,
21897
22136
  children: passageElement && /* @__PURE__ */ jsx(FrozenStateContext.Provider, {
21898
22137
  value: isOutgoing,
21899
- children: passageElement
22138
+ children: /* @__PURE__ */ jsx(OutgoingContext.Provider, {
22139
+ value: hasLeft,
22140
+ children: passageElement
22141
+ })
21900
22142
  })
21901
22143
  })]
21902
22144
  });
@@ -21949,9 +22191,12 @@ defineMacro({
21949
22191
  const { hooks } = ctx;
21950
22192
  const { condition, options } = ctx.args;
21951
22193
  if (condition === void 0) return null;
21952
- const registered = hooks.useRef(false);
21953
- if (!registered.current) {
21954
- registered.current = true;
22194
+ const frozen = hooks.useContext(FrozenStateContext);
22195
+ const [, rerender] = hooks.useState(0);
22196
+ hooks.useEffect(() => subscribeTriggerResets(() => rerender((n) => n + 1)), []);
22197
+ const registered = hooks.useRef(null);
22198
+ if (registered.current === null || registered.current !== triggerResets() && !frozen?.(useStoryStore.getState())) {
22199
+ registered.current = triggerResets();
21955
22200
  addMacroTrigger(condition, options);
21956
22201
  }
21957
22202
  return null;