@rohal12/spindle 0.59.6 → 0.59.8

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.
@@ -10269,7 +10269,13 @@ function widgetDef({ name = "", parameters = "" }) {
10269
10269
  function parseWidgetDef(rawArgs) {
10270
10270
  return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
10271
10271
  }
10272
- var BLOCK_WIDGET = /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
10272
+ /**
10273
+ * A definition: its arguments (read like a {widget} macro's, see
10274
+ * parseWidgetDef) and its body. Macro names are not case-sensitive.
10275
+ */
10276
+ var WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
10277
+ /** `{@children}`, also with selectors such as `{.highlight @children}`. */
10278
+ var CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
10273
10279
  /**
10274
10280
  * The names of the block widgets (those whose body renders `{@children}`)
10275
10281
  * that StoryInit and the passages tagged `widget` define. They must be
@@ -10280,7 +10286,12 @@ function blockWidgetNames(passages) {
10280
10286
  const names = [];
10281
10287
  for (const passage of passages) {
10282
10288
  if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
10283
- for (const match of passage.content.matchAll(BLOCK_WIDGET)) if (/\{@children\}/.test(match[2])) names.push(match[1]);
10289
+ for (const match of passage.content.matchAll(WIDGET_DEFINITION)) {
10290
+ if (!CHILDREN_PLACEHOLDER.test(match[2])) continue;
10291
+ try {
10292
+ names.push(parseWidgetDef(match[1]).name);
10293
+ } catch {}
10294
+ }
10284
10295
  }
10285
10296
  return names;
10286
10297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.6",
3
+ "version": "0.59.8",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -110,11 +110,22 @@ const defineData = (target: object, key: string, value: unknown): void => {
110
110
  });
111
111
  };
112
112
 
113
+ /**
114
+ * A reducer extracts string keys, so devalue never sees the symbol keys of
115
+ * the original object: refuse them here, as it does for a plain object.
116
+ */
117
+ function refuseSymbolKeys(value: object): void {
118
+ if (Object.getOwnPropertySymbols(value).length > 0) {
119
+ throw new TypeError('spindle: Cannot save an object with symbol keys');
120
+ }
121
+ }
122
+
113
123
  /**
114
124
  * Own enumerable keys of `value`, as a plain object; for an error also its
115
125
  * message, cause and (AggregateError) errors, which are not enumerable.
116
126
  */
117
127
  function ownData(value: object): Record<string, unknown> {
128
+ refuseSymbolKeys(value);
118
129
  const data: Record<string, unknown> = {};
119
130
  if (value instanceof Error) {
120
131
  for (const key of ERROR_HIDDEN_KEYS) {
@@ -163,6 +174,7 @@ function reducers(): Record<string, (value: unknown) => unknown> {
163
174
  }
164
175
  const keys = Object.keys(v);
165
176
  if (!keys.some(isEscapedOneCharKey)) return false;
177
+ refuseSymbolKeys(v);
166
178
  if (keys.includes('__proto__')) {
167
179
  throw new TypeError(
168
180
  'spindle: Cannot save a property named "__proto__"',
@@ -5,6 +5,7 @@ import { NobrContext } from '../markup/render';
5
5
  import { StoryInterface } from './StoryInterface';
6
6
  import { TriggerDialogHost } from './TriggerDialogHost';
7
7
  import { RuntimeErrors } from './RuntimeErrors';
8
+ import { quickLoad, quickSave } from '../quick-actions';
8
9
 
9
10
  export function App() {
10
11
  const { storyData, currentPassage, nobr } = useStoryFields(
@@ -15,14 +16,13 @@ export function App() {
15
16
 
16
17
  useEffect(() => {
17
18
  const onKeyDown = (e: KeyboardEvent) => {
18
- const { quickSaveKey, quickLoadKey, save, load } =
19
- useStoryStore.getState();
19
+ const { quickSaveKey, quickLoadKey } = useStoryStore.getState();
20
20
  if (quickSaveKey !== null && e.key === quickSaveKey) {
21
21
  e.preventDefault();
22
- save();
22
+ quickSave();
23
23
  } else if (quickLoadKey !== null && e.key === quickLoadKey) {
24
24
  e.preventDefault();
25
- load();
25
+ quickLoad();
26
26
  }
27
27
  };
28
28
  document.addEventListener('keydown', onKeyDown);
@@ -41,9 +41,7 @@ export function App() {
41
41
  </>
42
42
  );
43
43
 
44
- return nobr ? (
45
- <NobrContext.Provider value={true}>{content}</NobrContext.Provider>
46
- ) : (
47
- content
48
- );
44
+ // One provider whatever `nobr` is: a changing tree shape would remount the
45
+ // passage and run its mount-only macros again
46
+ return <NobrContext.Provider value={nobr}>{content}</NobrContext.Provider>;
49
47
  }
@@ -1,7 +1,7 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { evaluate } from '../../expression';
3
3
  import { readState } from '../../execute-mutation';
4
- import { deepEqual } from '../../structural';
4
+ import { deepEqualStrict } from '../../structural';
5
5
  import { defineMacro } from '../../define-macro';
6
6
  import { MacroError, logMacroError } from './MacroError';
7
7
  import { currentSourceLocation } from '../../utils/source-location';
@@ -67,7 +67,8 @@ function computeAndApply(
67
67
  return;
68
68
  }
69
69
 
70
- if (!deepEqual(prevRef.current, newValue)) {
70
+ // Strict: a change of only the aliases inside the result is a change
71
+ if (!deepEqualStrict(prevRef.current, newValue)) {
71
72
  prevRef.current = newValue;
72
73
  if (isLocal) {
73
74
  try {
@@ -2,6 +2,7 @@ import { useState } from 'preact/hooks';
2
2
  import { useStoryStore } from '../../store';
3
3
  import { useStoryFields } from '../../hooks/use-story-fields';
4
4
  import { settings } from '../../settings';
5
+ import { quickLoad, quickSave } from '../../quick-actions';
5
6
  import { defineMacro } from '../../define-macro';
6
7
  import { PassageDialog } from '../PassageDialog';
7
8
  import type { ActionType } from '../../action-registry';
@@ -110,8 +111,11 @@ defineMenubarAction({
110
111
  label: 'QuickSave',
111
112
  actionType: 'save',
112
113
  setup: () => {
113
- const { save, quickSaveKey } = useStoryFields('save', 'quickSaveKey');
114
- return { perform: save, title: hotkeyTitle('Quick Save', quickSaveKey) };
114
+ const { quickSaveKey } = useStoryFields('quickSaveKey');
115
+ return {
116
+ perform: quickSave,
117
+ title: hotkeyTitle('Quick Save', quickSaveKey),
118
+ };
115
119
  },
116
120
  });
117
121
 
@@ -122,14 +126,13 @@ defineMenubarAction({
122
126
  confirm: 'Load saved game? Current progress will be lost.',
123
127
  setup: () => {
124
128
  // knownSaves: re-render when the saves change, as hasSave() reads them
125
- const { load, hasSave, quickLoadKey } = useStoryFields(
126
- 'load',
129
+ const { hasSave, quickLoadKey } = useStoryFields(
127
130
  'hasSave',
128
131
  'quickLoadKey',
129
132
  'knownSaves',
130
133
  );
131
134
  return {
132
- perform: () => load(),
135
+ perform: quickLoad,
133
136
  disabled: !hasSave(),
134
137
  title: hotkeyTitle('Quick Load', quickLoadKey),
135
138
  };
@@ -80,6 +80,18 @@ function cleanupSnapshots(containerEl: Element | null): void {
80
80
  }
81
81
  }
82
82
 
83
+ /**
84
+ * `config` for players who prefer reduced motion: the CSS shortens the
85
+ * animations (see styles.css), so the waits that follow them shrink too,
86
+ * leaving no empty passage area before the next passage.
87
+ */
88
+ function reduceMotion(config: ResolvedTransition): ResolvedTransition {
89
+ if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
90
+ return config;
91
+ }
92
+ return { ...config, duration: Math.min(config.duration, 10), pause: 0 };
93
+ }
94
+
83
95
  defineMacro({
84
96
  name: 'passage',
85
97
  interpolate: true,
@@ -131,9 +143,6 @@ defineMacro({
131
143
  const store = useStoryStore.getState();
132
144
  const { history, historyIndex, transitionConfig } = store;
133
145
 
134
- // Always consume the next transition (one-shot), regardless of what we do
135
- const next = store.consumeNextTransition();
136
-
137
146
  // Determine if this is a first load, restart, or save-load
138
147
  const prevLen = prevHistoryLenRef.current;
139
148
  prevHistoryLenRef.current = history.length;
@@ -152,6 +161,10 @@ defineMacro({
152
161
  return;
153
162
  }
154
163
 
164
+ // One-shot, used up by the first navigation, whatever we do with it
165
+ // (not by the initial render, which no navigation caused)
166
+ const next = store.consumeNextTransition();
167
+
155
168
  // Cancel any in-progress transition
156
169
  cancelTransition();
157
170
 
@@ -172,10 +185,8 @@ defineMacro({
172
185
  }
173
186
 
174
187
  // Resolve transition from tags → nextTransition → store default → built-in
175
- const config = resolveTransition(
176
- targetPassage.tags,
177
- next,
178
- transitionConfig,
188
+ const config = reduceMotion(
189
+ resolveTransition(targetPassage.tags, next, transitionConfig),
179
190
  );
180
191
 
181
192
  resolvedTypeRef.current = config.type;
@@ -56,6 +56,7 @@ export function SaveManagerContent() {
56
56
  } | null>(null);
57
57
  const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
58
58
  const [renamingId, setRenamingId] = useState<string | null>(null);
59
+ const renameCancelled = useRef(false);
59
60
  const [renameValue, setRenameValue] = useState('');
60
61
  const renameInputRef = useRef<HTMLInputElement>(null);
61
62
  const fileInputRef = useRef<HTMLInputElement>(null);
@@ -127,9 +128,8 @@ export function SaveManagerContent() {
127
128
  const playthrough = resolvePlaythroughId();
128
129
  try {
129
130
  await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
130
- const ptId = await playthrough;
131
- if (!ptId) throw new Error('No playthrough');
132
- return createSave(ifid, ptId, payload);
131
+ // Queued now, in call order with other storage operations
132
+ return createSave(ifid, playthrough, payload);
133
133
  });
134
134
  showStatus('Save created');
135
135
  await refresh();
@@ -143,8 +143,7 @@ export function SaveManagerContent() {
143
143
  try {
144
144
  await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
145
145
  // Only the current playthrough's saves offer "Save Here"
146
- const ptId = (await playthrough) || undefined;
147
- if (!(await overwriteSave(saveId, payload, undefined, ptId))) {
146
+ if (!(await overwriteSave(saveId, payload, undefined, playthrough))) {
148
147
  throw new Error('Save not found');
149
148
  }
150
149
  });
@@ -193,6 +192,7 @@ export function SaveManagerContent() {
193
192
  };
194
193
 
195
194
  const handleRenameStart = (save: SaveRecord) => {
195
+ renameCancelled.current = false;
196
196
  setRenamingId(save.meta.id);
197
197
  setRenameValue(save.meta.title);
198
198
  };
@@ -211,7 +211,18 @@ export function SaveManagerContent() {
211
211
 
212
212
  const handleRenameKeyDown = (e: KeyboardEvent) => {
213
213
  if (e.key === 'Enter') handleRenameConfirm();
214
- else if (e.key === 'Escape') setRenamingId(null);
214
+ else if (e.key === 'Escape') {
215
+ // Cancel the edit only: the dialog stays open, and the blur that
216
+ // follows removing the input must not confirm it
217
+ e.preventDefault();
218
+ e.stopPropagation();
219
+ renameCancelled.current = true;
220
+ setRenamingId(null);
221
+ }
222
+ };
223
+
224
+ const handleRenameBlur = () => {
225
+ if (!renameCancelled.current) handleRenameConfirm();
215
226
  };
216
227
 
217
228
  const handleExport = async (saveId: string) => {
@@ -352,7 +363,7 @@ export function SaveManagerContent() {
352
363
  )
353
364
  }
354
365
  onKeyDown={handleRenameKeyDown}
355
- onBlur={handleRenameConfirm}
366
+ onBlur={handleRenameBlur}
356
367
  />
357
368
  ) : (
358
369
  <div class="save-slot-title">{save.meta.title}</div>
@@ -1,5 +1,5 @@
1
1
  import { render } from 'preact';
2
- import { useContext } from 'preact/hooks';
2
+ import { useContext, useEffect, useRef } from 'preact/hooks';
3
3
  import {
4
4
  renderNodes,
5
5
  LocalsUpdateContext,
@@ -19,7 +19,10 @@ import { DialogCloseContext } from '../PassageDialog';
19
19
  * Return a function that runs a macro body once, outside the passage tree:
20
20
  * it renders `children` into a detached node, so the body's macros ({set},
21
21
  * {if}, {goto}, ...) fire their side effects through the normal Preact
22
- * pipeline, then unmounts it. Used by {button} and {link} on click.
22
+ * pipeline. Used by {button} and {link} on click. The body stays mounted
23
+ * until the owning macro unmounts (its passage changes, say), so work it
24
+ * starts in an effect, such as the timer of a {timed} or {repeat}, can
25
+ * finish; the passage leaving cancels it.
23
26
  *
24
27
  * A detached render starts with no contexts, so the hook captures the ones
25
28
  * the owning macro sees and provides them again: the locals scope (read
@@ -41,6 +44,16 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
41
44
  const repeat = useContext(RepeatContext);
42
45
  const closeDialog = useContext(DialogCloseContext);
43
46
 
47
+ const mounted = useRef<HTMLElement[]>([]);
48
+ useEffect(
49
+ () => () => {
50
+ for (const container of mounted.current.splice(0)) {
51
+ render(null, container);
52
+ }
53
+ },
54
+ [],
55
+ );
56
+
44
57
  const run = (children: ASTNode[]) => {
45
58
  const locals = liveLocalsView(updater.getValues);
46
59
  const container = document.createElement('div');
@@ -62,7 +75,7 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
62
75
  </LocalsUpdateContext.Provider>,
63
76
  container,
64
77
  );
65
- render(null, container);
78
+ mounted.current.push(container);
66
79
  };
67
80
  return (children) => runWithCommittedMutations(() => run(children));
68
81
  }
@@ -381,9 +381,9 @@ function relink(
381
381
  function cloneNamespaces(from: VariableNamespaces): VariableNamespaces {
382
382
  const seen = new Map<object, object>();
383
383
  return {
384
- variables: deepClone(from.variables, { seen }),
385
- temporary: deepClone(from.temporary, { seen }),
386
- transient: deepClone(from.transient, { seen }),
384
+ variables: deepClone(from.variables, { keepUnregistered: true, seen }),
385
+ temporary: deepClone(from.temporary, { keepUnregistered: true, seen }),
386
+ transient: deepClone(from.transient, { keepUnregistered: true, seen }),
387
387
  };
388
388
  }
389
389
 
package/src/expression.ts CHANGED
@@ -123,32 +123,22 @@ export function historyQueries(
123
123
  }
124
124
 
125
125
  let cachedFns: ExpressionFns | null = null;
126
- let cachedVisitCounts: Counts | null = null;
127
- let cachedRenderCounts: Counts | null = null;
128
126
 
127
+ /**
128
+ * The functions compiled code calls. The history queries read the counters
129
+ * when called, not when the functions were built: mutation code that
130
+ * navigates (`Story.goto()`) goes on with the counters of the new state,
131
+ * as the Story API does.
132
+ */
129
133
  export function buildExpressionFns() {
130
- const state = useStoryStore.getState();
131
- const { visitCounts, renderCounts } = state;
132
-
133
- if (
134
- cachedFns &&
135
- cachedVisitCounts === visitCounts &&
136
- cachedRenderCounts === renderCounts
137
- ) {
138
- return cachedFns;
139
- }
140
-
141
- cachedFns = {
134
+ cachedFns ??= {
142
135
  ...historyQueries(
143
- () => visitCounts,
144
- () => renderCounts,
136
+ () => useStoryStore.getState().visitCounts,
137
+ () => useStoryStore.getState().renderCounts,
145
138
  ),
146
139
  random,
147
140
  randomInt,
148
141
  };
149
- cachedVisitCounts = visitCounts;
150
- cachedRenderCounts = renderCounts;
151
-
152
142
  return cachedFns;
153
143
  }
154
144
 
@@ -221,8 +211,6 @@ export function clearExpressionCache(): void {
221
211
  fnCache.clear();
222
212
  clearTransformCache();
223
213
  cachedFns = null;
224
- cachedVisitCounts = null;
225
- cachedRenderCounts = null;
226
214
  }
227
215
 
228
216
  export function evaluateWithState(expr: string, state: StoryState): unknown {
@@ -13,9 +13,23 @@ const FOCUSABLE = [
13
13
  '[tabindex]:not([tabindex="-1"])',
14
14
  ].join(',');
15
15
 
16
+ /**
17
+ * Whether CSS leaves `el` in the tab sequence: not `visibility: hidden`
18
+ * (inherited, so the computed value covers ancestors) and not inside a
19
+ * `display: none` element.
20
+ */
21
+ function isRendered(el: HTMLElement): boolean {
22
+ const { visibility } = getComputedStyle(el);
23
+ if (visibility === 'hidden' || visibility === 'collapse') return false;
24
+ for (let e: HTMLElement | null = el; e; e = e.parentElement) {
25
+ if (getComputedStyle(e).display === 'none') return false;
26
+ }
27
+ return true;
28
+ }
29
+
16
30
  function focusables(root: HTMLElement): HTMLElement[] {
17
31
  return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
18
- (el) => !el.hidden && !el.closest('[hidden], [inert]'),
32
+ (el) => !el.hidden && !el.closest('[hidden], [inert]') && isRendered(el),
19
33
  );
20
34
  }
21
35
 
package/src/index.tsx CHANGED
@@ -62,6 +62,21 @@ function stopWithErrors(errors: string[]): never {
62
62
  );
63
63
  }
64
64
 
65
+ /**
66
+ * The undeclared variables the passages refer to, with the macros known now:
67
+ * the input macros that bind a variable (registered by author JS or
68
+ * StoryInit) and the roles of their parameters decide what is a reference.
69
+ */
70
+ function variableErrors(
71
+ storyData: StoryData,
72
+ schema: Parameters<typeof validatePassages>[1],
73
+ ): string[] {
74
+ const storeVarMacros = getMacroRegistry()
75
+ .filter((m) => m.storeVar)
76
+ .map((m) => m.name);
77
+ return validatePassages(storyData.passages, schema, storeVarMacros);
78
+ }
79
+
65
80
  /**
66
81
  * The markup errors (malformed markup, unknown macros, syntax errors in
67
82
  * code) of the passages for which `only` holds, with the macros and widgets
@@ -134,10 +149,6 @@ export function boot() {
134
149
  }
135
150
 
136
151
  const schema = parseStoryVariables(storyVarsPassage.content);
137
- // Include input macros registered by author JS, which ran above.
138
- const storeVarMacros = getMacroRegistry()
139
- .filter((m) => m.storeVar)
140
- .map((m) => m.name);
141
152
  // Pass 1: Register the block widgets as block macros BEFORE any passage
142
153
  // is parsed (validation and StoryInit included), so that passages
143
154
  // invoking block widgets and widget bodies using other block widgets
@@ -146,7 +157,7 @@ export function boot() {
146
157
  registerBlockMacro(name);
147
158
  }
148
159
 
149
- const errors = validatePassages(storyData.passages, schema, storeVarMacros);
160
+ const errors = variableErrors(storyData, schema);
150
161
  // StoryInit's markup must be valid before it runs; the other passages are
151
162
  // validated once it has run, as it may define macros (see below).
152
163
  errors.push(...markupErrors(storyData, (p) => p.name === 'StoryInit'));
@@ -186,8 +197,12 @@ export function boot() {
186
197
  // storyinit after all state is settled (defaults + StoryInit + session)
187
198
  initializeStory(loadSession(storyData.ifid));
188
199
 
189
- // Every other passage's markup, now that StoryInit may have defined macros
190
- const markup = markupErrors(storyData, (p) => p.name !== 'StoryInit');
200
+ // Every other passage's markup, now that StoryInit may have defined macros;
201
+ // so are the variable references, which macros' parameters decide
202
+ const markup = [
203
+ ...variableErrors(storyData, schema),
204
+ ...markupErrors(storyData, (p) => p.name !== 'StoryInit'),
205
+ ];
191
206
  if (markup.length > 0) stopWithErrors(markup);
192
207
 
193
208
  // Pass 2: Full parse and register widgets from passages tagged "widget"
@@ -0,0 +1,23 @@
1
+ import { useStoryStore } from './store';
2
+ import { showRuntimeError } from './runtime-errors';
3
+
4
+ /**
5
+ * QuickSave and QuickLoad as the built-in buttons and hotkeys perform them:
6
+ * a failure is shown on the page (the Story API's save() and load() only
7
+ * reject, for the code that awaits them).
8
+ */
9
+ export async function quickSave(): Promise<void> {
10
+ try {
11
+ await useStoryStore.getState().save();
12
+ } catch (error) {
13
+ showRuntimeError('The game could not be saved:', error);
14
+ }
15
+ }
16
+
17
+ export async function quickLoad(): Promise<void> {
18
+ try {
19
+ await useStoryStore.getState().load();
20
+ } catch (error) {
21
+ showRuntimeError('The saved game could not be loaded:', error);
22
+ }
23
+ }
@@ -291,10 +291,12 @@ export const createSave = queued(createSaveNow);
291
291
 
292
292
  async function createSaveNow(
293
293
  ifid: string,
294
- playthroughId: string,
294
+ playthroughPromise: string | PromiseLike<string>,
295
295
  payload: SavePayload,
296
296
  custom: Record<string, unknown> = {},
297
297
  ): Promise<SaveRecord> {
298
+ const playthroughId = await playthroughPromise;
299
+ if (!playthroughId) throw new Error('No playthrough');
298
300
  const now = new Date().toISOString();
299
301
  const meta: SaveMeta = {
300
302
  id: randomUUID(),
@@ -313,6 +315,10 @@ async function createSaveNow(
313
315
  }
314
316
 
315
317
  /**
318
+ * A playthrough passed as a promise (see resolvePlaythroughId) is awaited
319
+ * inside the operation, so the call takes its place in the order of
320
+ * operations when it is made.
321
+ *
316
322
  * Replace the payload of an existing save, keeping its ID and `createdAt`.
317
323
  * Pass the current `playthroughId` when the new payload comes from the
318
324
  * running game: the save then holds that playthrough's state, so it is
@@ -333,8 +339,9 @@ async function overwriteSaveNow(
333
339
  saveId: string,
334
340
  payload: SavePayload,
335
341
  custom?: Record<string, unknown>,
336
- playthroughId?: string,
342
+ playthroughPromise?: string | PromiseLike<string>,
337
343
  ): Promise<SaveRecord | undefined> {
344
+ const playthroughId = await playthroughPromise;
338
345
  const backend = await getBackend();
339
346
  const existing = await backend.getSave(saveId);
340
347
  if (!existing) return undefined;
@@ -706,14 +713,22 @@ const SESSION_KEY_PREFIX = 'spindle.session.';
706
713
  * encodePayload). Throws, like a save, when the state holds a value a save
707
714
  * cannot (a function, an instance of an unregistered class, a unique
708
715
  * symbol); the session then keeps its previous copy.
716
+ *
717
+ * A storage that is unavailable or full is returned, not thrown: the session
718
+ * keeps its previous copy then too, but the story can go on, so the caller
719
+ * decides how to tell the player.
709
720
  */
710
- export function saveSession(ifid: string, payload: SavePayload): void {
721
+ export function saveSession(
722
+ ifid: string,
723
+ payload: SavePayload,
724
+ ): { error: unknown } | undefined {
711
725
  const text = JSON.stringify(encodePayload(payload));
712
726
  try {
713
727
  sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ifid}`, text);
714
- } catch {
715
- // sessionStorage unavailable or full — silently ignore
728
+ } catch (error) {
729
+ return { error };
716
730
  }
731
+ return undefined;
717
732
  }
718
733
 
719
734
  /**
@@ -882,13 +897,15 @@ async function clearAllDataNow(): Promise<void> {
882
897
  resetBackend();
883
898
 
884
899
  // Clear all Spindle session keys from sessionStorage
885
- if (typeof sessionStorage !== 'undefined') {
900
+ try {
886
901
  const toRemove: string[] = [];
887
902
  for (let i = 0; i < sessionStorage.length; i++) {
888
903
  const key = sessionStorage.key(i);
889
904
  if (key?.startsWith('spindle.session.')) toRemove.push(key);
890
905
  }
891
906
  for (const key of toRemove) sessionStorage.removeItem(key);
907
+ } catch {
908
+ // no sessionStorage (or access denied): nothing to clear
892
909
  }
893
910
  }
894
911
 
@@ -410,8 +410,10 @@ function createIDBBackend(): StorageBackend {
410
410
  let _backend: StorageBackend | null = null;
411
411
 
412
412
  async function detectBackend(): Promise<StorageBackend> {
413
- if (typeof indexedDB !== 'undefined') {
414
- try {
413
+ // Even looking up a storage API can throw (a sandboxed frame without
414
+ // allow-same-origin denies the localStorage property), hence the try.
415
+ try {
416
+ if (typeof indexedDB !== 'undefined') {
415
417
  await new Promise<void>((resolve, reject) => {
416
418
  const req = indexedDB.open('__spindle_probe__', 1);
417
419
  req.onsuccess = () => {
@@ -422,21 +424,21 @@ async function detectBackend(): Promise<StorageBackend> {
422
424
  req.onerror = () => reject(req.error);
423
425
  });
424
426
  return createIDBBackend();
425
- } catch {
426
- // fall through
427
427
  }
428
+ } catch {
429
+ // fall through
428
430
  }
429
431
 
430
- if (typeof localStorage !== 'undefined') {
431
- try {
432
+ try {
433
+ if (typeof localStorage !== 'undefined') {
432
434
  const probeKey = '__spindle_probe__';
433
435
  localStorage.setItem(probeKey, '1');
434
436
  const val = localStorage.getItem(probeKey);
435
437
  localStorage.removeItem(probeKey);
436
438
  if (val === '1') return createLocalStorageBackend();
437
- } catch {
438
- // fall through
439
439
  }
440
+ } catch {
441
+ // fall through
440
442
  }
441
443
 
442
444
  return createMemoryBackend();
package/src/settings.ts CHANGED
@@ -39,7 +39,11 @@ function storageKey(): string {
39
39
  }
40
40
 
41
41
  function persist(): void {
42
- localStorage.setItem(storageKey(), JSON.stringify(values));
42
+ try {
43
+ localStorage.setItem(storageKey(), JSON.stringify(values));
44
+ } catch {
45
+ // storage unavailable or full: the setting holds for this session only
46
+ }
43
47
  }
44
48
 
45
49
  function loadFromStorage(): void {