@lazily-hub/lazily-js 0.21.0 → 0.24.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.
@@ -0,0 +1,44 @@
1
+ [
2
+ {
3
+ "name": "reactive: Context",
4
+ "path": "src/reactive.js",
5
+ "import": "{ Context }",
6
+ "limit": "2239 B"
7
+ },
8
+ {
9
+ "name": "reactive: Context + handles + defaultEqual",
10
+ "path": "src/reactive.js",
11
+ "import": "{ Context, CellHandle, SlotHandle, SignalHandle, EffectHandle, defaultEqual }",
12
+ "limit": "2246 B"
13
+ },
14
+ {
15
+ "name": "state-machine: StateMachine",
16
+ "path": "src/state-machine.js",
17
+ "import": "{ StateMachine }",
18
+ "limit": "267 B"
19
+ },
20
+ {
21
+ "name": "sem-tree: SemTree",
22
+ "path": "src/sem-tree.js",
23
+ "import": "{ SemTree }",
24
+ "limit": "505 B"
25
+ },
26
+ {
27
+ "name": "stable-id: contentHash",
28
+ "path": "src/stable-id.js",
29
+ "import": "{ contentHash }",
30
+ "limit": "152 B"
31
+ },
32
+ {
33
+ "name": "collections: CellMap + CellTree + reconcileCollections",
34
+ "path": "src/collections.js",
35
+ "import": "{ CellMap, CellTree, reconcileCollections }",
36
+ "limit": "1645 B"
37
+ },
38
+ {
39
+ "name": "index: PROTOCOL_ID + Snapshot (tree-shaken kitchen sink)",
40
+ "path": "src/index.js",
41
+ "import": "{ PROTOCOL_ID, Snapshot }",
42
+ "limit": "2409 B"
43
+ }
44
+ ]
package/BENCHMARKS.md CHANGED
@@ -19,7 +19,7 @@ like-for-like single-process counterpart here and are intentionally omitted.
19
19
 
20
20
  <!-- benchmark-results:start -->
21
21
 
22
- Generated for package `@lazily-hub/lazily-js` version `0.20.0`.
22
+ Generated for package `@lazily-hub/lazily-js` version `0.22.0`.
23
23
 
24
24
  Environment: Node.js `26.4.0` on `linux x64`.
25
25
 
@@ -33,34 +33,37 @@ Mean wall-clock time per iteration; 95% CI half-width from the standard error.
33
33
 
34
34
  | Group | Case | Mean | 95% CI | p75 | p99 | Samples |
35
35
  |---|---|---:|---:|---:|---:|---:|
