@rohal12/spindle 0.51.1 → 0.51.3

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.
@@ -2809,18 +2809,23 @@ async function getCurrentPlaythroughId(ifid) {
2809
2809
  }
2810
2810
  /**
2811
2811
  * Run a save through the `beforesave` / `aftersave` hooks. `beforesave` fires
2812
- * before `getPayload()` captures the state, so data a hook sets is part of the
2813
- * save; `aftersave` fires once `write` has stored it. Every user-facing save
2814
- * path goes through here.
2812
+ * before the payload is captured, so data a hook sets is part of the save;
2813
+ * `aftersave` fires once `write` has stored it. Every user-facing save path
2814
+ * goes through here.
2815
+ *
2816
+ * `beginCapture` is called just before the hooks and returns the function
2817
+ * that captures the payload after them, so the capture can tell which
2818
+ * variables the hooks changed (the store's `beginSave`).
2815
2819
  *
2816
2820
  * The hook and capture run synchronously, but an error they throw (a failing
2817
2821
  * hook, an unserializable payload) rejects the returned promise like a failed
2818
2822
  * write rather than escaping to the caller.
2819
2823
  */
2820
- function saveWithHooks(slot, custom, getPayload, write) {
2824
+ function saveWithHooks(slot, custom, beginCapture, write) {
2821
2825
  try {
2826
+ const capture = beginCapture();
2822
2827
  emit("beforesave", slot, custom);
2823
- return write(getPayload()).then((result) => {
2828
+ return write(capture()).then((result) => {
2824
2829
  emit("aftersave", slot);
2825
2830
  return result;
2826
2831
  });
@@ -3703,6 +3708,22 @@ function loadedEntryMoment(payload) {
3703
3708
  if (typeof moment.variables !== "object" || moment.variables === null) return;
3704
3709
  return moment;
3705
3710
  }
3711
+ /**
3712
+ * Copy the variables that changed between `before` and `after` (the live
3713
+ * variables around the `beforesave` hooks) into the payload's snapshot of the
3714
+ * saved moment. A load restores that snapshot, the state on entering the
3715
+ * passage, and runs the passage again, so data a hook adds to a save would
3716
+ * otherwise be lost on load (#227). Only the payload's copy changes: the live
3717
+ * history keeps the recorded snapshot (#159). Store updates are immutable, so
3718
+ * a value a hook did not touch keeps its identity.
3719
+ */
3720
+ function keepHookWrites(payload, before, after) {
3721
+ const moment = payload.history[payload.historyIndex];
3722
+ if (!moment) return;
3723
+ const has = (vars, key) => Object.prototype.hasOwnProperty.call(vars, key);
3724
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) if (!has(after, key)) delete moment.variables[key];
3725
+ else if (!has(before, key) || after[key] !== before[key]) moment.variables[key] = deepClone(after[key]);
3726
+ }
3706
3727
  /** Restore or reset PRNG from a history moment's snapshot. */
3707
3728
  function restorePRNGFromMoment(moment) {
3708
3729
  if (moment?.prng) restorePRNG(moment.prng.seed, moment.prng.pull);
@@ -3985,7 +4006,7 @@ var useStoryStore = create()(immer((set, get) => ({
3985
4006
  save: (slot, custom) => {
3986
4007
  const { storyData, playthroughId } = get();
3987
4008
  if (!storyData) return Promise.resolve();
3988
- return handled(saveWithHooks(slot, custom, () => get().getSavePayload(), async (payload) => {
4009
+ return handled(saveWithHooks(slot, custom, get().beginSave, async (payload) => {
3989
4010
  set((state) => {
3990
4011
  state.saveError = null;
3991
4012
  });
@@ -4127,6 +4148,14 @@ var useStoryStore = create()(immer((set, get) => ({
4127
4148
  prng: snapshotPRNG()
4128
4149
  };
4129
4150
  },
4151
+ beginSave: () => {
4152
+ const before = get().variables;
4153
+ return () => {
4154
+ const payload = get().getSavePayload();
4155
+ keepHookWrites(payload, before, get().variables);
4156
+ return payload;
4157
+ };
4158
+ },
4130
4159
  loadFromPayload: (payload, slot) => {
4131
4160
  if (payload.history.length === 0) {
4132
4161
  console.warn("loadFromPayload: rejecting payload with empty history");
@@ -4825,16 +4854,17 @@ function defineMacro(config, source = "builtin") {
4825
4854
  //#endregion
4826
4855
  //#region src/passage-render-state.ts
4827
4856
  /**
4828
- * Name of the passage most recently mounted by <Passage> (null until the
4829
- * first render, e.g. when the story interface has no {passage}).
4830
- * Lets Story.waitForActions() tell whether the current passage is on screen.
4857
+ * Navigation id (the store's `navigationId`) of the passage most recently
4858
+ * mounted by <Passage> (null until the first render, e.g. when the story
4859
+ * interface has no {passage}). Lets Story.waitForActions() tell whether the
4860
+ * current navigation is on screen, also when it revisits the passage shown.
4831
4861
  */
4832
- var renderedPassage = null;
4833
- function markPassageRendered(name) {
4834
- renderedPassage = name;
4862
+ var renderedNavigationId = null;
4863
+ function markPassageRendered(navigationId) {
4864
+ renderedNavigationId = navigationId;
4835
4865
  }
4836
- function getRenderedPassage() {
4837
- return renderedPassage;
4866
+ function getRenderedNavigationId() {
4867
+ return renderedNavigationId;
4838
4868
  }
4839
4869
  //#endregion
4840
4870
  //#region src/story-api.ts
@@ -5128,11 +5158,10 @@ function createStoryAPI() {
5128
5158
  await new Promise((resolve) => {
5129
5159
  requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
5130
5160
  });
5131
- const { currentPassage, renderDeferred } = useStoryStore.getState();
5132
- const rendered = getRenderedPassage();
5133
- if (rendered !== null && rendered !== currentPassage && !renderDeferred) await new Promise((resolve) => {
5134
- const off = on("passagerender", (name) => {
5135
- if (name === useStoryStore.getState().currentPassage) {
5161
+ const isRendered = () => getRenderedNavigationId() === useStoryStore.getState().navigationId;
5162
+ if (getRenderedNavigationId() !== null && !isRendered() && !useStoryStore.getState().renderDeferred) await new Promise((resolve) => {
5163
+ const off = on("passagerender", () => {
5164
+ if (isRendered()) {
5136
5165
  off();
5137
5166
  resolve();
5138
5167
  }
@@ -6424,7 +6453,7 @@ function SaveManagerContent() {
6424
6453
  const closeDialog = useContext(DialogCloseContext);
6425
6454
  const storyData = useStoryStore((s) => s.storyData);
6426
6455
  const playthroughId = useStoryStore((s) => s.playthroughId);
6427
- const getSavePayload = useStoryStore((s) => s.getSavePayload);
6456
+ const beginSave = useStoryStore((s) => s.beginSave);
6428
6457
  const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
6429
6458
  const ifid = storyData?.ifid ?? "";
6430
6459
  const refresh = useCallback(async () => {
@@ -6464,7 +6493,7 @@ function SaveManagerContent() {
6464
6493
  const handleNewSave = async () => {
6465
6494
  if (!ifid || !playthroughId) return;
6466
6495
  try {
6467
- await saveWithHooks(void 0, void 0, getSavePayload, (payload) => createSave(ifid, playthroughId, payload));
6496
+ await saveWithHooks(void 0, void 0, beginSave, (payload) => createSave(ifid, playthroughId, payload));
6468
6497
  showStatus("Save created");
6469
6498
  await refresh();
6470
6499
  } catch {
@@ -6473,7 +6502,7 @@ function SaveManagerContent() {
6473
6502
  };
6474
6503
  const handleOverwrite = async (saveId) => {
6475
6504
  try {
6476
- await saveWithHooks(void 0, void 0, getSavePayload, async (payload) => {
6505
+ await saveWithHooks(void 0, void 0, beginSave, async (payload) => {
6477
6506
  if (!await overwriteSave(saveId, payload)) throw new Error("Save not found");
6478
6507
  });
6479
6508
  showStatus("Save overwritten");
@@ -7152,7 +7181,7 @@ var CODE_PASSAGES = new Set([
7152
7181
  "PassageFooter",
7153
7182
  "PassageDone"
7154
7183
  ]);
7155
- function Passage({ passage, dataTransition }) {
7184
+ function Passage({ passage, dataTransition, navigationId }) {
7156
7185
  const storyData = useStoryStore((s) => s.storyData);
7157
7186
  const isCodePassage = CODE_PASSAGES.has(passage.name);
7158
7187
  const [doneReady, setDoneReady] = useState(false);
@@ -7210,9 +7239,9 @@ function Passage({ passage, dataTransition }) {
7210
7239
  }, [doneReady, donePassage?.content]);
7211
7240
  const elRef = useRef(null);
7212
7241
  useLayoutEffect(() => {
7213
- markPassageRendered(passage.name);
7242
+ if (navigationId !== void 0) markPassageRendered(navigationId);
7214
7243
  if (elRef.current) emitFromRender("passagerender", passage.name, elRef.current);
7215
- }, [passage.name]);
7244
+ }, [passage.name, navigationId]);
7216
7245
  const nobr = passage.tags.includes("nobr");
7217
7246
  const inner = /* @__PURE__ */ jsxs("div", {
7218
7247
  ref: elRef,
@@ -7458,7 +7487,8 @@ defineMacro({
7458
7487
  ref: containerRef,
7459
7488
  children: effectivePassage && /* @__PURE__ */ jsx(Passage, {
7460
7489
  passage: effectivePassage,
7461
- dataTransition: renderDeferred ? "none" : resolvedTypeRef.current
7490
+ dataTransition: renderDeferred ? "none" : resolvedTypeRef.current,
7491
+ navigationId: renderDeferred ? void 0 : displayed.id
7462
7492
  }, renderDeferred ? "loading" : `nav-${displayed.id}`)
7463
7493
  })]
7464
7494
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.51.1",
3
+ "version": "0.51.3",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -42,6 +42,8 @@ export function renderPassageContent(passage: PassageData) {
42
42
  interface PassageProps {
43
43
  passage: PassageData;
44
44
  dataTransition?: string;
45
+ /** The navigation this passage displays; omitted for placeholders. */
46
+ navigationId?: number;
45
47
  }
46
48
 
47
49
  const CODE_PASSAGES = new Set([
@@ -51,7 +53,11 @@ const CODE_PASSAGES = new Set([
51
53
  'PassageDone',
52
54
  ]);
53
55
 
54
- export function Passage({ passage, dataTransition }: PassageProps) {
56
+ export function Passage({
57
+ passage,
58
+ dataTransition,
59
+ navigationId,
60
+ }: PassageProps) {
55
61
  const storyData = useStoryStore((s) => s.storyData);
56
62
  const isCodePassage = CODE_PASSAGES.has(passage.name);
57
63
  const [doneReady, setDoneReady] = useState(false);
@@ -121,11 +127,11 @@ export function Passage({ passage, dataTransition }: PassageProps) {
121
127
  // Passage so every navigation that shows a passage mounts a new one.
122
128
  const elRef = useRef<HTMLDivElement>(null);
123
129
  useLayoutEffect(() => {
124
- markPassageRendered(passage.name);
130
+ if (navigationId !== undefined) markPassageRendered(navigationId);
125
131
  if (elRef.current) {
126
132
  emitFromRender('passagerender', passage.name, elRef.current);
127
133
  }
128
- }, [passage.name]);
134
+ }, [passage.name, navigationId]);
129
135
 
130
136
  const nobr = passage.tags.includes('nobr');
131
137
 
@@ -292,6 +292,7 @@ defineMacro({
292
292
  passage={effectivePassage}
293
293
  key={renderDeferred ? 'loading' : `nav-${displayed.id}`}
294
294
  dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
295
+ navigationId={renderDeferred ? undefined : displayed.id}
295
296
  />
296
297
  )}
297
298
  </div>
@@ -62,7 +62,7 @@ export function SaveManagerContent() {
62
62
 
63
63
  const storyData = useStoryStore((s) => s.storyData);
64
64
  const playthroughId = useStoryStore((s) => s.playthroughId);
65
- const getSavePayload = useStoryStore((s) => s.getSavePayload);
65
+ const beginSave = useStoryStore((s) => s.beginSave);
66
66
  const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
67
67
  const ifid = storyData?.ifid ?? '';
68
68
 
@@ -108,7 +108,7 @@ export function SaveManagerContent() {
108
108
  const handleNewSave = async () => {
109
109
  if (!ifid || !playthroughId) return;
110
110
  try {
111
- await saveWithHooks(undefined, undefined, getSavePayload, (payload) =>
111
+ await saveWithHooks(undefined, undefined, beginSave, (payload) =>
112
112
  createSave(ifid, playthroughId, payload),
113
113
  );
114
114
  showStatus('Save created');
@@ -120,16 +120,11 @@ export function SaveManagerContent() {
120
120
 
121
121
  const handleOverwrite = async (saveId: string) => {
122
122
  try {
123
- await saveWithHooks(
124
- undefined,
125
- undefined,
126
- getSavePayload,
127
- async (payload) => {
128
- if (!(await overwriteSave(saveId, payload))) {
129
- throw new Error('Save not found');
130
- }
131
- },
132
- );
123
+ await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
124
+ if (!(await overwriteSave(saveId, payload))) {
125
+ throw new Error('Save not found');
126
+ }
127
+ });
133
128
  showStatus('Save overwritten');
134
129
  await refresh();
135
130
  } catch {
@@ -1,14 +1,15 @@
1
1
  /**
2
- * Name of the passage most recently mounted by <Passage> (null until the
3
- * first render, e.g. when the story interface has no {passage}).
4
- * Lets Story.waitForActions() tell whether the current passage is on screen.
2
+ * Navigation id (the store's `navigationId`) of the passage most recently
3
+ * mounted by <Passage> (null until the first render, e.g. when the story
4
+ * interface has no {passage}). Lets Story.waitForActions() tell whether the
5
+ * current navigation is on screen, also when it revisits the passage shown.
5
6
  */
6
- let renderedPassage: string | null = null;
7
+ let renderedNavigationId: number | null = null;
7
8
 
8
- export function markPassageRendered(name: string): void {
9
- renderedPassage = name;
9
+ export function markPassageRendered(navigationId: number): void {
10
+ renderedNavigationId = navigationId;
10
11
  }
11
12
 
12
- export function getRenderedPassage(): string | null {
13
- return renderedPassage;
13
+ export function getRenderedNavigationId(): number | null {
14
+ return renderedNavigationId;
14
15
  }
@@ -99,9 +99,13 @@ export async function getCurrentPlaythroughId(
99
99
 
100
100
  /**
101
101
  * Run a save through the `beforesave` / `aftersave` hooks. `beforesave` fires
102
- * before `getPayload()` captures the state, so data a hook sets is part of the
103
- * save; `aftersave` fires once `write` has stored it. Every user-facing save
104
- * path goes through here.
102
+ * before the payload is captured, so data a hook sets is part of the save;
103
+ * `aftersave` fires once `write` has stored it. Every user-facing save path
104
+ * goes through here.
105
+ *
106
+ * `beginCapture` is called just before the hooks and returns the function
107
+ * that captures the payload after them, so the capture can tell which
108
+ * variables the hooks changed (the store's `beginSave`).
105
109
  *
106
110
  * The hook and capture run synchronously, but an error they throw (a failing
107
111
  * hook, an unserializable payload) rejects the returned promise like a failed
@@ -110,12 +114,13 @@ export async function getCurrentPlaythroughId(
110
114
  export function saveWithHooks<T>(
111
115
  slot: string | undefined,
112
116
  custom: Record<string, unknown> | undefined,
113
- getPayload: () => SavePayload,
117
+ beginCapture: () => () => SavePayload,
114
118
  write: (payload: SavePayload) => Promise<T>,
115
119
  ): Promise<T> {
116
120
  try {
121
+ const capture = beginCapture();
117
122
  emit('beforesave', slot, custom);
118
- const payload = getPayload();
123
+ const payload = capture();
119
124
  return write(payload).then((result) => {
120
125
  emit('aftersave', slot);
121
126
  return result;
package/src/store.ts CHANGED
@@ -297,6 +297,33 @@ function loadedEntryMoment(
297
297
  return moment;
298
298
  }
299
299
 
300
+ /**
301
+ * Copy the variables that changed between `before` and `after` (the live
302
+ * variables around the `beforesave` hooks) into the payload's snapshot of the
303
+ * saved moment. A load restores that snapshot, the state on entering the
304
+ * passage, and runs the passage again, so data a hook adds to a save would
305
+ * otherwise be lost on load (#227). Only the payload's copy changes: the live
306
+ * history keeps the recorded snapshot (#159). Store updates are immutable, so
307
+ * a value a hook did not touch keeps its identity.
308
+ */
309
+ function keepHookWrites(
310
+ payload: SavePayload,
311
+ before: Record<string, unknown>,
312
+ after: Record<string, unknown>,
313
+ ): void {
314
+ const moment = payload.history[payload.historyIndex];
315
+ if (!moment) return;
316
+ const has = (vars: Record<string, unknown>, key: string) =>
317
+ Object.prototype.hasOwnProperty.call(vars, key);
318
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
319
+ if (!has(after, key)) {
320
+ delete moment.variables[key];
321
+ } else if (!has(before, key) || after[key] !== before[key]) {
322
+ moment.variables[key] = deepClone(after[key]);
323
+ }
324
+ }
325
+ }
326
+
300
327
  /** Restore or reset PRNG from a history moment's snapshot. */
301
328
  function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
302
329
  if (moment?.prng) {
@@ -384,6 +411,12 @@ export interface StoryState {
384
411
  clearAllData: () => void;
385
412
  deletePlaythrough: (playthroughId: string) => void;
386
413
  getSavePayload: () => SavePayload;
414
+ /**
415
+ * Start capturing a save, before the `beforesave` hooks run. The returned
416
+ * function builds the payload after them, and keeps the variables the
417
+ * hooks changed in the saved moment's snapshot, so a load restores them.
418
+ */
419
+ beginSave: () => () => SavePayload;
387
420
  /**
388
421
  * Replace the game state with a live (deserialized) payload. `slot` is
389
422
  * passed to the `beforeload`/`afterload` events.
@@ -799,29 +832,18 @@ export const useStoryStore = create<StoryState>()(
799
832
  if (!storyData) return Promise.resolve();
800
833
 
801
834
  return handled(
802
- saveWithHooks(
803
- slot,
804
- custom,
805
- () => get().getSavePayload(),
806
- async (payload) => {
807
- set((state) => {
808
- state.saveError = null;
809
- });
810
- await quickSave(
811
- storyData.ifid,
812
- playthroughId,
813
- payload,
814
- slot,
815
- custom,
816
- );
817
- set((state) => {
818
- state.knownSaves = {
819
- ...state.knownSaves,
820
- [slot ?? '']: true,
821
- };
822
- });
823
- },
824
- ).catch((err) => {
835
+ saveWithHooks(slot, custom, get().beginSave, async (payload) => {
836
+ set((state) => {
837
+ state.saveError = null;
838
+ });
839
+ await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
840
+ set((state) => {
841
+ state.knownSaves = {
842
+ ...state.knownSaves,
843
+ [slot ?? '']: true,
844
+ };
845
+ });
846
+ }).catch((err) => {
825
847
  console.error('spindle: failed to save', err);
826
848
  set((state) => {
827
849
  state.saveError =
@@ -997,6 +1019,15 @@ export const useStoryStore = create<StoryState>()(
997
1019
  };
998
1020
  },
999
1021
 
1022
+ beginSave: () => {
1023
+ const before = get().variables;
1024
+ return () => {
1025
+ const payload = get().getSavePayload();
1026
+ keepHookWrites(payload, before, get().variables);
1027
+ return payload;
1028
+ };
1029
+ },
1030
+
1000
1031
  loadFromPayload: (payload: SavePayload, slot?: string) => {
1001
1032
  if (payload.history.length === 0) {
1002
1033
  console.warn('loadFromPayload: rejecting payload with empty history');
package/src/story-api.ts CHANGED
@@ -30,7 +30,7 @@ import type { MacroDefinition } from './define-macro';
30
30
  import { getMacroRegistry as _getMacroRegistry } from './registry';
31
31
  import type { MacroMetadata } from './registry';
32
32
  import { getActions, getAction, type StoryAction } from './action-registry';
33
- import { getRenderedPassage } from './passage-render-state';
33
+ import { getRenderedNavigationId } from './passage-render-state';
34
34
  import {
35
35
  initPRNG,
36
36
  isPRNGEnabled,
@@ -548,13 +548,19 @@ function createStoryAPI(): StoryAPI {
548
548
  });
549
549
  // A navigation can still be rendering: the passage display mounts the
550
550
  // new passage from an effect, after a fade-through's outgoing phase.
551
- // Its actions exist only once it is mounted, so wait for that.
552
- const { currentPassage, renderDeferred } = useStoryStore.getState();
553
- const rendered = getRenderedPassage();
554
- if (rendered !== null && rendered !== currentPassage && !renderDeferred) {
551
+ // Its actions exist only once it is mounted, so wait for that. Compare
552
+ // navigations, not passage names: revisiting the passage shown mounts
553
+ // it anew (#228).
554
+ const isRendered = () =>
555
+ getRenderedNavigationId() === useStoryStore.getState().navigationId;
556
+ if (
557
+ getRenderedNavigationId() !== null &&
558
+ !isRendered() &&
559
+ !useStoryStore.getState().renderDeferred
560
+ ) {
555
561
  await new Promise<void>((resolve) => {
556
- const off = emitterOn('passagerender', (name) => {
557
- if (name === useStoryStore.getState().currentPassage) {
562
+ const off = emitterOn('passagerender', () => {
563
+ if (isRendered()) {
558
564
  off();
559
565
  resolve();
560
566
  }