@flighthq/snapshot 0.3.0-next.906.07cea63 → 0.3.1-next.209.43ef1bc

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.
@@ -6,9 +6,21 @@ import type { Snapshot } from '@flighthq/types/contract';
6
6
  * affects the snapshot, and vice versa. The freeze makes it a fixed point-in-time value safe to
7
7
  * store in an undo stack, send over the wire, or interpolate toward.
8
8
  *
9
- * `source` must be plain, structured-cloneable data (numbers, strings, booleans, arrays, nested
10
- * objects). Passing a class instance, function, or other non-cloneable value is programmer error and
11
- * throws via `structuredClone`.
9
+ * `source` must be plain, **acyclic**, structured-cloneable data (numbers, strings, booleans, arrays,
10
+ * nested objects). Passing a class instance, function, or other non-cloneable value is programmer
11
+ * error and throws via `structuredClone`.
12
+ *
13
+ * Acyclic is a narrower contract than structured-cloneable, and deliberately so. `structuredClone`
14
+ * itself handles cycles, and capture handles them too — but `equalsSnapshot`, `interpolateSnapshots`,
15
+ * and `restoreSnapshot` walk a snapshot without tracking what they have visited, and giving them a
16
+ * visited set would tax every acyclic walk in the common per-frame netcode path to serve a shape
17
+ * almost no game state has. So a cycle is programmer error here, not a supported input. Import
18
+ * `enableSnapshotGuards` to have it reported at capture time, where it is diagnosable, rather than as
19
+ * a stack overflow several frames later.
12
20
  */
13
21
  export declare function captureSnapshot<T>(source: Readonly<T>): Snapshot<T>;
22
+ /** Installs the capture-time guard, or clears it with `null`. The seam exists so the message and the
23
+ * `@flighthq/log` dependency live in the separately-importable guard module rather than here; not
24
+ * importing that module costs production nothing. Called by `enableSnapshotGuards`, not directly. */
25
+ export declare function setSnapshotCaptureGuard(guard: ((source: unknown) => void) | null): void;
14
26
  //# sourceMappingURL=captureSnapshot.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"captureSnapshot.d.ts","sourceRoot":"","sources":["../src/captureSnapshot.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAEzD;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAInE"}