36
- | cached_reads | context | 31.966 ns | ± 1.575 ns | 30.743 ns | 70.839 ns | 100 |
37
- | cold_first_get | context | 742.710 ns | ± 126.218 ns | 612.500 ns | 1.899 us | 100 |
38
- | dependency_fan_out | context / 32 | 6.129 us | ± 1.563 us | 5.023 us | 41.656 us | 100 |
39
- | dependency_fan_out | context / 256 | 48.997 us | ± 3.461 us | 47.868 us | 130.607 us | 100 |
40
- | set_cell_invalidation | high_fan_out / 512 | 5.482 us | ± 1.327 us | 6.410 us | 18.184 us | 100 |
41
- | memo_equality_suppression | context | 2.191 us | ± 356.052 ns | 3.813 us | 4.961 us | 100 |
42
- | effect_flushing | context | 149.841 ns | ± 15.108 ns | 148.815 ns | 452.666 ns | 100 |
43
- | batch_storms | context / 64 | 13.088 us | ± 281.500 ns | 13.352 us | 17.526 us | 100 |
44
- | typed_cache_reads | context_cell | 29.928 ns | ± 0.847 ns | 29.585 ns | 46.606 ns | 100 |
45
- | typed_cache_reads | context_slot | 46.437 ns | ± 5.381 ns | 39.915 ns | 179.573 ns | 100 |
46
- | textcrdt_insert_str | 64 | 8.939 us | ± 2.581 us | 7.678 us | 50.727 us | 100 |
47
- | textcrdt_insert_str | 256 | 28.383 us | ± 1.991 us | 25.568 us | 63.137 us | 100 |
48
- | textcrdt_insert_str | 1024 | 111.611 us | ± 6.489 us | 124.136 us | 205.387 us | 100 |
49
- | textcrdt_repeated_text | 64 | 40.149 us | ± 2.146 us | 36.146 us | 74.301 us | 100 |
50
- | textcrdt_repeated_text | 256 | 181.856 us | ± 13.068 us | 187.465 us | 331.261 us | 100 |
51
- | textcrdt_repeated_text | 1024 | 864.585 us | ± 45.266 us | 996.343 us | 1.519 ms | 100 |
52
- | textcrdt_merge | 64 | 12.103 us | ± 798.827 ns | 12.955 us | 24.361 us | 100 |
53
- | textcrdt_merge | 256 | 53.192 us | ± 4.002 us | 52.265 us | 143.726 us | 100 |
54
- | textcrdt_merge | 1024 | 270.364 us | ± 17.669 us | 268.422 us | 593.144 us | 100 |
55
- | textcrdt_delta_sync | 64 | 19.106 us | ± 1.372 us | 20.792 us | 29.232 us | 100 |
56
- | textcrdt_delta_sync | 256 | 67.860 us | ± 5.469 us | 62.273 us | 196.534 us | 100 |
57
- | textcrdt_delta_sync | 1024 | 262.078 us | ± 11.701 us | 257.410 us | 473.407 us | 100 |
58
- | seqcrdt_insert_back | 64 | 82.803 us | ± 7.276 us | 79.240 us | 226.953 us | 100 |
59
- | seqcrdt_insert_back | 256 | 1.561 ms | ± 57.393 us | 1.724 ms | 2.313 ms | 100 |
60
- | seqcrdt_insert_back | 1024 | 43.643 ms | ± 971.721 us | 44.688 ms | 50.468 ms | 20 |
61
- | seqcrdt_merge | 64 | 7.361 us | ± 624.875 ns | 8.053 us | 19.173 us | 100 |
62
- | seqcrdt_merge | 256 | 26.977 us | ± 2.251 us | 30.373 us | 73.848 us | 100 |
63
- | seqcrdt_merge | 1024 | 113.428 us | ± 6.981 us | 121.168 us | 252.144 us | 100 |
36
+ | cached_reads | context | 8.061 ns | ± 0.779 ns | 8.427 ns | 26.939 ns | 100 |
37
+ | cold_first_get | context | 423.300 ns | ± 43.318 ns | 342.500 ns | 1.373 us | 100 |
38
+ | dependency_fan_out | context / 32 | 3.771 us | ± 173.255 ns | 3.800 us | 7.261 us | 100 |
39
+ | dependency_fan_out | context / 256 | 47.978 us | ± 1.173 us | 47.323 us | 66.724 us | 100 |
40
+ | set_cell_invalidation | high_fan_out / 512 | 3.424 us | ± 84.646 ns | 3.410 us | 5.966 us | 100 |
41
+ | memo_equality_suppression | context | 2.783 us | ± 561.495 ns | 4.138 us | 13.663 us | 100 |
42
+ | effect_flushing | context | 188.019 ns | ± 8.596 ns | 191.316 ns | 376.753 ns | 100 |
43
+ | batch_storms | context / 64 | 14.257 us | ± 614.920 ns | 14.799 us | 26.218 us | 70 |
44
+ | typed_cache_reads | context_cell | 4.742 ns | ± 0.403 ns | 5.609 ns | 8.350 ns | 100 |
45
+ | typed_cache_reads | context_slot | 42.333 ns | ± 35.346 ns | 19.026 ns | 188.015 ns | 100 |
46
+ | default_equal | array | 34.602 ns | ± 2.774 ns | 30.943 ns | 99.200 ns | 100 |
47
+ | default_equal | object | 94.251 ns | ± 3.295 ns | 93.296 ns | 161.861 ns | 100 |
48
+ | node_allocation | scale / 4096 | 163.011 us | ± 8.360 us | 159.941 us | 322.151 us | 100 |
49
+ | textcrdt_insert_str | 64 | 9.882 us | ± 3.182 us | 8.358 us | 59.799 us | 100 |
50
+ | textcrdt_insert_str | 256 | 28.812 us | ± 2.264 us | 25.503 us | 65.357 us | 100 |
51
+ | textcrdt_insert_str | 1024 | 97.365 us | ± 5.110 us | 91.328 us | 166.233 us | 100 |
52
+ | textcrdt_repeated_text | 64 | 40.114 us | ± 1.467 us | 37.831 us | 64.145 us | 100 |
53
+ | textcrdt_repeated_text | 256 | 159.171 us | ± 8.707 us | 171.325 us | 335.391 us | 100 |
54
+ | textcrdt_repeated_text | 1024 | 915.084 us | ± 45.457 us | 1.025 ms | 1.667 ms | 100 |
55
+ | textcrdt_merge | 64 | 13.957 us | ± 1.733 us | 14.396 us | 22.914 us | 100 |
56
+ | textcrdt_merge | 256 | 65.644 us | ± 5.379 us | 69.226 us | 130.124 us | 100 |
57
+ | textcrdt_merge | 1024 | 297.418 us | ± 16.007 us | 319.127 us | 535.482 us | 100 |
58
+ | textcrdt_delta_sync | 64 | 21.147 us | ± 2.856 us | 21.657 us | 42.016 us | 100 |
59
+ | textcrdt_delta_sync | 256 | 64.549 us | ± 3.040 us | 64.218 us | 151.264 us | 100 |
60
+ | textcrdt_delta_sync | 1024 | 252.134 us | ± 11.758 us | 248.660 us | 496.058 us | 100 |
61
+ | seqcrdt_insert_back | 64 | 174.152 us | ± 173.997 us | 108.671 us | 227.421 us | 100 |
62
+ | seqcrdt_insert_back | 256 | 1.645 ms | ± 70.745 us | 1.836 ms | 2.722 ms | 100 |
63
+ | seqcrdt_insert_back | 1024 | 43.273 ms | ± 2.036 ms | 44.806 ms | 55.153 ms | 20 |
64
+ | seqcrdt_merge | 64 | 7.911 us | ± 1.244 us | 7.920 us | 23.291 us | 100 |
65
+ | seqcrdt_merge | 256 | 27.352 us | ± 2.299 us | 31.073 us | 66.754 us | 100 |
66
+ | seqcrdt_merge | 1024 | 117.426 us | ± 8.835 us | 127.046 us | 355.789 us | 100 |
64
67
 
