@rohal12/spindle 0.50.0 → 0.51.0

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.
package/src/story-api.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore, trackRuntimeUnsub } from './store';
2
+ import type { VariableNamespaces } from './store';
2
3
  import {
3
4
  on as emitterOn,
4
5
  emit,
@@ -154,12 +155,12 @@ export interface StoryAPI {
154
155
  back(): void;
155
156
  forward(): void;
156
157
  restart(): void;
157
- save(slot?: string, custom?: Record<string, unknown>): void;
158
- load(slot?: string): void;
158
+ save(slot?: string, custom?: Record<string, unknown>): Promise<void>;
159
+ load(slot?: string): Promise<void>;
159
160
  hasSave(slot?: string): boolean;
160
161
  getSaveInfo(slot?: string): Promise<SaveInfo | null>;
161
162
  listSaves(): Promise<SaveInfo[]>;
162
- deleteSave(slot?: string): void;
163
+ deleteSave(slot?: string): Promise<void>;
163
164
  exportSave(slot?: string): Promise<SaveExport | null>;
164
165
  importSave(data: unknown, slot?: string): Promise<SaveInfo>;
165
166
  visited(name?: string): number;
@@ -285,23 +286,15 @@ function warnIfUndeclared(isTransient: boolean, key: string): void {
285
286
  );
286
287
  }
287
288
 
288
- /** Set a single variable, resolving dot-paths if present. */
289
- function setOne(name: string, value: unknown): void {
289
+ /** Set a single variable on an Immer draft, resolving dot-paths if present. */
290
+ function setOne(draft: VariableNamespaces, name: string, value: unknown): void {
290
291
  const { isTransient, key } = parseName(name);
291
- const namespace = isTransient ? 'transient' : 'variables';
292
- warnIfUndeclared(isTransient, key);
292
+ const namespace = isTransient ? draft.transient : draft.variables;
293
293
 
294
294
  if (key.includes('.')) {
295
- useStoryStore.setState((state) => {
296
- setByPath(state[namespace] as Record<string, unknown>, key, value);
297
- });
295
+ setByPath(namespace, key, value);
298
296
  } else {
299
- const state = useStoryStore.getState();
300
- if (isTransient) {
301
- state.setTransient(key, value);
302
- } else {
303
- state.setVariable(key, value);
304
- }
297
+ namespace[key] = value;
305
298
  }
306
299
  }
307
300
 
@@ -316,13 +309,22 @@ function createStoryAPI(): StoryAPI {
316
309
  },
317
310
 
318
311
  set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
319
- if (typeof nameOrVars === 'string') {
320
- setOne(nameOrVars, value);
321
- } else {
322
- for (const [k, v] of Object.entries(nameOrVars)) {
323
- setOne(k, v);
324
- }
312
+ const names =
313
+ typeof nameOrVars === 'string' ? [nameOrVars] : Object.keys(nameOrVars);
314
+ for (const name of names) {
315
+ const { isTransient, key } = parseName(name);
316
+ warnIfUndeclared(isTransient, key);
325
317
  }
318
+ // One store update for all keys, so watchers see them together
319
+ useStoryStore.getState().updateVariables((draft) => {
320
+ if (typeof nameOrVars === 'string') {
321
+ setOne(draft, nameOrVars, value);
322
+ } else {
323
+ for (const [k, v] of Object.entries(nameOrVars)) {
324
+ setOne(draft, k, v);
325
+ }
326
+ }
327
+ });
326
328
  },
327
329
 
328
330
  goto(passageName: string): void {
@@ -341,12 +343,12 @@ function createStoryAPI(): StoryAPI {
341
343
  useStoryStore.getState().restart();
342
344
  },
343
345
 
344
- save(slot?: string, custom?: Record<string, unknown>): void {
345
- useStoryStore.getState().save(slot, custom);
346
+ save(slot?: string, custom?: Record<string, unknown>): Promise<void> {
347
+ return useStoryStore.getState().save(slot, custom);
346
348
  },
347
349
 
348
- load(slot?: string): void {
349
- useStoryStore.getState().load(slot);
350
+ load(slot?: string): Promise<void> {
351
+ return useStoryStore.getState().load(slot);
350
352
  },
351
353
 
352
354
  hasSave(slot?: string): boolean {
@@ -361,8 +363,8 @@ function createStoryAPI(): StoryAPI {
361
363
  return useStoryStore.getState().listSaves();
362
364
  },
363
365
 
364
- deleteSave(slot?: string): void {
365
- useStoryStore.getState().deleteSave(slot);
366
+ deleteSave(slot?: string): Promise<void> {
367
+ return useStoryStore.getState().deleteSave(slot);
366
368
  },
367
369
 
368
370
  exportSave(slot?: string): Promise<SaveExport | null> {
package/src/story-init.ts CHANGED
@@ -1,16 +1,33 @@
1
1
  import { h, render } from 'preact';
2
- import { useStoryStore } from './store';
2
+ import { useStoryStore, recordStoryInitState } from './store';
3
3
  import { tokenize } from './markup/tokenizer';
4
4
  import { buildAST } from './markup/ast';
5
5
  import { renderNodes } from './markup/render';
6
6
  import { setSaveTitlePassage } from './saves/save-manager';
7
7
 
8
+ /** Hidden container holding the currently mounted StoryInit tree. */
9
+ let storyInitContainer: HTMLElement | null = null;
10
+
11
+ /**
12
+ * Unmount the current StoryInit tree (running its effect cleanups, e.g.
13
+ * pending {timed}/{repeat} timers) and remove its container.
14
+ */
15
+ function unmountStoryInit(): void {
16
+ if (!storyInitContainer) return;
17
+ render(null, storyInitContainer);
18
+ storyInitContainer.remove();
19
+ storyInitContainer = null;
20
+ }
21
+
8
22
  /**
9
23
  * Execute the StoryInit passage: tokenize, parse, and render all macros
10
24
  * into a detached DOM node so their side effects fire through the normal
11
25
  * Preact pipeline. This is macro-agnostic — any macro works in StoryInit.
26
+ * Re-executing (on restart) first unmounts the previous StoryInit tree.
12
27
  */
13
28
  export function executeStoryInit() {
29
+ unmountStoryInit();
30
+
14
31
  const state = useStoryStore.getState();
15
32
  if (!state.storyData) return;
16
33
 
@@ -19,16 +36,22 @@ export function executeStoryInit() {
19
36
  const tokens = tokenize(storyInit.content);
20
37
  const ast = buildAST(tokens);
21
38
 
22
- // Mount into a persistent hidden container. We intentionally do NOT
23
- // unmount — this lets async effects (useEffect, setTimeout, etc.)
24
- // inside StoryInit macros fire through the normal Preact pipeline.
39
+ // Mount into a persistent hidden container. It stays mounted until the
40
+ // next execution (restart) — this lets async effects (useEffect,
41
+ // setTimeout, etc.) inside StoryInit macros fire through the normal
42
+ // Preact pipeline.
25
43
  const container = document.createElement('div');
26
44
  container.style.display = 'none';
27
45
  document.body.appendChild(container);
46
+ storyInitContainer = container;
28
47
  render(
29
48
  h(() => renderNodes(ast) as any, null),
30
49
  container,
31
50
  );
51
+
52
+ // The start moment was recorded before StoryInit ran; its synchronous
53
+ // changes ({set}, {do}) belong to it.
54
+ recordStoryInitState();
32
55
  }
33
56
 
34
57
  // Register SaveTitle passage if it exists
@@ -1,4 +1,5 @@
1
1
  import type { Passage } from './parser';
2
+ import { tokenize } from './markup/tokenizer';
2
3
 
3
4
  export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
4
5
 
@@ -17,6 +18,10 @@ function declarationRegex(sigil: string): RegExp {
17
18
  return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
18
19
  }
19
20
  const VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
21
+ /** `{` followed by a sigil starts an interpolation block inside literal text. */
22
+ const INTERP_START_RE = /^[$_@%]\w/;
23
+ /** Quoted first argument of an input macro naming a story variable. */
24
+ const QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
20
25
  const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
21
26
 
22
27
  const VALID_VAR_TYPES = new Set<string>(['number', 'string', 'boolean']);
@@ -148,15 +153,190 @@ function validateRef(
148
153
  return null;
149
154
  }
150
155
 
156
+ /**
157
+ * Built-in input macros whose first argument names the bound story variable,
158
+ * quoted or not (e.g. `{textbox "$name"}`).
159
+ */
160
+ const BUILTIN_STORE_VAR_MACROS: readonly string[] = [
161
+ 'checkbox',
162
+ 'cycle',
163
+ 'listbox',
164
+ 'numberbox',
165
+ 'radiobutton',
166
+ 'textarea',
167
+ 'textbox',
168
+ ];
169
+
170
+ type RefCallback = (ref: string) => void;
171
+
172
+ /** Report every `$var.path` in a code segment free of strings/comments. */
173
+ function scanRefs(segment: string, onRef: RefCallback): void {
174
+ for (const match of segment.matchAll(VAR_REF_RE)) onRef(match[1]!);
175
+ }
176
+
177
+ /** Index of the quote closing the string opened at `start` (or the end). */
178
+ function findClosingQuote(code: string, start: number): number {
179
+ const quote = code[start];
180
+ let i = start + 1;
181
+ while (i < code.length && code[i] !== quote) {
182
+ i += code[i] === '\\' ? 2 : 1;
183
+ }
184
+ return Math.min(i, code.length);
185
+ }
186
+
187
+ /**
188
+ * Scan literal text (string contents, HTML attribute values) for `{$…}`
189
+ * interpolation blocks, which interpolating macros and HTML attributes
190
+ * resolve at runtime. A bare `$word` in literal text is not a reference.
191
+ */
192
+ function scanInterpolations(text: string, onRef: RefCallback): void {
193
+ let i = text.indexOf('{');
194
+ while (i !== -1) {
195
+ const next = INTERP_START_RE.test(text.slice(i + 1, i + 3))
196
+ ? scanCode(text, i + 1, onRef, true)
197
+ : i + 1;
198
+ i = text.indexOf('{', next);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Scan a template literal starting just after its opening backtick: literal
204
+ * parts are text, `${…}` parts are code. Returns the index just past the
205
+ * closing backtick.
206
+ */
207
+ function scanTemplate(code: string, start: number, onRef: RefCallback): number {
208
+ let i = start;
209
+ let textStart = start;
210
+ while (i < code.length) {
211
+ const ch = code[i];
212
+ if (ch === '\\') {
213
+ i += 2;
214
+ } else if (ch === '`') {
215
+ scanInterpolations(code.slice(textStart, i), onRef);
216
+ return i + 1;
217
+ } else if (ch === '$' && code[i + 1] === '{') {
218
+ scanInterpolations(code.slice(textStart, i), onRef);
219
+ i = textStart = scanCode(code, i + 2, onRef, true);
220
+ } else {
221
+ i++;
222
+ }
223
+ }
224
+ scanInterpolations(code.slice(textStart), onRef);
225
+ return code.length;
226
+ }
227
+
228
+ /**
229
+ * Report `$var` references in JavaScript code. Like the string-aware
230
+ * expression transformer, sigils inside string literals are left alone while
231
+ * template-literal `${…}` parts are code; comments are skipped too. When
232
+ * `nested`, stops at the `}` closing the enclosing block and returns the index
233
+ * just past it.
234
+ */
235
+ function scanCode(
236
+ code: string,
237
+ start: number,
238
+ onRef: RefCallback,
239
+ nested = false,
240
+ ): number {
241
+ let i = start;
242
+ let segStart = start;
243
+ let depth = 0;
244
+ while (i < code.length) {
245
+ const ch = code[i];
246
+ const next = code[i + 1];
247
+ const isComment = ch === '/' && (next === '/' || next === '*');
248
+ if (ch === '"' || ch === "'" || ch === '`' || isComment) {
249
+ scanRefs(code.slice(segStart, i), onRef);
250
+ if (ch === '`') {
251
+ i = scanTemplate(code, i + 1, onRef);
252
+ } else if (isComment) {
253
+ const close = code.indexOf(next === '/' ? '\n' : '*/', i + 2);
254
+ i = close === -1 ? code.length : next === '/' ? close : close + 2;
255
+ } else {
256
+ const close = findClosingQuote(code, i);
257
+ scanInterpolations(code.slice(i + 1, close), onRef);
258
+ i = Math.min(close + 1, code.length);
259
+ }
260
+ segStart = i;
261
+ continue;
262
+ }
263
+ if (nested && ch === '{') {
264
+ depth++;
265
+ } else if (nested && ch === '}' && depth-- === 0) {
266
+ scanRefs(code.slice(segStart, i), onRef);
267
+ return i + 1;
268
+ }
269
+ i++;
270
+ }
271
+ scanRefs(code.slice(segStart), onRef);
272
+ return code.length;
273
+ }
274
+
275
+ /**
276
+ * Report the `$var` references a passage evaluates at runtime: `{$var}`
277
+ * displays, `{$expr}` expressions, macro arguments and `{do}` bodies (as
278
+ * code), quoted variable names bound by input macros, and `{$…}`
279
+ * interpolations in HTML attributes. Prose is literal text and not scanned.
280
+ */
281
+ function collectPassageRefs(
282
+ content: string,
283
+ storeVarMacros: ReadonlySet<string>,
284
+ onRef: RefCallback,
285
+ ): void {
286
+ const tokens = tokenize(content);
287
+ for (let t = 0; t < tokens.length; t++) {
288
+ const token = tokens[t]!;
289
+ if (token.type === 'variable') {
290
+ if (token.scope === 'variable' && token.name) onRef(token.name);
291
+ } else if (token.type === 'expression') {
292
+ scanCode(token.expression, 0, onRef);
293
+ } else if (token.type === 'html') {
294
+ for (const value of Object.values(token.attributes)) {
295
+ scanInterpolations(value, onRef);
296
+ }
297
+ } else if (token.type === 'macro' && !token.isClose) {
298
+ scanCode(token.rawArgs, 0, onRef);
299
+
300
+ if (storeVarMacros.has(token.name.toLowerCase())) {
301
+ const first = token.rawArgs.trim().split(/\s+/)[0] ?? '';
302
+ const quoted = QUOTED_VAR_ARG_RE.exec(first);
303
+ if (quoted) onRef(quoted[1]!);
304
+ }
305
+
306
+ if (token.name === 'do') {
307
+ // A {do} body is JavaScript: scan its source text as code, however
308
+ // the markup tokenizer split it up.
309
+ let close = t + 1;
310
+ while (close < tokens.length) {
311
+ const c = tokens[close]!;
312
+ if (c.type === 'macro' && c.isClose && c.name === 'do') break;
313
+ close++;
314
+ }
315
+ if (close < tokens.length) {
316
+ scanCode(content.slice(token.end, tokens[close]!.start), 0, onRef);
317
+ t = close;
318
+ }
319
+ }
320
+ }
321
+ }
322
+ }
323
+
151
324
  /**
152
325
  * Scan all passages for $var references, check against schema.
153
326
  * Returns list of error messages (empty = valid).
327
+ *
328
+ * `storeVarMacros` lists the input macros whose first argument names a bound
329
+ * variable; it defaults to the built-in ones.
154
330
  */
155
331
  export function validatePassages(
156
332
  passages: Map<string, Passage>,
157
333
  schema: Map<string, VariableSchema>,
334
+ storeVarMacros: Iterable<string> = BUILTIN_STORE_VAR_MACROS,
158
335
  ): string[] {
159
336
  const errors: string[] = [];
337
+ const storeVarSet = new Set(
338
+ Array.from(storeVarMacros, (m) => m.toLowerCase()),
339
+ );
160
340
 
161
341
  for (const [name, passage] of passages) {
162
342
  // Don't validate the StoryVariables/StoryTransients passages themselves
@@ -164,15 +344,12 @@ export function validatePassages(
164
344
 
165
345
  const forLocals = extractForLocals(passage.content);
166
346
 
167
- let match: RegExpExecArray | null;
168
- VAR_REF_RE.lastIndex = 0;
169
- while ((match = VAR_REF_RE.exec(passage.content)) !== null) {
170
- const ref = match[1]!;
347
+ collectPassageRefs(passage.content, storeVarSet, (ref) => {
171
348
  const error = validateRef(ref, schema, forLocals);
172
349
  if (error) {
173
350
  errors.push(`Passage "${name}": ${error}`);
174
351
  }
175
- }
352
+ });
176
353
  }
177
354
 
178
355
  return errors;
package/src/triggers.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { evaluate, execute } from './expression';
1
+ import { evaluate } from './expression';
2
+ import { executeMutation } from './execute-mutation';
2
3
  import { useStoryStore } from './store';
3
4
 
4
5
  export interface WatchOptions {
@@ -25,6 +26,8 @@ interface Trigger {
25
26
  options?: WatchOptions;
26
27
  lastResult: boolean;
27
28
  priority: number;
29
+ /** Identity of a {watch} macro's watcher (see addMacroTrigger). */
30
+ macroKey?: string;
28
31
  }
29
32
 
30
33
  let nextId = 0;
@@ -58,9 +61,10 @@ function evalCondition(condition: string): boolean {
58
61
  }
59
62
  }
60
63
 
61
- export function addTrigger(
64
+ function registerTrigger(
62
65
  condition: string,
63
66
  callbackOrOptions: (() => void) | WatchOptions,
67
+ macroKey?: string,
64
68
  ): () => void {
65
69
  const id = nextId++;
66
70
  const isCallback = typeof callbackOrOptions === 'function';
@@ -75,6 +79,7 @@ export function addTrigger(
75
79
  options,
76
80
  lastResult: evalCondition(condition),
77
81
  priority: options?.priority ?? 0,
82
+ macroKey,
78
83
  };
79
84
 
80
85
  triggers.push(trigger);
@@ -85,6 +90,32 @@ export function addTrigger(
85
90
  };
86
91
  }
87
92
 
93
+ export function addTrigger(
94
+ condition: string,
95
+ callbackOrOptions: (() => void) | WatchOptions,
96
+ ): () => void {
97
+ return registerTrigger(condition, callbackOrOptions);
98
+ }
99
+
100
+ /**
101
+ * Register the watcher of a {watch} macro. Watchers outlive the passage
102
+ * that declared them, and that passage mounts its macros again on every
103
+ * visit (and on re-renders that remount them), so an identical watcher
104
+ * (same condition and options) that is still registered is kept instead
105
+ * of being added a second time.
106
+ */
107
+ export function addMacroTrigger(
108
+ condition: string,
109
+ options: WatchOptions,
110
+ ): void {
111
+ const sortedOptions = Object.fromEntries(
112
+ Object.entries(options).sort(([a], [b]) => (a < b ? -1 : 1)),
113
+ );
114
+ const macroKey = JSON.stringify([condition, sortedOptions]);
115
+ if (triggers.some((t) => t.macroKey === macroKey)) return;
116
+ registerTrigger(condition, options, macroKey);
117
+ }
118
+
88
119
  export function removeTrigger(name: string): void {
89
120
  triggers = triggers.filter((t) => t.name !== name);
90
121
  }
@@ -100,8 +131,16 @@ function fireTrigger(trigger: Trigger): void {
100
131
  if (!options) return;
101
132
 
102
133
  if (options.run) {
103
- const state = useStoryStore.getState();
104
- execute(options.run, state.variables, state.temporary);
134
+ // Store state is frozen; go through the mutation pipeline so the run
135
+ // action works on clones and its changes are committed to the store.
136
+ try {
137
+ executeMutation(options.run, {}, () => {});
138
+ } catch (err) {
139
+ console.error(
140
+ `spindle: Error in watch run action for "${trigger.condition}":`,
141
+ err,
142
+ );
143
+ }
105
144
  }
106
145
 
107
146
  if (options.dialog) {
@@ -116,42 +155,105 @@ function fireTrigger(trigger: Trigger): void {
116
155
 
117
156
  const MAX_RECHECK_DEPTH = 10;
118
157
 
119
- export function checkTriggers(): void {
120
- if (checking) return;
121
- checking = true;
122
-
123
- try {
124
- for (let depth = 0; depth < MAX_RECHECK_DEPTH; depth++) {
125
- let anyFired = false;
158
+ function runCheckLoop(): void {
159
+ for (let depth = 0; depth < MAX_RECHECK_DEPTH; depth++) {
160
+ let anyFired = false;
126
161
 
127
- // Snapshot triggers list — firing may remove `once` triggers
128
- const current = [...triggers];
129
- for (const trigger of current) {
130
- // Skip if removed during this cycle
131
- if (!triggers.includes(trigger)) continue;
162
+ // Snapshot triggers list — firing may remove `once` triggers
163
+ const current = [...triggers];
164
+ for (const trigger of current) {
165
+ // Skip if removed during this cycle
166
+ if (!triggers.includes(trigger)) continue;
132
167
 
133
- const result = evalCondition(trigger.condition);
134
- const wasFalse = !trigger.lastResult;
135
- trigger.lastResult = result;
168
+ const result = evalCondition(trigger.condition);
169
+ const wasFalse = !trigger.lastResult;
170
+ trigger.lastResult = result;
136
171
 
137
- if (result && wasFalse) {
138
- anyFired = true;
172
+ if (result && wasFalse) {
173
+ anyFired = true;
139
174
 
140
- if (trigger.options?.once) {
141
- triggers = triggers.filter((t) => t.id !== trigger.id);
142
- }
143
-
144
- fireTrigger(trigger);
175
+ if (trigger.options?.once) {
176
+ triggers = triggers.filter((t) => t.id !== trigger.id);
145
177
  }
146
- }
147
178
 
148
- if (!anyFired) break;
179
+ fireTrigger(trigger);
180
+ }
149
181
  }
182
+
183
+ if (!anyFired) break;
184
+ }
185
+ }
186
+
187
+ export function checkTriggers(): void {
188
+ if (checking) return;
189
+ checking = true;
190
+
191
+ try {
192
+ runCheckLoop();
150
193
  } finally {
151
194
  checking = false;
152
195
  }
153
196
  }
154
197
 
198
+ /** Number of live connectTriggersToStore() subscriptions. */
199
+ let connections = 0;
200
+
201
+ /**
202
+ * Check watchers against a navigation that navigate() has just completed.
203
+ * Called by the store before it records the entered moment, so run actions
204
+ * become part of that moment. Runs even when the navigation itself came
205
+ * from a watcher (a check already in progress), since that check started
206
+ * before the navigation and cannot see it.
207
+ */
208
+ export function checkTriggersOnNavigation(): void {
209
+ if (connections === 0) return;
210
+ const wasChecking = checking;
211
+ checking = true;
212
+
213
+ try {
214
+ runCheckLoop();
215
+ } finally {
216
+ checking = wasChecking;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Re-evaluate watchers whenever a namespace their conditions can read
222
+ * ($variables, _temporary, %transient) changes. Navigation is left to the
223
+ * store: navigate() checks watchers once the new moment is complete
224
+ * (checkTriggersOnNavigation), while history traversal and loads
225
+ * reinitialize watcher state instead of firing.
226
+ */
227
+ export function connectTriggersToStore(): () => void {
228
+ let prev = useStoryStore.getState();
229
+ connections++;
230
+ const unsubscribe = useStoryStore.subscribe((state) => {
231
+ const before = prev;
232
+ // Update before checking: run/goto actions re-enter this listener.
233
+ prev = state;
234
+
235
+ if (state.navigationId !== before.navigationId) return;
236
+
237
+ if (
238
+ state.variables === before.variables &&
239
+ state.temporary === before.temporary &&
240
+ state.transient === before.transient
241
+ ) {
242
+ return;
243
+ }
244
+
245
+ checkTriggers();
246
+ });
247
+
248
+ let connected = true;
249
+ return () => {
250
+ if (!connected) return;
251
+ connected = false;
252
+ connections--;
253
+ unsubscribe();
254
+ };
255
+ }
256
+
155
257
  export function reinitTriggerState(): void {
156
258
  for (const trigger of triggers) {
157
259
  trigger.lastResult = evalCondition(trigger.condition);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Read-only view of a locals scope that always reflects its current values.
3
+ *
4
+ * Used as the LocalsValuesContext value for one-shot detached renders
5
+ * ({button}/{link} bodies). Those trees are unmounted right after rendering,
6
+ * so the owning scope's re-render never reaches them; reading through
7
+ * getValues() lets a macro see a local assigned earlier in the same body.
8
+ */
9
+ export function liveLocalsView(
10
+ getValues: () => Record<string, unknown>,
11
+ ): Record<string, unknown> {
12
+ return new Proxy({} as Record<string, unknown>, {
13
+ get: (_, key) => (typeof key === 'string' ? getValues()[key] : undefined),
14
+ has: (_, key) => key in getValues(),
15
+ ownKeys: () => Reflect.ownKeys(getValues()),
16
+ getOwnPropertyDescriptor: (_, key) => {
17
+ const desc = Object.getOwnPropertyDescriptor(getValues(), key);
18
+ // Keys don't exist on the proxy target, so they must be configurable.
19
+ return desc ? { ...desc, configurable: true } : undefined;
20
+ },
21
+ });
22
+ }
package/types/index.d.ts CHANGED
@@ -541,11 +541,18 @@ export interface StoryAPI {
541
541
  /** Restart the story from the beginning. */
542
542
  restart(): void;
543
543
 
544
- /** Save the current state. Pass `slot` for a named save, `custom` for metadata. */
545
- save(slot?: string, custom?: Record<string, unknown>): void;
544
+ /**
545
+ * Save the current state. Pass `slot` for a named save, `custom` for metadata.
546
+ * Resolves once the save is persisted (after `aftersave` handlers ran and
547
+ * `hasSave(slot)` is true); rejects if persisting fails.
548
+ */
549
+ save(slot?: string, custom?: Record<string, unknown>): Promise<void>;
546
550
 
547
- /** Load a saved state (quick load). */
548
- load(slot?: string): void;
551
+ /**
552
+ * Load a saved state (quick load). Resolves once the loaded state is applied
553
+ * (immediately if the slot is empty); rejects if loading fails.
554
+ */
555
+ load(slot?: string): Promise<void>;
549
556
 
550
557
  /** Check whether a save exists. */
551
558
  hasSave(slot?: string): boolean;
@@ -556,8 +563,11 @@ export interface StoryAPI {
556
563
  /** List metadata for all known save slots. */
557
564
  listSaves(): Promise<SaveInfo[]>;
558
565
 
559
- /** Delete a save by slot name. */
560
- deleteSave(slot?: string): void;
566
+ /**
567
+ * Delete a save by slot name. Resolves once the save is removed and
568
+ * `hasSave(slot)` is false; rejects if deleting fails.
569
+ */
570
+ deleteSave(slot?: string): Promise<void>;
561
571
 
562
572
  /**
563
573
  * Export the save in a slot as a portable object (plain JSON).