1
+ {"version":3,"file":"captureSnapshot.d.ts","sourceRoot":"","sources":["../src/captureSnapshot.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAEzD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAKnE;AAED;;sGAEsG;AACtG,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,IAAI,GAAG,IAAI,CAEvF"}
@@ -5,19 +5,40 @@
5
5
  * affects the snapshot, and vice versa. The freeze makes it a fixed point-in-time value safe to
6
6
  * store in an undo stack, send over the wire, or interpolate toward.
7
7
  *
8
- * `source` must be plain, structured-cloneable data (numbers, strings, booleans, arrays, nested
9
- * objects). Passing a class instance, function, or other non-cloneable value is programmer error and
10
- * throws via `structuredClone`.
8
+ * `source` must be plain, **acyclic**, structured-cloneable data (numbers, strings, booleans, arrays,
9
+ * nested objects). Passing a class instance, function, or other non-cloneable value is programmer
10
+ * error and throws via `structuredClone`.
11
+ *
12
+ * Acyclic is a narrower contract than structured-cloneable, and deliberately so. `structuredClone`
13
+ * itself handles cycles, and capture handles them too — but `equalsSnapshot`, `interpolateSnapshots`,
14
+ * and `restoreSnapshot` walk a snapshot without tracking what they have visited, and giving them a
15
+ * visited set would tax every acyclic walk in the common per-frame netcode path to serve a shape
16
+ * almost no game state has. So a cycle is programmer error here, not a supported input. Import
17
+ * `enableSnapshotGuards` to have it reported at capture time, where it is diagnosable, rather than as
18
+ * a stack overflow several frames later.
11
19
  */
12
20
  export function captureSnapshot(source) {
21
+ _captureGuard?.(source);
13
22
  const clone = structuredClone(source);
14
23
  freezeSnapshotDeep(clone);
15
24
  return clone;
16
25
  }
26
+ /** Installs the capture-time guard, or clears it with `null`. The seam exists so the message and the
27
+ * `@flighthq/log` dependency live in the separately-importable guard module rather than here; not
28
+ * importing that module costs production nothing. Called by `enableSnapshotGuards`, not directly. */
29
+ export function setSnapshotCaptureGuard(guard) {
30
+ _captureGuard = guard;
31
+ }
32
+ let _captureGuard = null;
17
33
  // Recursively `Object.freeze`s every object and array reachable from `value`, so the whole tree is
18
34
  // immutable — not just the top level. Primitives and `null` are already immutable and skipped.
35
+ //
36
+ // `Object.isFrozen` doubles as the visited check. structuredClone preserves cycles and shared
37
+ // references, so without it a cyclic clone recurses until the stack overflows, and a diamond is walked
38
+ // once per path. Freezing before descending means an already-frozen node has been seen, which makes
39
+ // the walk terminate on cycles and linear on shared subtrees, with no side table to allocate.
19
40
  function freezeSnapshotDeep(value) {
20
- if (value === null || typeof value !== 'object') {
41
+ if (value === null || typeof value !== 'object' || Object.isFrozen(value)) {
21
42
  return;
22
43
  }
23
44
  Object.freeze(value);
@@ -1 +1 @@
1
- {"version":3,"file":"captureSnapshot.js","sourceRoot":"","sources":["../src/captureSnapshot.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAI,MAAmB;IACpD,MAAM,KAAK,GAAG,eAAe,CAAC,MAAM,CAAM,CAAC;IAC3C,kBAAkB,CAAC,KAAK,CAAC,CAAC;IAC1B,OAAO,KAAoB,CAAC;AAC9B,CAAC;AAED,mGAAmG;AACnG,+FAA+F;AAC/F,SAAS,kBAAkB,CAAC,KAAc;IACxC,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAChD,OAAO;IACT,CAAC;IACD,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACrB,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YACrD,kBAAkB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;QACnC,CAAC;QACD,OAAO;IACT,CAAC;IACD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,KAAgC,CAAC,EAAE,CAAC;QAChE,kBAAkB,CAAE,KAAiC,CAAC,GAAG,CAAC,CAAC,CAAC;IAC9D,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"captureSnapshot.js","sourceRoot":"","sources":["../src/captureSnapshot.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,eAAe,CAAI,MAAmB;IACpD,aAAa,EAAE,CAAC,MAAM,CAAC,CAAC;IACxB,MAAM,KAAK,GAAG,eAAe,CAAC,MAAM,CAAM,CAAC;IAC3C,kBAAkB,CAAC,KAAK,CAAC,CAAC;IAC1B,OAAO,KAAoB,CAAC;AAC9B,CAAC;AAED;;sGAEsG;AACtG,MAAM,UAAU,uBAAuB,CAAC,KAAyC;IAC/E,aAAa,GAAG,KAAK,CAAC;AACxB,CAAC;AAED,IAAI,aAAa,GAAuC,IAAI,CAAC;AAE7D,mGAAmG;AACnG,+FAA+F;AAC/F,EAAE;AACF,8FAA8F;AAC9F,uGAAuG;AACvG,oGAAoG;AACpG,8FAA8F;AAC9F,SAAS,kBAAkB,CAAC,KAAc;IACxC,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC1E,OAAO;IACT,CAAC;IACD,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACrB,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YACrD,kBAAkB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;QACnC,CAAC;QACD,OAAO;IACT,CAAC;IACD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,KAAgC,CAAC,EAAE,CAAC;QAChE,kBAAkB,CAAE,KAAiC,CAAC,GAAG,CAAC,CAAC,CAAC;IAC9D,CAAC;AACH,CAAC"}
@@ -1,4 +1,5 @@
1
1
  export * from './captureSnapshot';
2
+ export * from './enableSnapshotGuards';
2
3
  export * from './equalsSnapshot';
3
4
  export * from './interpolateSnapshots';
4
5
  export * from './restoreSnapshot';
@@ -1 +1 @@
1
- {"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC"}
1
+ {"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC"}
package/dist/contract.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './captureSnapshot';
2
+ export * from './enableSnapshotGuards';
2
3
  export * from './equalsSnapshot';
3
4
  export * from './interpolateSnapshots';
4
5
  export * from './restoreSnapshot';
@@ -1 +1 @@
1
- {"version":3,"file":"contract.js","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC"}
1
+ {"version":3,"file":"contract.js","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC"}
@@ -0,0 +1,28 @@
1
+ /** Uninstalls the guard installed by `enableSnapshotGuards`. */
2
+ export declare function disableSnapshotGuards(): void;
3
+ /**
4
+ * Installs the caller-facing snapshot guard (opt-in, dev-only). It inspects each `captureSnapshot`
5
+ * source and warns once — through `@flighthq/log` — about the two shapes that pass `structuredClone`
6
+ * but then break the guarantees the rest of the package is built on.
7
+ *
8
+ * **Non-plain values.** `Map`, `Set`, `Date`, and typed arrays clone successfully, so capture appears
9
+ * to work, but neither of the package's two contracts survives: `Object.freeze` on a `Map` leaves
10
+ * `set`/`delete` fully functional, so the snapshot is not immutable, and `equalsSnapshot` compares
11
+ * leaves with `===`, so two `Map`s holding identical entries — or two `Date`s at the same instant —
12
+ * are reported unequal. State that looks unchanged then pushes a fresh undo entry every frame.
13
+ *
14
+ * **Cycles.** The package contract is acyclic plain data, narrower than structured-cloneable on
15
+ * purpose: `structuredClone` supports cycles and `captureSnapshot` handles them, but `equalsSnapshot`,
16
+ * `interpolateSnapshots`, and `restoreSnapshot` all recurse without tracking what they have visited,
17
+ * and adding a visited set would tax every acyclic walk in the per-frame path. So a cycle is programmer
18
+ * error, and this is where it becomes diagnosable rather than a stack overflow several frames later. `restoreSnapshot`
19
+ * is the one that hides: the first restore into a fresh target clones and succeeds, and only the
20
+ * second — once the target itself holds the cycle — recurses forever. A `RangeError` with no Flight
21
+ * frame in the stack is the least diagnosable failure in the package, which is why it is worth a
22
+ * message at the point the cyclic state is captured rather than several frames later.
23
+ *
24
+ * Not importing this module costs production nothing: the messages and the `@flighthq/log` dependency
25
+ * live only here.
26
+ */
27
+ export declare function enableSnapshotGuards(): void;
28
+ //# sourceMappingURL=enableSnapshotGuards.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"enableSnapshotGuards.d.ts","sourceRoot":"","sources":["../src/enableSnapshotGuards.ts"],"names":[],"mappings":"AAKA,gEAAgE;AAChE,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C"}
@@ -0,0 +1,99 @@
1
+ import { logOnce } from '@flighthq/log/contract';
2
+ import { LogLevel } from '@flighthq/types/contract';
3
+ import { setSnapshotCaptureGuard } from './captureSnapshot';
4
+ /** Uninstalls the guard installed by `enableSnapshotGuards`. */
5
+ export function disableSnapshotGuards() {
6
+ setSnapshotCaptureGuard(null);
7
+ }
8
+ /**
9
+ * Installs the caller-facing snapshot guard (opt-in, dev-only). It inspects each `captureSnapshot`
10
+ * source and warns once — through `@flighthq/log` — about the two shapes that pass `structuredClone`
11
+ * but then break the guarantees the rest of the package is built on.
12
+ *
13
+ * **Non-plain values.** `Map`, `Set`, `Date`, and typed arrays clone successfully, so capture appears
14
+ * to work, but neither of the package's two contracts survives: `Object.freeze` on a `Map` leaves
15
+ * `set`/`delete` fully functional, so the snapshot is not immutable, and `equalsSnapshot` compares
16
+ * leaves with `===`, so two `Map`s holding identical entries — or two `Date`s at the same instant —
17
+ * are reported unequal. State that looks unchanged then pushes a fresh undo entry every frame.
18
+ *
19
+ * **Cycles.** The package contract is acyclic plain data, narrower than structured-cloneable on
20
+ * purpose: `structuredClone` supports cycles and `captureSnapshot` handles them, but `equalsSnapshot`,
21
+ * `interpolateSnapshots`, and `restoreSnapshot` all recurse without tracking what they have visited,
22
+ * and adding a visited set would tax every acyclic walk in the per-frame path. So a cycle is programmer
23
+ * error, and this is where it becomes diagnosable rather than a stack overflow several frames later. `restoreSnapshot`
24
+ * is the one that hides: the first restore into a fresh target clones and succeeds, and only the
25
+ * second — once the target itself holds the cycle — recurses forever. A `RangeError` with no Flight
26
+ * frame in the stack is the least diagnosable failure in the package, which is why it is worth a
27
+ * message at the point the cyclic state is captured rather than several frames later.
28
+ *
29
+ * Not importing this module costs production nothing: the messages and the `@flighthq/log` dependency
30
+ * live only here.
31
+ */
32
+ export function enableSnapshotGuards() {
33
+ setSnapshotCaptureGuard(warnOnUnsupportedSnapshotSource);
34
+ }
35
+ // Walks the source once, reporting the first occurrence of each problem kind. Tracks visited objects
36
+ // so the walk itself terminates on the very cycles it is looking for.
37
+ function warnOnUnsupportedSnapshotSource(source) {
38
+ const seen = new Set();
39
+ const nonPlain = new Set();
40
+ let cyclic = false;
41
+ const visit = (value) => {
42
+ if (value === null || typeof value !== 'object')
43
+ return;
44
+ if (seen.has(value)) {
45
+ cyclic = true;
46
+ return;
47
+ }
48
+ seen.add(value);
49
+ const kind = nonPlainSnapshotKind(value);
50
+ if (kind !== null) {
51
+ nonPlain.add(kind);
52
+ // A non-plain container's contents are not part of the plain-data tree; naming the container is
53
+ // the actionable half, and descending into a Map's entries would only repeat it.
54
+ return;
55
+ }
56
+ if (Array.isArray(value)) {
57
+ for (const entry of value)
58
+ visit(entry);
59
+ return;
60
+ }
61
+ for (const key of Object.keys(value)) {
62
+ visit(value[key]);
63
+ }
64
+ };
65
+ visit(source);
66
+ if (nonPlain.size > 0) {
67
+ logOnce('snapshot:non-plain-source', LogLevel.Warn, {
68
+ message: `captureSnapshot: source contains non-plain value(s) (${Array.from(nonPlain).sort().join(', ')}). ` +
69
+ 'They clone, but a frozen Map/Set stays mutable and equalsSnapshot compares them by reference, ' +
70
+ 'so the snapshot is neither immutable nor comparable. Convert to plain arrays/objects first.',
71
+ }, 'snapshot');
72
+ }
73
+ if (cyclic) {
74
+ logOnce('snapshot:cyclic-source', LogLevel.Warn, {
75
+ message: 'captureSnapshot: source contains a cycle. Capture handles it, but equalsSnapshot, ' +
76
+ 'interpolateSnapshots, and restoreSnapshot recurse without cycle tracking and will overflow ' +
77
+ 'the stack — restoreSnapshot only on the second restore into the same target.',
78
+ }, 'snapshot');
79
+ }
80
+ }
81
+ // Names the non-plain built-ins that structuredClone accepts, so the warning can say which one was
82
+ // found. Anything structuredClone rejects outright (functions, class instances) already throws at the
83
+ // capture call and needs no guard.
84
+ function nonPlainSnapshotKind(value) {
85
+ if (value instanceof Map)
86
+ return 'Map';
87
+ if (value instanceof Set)
88
+ return 'Set';
89
+ if (value instanceof Date)
90
+ return 'Date';
91
+ if (value instanceof RegExp)
92
+ return 'RegExp';
93
+ if (ArrayBuffer.isView(value))
94
+ return 'TypedArray';
95
+ if (value instanceof ArrayBuffer)
96
+ return 'ArrayBuffer';
97
+ return null;
98
+ }
99
+ //# sourceMappingURL=enableSnapshotGuards.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"enableSnapshotGuards.js","sourceRoot":"","sources":["../src/enableSnapshotGuards.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { captureSnapshot, equalsSnapshot, interpolateSnapshots, restoreSnapshot } from './contract';
1
+ export { captureSnapshot, disableSnapshotGuards, enableSnapshotGuards, equalsSnapshot, interpolateSnapshots, restoreSnapshot, } from './contract';
2
2
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,oBAAoB,EACpB,cAAc,EACd,oBAAoB,EACpB,eAAe,GAChB,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { captureSnapshot, equalsSnapshot, interpolateSnapshots, restoreSnapshot } from './contract';
1
+ export { captureSnapshot, disableSnapshotGuards, enableSnapshotGuards, equalsSnapshot, interpolateSnapshots, restoreSnapshot, } from './contract';
2
2
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,oBAAoB,EACpB,cAAc,EACd,oBAAoB,EACpB,eAAe,GAChB,MAAM,YAAY,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"interpolateSnapshots.d.ts","sourceRoot":"","sources":["../src/interpolateSnapshots.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAEzE;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EACd,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EACd,CAAC,EAAE,MAAM,EACT,GAAG,EAAE,CAAC,EACN,MAAM,CAAC,EAAE,QAAQ,CAAC,cAAc,CAAC,GAChC,IAAI,CAYN"}
1
+ {"version":3,"file":"interpolateSnapshots.d.ts","sourceRoot":"","sources":["../src/interpolateSnapshots.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAEzE;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EACd,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EACd,CAAC,EAAE,MAAM,EACT,GAAG,EAAE,CAAC,EACN,MAAM,CAAC,EAAE,QAAQ,CAAC,cAAc,CAAC,GAChC,IAAI,CAiBN"}
@@ -24,11 +24,16 @@ export function interpolateSnapshots(a, b, t, out, schema) {
24
24
  typeof out !== 'object') {
25
25
  return;
26
26
  }
27
- interpolateSnapshotsInto(out, a, b, clamp(t, 0, 1), schema, '');
27
+ // The schema is compiled to a Set once per call rather than scanned per leaf: SnapshotSchema is a
28
+ // readonly string[], so the old per-leaf `includes` was linear in the schema on every numeric field.
29
+ // `undefined` (no schema, every numeric leaf interpolates) stays distinct from an empty Set (a schema
30
+ // that lists nothing, so nothing interpolates) — collapsing those two inverts the caller's intent.
31
+ const paths = schema === undefined ? undefined : new Set(schema);
32
+ interpolateSnapshotsInto(out, a, b, clamp(t, 0, 1), paths, '');
28
33
  }
29
34
  // Walks `b` (the destination shape) key by key, writing each interpolated or snapped field into
30
35
  // `out`, reading the matching value from `a`. `prefix` is the dotted path to the current container.
31
- function interpolateSnapshotsInto(out, a, b, t, schema, prefix) {
36
+ function interpolateSnapshotsInto(out, a, b, t, paths, prefix) {
32
37
  const outRecord = out;
33
38
  const aRecord = a;
34
39
  const bRecord = b;
@@ -36,11 +41,12 @@ function interpolateSnapshotsInto(out, a, b, t, schema, prefix) {
36
41
  out.length = b.length;
37
42
  }
38
43
  for (const key of Object.keys(bRecord)) {
39
- const path = prefix === '' ? key : `${prefix}.${key}`;
40
44
  const aValue = aRecord[key];
41
45
  const bValue = bRecord[key];
42
46
  if (typeof aValue === 'number' && typeof bValue === 'number') {
43
- outRecord[key] = isSnapshotPathInterpolated(schema, path) ? lerp(aValue, bValue, t) : bValue;
47
+ // With no schema every numeric leaf interpolates, so the dotted path is never consulted — and
48
+ // building it was the walk's main per-leaf allocation. Only pay for it when a schema exists.
49
+ outRecord[key] = paths === undefined || paths.has(snapshotPath(prefix, key)) ? lerp(aValue, bValue, t) : bValue;
44
50
  continue;
45
51
  }
46
52
  if (aValue !== null &&
@@ -50,15 +56,15 @@ function interpolateSnapshotsInto(out, a, b, t, schema, prefix) {
50
56
  Array.isArray(aValue) === Array.isArray(bValue)) {
51
57
  const container = ensureSnapshotContainer(outRecord[key], Array.isArray(bValue));
52
58
  outRecord[key] = container;
53
- interpolateSnapshotsInto(container, aValue, bValue, t, schema, path);
59
+ interpolateSnapshotsInto(container, aValue, bValue, t, paths, paths === undefined ? '' : snapshotPath(prefix, key));
54
60
  continue;
55
61
  }
56
62
  outRecord[key] = cloneSnapshotValue(bValue);
57
63
  }
58
64
  }
59
- // A numeric leaf interpolates when there is no schema, or when its path is listed in the schema.
60
- function isSnapshotPathInterpolated(schema, path) {
61
- return schema === undefined || schema.includes(path);
65
+ // Joins a container prefix and key into the dotted path a schema lists.
66
+ function snapshotPath(prefix, key) {
67
+ return prefix === '' ? key : `${prefix}.${key}`;
62
68
  }
63
69
  // Reuses `existing` when it is already a mutable container of the needed kind, else allocates a fresh
64
70
  // empty one, so the walk can recurse into `out` even when `out` did not mirror the snapshot's shape.
@@ -1 +1 @@
1
- {"version":3,"file":"interpolateSnapshots.js","sourceRoot":"","sources":["../src/interpolateSnapshots.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"interpolateSnapshots.js","sourceRoot":"","sources":["../src/interpolateSnapshots.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAC;AAGtD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,oBAAoB,CAClC,CAAc,EACd,CAAc,EACd,CAAS,EACT,GAAM,EACN,MAAiC;IAEjC,IACE,CAAC,KAAK,IAAI;QACV,OAAO,CAAC,KAAK,QAAQ;QACrB,CAAC,KAAK,IAAI;QACV,OAAO,CAAC,KAAK,QAAQ;QACrB,GAAG,KAAK,IAAI;QACZ,OAAO,GAAG,KAAK,QAAQ,EACvB,CAAC;QACD,OAAO;IACT,CAAC;IACD,kGAAkG;IAClG,qGAAqG;IACrG,sGAAsG;IACtG,mGAAmG;IACnG,MAAM,KAAK,GAAG,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC;IACjE,wBAAwB,CAAC,GAAa,EAAE,CAAW,EAAE,CAAW,EAAE,KAAK,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;AAC/F,CAAC;AAED,gGAAgG;AAChG,oGAAoG;AACpG,SAAS,wBAAwB,CAC/B,GAAW,EACX,CAAS,EACT,CAAS,EACT,CAAS,EACT,KAAsC,EACtC,MAAc;IAEd,MAAM,SAAS,GAAG,GAA8B,CAAC;IACjD,MAAM,OAAO,GAAG,CAA4B,CAAC;IAC7C,MAAM,OAAO,GAAG,CAA4B,CAAC;IAC7C,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;QACpB,GAAiB,CAAC,MAAM,GAAI,CAAe,CAAC,MAAM,CAAC;IACtD,CAAC;IACD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;QACvC,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;QAC5B,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;QAC5B,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;YAC7D,8FAA8F;YAC9F,6FAA6F;YAC7F,SAAS,CAAC,GAAG,CAAC,GAAG,KAAK,KAAK,SAAS,IAAI,KAAK,CAAC,GAAG,CAAC,YAAY,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;YAChH,SAAS;QACX,CAAC;QACD,IACE,MAAM,KAAK,IAAI;YACf,OAAO,MAAM,KAAK,QAAQ;YAC1B,MAAM,KAAK,IAAI;YACf,OAAO,MAAM,KAAK,QAAQ;YAC1B,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAC/C,CAAC;YACD,MAAM,SAAS,GAAG,uBAAuB,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;YACjF,SAAS,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC;YAC3B,wBAAwB,CACtB,SAAS,EACT,MAAM,EACN,MAAM,EACN,CAAC,EACD,KAAK,EACL,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,YAAY,CAAC,MAAM,EAAE,GAAG,CAAC,CACrD,CAAC;YACF,SAAS;QACX,CAAC;QACD,SAAS,CAAC,GAAG,CAAC,GAAG,kBAAkB,CAAC,MAAM,CAAC,CAAC;IAC9C,CAAC;AACH,CAAC;AAED,wEAAwE;AACxE,SAAS,YAAY,CAAC,MAAc,EAAE,GAAW;IAC/C,OAAO,MAAM,KAAK,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,GAAG,EAAE,CAAC;AAClD,CAAC;AAED,sGAAsG;AACtG,qGAAqG;AACrG,SAAS,uBAAuB,CAAC,QAAiB,EAAE,OAAgB;IAClE,IAAI,QAAQ,KAAK,IAAI,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,KAAK,OAAO,EAAE,CAAC;QAC7F,OAAO,QAAkB,CAAC;IAC5B,CAAC;IACD,OAAO,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;AAC3B,CAAC;AAED,qGAAqG;AACrG,gFAAgF;AAChF,SAAS,kBAAkB,CAAC,KAAc;IACxC,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAChD,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,eAAe,CAAC,KAAK,CAAC,CAAC;AAChC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "name": "@flighthq/snapshot",
3
- "version": "0.3.0-next.906.07cea63",
3
+ "version": "0.3.1-next.209.43ef1bc",
4
+ "author": "Joshua Granick and other contributors",
5
+ "license": "MIT",
4
6
  "repository": {
5
7
  "type": "git",
6
8
  "url": "https://github.com/flighthq/flight.git",
@@ -36,8 +38,9 @@
36
38
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
37
39
  },
38
40
  "dependencies": {
39
- "@flighthq/math": "0.3.0-next.906.07cea63",
40
- "@flighthq/types": "0.3.0-next.906.07cea63"
41
+ "@flighthq/log": "0.3.1-next.209.43ef1bc",
42
+ "@flighthq/math": "0.3.1-next.209.43ef1bc",
43
+ "@flighthq/types": "0.3.1-next.209.43ef1bc"
41
44
  },
42
45
  "devDependencies": {
43
46
  "typescript": "^5.3.0"
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { captureSnapshot } from './captureSnapshot';
3
+ import { captureSnapshot, setSnapshotCaptureGuard } from './captureSnapshot';
4
4
 
5
5
  describe('captureSnapshot', () => {
6
6
  it('returns a deep-equal copy of the source', () => {
@@ -48,3 +48,76 @@ describe('captureSnapshot', () => {
48
48
  expect(snapshot.c).toBe(1);
49
49
  });
50
50
  });
51
+
52
+ describe('captureSnapshot cyclic and shared structure', () => {
53
+ // The package contract is acyclic, so these are not a supported input — they cover incidental
54
+ // robustness, not a guarantee. Object.isFrozen doubles as the freeze walk's visited mark at no cost,
55
+ // so capture survives a cycle rather than overflowing; the downstream walks deliberately do not, and
56
+ // enableSnapshotGuards reports a cyclic source at capture time.
57
+ it('captures a self-referencing object without overflowing the stack', () => {
58
+ const node: Record<string, unknown> = { name: 'root' };
59
+ node['self'] = node;
60
+
61
+ const snapshot = captureSnapshot(node) as Record<string, unknown>;
62
+
63
+ expect(Object.isFrozen(snapshot)).toBe(true);
64
+ expect(snapshot['self']).toBe(snapshot);
65
+ });
66
+
67
+ it('captures a mutually-referencing pair', () => {
68
+ const a: Record<string, unknown> = { tag: 'a' };
69
+ const b: Record<string, unknown> = { tag: 'b', a };
70
+ a['b'] = b;
71
+
72
+ const snapshot = captureSnapshot(a) as Record<string, Record<string, unknown>>;
73
+
74
+ expect(Object.isFrozen(snapshot['b'])).toBe(true);
75
+ expect(snapshot['b']!['a']).toBe(snapshot);
76
+ });
77
+
78
+ // A diamond is not a cycle, but shares the visited path: structuredClone keeps the sharing, and the
79
+ // freeze walk must still freeze the shared node rather than skip it.
80
+ it('freezes a shared subtree reached by two paths', () => {
81
+ const shared = { v: 1 };
82
+
83
+ const snapshot = captureSnapshot({ left: shared, right: shared }) as { left: object; right: object };
84
+
85
+ expect(snapshot.left).toBe(snapshot.right);
86
+ expect(Object.isFrozen(snapshot.left)).toBe(true);
87
+ });
88
+ });
89
+
90
+ describe('setSnapshotCaptureGuard', () => {
91
+ afterEach(() => setSnapshotCaptureGuard(null));
92
+
93
+ // The seam exists so the warning text and the @flighthq/log dependency stay in the separately
94
+ // importable guard module; the core path only calls whatever is installed.
95
+ it('passes the capture source to the installed guard', () => {
96
+ const seen: unknown[] = [];
97
+ setSnapshotCaptureGuard((source) => seen.push(source));
98
+ const source = { hp: 1 };
99
+
100
+ captureSnapshot(source);
101
+
102
+ expect(seen).toEqual([source]);
103
+ });
104
+
105
+ it('runs the guard against the source rather than the frozen clone', () => {
106
+ let wasFrozen = true;
107
+ setSnapshotCaptureGuard((source) => (wasFrozen = Object.isFrozen(source)));
108
+
109
+ captureSnapshot({ hp: 1 });
110
+
111
+ expect(wasFrozen).toBe(false);
112
+ });
113
+
114
+ it('stops calling the guard once cleared with null', () => {
115
+ let calls = 0;
116
+ setSnapshotCaptureGuard(() => (calls += 1));
117
+ captureSnapshot({ a: 1 });
118
+ setSnapshotCaptureGuard(null);
119
+ captureSnapshot({ a: 2 });
120
+
121
+ expect(calls).toBe(1);
122
+ });
123
+ });
@@ -0,0 +1,62 @@
1
+ import { setLogSink } from '@flighthq/log/contract';
2
+ import type { LogEntry } from '@flighthq/types/contract';
3
+
4
+ import { captureSnapshot } from './captureSnapshot';
5
+ import { disableSnapshotGuards, enableSnapshotGuards } from './enableSnapshotGuards';
6
+
7
+ let entries: LogEntry[];
8
+
9
+ beforeEach(() => {
10
+ entries = [];
11
+ setLogSink((entry) => entries.push(entry));
12
+ });
13
+
14
+ afterEach(() => {
15
+ disableSnapshotGuards();
16
+ setLogSink(null);
17
+ });
18
+
19
+ function messages(): string[] {
20
+ return entries.map((e) => String((e.data as { message?: unknown } | undefined)?.message ?? ''));
21
+ }
22
+
23
+ describe('disableSnapshotGuards', () => {
24
+ it('stops the guard from inspecting later captures', () => {
25
+ enableSnapshotGuards();
26
+ disableSnapshotGuards();
27
+ captureSnapshot({ when: new Date(0) });
28
+ expect(messages()).toEqual([]);
29
+ });
30
+ });
31
+
32
+ describe('enableSnapshotGuards', () => {
33
+ it('says nothing for a plain acyclic source', () => {
34
+ enableSnapshotGuards();
35
+ captureSnapshot({ hp: 1, items: [{ id: 'a' }], nested: { deep: { ok: true } } });
36
+ expect(messages()).toEqual([]);
37
+ });
38
+
39
+ it('warns about a Map, which clones but is neither frozen nor comparable', () => {
40
+ enableSnapshotGuards();
41
+ captureSnapshot({ byId: new Map([['a', 1]]) });
42
+ expect(messages().join('\n')).toContain('Map');
43
+ });
44
+
45
+ it('warns about a cycle, naming the operations that cannot walk it', () => {
46
+ enableSnapshotGuards();
47
+ const node: Record<string, unknown> = { name: 'root' };
48
+ node['self'] = node;
49
+ captureSnapshot(node);
50
+ expect(messages().join('\n')).toContain('cycle');
51
+ });
52
+
53
+ // The guard walks the same shape it is warning about, so it has to survive it — a cycle guard that
54
+ // overflows the stack before it can report the cycle would be worse than no guard.
55
+ it('terminates on the cyclic source it is reporting', () => {
56
+ enableSnapshotGuards();
57
+ const a: Record<string, unknown> = {};
58
+ const b: Record<string, unknown> = { a };
59
+ a['b'] = b;
60
+ expect(() => captureSnapshot(a)).not.toThrow();
61
+ });
62
+ });
@@ -56,3 +56,15 @@ describe('equalsSnapshot', () => {
56
56
  expect(equalsSnapshot(a, b)).toBe(false);
57
57
  });
58
58
  });
59
+
60
+ describe('equalsSnapshot documented leaf semantics', () => {
61
+ // Leaves are compared with ===, which the doc comment calls out precisely because both of these
62
+ // surprise people. Pinned so the comparison cannot quietly become Object.is.
63
+ it('reports two NaN leaves as unequal, since === says so', () => {
64
+ expect(equalsSnapshot(captureSnapshot({ v: NaN }), captureSnapshot({ v: NaN }))).toBe(false);
65
+ });
66
+
67
+ it('reports -0 and +0 as equal, since === says so', () => {
68
+ expect(equalsSnapshot(captureSnapshot({ v: -0 }), captureSnapshot({ v: 0 }))).toBe(true);
69
+ });
70
+ });
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
2
2
 
3
3
  import { captureSnapshot } from './captureSnapshot';
4
4
  import { interpolateSnapshots } from './interpolateSnapshots';
5
+ import { restoreSnapshot } from './restoreSnapshot';
5
6
 
6
7
  describe('interpolateSnapshots', () => {
7
8
  it('lerps numerics and snaps the string at t=0.5', () => {
@@ -95,3 +96,58 @@ describe('interpolateSnapshots', () => {
95
96
  expect(out.tag).toBeNull();
96
97
  });
97
98
  });
99
+
100
+ describe('interpolateSnapshots schema paths', () => {
101
+ // A schema listing an array-indexed path is the case the dotted-path walk exists for: array
102
+ // elements are addressed by index, so 'players.0.health' interpolates while 'players.1.health' —
103
+ // absent from the schema — snaps to b.
104
+ it('interpolates only the array-indexed path listed in the schema', () => {
105
+ const a = captureSnapshot({ players: [{ health: 0 }, { health: 0 }] });
106
+ const b = captureSnapshot({ players: [{ health: 100 }, { health: 100 }] });
107
+ const out = { players: [{ health: 0 }, { health: 0 }] };
108
+
109
+ interpolateSnapshots(a, b, 0.5, out, ['players.0.health']);
110
+
111
+ expect(out.players[0]!.health).toBe(50);
112
+ expect(out.players[1]!.health).toBe(100);
113
+ });
114
+
115
+ // An empty schema is not the same as no schema: it lists nothing, so nothing interpolates and every
116
+ // numeric leaf snaps to b. Collapsing the two would silently invert the caller's intent.
117
+ it('treats an empty schema as "interpolate nothing", not "interpolate everything"', () => {
118
+ const a = captureSnapshot({ hp: 0 });
119
+ const b = captureSnapshot({ hp: 100 });
120
+ const out = { hp: 0 };
121
+
122
+ interpolateSnapshots(a, b, 0.5, out, []);
123
+
124
+ expect(out.hp).toBe(100);
125
+ });
126
+
127
+ it('interpolates every numeric leaf when no schema is given', () => {
128
+ const a = captureSnapshot({ hp: 0, mp: 0 });
129
+ const b = captureSnapshot({ hp: 100, mp: 50 });
130
+ const out = { hp: 0, mp: 0 };
131
+
132
+ interpolateSnapshots(a, b, 0.5, out);
133
+
134
+ expect(out.hp).toBe(50);
135
+ expect(out.mp).toBe(25);
136
+ });
137
+
138
+ // The documented use: out is the caller's live render-state object, interpolated each frame and
139
+ // later restored from a snapshot. The two operations must not fight over the same object.
140
+ it('interpolates into an out object that is later restored into', () => {
141
+ const a = captureSnapshot({ pos: { x: 0, y: 0 }, tag: 'a' });
142
+ const b = captureSnapshot({ pos: { x: 10, y: 20 }, tag: 'b' });
143
+ const live = { pos: { x: 0, y: 0 }, tag: 'a' };
144
+
145
+ interpolateSnapshots(a, b, 0.5, live);
146
+ expect(live.pos).toEqual({ x: 5, y: 10 });
147
+ expect(live.tag).toBe('b');
148
+
149
+ restoreSnapshot(a, live);
150
+ expect(live.pos).toEqual({ x: 0, y: 0 });
151
+ expect(live.tag).toBe('a');
152
+ });
153
+ });
@@ -84,3 +84,40 @@ describe('restoreSnapshot', () => {
84
84
  expect(target.items).toEqual([1, 2]);
85
85
  });
86
86
  });
87
+
88
+ describe('restoreSnapshot array resizing', () => {
89
+ // Shrink and grow are separate paths through the same length assignment, and a restore loop that
90
+ // only ever grows would never notice a shrink that leaves stale trailing entries behind.
91
+ it('shrinks a nested array to the snapshot length', () => {
92
+ const snapshot = captureSnapshot({ items: [{ id: 'a' }] });
93
+ const live = { items: [{ id: 'x' }, { id: 'y' }, { id: 'z' }] };
94
+
95
+ restoreSnapshot(snapshot, live);
96
+
97
+ expect(live.items).toEqual([{ id: 'a' }]);
98
+ });
99
+
100
+ it('grows a nested array to the snapshot length', () => {
101
+ const snapshot = captureSnapshot({ items: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] });
102
+ const live = { items: [{ id: 'x' }] };
103
+
104
+ restoreSnapshot(snapshot, live);
105
+
106
+ expect(live.items).toEqual([{ id: 'a' }, { id: 'b' }, { id: 'c' }]);
107
+ });
108
+
109
+ // Round trip through a mutation: capture, mutate the live object freely, restore, and the live
110
+ // object is back to the captured state without aliasing the frozen snapshot.
111
+ it('round-trips capture, mutate, restore without aliasing the snapshot', () => {
112
+ const live = { hp: 10, items: [{ id: 'a' }] };
113
+ const snapshot = captureSnapshot(live);
114
+
115
+ live.hp = 99;
116
+ live.items.push({ id: 'b' });
117
+ restoreSnapshot(snapshot, live);
118
+
119
+ expect(live).toEqual({ hp: 10, items: [{ id: 'a' }] });
120
+ live.items[0]!.id = 'mutated';
121
+ expect(snapshot.items[0]!.id).toBe('a');
122
+ });
123
+ });