@rohal12/spindle 0.59.0 → 0.59.1

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.
@@ -13080,7 +13080,15 @@ function createIDBBackend() {
13080
13080
  const rows = name === "meta";
13081
13081
  /** Run one request in a transaction of its own. */
13082
13082
  const request = async (mode, make) => {
13083
- return idbReq(make((await openDB()).transaction(name, mode).objectStore(name)));
13083
+ const tx = (await openDB()).transaction(name, mode);
13084
+ const result = idbReq(make(tx.objectStore(name)));
13085
+ if (mode === "readonly") return result;
13086
+ const committed = new Promise((resolve, reject) => {
13087
+ tx.oncomplete = () => resolve();
13088
+ tx.onabort = () => reject(tx.error ?? new DOMException("Transaction aborted", "AbortError"));
13089
+ });
13090
+ const [value] = await Promise.all([result, committed]);
13091
+ return value;
13084
13092
  };
13085
13093
  return {
13086
13094
  async get(key) {
@@ -13319,15 +13327,22 @@ var ERROR_HIDDEN_KEYS = [
13319
13327
  "errors"
13320
13328
  ];
13321
13329
  function deepClone(value, options = {}) {
13322
- const seen = /* @__PURE__ */ new Map();
13330
+ const seen = options.seen ?? /* @__PURE__ */ new Map();
13323
13331
  /** Record `copy` as the copy of `obj` before its contents are copied. */
13324
13332
  const keep = (obj, copy) => {
13325
13333
  seen.set(obj, copy);
13326
13334
  return copy;
13327
13335
  };
13328
- /** Copy the own (enumerable) keys of `obj` into `copy`. */
13336
+ /**
13337
+ * Copy the own (enumerable) keys of `obj` into `copy`, symbol keys too: a
13338
+ * save refuses them, which a copy must not hide.
13339
+ */
13329
13340
  function copyKeys(obj, copy) {
13330
13341
  for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
13342
+ for (const sym of Object.getOwnPropertySymbols(obj)) {
13343
+ if (!Object.prototype.propertyIsEnumerable.call(obj, sym)) continue;
13344
+ copy[sym] = clone(obj[sym]);
13345
+ }
13331
13346
  return copy;
13332
13347
  }
13333
13348
  function cloneBuiltin(val) {
@@ -13410,65 +13425,83 @@ function loosePairs() {
13410
13425
  /**
13411
13426
  * Pairs that also require the same reference structure: each object of one
13412
13427
  * value pairs with exactly one of the other, so shared references and
13413
- * cycles are shared at the same places.
13428
+ * cycles are shared at the same places. `ab` and `ba` hold the pairs (and
13429
+ * may hold some already); those made are listed in `added`.
13414
13430
  */
13415
- function strictPairs() {
13416
- const ab = /* @__PURE__ */ new Map();
13417
- const ba = /* @__PURE__ */ new Map();
13418
- const pairs = (a, b) => {
13431
+ function strictPairs(ab = /* @__PURE__ */ new Map(), ba = /* @__PURE__ */ new Map()) {
13432
+ const added = [];
13433
+ const pairs = ((a, b) => {
13419
13434
  const known = ab.get(a);
13420
13435
  if (known !== void 0) return known === b;
13421
13436
  if (ba.has(b)) return false;
13422
13437
  ab.set(a, b);
13423
13438
  ba.set(b, a);
13424
- };
13439
+ added.push([a, b]);
13440
+ });
13425
13441
  pairs.strict = true;
13442
+ pairs.added = added;
13426
13443
  return pairs;
13427
13444
  }
13428
13445
  /**
13429
- * Whether `a` and `b` are equal as deepEqual() has it and also share
13430
- * references at the same places (a save of one loads as the other).
13431
- */
13432
- function deepEqualStrict(a, b) {
13433
- return equal(a, b, strictPairs());
13434
- }
13435
- /**
13436
13446
  * `curr` with each part that equals (see deepEqualStrict) the part of
13437
13447
  * `prev` at the same place replaced by `prev`'s: the history moments of a
13438
13448
  * save then share what did not change between them, which a save stores
13439
13449
  * once. Containers on the way to a change are copied; neither value is
13440
- * changed. Shared references and cycles within `curr` stay so.
13450
+ * changed. The result has the reference structure of `curr`: what `curr`
13451
+ * shares or cycles through stays so, and what it does not share stays
13452
+ * apart, whichever parts are reused.
13441
13453
  */
13442
13454
  function shareEqual(prev, curr) {
13455
+ if (!isObjectValue(prev) || !isObjectValue(curr)) return curr;
13456
+ /** The object of the result standing for each object of `curr` met. */
13443
13457
  const done = /* @__PURE__ */ new Map();
13444
- function share(p, c) {
13445
- if (p === c || !isObjectValue(c) || !isObjectValue(p)) return c;
13446
- if (done.has(c)) return done.get(c);
13447
- if (deepEqualStrict(p, c)) {
13448
- done.set(c, p);
13449
- return p;
13458
+ /** The object of `curr` each object of `prev` in the result stands for. */
13459
+ const used = /* @__PURE__ */ new Map();
13460
+ /** `value` for the result: copied, as far as it is not met already. */
13461
+ const fresh = (value) => isObjectValue(value) ? deepClone(value, {
13462
+ keepUnregistered: true,
13463
+ seen: done
13464
+ }) : value;
13465
+ /**
13466
+ * Whether `p` and `c` are equal and so can stand for each other, which
13467
+ * needs each object in them to stand for one object only, in the whole
13468
+ * result. Then every pair of objects they hold is placed in the result.
13469
+ */
13470
+ function reuse(p, c) {
13471
+ const pairs = strictPairs(used, done);
13472
+ if (equal(p, c, pairs)) return true;
13473
+ for (const [a, b] of pairs.added) {
13474
+ used.delete(a);
13475
+ done.delete(b);
13450
13476
  }
13477
+ return false;
13478
+ }
13479
+ function share(p, c) {
13480
+ if (!isObjectValue(c)) return c;
13481
+ const placed = done.get(c);
13482
+ if (placed !== void 0) return placed;
13483
+ if (!isObjectValue(p)) return fresh(c);
13484
+ if (reuse(p, c)) return p;
13451
13485
  if (c instanceof Map && p instanceof Map) {
13452
13486
  const out = /* @__PURE__ */ new Map();
13453
13487
  done.set(c, out);
13454
- for (const [k, v] of c) out.set(k, p.has(k) ? share(p.get(k), v) : v);
13488
+ for (const [k, v] of c) out.set(fresh(k), share(p.get(k), v));
13455
13489
  return out;
13456
13490
  }
13457
13491
  if (Array.isArray(c) && Array.isArray(p)) {
13458
13492
  const out = new Array(c.length);
13459
13493
  done.set(c, out);
13460
- for (let i = 0; i < c.length; i++) if (i in c) out[i] = i < p.length ? share(p[i], c[i]) : c[i];
13494
+ for (let i = 0; i < c.length; i++) if (i in c) out[i] = share(p[i], c[i]);
13461
13495
  return out;
13462
13496
  }
13463
13497
  if (mergesWith(p, c, false)) {
13464
13498
  const out = Object.create(Object.getPrototypeOf(c));
13465
13499
  done.set(c, out);
13466
13500
  const cr = c;
13467
- for (const key of Object.keys(cr)) setOwn(out, key, hasOwn(p, key) ? share(p[key], cr[key]) : cr[key]);
13501
+ for (const key of Object.keys(cr)) setOwn(out, key, share(hasOwn(p, key) ? p[key] : void 0, cr[key]));
13468
13502
  return out;
13469
13503
  }
13470
- done.set(c, c);
13471
- return c;
13504
+ return fresh(c);
13472
13505
  }
13473
13506
  return share(prev, curr);
13474
13507
  }
@@ -14942,7 +14975,10 @@ var lastNavigationVars = createNamespace();
14942
14975
  * object) or deleted. A recorded value is a deep copy, so that changing a
14943
14976
  * live value in place (a class instance is not frozen) cannot change
14944
14977
  * history; instances of unregistered classes are kept, as the store keeps
14945
- * them (a save then refuses them).
14978
+ * them (a save then refuses them). The copies are made together, so what
14979
+ * variables share (an object, a cycle) stays shared in them, and a variable
14980
+ * left as it is that shares with a replaced one is recorded again, to share
14981
+ * with its copy.
14946
14982
  */
14947
14983
  function computeVarPatches(prev, curr) {
14948
14984
  const forward = [];
@@ -14950,11 +14986,33 @@ function computeVarPatches(prev, curr) {
14950
14986
  key,
14951
14987
  deleted: true
14952
14988
  });
14953
- for (const key of Object.keys(curr)) if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) forward.push({
14989
+ const seen = /* @__PURE__ */ new Map();
14990
+ const record = (key) => forward.push({
14954
14991
  key,
14955
14992
  deleted: false,
14956
- value: deepClone(curr[key], { keepUnregistered: true })
14993
+ value: deepClone(curr[key], {
14994
+ keepUnregistered: true,
14995
+ seen
14996
+ })
14957
14997
  });
14998
+ const unchanged = [];
14999
+ for (const key of Object.keys(curr)) if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) record(key);
15000
+ else if (typeof curr[key] === "object" && curr[key] !== null) unchanged.push(key);
15001
+ for (let found = seen.size > 0; found;) {
15002
+ found = false;
15003
+ for (const key of unchanged) {
15004
+ const probe = /* @__PURE__ */ new Map();
15005
+ deepClone(curr[key], {
15006
+ keepUnregistered: true,
15007
+ seen: probe
15008
+ });
15009
+ if (![...probe.keys()].some((object) => seen.has(object))) continue;
15010
+ record(key);
15011
+ unchanged.splice(unchanged.indexOf(key), 1);
15012
+ found = true;
15013
+ break;
15014
+ }
15015
+ }
14958
15016
  return { forward };
14959
15017
  }
14960
15018
  /**
@@ -17364,10 +17422,6 @@ function parameterLookup(macros) {
17364
17422
  }
17365
17423
  /** What a `string` or `text` argument holds when its parameter doesn't say. */
17366
17424
  var holdsByDefault = (interpolate) => interpolate ? "markup" : "text";
17367
- /** Whether a parameter in `params` (options too) holds code in a string. */
17368
- function holdsCode(params) {
17369
- return params.some((p) => p.holds === "expression" || p.holds === "statements" || !!p.parameters && holdsCode(p.parameters));
17370
- }
17371
17425
  /** The goal of the code an argument of this type holds, if it is code. */
17372
17426
  function codeGoal(param) {
17373
17427
  if (param.type === "expression" || param.type === "passage") return "expression";
@@ -17564,7 +17618,7 @@ function* argPieces(args, offset, params, macro, label) {
17564
17618
  };
17565
17619
  }
17566
17620
  }
17567
- yield* visit(params, values);
17621
+ yield* [...visit(params, values)].sort((a, b) => a.offset - b.offset);
17568
17622
  }
17569
17623
  /**
17570
17624
  * Index of `part` in `text` from `from` on, else anywhere, else `from`: the
@@ -17819,7 +17873,6 @@ function declarationRegex(sigil) {
17819
17873
  var VAR_PATH_RE = /\$(\w+(?:\.\w+)*)/y;
17820
17874
  /** Quoted first argument of an input macro naming a story variable. */
17821
17875
  var QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
17822
- var FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
17823
17876
  var VALID_VAR_TYPES = new Set([
17824
17877
  "number",
17825
17878
  "string",
@@ -17882,30 +17935,14 @@ function parseStoryVariables(content, sigil = "$") {
17882
17935
  return schema;
17883
17936
  }
17884
17937
  /**
17885
- * Extract for-loop local variable names from passage content.
17886
- * `{for @item of ...}` → "item"
17887
- * `{for @index, @item of ...}` → "index", "item"
17888
- */
17889
- function extractForLocals(content) {
17890
- const locals = /* @__PURE__ */ new Set();
17891
- let match;
17892
- FOR_LOCAL_RE.lastIndex = 0;
17893
- while ((match = FOR_LOCAL_RE.exec(content)) !== null) {
17894
- locals.add(match[1]);
17895
- if (match[2]) locals.add(match[2]);
17896
- }
17897
- return locals;
17898
- }
17899
- /**
17900
17938
  * Validate a single variable reference path (e.g. "player.health") against
17901
17939
  * the schema. Returns an error message or null if valid.
17902
17940
  */
17903
- function validateRef(ref, schema, forLocals) {
17941
+ function validateRef(ref, schema) {
17904
17942
  const parts = ref.split(".");
17905
17943
  const rootName = parts[0];
17906
17944
  const nameError = variableNameError(rootName, "$" + rootName);
17907
17945
  if (nameError) return nameError;
17908
- if (forLocals.has(rootName)) return null;
17909
17946
  const rootSchema = schema.get(rootName);
17910
17947
  if (!rootSchema) return `Undeclared variable: $${ref}`;
17911
17948
  let current = rootSchema;
@@ -17961,8 +17998,9 @@ function scanInterpolations(text, onRef, storeVarMacros = NO_STORE_VAR_MACROS) {
17961
17998
  * Report `$var` references in JavaScript code, lexed as the expression
17962
17999
  * engine lexes it (`lexJs`): references in code, but none inside string or
17963
18000
  * regex literals or comments, nor property names (`a.$b`, `{ $b: 1 }`).
17964
- * The text of string and template literals is scanned for the markup that
17965
- * labels evaluate (`{$…}`); the `${…}` parts of template literals are code.
18001
+ * The text of string and template literals is not scanned: a string in code
18002
+ * is literal text unless a macro argument that holds markup is read as such
18003
+ * (see scanArgs); the `${…}` parts of template literals are code.
17966
18004
  */
17967
18005
  function scanCode(code, onRef, goal = "expression") {
17968
18006
  const parsed = parseOrError(code, goal);
@@ -17975,37 +18013,17 @@ function scanCode(code, onRef, goal = "expression") {
17975
18013
  VAR_PATH_RE.lastIndex = ref.start;
17976
18014
  onRef(VAR_PATH_RE.exec(code)[1]);
17977
18015
  }
17978
- for (const text of parsed.strings) scanInterpolations(text, onRef);
17979
18016
  }
17980
18017
  /** `scanCode` for code acorn can't parse: lexed leniently. */
17981
18018
  function scanCodeLeniently(code, onRef, goal) {
17982
- /** Open template literals: their text so far, null in an interpolation. */
17983
- const templates = [];
17984
18019
  lexJs(code, {
17985
18020
  variable(sigil, _name, index) {
17986
18021
  if (sigil !== "$") return;
17987
18022
  VAR_PATH_RE.lastIndex = index;
17988
18023
  onRef(VAR_PATH_RE.exec(code)[1]);
17989
18024
  },
17990
- literal(text, _index, nesting) {
17991
- const template = templates[templates.length - 1];
17992
- if (template?.nesting === nesting) if (template.text === null) template.text = "";
17993
- else if (text === "`" || text === "${") {
17994
- scanInterpolations(template.text, onRef);
17995
- if (text === "`") templates.pop();
17996
- else template.text = null;
17997
- } else template.text += text;
17998
- else if (text === "`") templates.push({
17999
- nesting,
18000
- text: ""
18001
- });
18002
- else if (text[0] === "\"" || text[0] === "'") {
18003
- const { closed } = scanStringLiteral(text, 0);
18004
- scanInterpolations(text.slice(1, closed ? -1 : void 0), onRef);
18005
- }
18006
- }
18025
+ literal() {}
18007
18026
  }, goal);
18008
- for (const t of templates) if (t.text) scanInterpolations(t.text, onRef);
18009
18027
  }
18010
18028
  /**
18011
18029
  * Report the `$var` references a passage evaluates at runtime: `{$var}`
@@ -18017,14 +18035,23 @@ function collectPassageRefs(content, storeVarMacros, onRef, parametersOf) {
18017
18035
  withParseCache(() => collectTokenRefs(content, tokensOf(content, false), storeVarMacros, onRef, parametersOf));
18018
18036
  }
18019
18037
  /**
18020
- * Report the references in the code that a macro's quoted arguments hold
18021
- * (see ParameterDef.holds), the condition and `run` action of `{watch}`:
18022
- * the argument scan sees only the strings. Only the macros that declare
18023
- * code in a string have their arguments read.
18038
+ * Report the references in the arguments of a macro by what its parameters
18039
+ * say each holds (see ParameterDef.holds): code is scanned as code and
18040
+ * markup as markup, but literal text (a placeholder, a passage name) holds
18041
+ * none. A macro without declared parameters, or arguments that do not
18042
+ * parse, are scanned as code.
18024
18043
  */
18025
- function scanStringCode(token, params, onRef) {
18026
- if (!params || !token.rawArgs || !holdsCode(params)) return;
18027
- for (const piece of argPieces(token.rawArgs, 0, params, token.name, "")) if (piece.kind === "code" && piece.inString) scanCode(piece.code, onRef, piece.goal);
18044
+ function scanArgs(token, params, onRef) {
18045
+ if (!token.rawArgs) return;
18046
+ if (params) {
18047
+ const pieces = [...argPieces(token.rawArgs, 0, params, token.name, "")];
18048
+ if (!pieces.some((piece) => piece.kind === "argument-error")) {
18049
+ for (const piece of pieces) if (piece.kind === "code") scanCode(piece.code, onRef, piece.goal);
18050
+ else if (piece.kind === "text") scanInterpolations(piece.text, onRef);
18051
+ return;
18052
+ }
18053
+ }
18054
+ scanCode(token.rawArgs, onRef);
18028
18055
  }
18029
18056
  /** Report the `$var` references in the tokens of `content`. */
18030
18057
  function collectTokenRefs(content, tokens, storeVarMacros, onRef, parametersOf = () => void 0) {
@@ -18036,12 +18063,12 @@ function collectTokenRefs(content, tokens, storeVarMacros, onRef, parametersOf =
18036
18063
  else if (token.type === "html") for (const [name, value] of Object.entries(token.attributes)) if (isCodeAttribute(name)) scanSigilReferences(value, onRef);
18037
18064
  else scanInterpolations(value, onRef, storeVarMacros);
18038
18065
  else if (token.type === "macro" && !token.isClose) {
18039
- scanCode(token.rawArgs, onRef);
18040
- scanStringCode(token, parametersOf(token.name), onRef);
18066
+ scanArgs(token, parametersOf(token.name), onRef);
18041
18067
  if (storeVarMacros.has(token.name.toLowerCase())) {
18042
18068
  const first = token.rawArgs.trim().split(/\s+/)[0] ?? "";
18043
18069
  const quoted = QUOTED_VAR_ARG_RE.exec(first);
18044
18070
  if (quoted) onRef(quoted[1]);
18071
+ else if (!/^["'`]/.test(first)) scanCode(first, onRef);
18045
18072
  }
18046
18073
  if (token.name === "do") {
18047
18074
  let close = t + 1;
@@ -18071,9 +18098,8 @@ function validatePassages(passages, schema, storeVarMacros = BUILTIN_STORE_VAR_M
18071
18098
  const storeVarSet = new Set(Array.from(storeVarMacros, (m) => m.toLowerCase()));
18072
18099
  for (const [name, passage] of passages) {
18073
18100
  if (NOT_MARKUP.has(name)) continue;
18074
- const forLocals = extractForLocals(passage.content);
18075
18101
  collectPassageRefs(passage.content, storeVarSet, (ref) => {
18076
- const error = validateRef(ref, schema, forLocals);
18102
+ const error = validateRef(ref, schema);
18077
18103
  if (error) errors.push(`Passage "${name}": ${error}`);
18078
18104
  }, parametersOf);
18079
18105
  }
@@ -18336,7 +18362,10 @@ defineMacro({
18336
18362
  ],
18337
18363
  render(_props, ctx) {
18338
18364
  try {
18339
- const { currentExpr, maxExpr, labelMode } = meterArgs(ctx.args);
18365
+ const { currentExpr, maxExpr, labelMode } = meterArgs({
18366
+ ...ctx.args,
18367
+ label: ctx.resolve?.(ctx.args.label)
18368
+ });
18340
18369
  const current = Number(ctx.evaluate(currentExpr));
18341
18370
  const max = Number(ctx.evaluate(maxExpr));
18342
18371
  const pct = max === 0 ? 0 : Math.max(0, Math.min(100, current / max * 100));
@@ -18737,7 +18766,7 @@ defineMacro({
18737
18766
  type: "passage"
18738
18767
  }],
18739
18768
  render({ children = [] }, ctx) {
18740
- const display = ctx.args.text ?? "";
18769
+ const display = ctx.resolve?.(ctx.args.text ?? "") ?? "";
18741
18770
  let passage = null;
18742
18771
  let failure;
18743
18772
  if (ctx.args.passage !== void 0) try {
@@ -10566,7 +10566,7 @@ function* argPieces(args, offset, params, macro, label) {
10566
10566
  };
10567
10567
  }
10568
10568
  }
10569
- yield* visit(params, values);
10569
+ yield* [...visit(params, values)].sort((a, b) => a.offset - b.offset);
10570
10570
  }
10571
10571
  /**
10572
10572
  * Index of `part` in `text` from `from` on, else anywhere, else `from`: the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.0",
3
+ "version": "0.59.1",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -8,6 +8,7 @@ import type {
8
8
  import type { StoryAPI } from '../story-api';
9
9
  import type { StoryAction } from '../action-registry';
10
10
  import { errorMessage } from '../utils/error-message';
11
+ import { deepEqual } from '../structural';
11
12
 
12
13
  export interface RunOptions {
13
14
  onStep?: (stepIndex: number, step: AutomationStep) => void;
@@ -88,7 +89,7 @@ async function executeStep(
88
89
  if (assert.variables) {
89
90
  for (const [key, expected] of Object.entries(assert.variables)) {
90
91
  const actual = story.get(key);
91
- if (actual !== expected) {
92
+ if (!deepEqual(actual, expected)) {
92
93
  errors.push({
93
94
  step: index,
94
95
  message: `Variable "${key}": expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
package/src/code-check.ts CHANGED
@@ -435,7 +435,8 @@ export function* argPieces(
435
435
  }
436
436
  }
437
437
  }
438
- yield* visit(params, values);
438
+ // Options are visited in metadata order, which is not the order written
439
+ yield* [...visit(params, values)].sort((a, b) => a.offset - b.offset);
439
440
  }
440
441
 
441
442
  /**
@@ -16,7 +16,8 @@ defineMacro({
16
16
  { name: 'passage', type: 'passage' },
17
17
  ],
18
18
  render({ children = [] }, ctx) {
19
- const display = ctx.args.text ?? '';
19
+ // The label is markup: resolved once, for the element and its action
20
+ const display = ctx.resolve?.(ctx.args.text ?? '') ?? '';
20
21
  let passage: string | null = null;
21
22
  // Boxed: anything can be thrown, including null and other falsy values
22
23
  let failure: { error: unknown } | undefined;
@@ -44,7 +44,11 @@ defineMacro({
44
44
  ],
45
45
  render(_props, ctx) {
46
46
  try {
47
- const { currentExpr, maxExpr, labelMode } = meterArgs(ctx.args);
47
+ const { currentExpr, maxExpr, labelMode } = meterArgs({
48
+ ...ctx.args,
49
+ // The label is markup: resolved before reading none, % or a unit
50
+ label: ctx.resolve?.(ctx.args.label),
51
+ });
48
52
  const current = Number(ctx.evaluate!(currentExpr));
49
53
  const max = Number(ctx.evaluate!(maxExpr));
50
54
  const pct =
@@ -315,7 +315,20 @@ function createIDBBackend(): StorageBackend {
315
315
  make: (store: IDBObjectStore) => IDBRequest<R>,
316
316
  ): Promise<R> => {
317
317
  const db = await openDB();
318
- return idbReq(make(db.transaction(name, mode).objectStore(name)));
318
+ const tx = db.transaction(name, mode);
319
+ const result = idbReq(make(tx.objectStore(name)));
320
+ // A request can succeed and its transaction still roll back, so a
321
+ // write is done only when the transaction completes.
322
+ if (mode === 'readonly') return result;
323
+ const committed = new Promise<void>((resolve, reject) => {
324
+ tx.oncomplete = () => resolve();
325
+ tx.onabort = () =>
326
+ reject(
327
+ tx.error ?? new DOMException('Transaction aborted', 'AbortError'),
328
+ );
329
+ });
330
+ const [value] = await Promise.all([result, committed]);
331
+ return value;
319
332
  };
320
333
 
321
334
  return {
package/src/store.ts CHANGED
@@ -120,7 +120,10 @@ let lastNavigationVars: Namespace = createNamespace();
120
120
  * object) or deleted. A recorded value is a deep copy, so that changing a
121
121
  * live value in place (a class instance is not frozen) cannot change
122
122
  * history; instances of unregistered classes are kept, as the store keeps
123
- * them (a save then refuses them).
123
+ * them (a save then refuses them). The copies are made together, so what
124
+ * variables share (an object, a cycle) stays shared in them, and a variable
125
+ * left as it is that shares with a replaced one is recorded again, to share
126
+ * with its copy.
124
127
  */
125
128
  function computeVarPatches(
126
129
  prev: Record<string, unknown>,
@@ -132,13 +135,32 @@ function computeVarPatches(
132
135
  // whose inherited `constructor` is no variable
133
136
  if (!hasOwn(curr, key)) forward.push({ key, deleted: true });
134
137
  }
138
+ const seen = new Map<object, object>();
139
+ const record = (key: string) =>
140
+ forward.push({
141
+ key,
142
+ deleted: false,
143
+ value: deepClone(curr[key], { keepUnregistered: true, seen }),
144
+ });
145
+ const unchanged: string[] = [];
135
146
  for (const key of Object.keys(curr)) {
136
- if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) {
137
- forward.push({
138
- key,
139
- deleted: false,
140
- value: deepClone(curr[key], { keepUnregistered: true }),
141
- });
147
+ if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) record(key);
148
+ else if (typeof curr[key] === 'object' && curr[key] !== null) {
149
+ unchanged.push(key);
150
+ }
151
+ }
152
+ // Probe an unchanged variable with a copy of its own: it shares with a
153
+ // recorded one if the copy meets an object already copied
154
+ for (let found = seen.size > 0; found; ) {
155
+ found = false;
156
+ for (const key of unchanged) {
157
+ const probe = new Map<object, object>();
158
+ deepClone(curr[key], { keepUnregistered: true, seen: probe });
159
+ if (![...probe.keys()].some((object) => seen.has(object))) continue;
160
+ record(key);
161
+ unchanged.splice(unchanged.indexOf(key), 1);
162
+ found = true;
163
+ break;
142
164
  }
143
165
  }
144
166
  return { forward };