65
68
  <!-- benchmark-results:end -->
66
69
 
package/README.md CHANGED
@@ -101,7 +101,7 @@ and JSON Schemas in `lazily-spec` and the Lean models in `lazily-formal`.
101
101
  |--------|------------|
102
102
  | `@lazily-hub/lazily-js` | `lazily-spec` IPC wire types: `Snapshot`, `Delta`, `DeltaOp`, `IpcMessage` (`Snapshot` / `Delta` / `CrdtSync`), `NodeState`, `IpcValue`, `PeerPermissions`, `SessionHandshake`, `BINDING_CAPABILITIES` |
103
103
  | `@lazily-hub/lazily-js/transport` | Cross-process zero-copy transport (`#lzzcpy`): `ShmBlobArena`, `InProcessBackend` / `ArrowBackend`, `BlobRouter`, `spillMessage` / `resolveValue`, and the FFI-gated `createShmBackend` (Node/Bun/Deno). Isomorphic — no FFI import; browser-safe |
104
- | `@lazily-hub/lazily-js/reactive` | Reactive dependency graph: `Context`, `Cell`, `Slot`, `Signal`, `Effect` |
104
+ | `@lazily-hub/lazily-js/reactive` | Reactive dependency graph: `createContext` (alias `Context`), `Cell`, `Slot`, `Signal`, `Effect`. Closure-based core (#lzjsclosure) — 2-8x faster reads than the prior class implementation |
105
105
  | `@lazily-hub/lazily-js/reactive-async` | Async reactive graph: `AsyncContext` — Promise-driven slots/effects with revision-guarded stale-completion discard, in-flight dedup, and cancellation |
106
106
  | `@lazily-hub/lazily-js/reactive-family` | Unified keyed reactive map: `ReactiveMap<K,V,H>` (reactive membership/order, `getOrInsertWith` mint-on-access, `remove`, `move`) + `CellMap` (adds cell-only `set` + eager `entry`/`entryWith`) and `SlotMap` (lazy `getOrInsertWith` + eager `materializeAll`; no `set`) specializations. No eager/lazy mode flag (`#reactivemap`) |
107
107
  | `@lazily-hub/lazily-js/async-reactive-family` | Async keyed reactive map: `AsyncReactiveMap` + `AsyncCellMap` / `AsyncSlotMap` over `AsyncContext` — eventual transparency (a pending slot observes `undefined` and resolves to the canonical value; eager ≡ lazy once resolved) (`#reactivemap`) |
@@ -122,8 +122,18 @@ and JSON Schemas in `lazily-spec` and the Lean models in `lazily-formal`.
122
122
 
123
123
  ## Reactive graph
124
124
 
125
- `Context` mirrors the single-threaded lazily-rs `Context` semantics in native
126
- JavaScript. The family is:
125
+ `Context` (alias `createContext`, from `@lazily-hub/lazily-js/reactive`) mirrors
126
+ the single-threaded lazily-rs `Context` semantics in native JavaScript. The
127
+ reactive core is implemented with the **closure factory technique** (#lzjsclosure,
128
+ rmemo-style): `createContext()` returns an object whose methods close over
129
+ captured graph state, and nodes are plain objects with a numeric discriminator
130
+ replacing `instanceof`. V8 inlines these small monomorphic closures more
131
+ aggressively than `class` + `#private` methods, so the read/invalidate hot paths
132
+ run 2-8x faster than the prior class implementation (see `bench/context.bench.mjs`
133
+ and `BENCHMARKS.md`). Both `createContext()` and the historical `new Context()`
134
+ are the same function — an alias, not a wrapper.
135
+
136
+ The family is:
127
137
 
128
138
  - **Slot** - lazy, memoized derived value;
129
139
  - **Cell** - mutable source value;
@@ -139,9 +149,9 @@ to suppress downstream work when a recompute produces the same value. `batch`
139
149
  coalesces invalidations and effect reruns.
140
150
 
141
151
  ```js
142
- import { Context } from "@lazily-hub/lazily-js/reactive";
152
+ import { Context, createContext } from "@lazily-hub/lazily-js/reactive";
143
153
 
144
- const ctx = new Context();
154
+ const ctx = createContext(); // idiomatic; `new Context()` is the same call
145
155
  const a = ctx.cell(2);
146
156
  const b = ctx.cell(3);
147
157
 
@@ -650,6 +660,37 @@ npm run benchmark-update # refresh BENCHMARKS.md's generated micro-bench table
650
660
  npm run benchmark-check # CI gate: exit 1 if the micro-bench row set is stale
651
661
  ```
652
662
 
663
+ ## Bundle size
664
+
665
+ `@lazily-hub/lazily-js` ships as pure ES modules with `"sideEffects": false` and
666
+ one file per subpath export, so bundlers tree-shake to exactly what you import.
667
+ The budgets below are enforced in CI by `npm run test:size` (size-limit); the
668
+ table is regenerated on every `npm run build` so it cannot drift from the
669
+ shipped bytes.
670
+
671
+ <!-- size-limits:start -->
672
+
673
+ Generated for package `@lazily-hub/lazily-js` version `0.24.0`. Every entry is **minified + brotlied, tree-shaken to the named import** (`size-limit` + esbuild, the same pipeline Webpack/Rollup/Vite apply via `"sideEffects": false`).
674
+
675
+ Refresh command:
676
+
677
+ ```bash
678
+ npm run build # regenerates this table as part of every build
679
+ npm run test:size # gate: fails CI if any entry exceeds its budget
680
+ ```
681
+
682
+ | Import | Size | Budget |
683
+ |---|---:|---:|
684
+ | reactive: Context | 2.24 KB ✓ | 2.24 KB |
685
+ | reactive: Context + handles + defaultEqual | 2.24 KB ✓ | 2.25 KB |
686
+ | state-machine: StateMachine | 267 B ✓ | 267 B |
687
+ | sem-tree: SemTree | 505 B ✓ | 505 B |
688
+ | stable-id: contentHash | 152 B ✓ | 152 B |
689
+ | collections: CellMap + CellTree + reconcileCollections | 1.65 KB ✓ | 1.65 KB |
690
+ | index: PROTOCOL_ID + Snapshot (tree-shaken kitchen sink) | 2.41 KB ✓ | 2.41 KB |
691
+
692
+ <!-- size-limits:end -->
693
+
653
694
  ## See also
654
695
 
655
696
  - [`lazily-spec`][spec] - language-agnostic wire protocol, schemas, and
@@ -10,7 +10,7 @@
10
10
  // comparable. Constants mirror `benches/context.rs` exactly.
11
11
 
12
12
  import { bench, blackBox, run } from "./harness.mjs";
13
- import { Context } from "../src/reactive.js";
13
+ import { Context, defaultEqual } from "../src/reactive.js";
14
14
 
15
15
  const FAN_OUT_WIDTHS = [32, 256];
16
16
  const MEMO_CHAIN_DEPTH = 32;
@@ -174,6 +174,38 @@ bench.batched(
174
174
  bench("typed_cache_reads", "context_cell", () => blackBox(ctx.getCell(blackBox(cell))));
175
175
  }
176
176
 
177
+ // --- default_equal: structural object comparison (#lzjsshalloweq) -----------
178
+ // Times the equality guard hot path on its own (object + array shapes). The
179
+ // inline Array.isArray fast path + index loop is the Phase 2 win.
180
+ {
181
+ const a = { id: 42, name: "alice", tags: ["x", "y", "z"], n: 7, active: true };
182
+ const b = { id: 42, name: "alice", tags: ["x", "y", "z"], n: 7, active: true };
183
+ bench("default_equal", "object", () => blackBox(defaultEqual(a, b)));
184
+ }
185
+ {
186
+ const a = [1, 2, 3, 4, 5, 6, 7, 8];
187
+ const b = [1, 2, 3, 4, 5, 6, 7, 8];
188
+ bench("default_equal", "array", () => blackBox(defaultEqual(a, b)));
189
+ }
190
+
191
+ // --- node_allocation: node creation at scale (#lzjslazyedges) ---------------
192
+ // Allocates a mix of cells, memos, and effects (no edges) so the per-node
193
+ // constructor cost — now free of eager [] edge arrays — is what is timed.
194
+ // The lazy-edges win is primarily RSS; this case guards the constructor
195
+ // time regression and surfaces the allocation speedup.
196
+ const ALLOC_NODES = 4096;
197
+ bench.batched(
198
+ "node_allocation",
199
+ `scale / ${ALLOC_NODES}`,
200
+ () => ({ ctx: new Context() }),
201
+ ({ ctx }) => {
202
+ for (let i = 0; i < ALLOC_NODES; i++) {
203
+ ctx.cell(i);
204
+ }
205
+ blackBox(ctx);
206
+ },
207
+ );
208
+
177
209
  await run({
178
210
  format: process.env.BENCH_FORMAT === "json" ? "json" : "markdown",
179
211
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazily-hub/lazily-js",
3
- "version": "0.21.0",
3
+ "version": "0.24.0",
4
4
  "description": "Native JavaScript port of the lazily reactive core: a full reactive graph (Cell/Slot/Signal/Effect), sync/async/thread-safe keyed reactive maps (ReactiveMap/CellMap/SlotMap), the lazily-spec IPC wire types + C-ABI FFI boundary (isomorphic channel + Node native binding), keyed cell collections + LIS reconciliation, the memoized semantic tree, the move-aware sequence CRDT, the Fugue/RGA text CRDT, manufactured text identity, full-Harel state charts, an FFI state-projection consumer, and an in-library instrumentation/benchmark API.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -147,22 +147,28 @@
147
147
  "default": "./src/distributed.js"
148
148
  }
149
149
  },
150
+ "sideEffects": false,
150
151
  "scripts": {
151
- "build": "node --check src/index.js && node --check src/transport.js && node --check src/shm-backend.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/reactive-family.js && node --check src/async-reactive-family.js && node --check src/thread-safe.js && node --check src/thread-safe-reactive-family.js && node --check src/ffi.js && node --check src/ffi-node.js && node --check src/instrumentation.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/queue.js && node --check src/merge.js && node --check src/relay.js && node --check src/rateshape.js && node --check src/membership.js && node --check src/coordination.js && node --check src/presence.js && node --check src/windowing.js && node --check src/resilience.js && node --check src/service.js && node --check src/temporal.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/indexeddb-outbox.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/graph-view.js && node --check src/signaling.js && node --check src/distributed.js",
152
+ "build": "node --check src/index.js && node --check src/transport.js && node --check src/shm-backend.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/reactive-family.js && node --check src/async-reactive-family.js && node --check src/thread-safe.js && node --check src/thread-safe-reactive-family.js && node --check src/ffi.js && node --check src/ffi-node.js && node --check src/instrumentation.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/queue.js && node --check src/merge.js && node --check src/relay.js && node --check src/rateshape.js && node --check src/membership.js && node --check src/coordination.js && node --check src/presence.js && node --check src/windowing.js && node --check src/resilience.js && node --check src/service.js && node --check src/temporal.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/indexeddb-outbox.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/graph-view.js && node --check src/signaling.js && node --check src/distributed.js && node scripts/update-size-limits.mjs",
152
153
  "test:formal": "node scripts/formal-check.mjs",
154
+ "test:size": "size-limit",
155
+ "size:update": "node scripts/update-size-limits.mjs",
156
+ "size:check": "node scripts/update-size-limits.mjs --check",
153
157
  "bench": "node bench/context.bench.mjs",
154
158
  "bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
155
159
  "benchmark-update": "node scripts/run-benchmarks.mjs",
156
160
  "benchmark-check": "node scripts/run-benchmarks.mjs --check",
157
- "test": "npm run test:formal && node --test test/*.test.js"
161
+ "test": "npm run test:formal && npm run test:size && node --test test/*.test.js"
158
162
  },
159
163
  "dependencies": {
160
164
  "koffi": "^3.0.2"
161
165
  },
162
166
  "devDependencies": {
167
+ "@size-limit/preset-small-lib": "^12.1.0",
163
168
  "@types/node": "^22.0.0",
164
169
  "ajv": "^8.20.0",
165
170
  "fake-indexeddb": "^6.2.5",
171
+ "size-limit": "^12.1.0",
166
172
  "tsx": "^4.19.0",
167
173
  "typescript": "^5.6.0"
168
174
  }
@@ -15,13 +15,32 @@ function deepEqual(a, b) {
15
15
  if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
16
16
  return false;
17
17
  }
18
+ // #lzjsshalloweq: fast path for plain arrays — Array.isArray + length check
19
+ // before Object.keys, index loop, no closure allocation.
20
+ if (Array.isArray(a) && Array.isArray(b)) {
21
+ const n = a.length;
22
+ if (n !== b.length) {
23
+ return false;
24
+ }
25
+ for (let i = 0; i < n; i++) {
26
+ if (!deepEqual(a[i], b[i])) {
27
+ return false;
28
+ }
29
+ }
30
+ return true;
31
+ }
18
32
  const aKeys = Object.keys(a);
19
33
  const bKeys = Object.keys(b);
20
- return (
21
- aKeys.length === bKeys.length &&
22
- aKeys.every((k) => Object.is(aKeys[k], bKeys[k])) &&
23
- aKeys.every((k) => deepEqual(a[k], b[k]))
24
- );
34
+ if (aKeys.length !== bKeys.length) {
35
+ return false;
36
+ }
37
+ for (let i = 0; i < aKeys.length; i++) {
38
+ const k = aKeys[i];
39
+ if (!Object.is(aKeys[k], bKeys[k]) || !deepEqual(a[k], b[k])) {
40
+ return false;
41
+ }
42
+ }
43
+ return true;
25
44
  }
26
45
 
27
46
  function indexOfKey(order, key) {
package/src/presence.js CHANGED
@@ -65,10 +65,15 @@ export class EphemeralCell {
65
65
  // ---------------------------------------------------------------------------
66
66
 
67
67
  function mapEquals(a, b) {
68
+ // #lzjsshalloweq: shallow key->value equality with an index loop (no
69
+ // closure). Short-circuits on the first mismatched value.
68
70
  const ak = Object.keys(a);
69
71
  const bk = Object.keys(b);
70
72
  if (ak.length !== bk.length) return false;
71
- for (const k of ak) if (a[k] !== b[k]) return false;
73
+ for (let i = 0; i < ak.length; i++) {
74
+ const k = ak[i];
75
+ if (a[k] !== b[k]) return false;
76
+ }
72
77
  return true;
73
78
  }
74
79