@rohal12/spindle 0.59.15 → 0.59.16

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.
@@ -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
  /**
@@ -11054,7 +11071,7 @@ function getPRNGPull() {
11054
11071
  * (see src/tooling.ts).
11055
11072
  */
11056
11073
  var TRANSFORM_CACHE_MAX = 500;
11057
- var NAMESPACES$1 = {
11074
+ var NAMESPACES$2 = {
11058
11075
  $: "variables",
11059
11076
  _: "temporary",
11060
11077
  "@": "locals",
@@ -11087,7 +11104,7 @@ function transform(expr, goal = "expression") {
11087
11104
  result += expr.slice(at, ref.start);
11088
11105
  if (ref.shorthand) result += `${ref.name}: `;
11089
11106
  else if (IDENT_END_RE.test(result.slice(-2))) result += " ";
11090
- result += `${NAMESPACES$1[ref.sigil]}["${ref.name}"]`;
11107
+ result += `${NAMESPACES$2[ref.sigil]}["${ref.name}"]`;
11091
11108
  at = ref.end;
11092
11109
  }
11093
11110
  result += expr.slice(at);
@@ -14437,10 +14454,12 @@ function shallowCopy(value) {
14437
14454
  * written in place.
14438
14455
  *
14439
14456
  * 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.
14457
+ * and arrays (by index), and, outside a draft, any key of an instance of a
14458
+ * registered subclass of a built-in (#408); anything else throws a
14459
+ * TypeError rather than writing where no clone or save would see it: an
14460
+ * inherited property such as a method counts as missing, and Map, Set,
14461
+ * Date and RegExp values and other keys of arrays are refused, as is a
14462
+ * "__proto__" segment.
14444
14463
  */
14445
14464
  function setByPath(root, segments, value, options = {}) {
14446
14465
  const parent = walkToParent(root, segments, options.createMissing ?? false);
@@ -14475,8 +14494,15 @@ function deleteByPath(root, segments) {
14475
14494
  function checkSegments(segments) {
14476
14495
  if (segments.includes("__proto__")) throw new TypeError(`spindle: Cannot use "__proto__" in a variable path ("${segments.join(".")}")`);
14477
14496
  }
14478
- /** Throw unless `holder` can take `key` as story state (see setByPath). */
14479
- function checkHolder(holder, key, segments, depth) {
14497
+ /**
14498
+ * Throw unless `holder` can take `key` as story state (see setByPath). An
14499
+ * instance of a registered subclass of a built-in keeps its own fields
14500
+ * through clones and saves, so it takes any key (#408) when written in
14501
+ * place: a draft of one, or one a draft shares, is refused as other
14502
+ * built-ins are, and the caller writes the whole value instead.
14503
+ */
14504
+ function checkHolder(holder, key, segments, depth, inPlace) {
14505
+ if (inPlace && registeredClassName(holder) !== void 0) return;
14480
14506
  const kind = builtinName(holder) ?? (Array.isArray(holder) && !isArrayKey(key) ? "an array" : "");
14481
14507
  if (kind) throw new TypeError(`spindle: Cannot set property "${key}" on ${kind} (at "${segments.slice(0, depth).join(".")}")`);
14482
14508
  }
@@ -14492,7 +14518,7 @@ function walkToParent(root, segments, createMissing) {
14492
14518
  let current = root;
14493
14519
  for (let i = 0; i < segments.length - 1; i++) {
14494
14520
  const seg = segments[i];
14495
- checkHolder(current, seg, segments, i);
14521
+ checkHolder(current, seg, segments, i, !copyOnWrite);
14496
14522
  let next = hasOwn(current, seg) ? current[seg] : void 0;
14497
14523
  if (next == null || typeof next !== "object") {
14498
14524
  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 +14530,7 @@ function walkToParent(root, segments, createMissing) {
14504
14530
  }
14505
14531
  current = next;
14506
14532
  }
14507
- checkHolder(current, segments[segments.length - 1], segments, segments.length - 1);
14533
+ checkHolder(current, segments[segments.length - 1], segments, segments.length - 1, !copyOnWrite);
14508
14534
  return current;
14509
14535
  }
14510
14536
  //#endregion
@@ -15659,11 +15685,13 @@ async function slotRecord(ifid, slot) {
15659
15685
  async function fillSlot(ifid, slot, saveId) {
15660
15686
  await (await getBackend()).setMeta(slotMetaKey(ifid, slot), saveId);
15661
15687
  if (isNamedSlot(slot)) await updateSlotIndex(ifid, (slots) => slots.includes(slot) ? slots : [...slots, slot]);
15688
+ announceSlots(ifid);
15662
15689
  }
15663
15690
  /** Clear a slot: its pointer, and its entry in the slot index. */
15664
15691
  async function emptySlot(ifid, slot) {
15665
15692
  await (await getBackend()).deleteMeta(slotMetaKey(ifid, slot));
15666
15693
  if (isNamedSlot(slot)) await updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
15694
+ announceSlots(ifid);
15667
15695
  }
15668
15696
  /** Clear every slot holding a save whose ID passes `test`. */
15669
15697
  async function emptySlotsHolding(ifid, test) {
@@ -15672,6 +15700,46 @@ async function emptySlotsHolding(ifid, test) {
15672
15700
  if (id && test(id)) await emptySlot(ifid, slot);
15673
15701
  }
15674
15702
  }
15703
+ var SLOTS_CHANNEL = "spindle.slots";
15704
+ /** Every story's slots: what clearing all data announces. */
15705
+ var ALL_STORIES = "*";
15706
+ var slotsChannel;
15707
+ function getSlotsChannel() {
15708
+ if (slotsChannel === void 0) try {
15709
+ slotsChannel = new BroadcastChannel(SLOTS_CHANNEL);
15710
+ slotsChannel.unref?.();
15711
+ } catch {
15712
+ slotsChannel = null;
15713
+ }
15714
+ return slotsChannel;
15715
+ }
15716
+ /** Tell the story's other tabs that its slots changed. */
15717
+ function announceSlots(ifid) {
15718
+ try {
15719
+ getSlotsChannel()?.postMessage(ifid);
15720
+ } catch {}
15721
+ }
15722
+ /**
15723
+ * Call `onChange` when another tab may have filled or emptied the story's
15724
+ * slots: when it says so, and when this tab is shown again. Returns the
15725
+ * function that stops it.
15726
+ */
15727
+ function watchSlotChanges(ifid, onChange) {
15728
+ const channel = getSlotsChannel();
15729
+ const onMessage = (event) => {
15730
+ if (event.data === ifid || event.data === ALL_STORIES) onChange();
15731
+ };
15732
+ const onShown = () => {
15733
+ if (document.visibilityState === "visible") onChange();
15734
+ };
15735
+ const doc = typeof document === "undefined" ? void 0 : document;
15736
+ channel?.addEventListener("message", onMessage);
15737
+ doc?.addEventListener("visibilitychange", onShown);
15738
+ return () => {
15739
+ channel?.removeEventListener("message", onMessage);
15740
+ doc?.removeEventListener("visibilitychange", onShown);
15741
+ };
15742
+ }
15675
15743
  /** The `custom` keys that say which slot a save lives in. */
15676
15744
  var slotCustom = (slot) => ({
15677
15745
  isAutosave: !isNamedSlot(slot),
@@ -15925,6 +15993,7 @@ function clearGameData(ifid) {
15925
15993
  await backend.deletePlaythroughsByIfid(ifid);
15926
15994
  await backend.deleteMetaByIfid(ifid);
15927
15995
  clearSession(ifid);
15996
+ announceSlots(ifid);
15928
15997
  });
15929
15998
  }
15930
15999
  var clearAllData = queued(clearAllDataNow);
@@ -15939,6 +16008,7 @@ async function clearAllDataNow() {
15939
16008
  }
15940
16009
  for (const key of toRemove) sessionStorage.removeItem(key);
15941
16010
  } catch {}
16011
+ announceSlots(ALL_STORIES);
15942
16012
  }
15943
16013
  /**
15944
16014
  * Delete a playthrough with its saves, emptying the slots that held them.
@@ -16023,7 +16093,7 @@ function initializeStory(session) {
16023
16093
  }
16024
16094
  //#endregion
16025
16095
  //#region src/execute-mutation.ts
16026
- var NAMESPACES = [
16096
+ var NAMESPACES$1 = [
16027
16097
  "variables",
16028
16098
  "temporary",
16029
16099
  "transient"
@@ -16146,7 +16216,7 @@ function mirror(draft, change, scopes = activeScopes, cloner = clonerOf) {
16146
16216
  }
16147
16217
  /** A cloner of a copy of its own (no references shared with others). */
16148
16218
  var clonerOf = (target) => clonerFor(target);
16149
- var NAMESPACE_KEYS$1 = new Set(NAMESPACES);
16219
+ var NAMESPACE_KEYS$1 = new Set(NAMESPACES$1);
16150
16220
  /**
16151
16221
  * Runs store updates on the running code's working copies. They stay the
16152
16222
  * code's own, so nothing may freeze them.
@@ -16241,7 +16311,7 @@ function routeStoreUpdate(recipe) {
16241
16311
  transient: inner.work.transient
16242
16312
  };
16243
16313
  const [next, patches] = scratch.produceWithPatches(view, recipe);
16244
- const changes = NAMESPACES.flatMap((ns) => {
16314
+ const changes = NAMESPACES$1.flatMap((ns) => {
16245
16315
  const own = patches.filter((p) => p.path[0] === ns);
16246
16316
  return own.length ? writtenPaths(view[ns], next[ns], own).map((change) => ({
16247
16317
  ...change,
@@ -16659,8 +16729,21 @@ function reinitTriggerState() {
16659
16729
  */
16660
16730
  function resetTriggers() {
16661
16731
  triggers = [];
16732
+ resets++;
16662
16733
  closeAllOpenDialogs();
16734
+ resetListeners.notify();
16735
+ }
16736
+ var resets = 0;
16737
+ var resetListeners = createListeners();
16738
+ /** How many times resetTriggers has forgotten every watcher. */
16739
+ function triggerResets() {
16740
+ return resets;
16663
16741
  }
16742
+ /**
16743
+ * Call `listener` after each resetTriggers (a restart): a {watch} that stays
16744
+ * mounted, in the story interface, registers its watcher again (#402).
16745
+ */
16746
+ var subscribeTriggerResets = resetListeners.subscribe;
16664
16747
  function subscribeTriggerDialogs(cb) {
16665
16748
  dialogNotify = cb;
16666
16749
  if (dialogQueue.length > 0) cb();
@@ -17148,8 +17231,21 @@ var latestStateApplied = 0;
17148
17231
  var slotLoadApplying = null;
17149
17232
  /** A replacement of the game state applied at its call. */
17150
17233
  function replaceStateNow() {
17151
- latestStateApplied = ++stateReplacementsIssued;
17234
+ applyReplacement(++stateReplacementsIssued);
17235
+ }
17236
+ var replacementListeners = createListeners();
17237
+ /** Record that replacement `n` is applied, and tell the listeners. */
17238
+ function applyReplacement(n) {
17239
+ latestStateApplied = n;
17240
+ replacementListeners.notify();
17152
17241
  }
17242
+ /**
17243
+ * Call `listener` whenever a boot, restart or load replaces the game state:
17244
+ * work the replaced game started that would write into the new one later (a
17245
+ * click body's timer in the story interface, which stays mounted) is to
17246
+ * stop (#401).
17247
+ */
17248
+ var subscribeStateReplacement = replacementListeners.subscribe;
17153
17249
  var runtimeUnsubs = [];
17154
17250
  var inRuntimePhase = false;
17155
17251
  /**
@@ -17381,6 +17477,16 @@ function recordKnownSave(set, slot, known) {
17381
17477
  }
17382
17478
  });
17383
17479
  }
17480
+ /** Look up which slots hold a save again, as an operation queued now. */
17481
+ function refreshKnownSaves(set, ifid) {
17482
+ return populateKnownSaves(ifid).then((known) => {
17483
+ set((state) => {
17484
+ state.knownSaves = known;
17485
+ });
17486
+ });
17487
+ }
17488
+ /** Stops following the slot changes of other tabs (see init). */
17489
+ var stopWatchingSlots;
17384
17490
  /**
17385
17491
  * Queue the clearing of saved data, then restart now: the new playthrough
17386
17492
  * is stored after it, and operations issued from here on belong to the new
@@ -17462,6 +17568,10 @@ var useStoryStore = create()(storyStore((set, get) => ({
17462
17568
  console.error("spindle: failed to init save system", err);
17463
17569
  return "";
17464
17570
  }));
17571
+ stopWatchingSlots?.();
17572
+ stopWatchingSlots = watchSlotChanges(storyData.ifid, () => {
17573
+ refreshKnownSaves(set, storyData.ifid).catch(() => {});
17574
+ });
17465
17575
  },
17466
17576
  navigate: (passageName) => {
17467
17577
  if (navigationTriggerPhase) {
@@ -17681,12 +17791,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
17681
17791
  });
17682
17792
  if (playthroughId !== "" && playthroughId === current) switchToPlaythrough(replacementId, deletion);
17683
17793
  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");
17794
+ return reported(deletion.then(() => refreshKnownSaves(set, storyData.ifid)), "spindle: failed to delete playthrough");
17690
17795
  },
17691
17796
  getSavePayload: () => {
17692
17797
  const { currentPassage, variables, history, historyIndex, visitCounts, renderCounts } = get();
@@ -17723,7 +17828,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
17723
17828
  return;
17724
17829
  }
17725
17830
  assertPassagesExist(get().storyData, payload);
17726
- latestStateApplied = replacement;
17831
+ applyReplacement(replacement);
17727
17832
  emit("beforeload", slot);
17728
17833
  const ifid = get().storyData?.ifid;
17729
17834
  if (playthroughId && ifid) switchToLoadedPlaythrough(ifid, playthroughId);
@@ -18399,37 +18504,85 @@ var settings = {
18399
18504
  //#endregion
18400
18505
  //#region src/utils/control-edits.ts
18401
18506
  /**
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.
18507
+ * Records the edits made to the items of a list from inside a {for}
18508
+ * iteration, which {for} tells apart from a list that is replaced:
18509
+ *
18510
+ * - a reader editing a variable through a control (`{textbox}`,
18511
+ * `{checkbox}`, ...): typing into a control inside an iteration changes
18512
+ * its item, but must not remount the iteration and drop the control's
18513
+ * focus (#369);
18514
+ * - mutation code an iteration runs as it renders (`{set $party[@i].hp +=
18515
+ * 1}`): remounting would run the iteration's mount-only macros, and so the
18516
+ * write, again for every write it makes (#400).
18517
+ *
18406
18518
  * An edit is attributed to the variable path it wrote, so a replacement made
18407
- * elsewhere (a watcher reacting to the edit) still remounts.
18519
+ * elsewhere (a watcher reacting to the edit, a button outside the loop)
18520
+ * still remounts.
18408
18521
  */
18409
18522
  var version = 0;
18410
- var MAX_LOG = 256;
18523
+ var MAX_LOG = 4096;
18524
+ /** Edits in the order made: paths below the namespaces (`['variables', 'a']`). */
18411
18525
  var log = [];
18412
- function noteControlEdit(segments) {
18526
+ function note(path) {
18413
18527
  log.push({
18414
18528
  version: ++version,
18415
- segments
18529
+ path
18416
18530
  });
18417
18531
  if (log.length > MAX_LOG) log.shift();
18418
18532
  }
18533
+ /** Note an edit a control made to the story variable at `segments`. */
18534
+ function noteControlEdit(segments) {
18535
+ note(["variables", ...segments]);
18536
+ }
18537
+ /**
18538
+ * Whether mutation code runs inside a {for} iteration: {for} provides true
18539
+ * around its iterations, and the writes of such code are noted as edits of
18540
+ * the items they reach (see runAsItemEdit).
18541
+ */
18542
+ var ItemEditContext = createContext(false);
18543
+ var NAMESPACES = [
18544
+ "variables",
18545
+ "temporary",
18546
+ "transient"
18547
+ ];
18548
+ /** Run `run`, noting each variable path it changes as an edit. */
18549
+ function runAsItemEdit(run) {
18550
+ const before = readState();
18551
+ try {
18552
+ run();
18553
+ } finally {
18554
+ const after = readState();
18555
+ for (const ns of NAMESPACES) {
18556
+ if (before[ns] === after[ns]) continue;
18557
+ for (const { path } of diffPaths(before[ns], after[ns], true)) note([ns, ...path]);
18558
+ }
18559
+ }
18560
+ }
18419
18561
  function controlEditVersion() {
18420
18562
  return version;
18421
18563
  }
18422
18564
  /**
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
- });
18565
+ * A test of whether an edit made since `since` wrote inside `item`, element
18566
+ * `index` of `list`: the path leads through the item (compared by reference
18567
+ * in `state`), or, for a primitive item, ends at its slot in the list.
18568
+ */
18569
+ function editedItems(since, state) {
18570
+ const through = /* @__PURE__ */ new Set();
18571
+ const ends = /* @__PURE__ */ new Map();
18572
+ for (let i = log.length - 1; i >= 0 && log[i].version > since; i--) {
18573
+ const { path } = log[i];
18574
+ let node = state;
18575
+ path.forEach((seg, n) => {
18576
+ if (n === path.length - 1 && node !== null && typeof node === "object") {
18577
+ const keys = ends.get(node) ?? /* @__PURE__ */ new Set();
18578
+ keys.add(seg);
18579
+ ends.set(node, keys);
18580
+ }
18581
+ node = node !== null && typeof node === "object" ? getByPath(node, [seg]) : void 0;
18582
+ if (node !== null && typeof node === "object") through.add(node);
18583
+ });
18584
+ }
18585
+ return (list, item, index) => typeof item === "object" && item !== null ? through.has(item) : ends.get(list)?.has(String(index)) === true;
18433
18586
  }
18434
18587
  //#endregion
18435
18588
  //#region src/action-registry.ts
@@ -18626,6 +18779,7 @@ function defineMacro(config, source = "builtin") {
18626
18779
  id = resolve(id);
18627
18780
  }
18628
18781
  const { update, getValues } = useContext(LocalsUpdateContext);
18782
+ const inIteration = useContext(ItemEditContext);
18629
18783
  const renderOptions = useRenderOptions();
18630
18784
  const renderNodes$1 = (nodes, options) => renderNodes(nodes, {
18631
18785
  ...renderOptions,
@@ -18656,7 +18810,7 @@ function defineMacro(config, source = "builtin") {
18656
18810
  id,
18657
18811
  resolve,
18658
18812
  cls: macroClass(config.name, className),
18659
- mutate: (code) => executeMutation(code, getValues(), update),
18813
+ mutate: inIteration ? (code) => runAsItemEdit(() => executeMutation(code, getValues(), update)) : (code) => executeMutation(code, getValues(), update),
18660
18814
  update,
18661
18815
  getValues,
18662
18816
  wrap: (content) => wrapContent(className, id, content)
@@ -18962,7 +19116,7 @@ function createStoryAPI() {
18962
19116
  },
18963
19117
  deferRender() {
18964
19118
  useStoryStore.getState().deferRender();
18965
- readyPromise = new Promise((resolve) => {
19119
+ readyPromise ??= new Promise((resolve) => {
18966
19120
  readyResolve = resolve;
18967
19121
  });
18968
19122
  },
@@ -19521,16 +19675,25 @@ var PRIMITIVE_SAMPLES = {
19521
19675
  number: Object(0),
19522
19676
  boolean: Object(false)
19523
19677
  };
19524
- function inferSchema(value) {
19678
+ /**
19679
+ * The schema of a default value. An object met again (a backreference, a
19680
+ * shared subtree) has the schema made for it the first time, so a cyclic
19681
+ * default gives a schema that refers to itself instead of recursing forever.
19682
+ */
19683
+ function inferSchema(value, seen = /* @__PURE__ */ new Map()) {
19525
19684
  if (Array.isArray(value)) return { type: "array" };
19526
19685
  if (value === null) return { type: "null" };
19527
19686
  if (typeof value === "object") {
19687
+ const known = seen.get(value);
19688
+ if (known) return known;
19528
19689
  const fields = /* @__PURE__ */ new Map();
19529
- for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
19530
- return {
19690
+ const schema = {
19531
19691
  type: "object",
19532
19692
  fields
19533
19693
  };
19694
+ seen.set(value, schema);
19695
+ for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val, seen));
19696
+ return schema;
19534
19697
  }
19535
19698
  const jsType = typeof value;
19536
19699
  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 +20191,32 @@ defineMacro({
20028
20191
  * Content-derived string key for a value, used to remount components when
20029
20192
  * the value's contents change (e.g. {for} iterations, #45).
20030
20193
  *
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.
20194
+ * A tree of JSON-compatible values (strings, finite numbers, booleans, null,
20195
+ * arrays and plain objects of those) produces exactly its `JSON.stringify`
20196
+ * output. Other values get distinct, unambiguous markers so their contents
20197
+ * are reflected too: Map and Set entries (nested at any depth), Date,
20198
+ * RegExp, BigInt, undefined, NaN/±Infinity, symbols (by description),
20199
+ * functions (by name) and registered class instances, with the class name
20200
+ * and, for a subclass of a built-in, its own fields (#407). A hole in an
20201
+ * array reads as undefined.
20202
+ *
20203
+ * An object met again, through a cycle or a second reference to it, is a
20204
+ * marker naming where it was first met rather than another copy of its
20205
+ * contents, so the key grows with the objects a value holds, not with the
20206
+ * paths to them (#406). The function never throws.
20039
20207
  */
20040
20208
  function stableKey(value) {
20041
20209
  try {
20042
- return keyOf(value, []);
20210
+ return keyOf(value, /* @__PURE__ */ new Map());
20043
20211
  } catch {
20044
20212
  return "<unkeyable>";
20045
20213
  }
20046
20214
  }
20047
- function keyOf(val, ancestors) {
20215
+ /**
20216
+ * The key of `val`. `seen` numbers the objects keyed so far, in the order
20217
+ * they were first met.
20218
+ */
20219
+ function keyOf(val, seen) {
20048
20220
  switch (typeof val) {
20049
20221
  case "string": return JSON.stringify(val);
20050
20222
  case "number": return Number.isFinite(val) ? JSON.stringify(val) : String(val);
@@ -20056,22 +20228,28 @@ function keyOf(val, ancestors) {
20056
20228
  }
20057
20229
  if (val === null) return "null";
20058
20230
  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();
20231
+ const first = seen.get(obj);
20232
+ if (first !== void 0) return `<ref ${first}>`;
20233
+ const className = registeredClassName(obj);
20234
+ const named = (key) => className === void 0 ? key : `Class(${JSON.stringify(className)})${key}`;
20235
+ const withFields = (key) => {
20236
+ const keys = className === void 0 ? [] : extraKeys(obj);
20237
+ return named(keys.length ? `${key}${fieldsOf(obj, keys, seen)}` : key);
20238
+ };
20239
+ if (val instanceof Date || val instanceof RegExp) {
20240
+ if (className !== void 0 && extraKeys(obj).length) seen.set(obj, seen.size);
20241
+ return withFields(val instanceof Date ? `Date(${Date.prototype.getTime.call(val)})` : `RegExp(${RegExp.prototype.toString.call(val)}@${val.lastIndex})`);
20074
20242
  }
20243
+ seen.set(obj, seen.size);
20244
+ if (Array.isArray(val)) return withFields(`[${Array.from(val, (v) => keyOf(v, seen)).join(",")}]`);
20245
+ if (val instanceof Map) return withFields(`Map[${Array.from(mapEntries(val), ([k, v]) => `[${keyOf(k, seen)},${keyOf(v, seen)}]`).join(",")}]`);
20246
+ if (val instanceof Set) return withFields(`Set[${Array.from(setMembers(val), (v) => keyOf(v, seen)).join(",")}]`);
20247
+ return named(fieldsOf(obj, Object.keys(obj), seen));
20248
+ }
20249
+ /** `{"key":value,...}` for the `keys` of `obj`. */
20250
+ function fieldsOf(obj, keys, seen) {
20251
+ const record = obj;
20252
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${keyOf(record[k], seen)}`).join(",")}}`;
20075
20253
  }
20076
20254
  //#endregion
20077
20255
  //#region src/components/macros/For.tsx
@@ -20153,12 +20331,11 @@ defineMacro({
20153
20331
  });
20154
20332
  }
20155
20333
  const { itemVar, indexVar } = loop;
20156
- const since = keys.edits;
20157
- const { variables } = readState();
20334
+ const edited = editedItems(keys.edits, readState());
20158
20335
  keys.edits = controlEditVersion();
20159
20336
  const generations = list.map((item, i) => {
20160
20337
  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++;
20338
+ if (keys.generations[i] === void 0 || contents !== keys.contents[i] && !edited(list, item, i)) keys.generations[i] = keys.next++;
20162
20339
  keys.contents[i] = contents;
20163
20340
  return keys.generations[i];
20164
20341
  });
@@ -20171,7 +20348,10 @@ defineMacro({
20171
20348
  indexValue: i,
20172
20349
  children
20173
20350
  }, `${i}-${generations[i]}`));
20174
- return ctx.wrap(content);
20351
+ return ctx.wrap(/* @__PURE__ */ jsx(ItemEditContext.Provider, {
20352
+ value: true,
20353
+ children: content
20354
+ }));
20175
20355
  },
20176
20356
  text({ rawArgs, children = [] }, ctx) {
20177
20357
  const { itemVar, indexVar, listExpr } = loopArgs(rawArgs, ctx.args);
@@ -20262,7 +20442,9 @@ var frozenAlways = () => true;
20262
20442
  * once it has rendered, so it runs once per click. It stays mounted
20263
20443
  * until the owning macro unmounts (its passage changes, say), so work it
20264
20444
  * starts in an effect, such as the timer of a {timed} or {repeat}, can
20265
- * finish; the passage leaving cancels it.
20445
+ * finish; the passage leaving cancels it, and so does a restart or a load
20446
+ * replacing the game, which a control of the story interface outlives
20447
+ * (#401).
20266
20448
  *
20267
20449
  * A detached render starts with no contexts, so the hook captures the ones
20268
20450
  * the owning macro sees and provides them again: the locals scope (read
@@ -20284,8 +20466,17 @@ function useDetachedBody() {
20284
20466
  const repeat = useContext(RepeatContext);
20285
20467
  const closeDialog = useContext(DialogCloseContext);
20286
20468
  const mounted = useRef([]);
20287
- useEffect(() => () => {
20288
- for (const container of mounted.current.splice(0)) render(null, container);
20469
+ useEffect(() => {
20470
+ const unsubscribe = subscribeStateReplacement(() => {
20471
+ const replaced = mounted.current.splice(0);
20472
+ queueMicrotask(() => {
20473
+ for (const container of replaced) render(null, container);
20474
+ });
20475
+ });
20476
+ return () => {
20477
+ unsubscribe();
20478
+ for (const container of mounted.current.splice(0)) render(null, container);
20479
+ };
20289
20480
  }, []);
20290
20481
  const run = (children) => {
20291
20482
  const locals = liveLocalsView(updater.getValues);
@@ -21949,9 +22140,12 @@ defineMacro({
21949
22140
  const { hooks } = ctx;
21950
22141
  const { condition, options } = ctx.args;
21951
22142
  if (condition === void 0) return null;
21952
- const registered = hooks.useRef(false);
21953
- if (!registered.current) {
21954
- registered.current = true;
22143
+ const frozen = hooks.useContext(FrozenStateContext);
22144
+ const [, rerender] = hooks.useState(0);
22145
+ hooks.useEffect(() => subscribeTriggerResets(() => rerender((n) => n + 1)), []);
22146
+ const registered = hooks.useRef(null);
22147
+ if (registered.current === null || registered.current !== triggerResets() && !frozen?.(useStoryStore.getState())) {
22148
+ registered.current = triggerResets();
21955
22149
  addMacroTrigger(condition, options);
21956
22150
  }
21957
22151
  return null;