@lazily-hub/lazily-js 0.23.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.
package/.size-limit.json CHANGED
@@ -3,42 +3,42 @@
3
3
  "name": "reactive: Context",
4
4
  "path": "src/reactive.js",
5
5
  "import": "{ Context }",
6
- "limit": "2.1 KB"
6
+ "limit": "2239 B"
7
7
  },
8
8
  {
9
9
  "name": "reactive: Context + handles + defaultEqual",
10
10
  "path": "src/reactive.js",
11
11
  "import": "{ Context, CellHandle, SlotHandle, SignalHandle, EffectHandle, defaultEqual }",
12
- "limit": "2.15 KB"
12
+ "limit": "2246 B"
13
13
  },
14
14
  {
15
15
  "name": "state-machine: StateMachine",
16
16
  "path": "src/state-machine.js",
17
17
  "import": "{ StateMachine }",
18
- "limit": "300 B"
18
+ "limit": "267 B"
19
19
  },
20
20
  {
21
21
  "name": "sem-tree: SemTree",
22
22
  "path": "src/sem-tree.js",
23
23
  "import": "{ SemTree }",
24
- "limit": "550 B"
24
+ "limit": "505 B"
25
25
  },
26
26
  {
27
27
  "name": "stable-id: contentHash",
28
28
  "path": "src/stable-id.js",
29
29
  "import": "{ contentHash }",
30
- "limit": "200 B"
30
+ "limit": "152 B"
31
31
  },
32
32
  {
33
33
  "name": "collections: CellMap + CellTree + reconcileCollections",
34
34
  "path": "src/collections.js",
35
35
  "import": "{ CellMap, CellTree, reconcileCollections }",
36
- "limit": "1.75 KB"
36
+ "limit": "1645 B"
37
37
  },
38
38
  {
39
39
  "name": "index: PROTOCOL_ID + Snapshot (tree-shaken kitchen sink)",
40
40
  "path": "src/index.js",
41
41
  "import": "{ PROTOCOL_ID, Snapshot }",
42
- "limit": "2.5 KB"
42
+ "limit": "2409 B"
43
43
  }
44
44
  ]
package/README.md CHANGED
@@ -670,7 +670,7 @@ shipped bytes.
670
670
 
671
671
  <!-- size-limits:start -->
672
672
 
673
- Generated for package `@lazily-hub/lazily-js` version `0.23.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`).
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
674
 
675
675
  Refresh command:
676
676
 
@@ -681,13 +681,13 @@ npm run test:size # gate: fails CI if any entry exceeds its budget
681
681
 
682
682
  | Import | Size | Budget |
683
683
  |---|---:|---:|
684
- | reactive: Context | 2.03 KB ✓ | 2.10 KB |
685
- | reactive: Context + handles + defaultEqual | 2.04 KB ✓ | 2.15 KB |
686
- | state-machine: StateMachine | 265 B ✓ | 300 B |
687
- | sem-tree: SemTree | 505 B ✓ | 550 B |
688
- | stable-id: contentHash | 152 B ✓ | 200 B |
689
- | collections: CellMap + CellTree + reconcileCollections | 1.65 KB ✓ | 1.75 KB |
690
- | index: PROTOCOL_ID + Snapshot (tree-shaken kitchen sink) | 2.41 KB ✓ | 2.50 KB |
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
691
 
692
692
  <!-- size-limits:end -->
693
693
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazily-hub/lazily-js",
3
- "version": "0.23.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": {
package/src/reactive.d.ts CHANGED
@@ -64,6 +64,19 @@ export interface Context {
64
64
  isEffectActive(handle: EffectHandle): boolean;
65
65
  disposeSignal(handle: SignalHandle<unknown>): void;
66
66
  isSignalActive(handle: SignalHandle<unknown>): boolean;
67
+ /**
68
+ * Tear down a lazy derived node (slot/computed/memo): detach its upstream and
69
+ * downstream dependency edges, free the node, and recycle its id. No-op on an
70
+ * already-disposed handle or the wrong kind. Callers must ensure no live compute
71
+ * still reads the slot.
72
+ */
73
+ disposeSlot<T>(handle: SlotHandle<T>): void;
74
+ /**
75
+ * Tear down a source cell: detach its downstream edges, free the node, and
76
+ * recycle its id. No-op on an already-disposed handle or the wrong kind. Callers
77
+ * must ensure no live slot still reads the cell (its next recompute would throw).
78
+ */
79
+ disposeCell<T>(handle: CellHandle<T>): void;
67
80
  isSet<T>(handle: SlotHandle<T>): boolean;
68
81
  /** Instrumentation counters, or `null` if not enabled at construction. */
69
82
  instrumentationSnapshot(): InstrumentationSnapshot | null;
package/src/reactive.js CHANGED
@@ -17,11 +17,21 @@
17
17
  // #lzjsclosure: this module uses the closure factory technique (rmemo-style)
18
18
  // rather than `class` + `#private` fields. `createContext()` returns an object
19
19
  // whose methods close over captured `let` state; nodes are plain objects with
20
- // a numeric `k` discriminator (CELL/SLOT/EFFECT) replacing `instanceof`. V8
21
- // inlines the small monomorphic closures more aggressively than prototype
22
- // methods touching `#private` fields, so the read/invalidate hot paths are
23
- // 2-8x faster than the prior class implementation (see bench/closure-vs-class
24
- // .bench.mjs) while also shaving ~8% off the minified+brotlied payload.
20
+ // a numeric discriminator (KIND_CELL/KIND_SLOT/KIND_EFFECT) replacing
21
+ // `instanceof`. V8 inlines the small monomorphic closures more aggressively
22
+ // than prototype methods touching `#private` fields, so the read/invalidate
23
+ // hot paths are 2-8x faster than the prior class implementation (see
24
+ // bench/closure-vs-class.bench.mjs) while also shaving ~8% off the
25
+ // minified+brotlied payload.
26
+ //
27
+ // #lzjsarenanodes: the hot per-node scalar state (kind + packed boolean flags)
28
+ // lives in two arena typed arrays (`kinds` / `flags`) keyed by id, not on the
29
+ // node objects themselves. The `nodes` array keeps only non-scalar fields
30
+ // (value, compute fn, edge arrays), shrinking each node object from up to 10
31
+ // properties to ≤4. That lets V8 emit one stable hidden class per shape and
32
+ // trims per-node memory from ~300 B to ~150 B on the spreadsheet-scale
33
+ // workloads in BENCHMARKS.md. Boolean fields are bit-packed: hasValue/memo/
34
+ // dirty/forceRecompute/inProgress (slots) and forceRun (effects).
25
35
 
26
36
  export function defaultEqual(a, b) {
27
37
  if (a === b) {
@@ -101,10 +111,28 @@ export class SignalHandle {
101
111
  }
102
112
 
103
113
  // -- Node discriminators (replace `instanceof` in hot loops) ----------------
104
-
105
- const CELL = 0;
106
- const SLOT = 1;
107
- const EFFECT = 2;
114
+ // #lzjsarenanodes: the kind lives in the `kinds` Uint8Array arena (one byte
115
+ // per id), not on the node object. KIND_NONE (0) marks a free id — it is what
116
+ // `kinds[id]` reads as for never-allocated ids (typed arrays zero-initialize)
117
+ // and what dispose* explicitly writes back when recycling an id. The node
118
+ // objects in `nodes[]` no longer carry a `k` field.
119
+
120
+ const KIND_NONE = 0;
121
+ const KIND_CELL = 1;
122
+ const KIND_SLOT = 2;
123
+ const KIND_EFFECT = 3;
124
+
125
+ // Packed boolean flags stored in the `flags` Uint8Array arena (one byte per
126
+ // id). Slot nodes use the first five bits; Effect nodes use F_FORCE_RUN. Cell
127
+ // nodes carry no flags. Packing the booleans into one byte/id (vs ~5 separate
128
+ // properties × ~8 B each on the slot object) is most of the #lzjsarenanodes
129
+ // per-node memory win.
130
+ const F_HAS_VALUE = 1 << 0; // slot: a memoized value is cached
131
+ const F_MEMO = 1 << 1; // slot: equality-suppressed recompute
132
+ const F_DIRTY = 1 << 2; // slot: invalidate-on-next-read marker
133
+ const F_FORCE_RECOMPUTE = 1 << 3; // slot: force a recompute even if not dirty
134
+ const F_IN_PROGRESS = 1 << 4; // slot: cycle-detection tripwire
135
+ const F_FORCE_RUN = 1 << 5; // effect: force the next run regardless
108
136
 
109
137
  function edgeInsert(edges, id) {
110
138
  for (let i = 0; i < edges.length; i++) {
@@ -147,6 +175,15 @@ function edgeRemove(edges, id) {
147
175
  function createContext(opts = {}) {
148
176
  const instrument = !!(opts && opts.instrument === true);
149
177
  const nodes = [];
178
+ // #lzjsarenanodes: arena typed arrays keyed by id. `kinds[id]` holds the
179
+ // KIND_* discriminator (replacing the per-node `k` field); `flags[id]` holds
180
+ // the packed F_* boolean bits (replacing hasValue/dirty/forceRecompute/
181
+ // inProgress/memo/forceRun). They start small and double on demand via
182
+ // ensureCapacity; both are `let` so growth can rebind them — every closure
183
+ // sees the latest binding. 2 bytes/id total versus ~50 B/id for the same
184
+ // fields as object properties is the bulk of the per-node savings.
185
+ let kinds = new Uint8Array(32);
186
+ let flags = new Uint8Array(32);
150
187
  let nextId = 1;
151
188
  const freeIds = [];
152
189
  const trackingStack = [];
@@ -179,9 +216,24 @@ function createContext(opts = {}) {
179
216
  };
180
217
  }
181
218
 
219
+ // #lzjsarenanodes: grow the arena typed arrays when an id falls outside the
220
+ // current capacity. Inlined into allocId (the sole caller) so the hot path
221
+ // stays a single `id < kinds.length` branch; doubling keeps growth amortized
222
+ // O(1) per alloc. `kinds`/`flags` are `let` so the new arrays are visible to
223
+ // every closure that reads them.
182
224
  function allocId() {
183
225
  if (instrument) counters.nodeAllocations++;
184
- return freeIds.pop() ?? nextId++;
226
+ const id = freeIds.pop() ?? nextId++;
227
+ if (id >= kinds.length) {
228
+ const newCap = kinds.length * 2;
229
+ const newKinds = new Uint8Array(newCap);
230
+ newKinds.set(kinds);
231
+ kinds = newKinds;
232
+ const newFlags = new Uint8Array(newCap);
233
+ newFlags.set(flags);
234
+ flags = newFlags;
235
+ }
236
+ return id;
185
237
  }
186
238
 
187
239
  // -- Creation ----------------------------------------------------------
@@ -192,7 +244,11 @@ function createContext(opts = {}) {
192
244
 
193
245
  function cellAny(value) {
194
246
  const id = allocId();
195
- nodes[id] = { k: CELL, value, dependents: null }; // #lzjslazyedges
247
+ // #lzjsarenanodes: kind + (no) flags live in the arena; the node object
248
+ // holds only value + the lazy dependents edge list.
249
+ kinds[id] = KIND_CELL;
250
+ flags[id] = 0;
251
+ nodes[id] = { value, dependents: null }; // #lzjslazyedges
196
252
  return id;
197
253
  }
198
254
 
@@ -210,17 +266,17 @@ function createContext(opts = {}) {
210
266
 
211
267
  function slotAny(memoFlag, compute) {
212
268
  const id = allocId();
269
+ // #lzjsarenanodes: kind + hasValue/dirty/forceRecompute/inProgress all
270
+ // start at 0/false; memo is the only flag that may be set at creation. The
271
+ // node object keeps value (varies), compute (closure), and the two lazy
272
+ // edge lists — down from 10 properties to 4.
273
+ kinds[id] = KIND_SLOT;
274
+ flags[id] = memoFlag ? F_MEMO : 0;
213
275
  nodes[id] = {
214
- k: SLOT,
215
276
  value: undefined,
216
- hasValue: false,
217
- memo: memoFlag,
218
277
  compute,
219
278
  dependencies: null, // #lzjslazyedges: allocated on first edge
220
279
  dependents: null, // #lzjslazyedges: allocated on first edge
221
- dirty: false,
222
- forceRecompute: false,
223
- inProgress: false,
224
280
  };
225
281
  return id;
226
282
  }
@@ -240,14 +296,15 @@ function createContext(opts = {}) {
240
296
 
241
297
  function effectAny(run) {
242
298
  const id = allocId();
243
- const node = {
244
- k: EFFECT,
299
+ // #lzjsarenanodes: forceRun starts set (force the initial run on
300
+ // registration); the node object keeps run/cleanup + the lazy edge list.
301
+ kinds[id] = KIND_EFFECT;
302
+ flags[id] = F_FORCE_RUN;
303
+ nodes[id] = {
245
304
  run,
246
305
  dependencies: null, // #lzjslazyedges
247
306
  cleanup: null,
248
- forceRun: true, // force the initial run on registration
249
307
  };
250
- nodes[id] = node;
251
308
  scheduleEffect(id, false);
252
309
  flushEffects();
253
310
  return id;
@@ -273,11 +330,15 @@ function createContext(opts = {}) {
273
330
  registerDependency(id, trackingStack[len - 1]);
274
331
  }
275
332
  refreshSlot(id);
276
- const node = nodes[id];
277
- if (node.k !== SLOT || !node.hasValue) {
333
+ // After refreshSlot, F_HAS_VALUE is set iff `id` is a SLOT that has
334
+ // produced a value: non-slot kinds (CELL/EFFECT/NONE) never carry the
335
+ // bit, and refreshSlot sets it on first successful recompute. So this one
336
+ // flag check replaces both the kind check and the old hasValue read on
337
+ // the hot cached path.
338
+ if ((flags[id] & F_HAS_VALUE) === 0) {
278
339
  throw new Error(`slot ${id} has no value`);
279
340
  }
280
- return node.value;
341
+ return nodes[id].value;
281
342
  }
282
343
 
283
344
  function getCellAny(id) {
@@ -285,11 +346,10 @@ function createContext(opts = {}) {
285
346
  if (len > 0) {
286
347
  registerDependency(id, trackingStack[len - 1]);
287
348
  }
288
- const node = nodes[id];
289
- if (node.k !== CELL) {
349
+ if (kinds[id] !== KIND_CELL) {
290
350
  throw new Error(`get_cell on non-cell id ${id}`);
291
351
  }
292
- return node.value;
352
+ return nodes[id].value;
293
353
  }
294
354
 
295
355
  // -- Write -------------------------------------------------------------
@@ -299,10 +359,10 @@ function createContext(opts = {}) {
299
359
  }
300
360
 
301
361
  function setCellAny(id, value) {
302
- const node = nodes[id];
303
- if (node.k !== CELL) {
362
+ if (kinds[id] !== KIND_CELL) {
304
363
  throw new Error(`set_cell on non-cell id ${id}`);
305
364
  }
365
+ const node = nodes[id];
306
366
  if (!defaultEqual(node.value, value)) {
307
367
  node.value = value;
308
368
  if (batchDepth > 0) {
@@ -347,9 +407,8 @@ function createContext(opts = {}) {
347
407
  batchedCells = new Set();
348
408
  const roots = [];
349
409
  for (const id of cells) {
350
- const node = nodes[id];
351
- if (node.k === CELL) {
352
- const deps = node.dependents;
410
+ if (kinds[id] === KIND_CELL) {
411
+ const deps = nodes[id].dependents;
353
412
  if (deps !== null) {
354
413
  for (let i = 0; i < deps.length; i++) {
355
414
  roots.push(deps[i]);
@@ -373,10 +432,15 @@ function createContext(opts = {}) {
373
432
  pendingEffects.splice(idx, 1);
374
433
  }
375
434
  scheduledEffects.delete(id);
376
- const node = nodes[id];
377
- if (node.k !== EFFECT) {
435
+ if (kinds[id] !== KIND_EFFECT) {
378
436
  return;
379
437
  }
438
+ const node = nodes[id];
439
+ // #lzjsarenanodes: clear the arena slots so the recycled id reads as
440
+ // KIND_NONE/flags=0, then drop the object reference so the run/cleanup
441
+ // closures can be GC'd.
442
+ kinds[id] = KIND_NONE;
443
+ flags[id] = 0;
380
444
  nodes[id] = undefined;
381
445
  freeIds.push(id);
382
446
  const deps = node.dependencies;
@@ -391,8 +455,7 @@ function createContext(opts = {}) {
391
455
  }
392
456
 
393
457
  function isEffectActive(handle) {
394
- const node = nodes[handle.id];
395
- return node !== undefined && node.k === EFFECT;
458
+ return kinds[handle.id] === KIND_EFFECT;
396
459
  }
397
460
 
398
461
  function disposeSignal(handle) {
@@ -403,12 +466,67 @@ function createContext(opts = {}) {
403
466
  return isEffectActive(handle.effect);
404
467
  }
405
468
 
469
+ // Tear down a lazy derived node (slot/computed/memo). A slot is both a consumer
470
+ // (it has `dependencies` — the cells/slots it reads) and a producer (it has
471
+ // `dependents` — the slots/effects that read it), so both edge sets must be
472
+ // detached: upstream so invalidation no longer reaches this node, downstream so
473
+ // a later rerun of a former dependent never dereferences the freed id (see
474
+ // `removeDependentEdge`, which reads `kinds[depId]`). Safe to call on an
475
+ // already-disposed handle or the wrong kind (no-op).
476
+ function disposeSlot(handle) {
477
+ const id = handle.id;
478
+ if (kinds[id] !== KIND_SLOT) {
479
+ return;
480
+ }
481
+ const node = nodes[id];
482
+ const deps = node.dependencies;
483
+ if (deps !== null) {
484
+ for (let i = 0; i < deps.length; i++) {
485
+ removeDependentEdge(deps[i], id);
486
+ }
487
+ }
488
+ const dependents = node.dependents;
489
+ if (dependents !== null) {
490
+ for (let i = 0; i < dependents.length; i++) {
491
+ removeDependencyEdge(dependents[i], id);
492
+ }
493
+ }
494
+ // #lzjsarenanodes: clear arena slots before recycling the id.
495
+ kinds[id] = KIND_NONE;
496
+ flags[id] = 0;
497
+ nodes[id] = undefined;
498
+ freeIds.push(id);
499
+ }
500
+
501
+ // Tear down a source cell. Cells have no `dependencies` (pure source), so only
502
+ // the downstream edges are detached. Callers must ensure nothing still reads the
503
+ // cell in a live compute — disposing a cell a live slot reads will throw on that
504
+ // slot's next recompute (same contract as disposing any still-referenced node).
505
+ function disposeCell(handle) {
506
+ const id = handle.id;
507
+ if (kinds[id] !== KIND_CELL) {
508
+ return;
509
+ }
510
+ const node = nodes[id];
511
+ const dependents = node.dependents;
512
+ if (dependents !== null) {
513
+ for (let i = 0; i < dependents.length; i++) {
514
+ removeDependencyEdge(dependents[i], id);
515
+ }
516
+ }
517
+ kinds[id] = KIND_NONE;
518
+ flags[id] = 0;
519
+ nodes[id] = undefined;
520
+ freeIds.push(id);
521
+ }
522
+
406
523
  function isSet(handle) {
407
- const node = nodes[handle.id];
408
- if (node === undefined || node.k !== SLOT) {
524
+ const id = handle.id;
525
+ if (kinds[id] !== KIND_SLOT) {
409
526
  return false;
410
527
  }
411
- return node.hasValue && !node.dirty;
528
+ // hasValue && !dirty, packed.
529
+ return (flags[id] & (F_HAS_VALUE | F_DIRTY)) === F_HAS_VALUE;
412
530
  }
413
531
 
414
532
  // -- Instrumentation ---------------------------------------------------
@@ -426,8 +544,9 @@ function createContext(opts = {}) {
426
544
  // -- Internals: edges --------------------------------------------------
427
545
 
428
546
  function registerDependency(depId, parentId) {
429
- const dep = nodes[depId];
430
- if (dep.k === CELL || dep.k === SLOT) {
547
+ const depKind = kinds[depId];
548
+ if (depKind === KIND_CELL || depKind === KIND_SLOT) {
549
+ const dep = nodes[depId];
431
550
  if (dep.dependents === null) {
432
551
  dep.dependents = [];
433
552
  }
@@ -436,8 +555,9 @@ function createContext(opts = {}) {
436
555
  counters.dependencyEdgesAdded++;
437
556
  }
438
557
  }
439
- const parent = nodes[parentId];
440
- if (parent.k === SLOT || parent.k === EFFECT) {
558
+ const parentKind = kinds[parentId];
559
+ if (parentKind === KIND_SLOT || parentKind === KIND_EFFECT) {
560
+ const parent = nodes[parentId];
441
561
  if (parent.dependencies === null) {
442
562
  parent.dependencies = [];
443
563
  }
@@ -446,8 +566,9 @@ function createContext(opts = {}) {
446
566
  }
447
567
 
448
568
  function removeDependentEdge(depId, parentId) {
449
- const dep = nodes[depId];
450
- if (dep.k === CELL || dep.k === SLOT) {
569
+ const depKind = kinds[depId];
570
+ if (depKind === KIND_CELL || depKind === KIND_SLOT) {
571
+ const dep = nodes[depId];
451
572
  if (dep.dependents === null) {
452
573
  return;
453
574
  }
@@ -458,46 +579,70 @@ function createContext(opts = {}) {
458
579
  }
459
580
  }
460
581
 
582
+ // Symmetric to {@link removeDependentEdge}: remove `depId` from
583
+ // `nodes[parentId].dependencies` (the consumer-side edge list). Only SLOT and
584
+ // EFFECT nodes carry a `dependencies` list; cells are pure sources. Used by the
585
+ // downstream teardown in {@link disposeSlot}/{@link disposeCell}.
586
+ function removeDependencyEdge(parentId, depId) {
587
+ const parentKind = kinds[parentId];
588
+ if (parentKind === KIND_NONE) {
589
+ return;
590
+ }
591
+ if (parentKind === KIND_SLOT || parentKind === KIND_EFFECT) {
592
+ const parent = nodes[parentId];
593
+ if (parent.dependencies === null) {
594
+ return;
595
+ }
596
+ const removed = edgeRemove(parent.dependencies, depId);
597
+ if (instrument && removed) {
598
+ counters.dependencyEdgesRemoved++;
599
+ }
600
+ }
601
+ }
602
+
461
603
  // -- Internals: refresh / recompute (pull-based, glitch-free) ----------
462
604
 
463
605
  function refreshSlot(id) {
464
- const node = nodes[id];
465
- if (node.k !== SLOT) {
606
+ if (kinds[id] !== KIND_SLOT) {
466
607
  return false;
467
608
  }
468
- if (node.hasValue && !node.dirty && !node.forceRecompute) {
609
+ const node = nodes[id];
610
+ const f = flags[id];
611
+ // hasValue && !dirty && !forceRecompute → cached, nothing to do.
612
+ if ((f & F_HAS_VALUE) !== 0 && (f & (F_DIRTY | F_FORCE_RECOMPUTE)) === 0) {
469
613
  return false;
470
614
  }
471
- if (node.inProgress) {
615
+ if ((f & F_IN_PROGRESS) !== 0) {
472
616
  throw new Error(
473
617
  `lazily: circular dependency detected at slot ${id}; a computed/memo slot depends on itself`,
474
618
  );
475
619
  }
476
- node.inProgress = true;
620
+ flags[id] = f | F_IN_PROGRESS;
477
621
  try {
478
622
  let dependencyChanged = false;
479
623
  const deps = node.dependencies;
480
624
  if (deps !== null) {
481
625
  for (let i = 0; i < deps.length; i++) {
482
626
  const dep = deps[i];
483
- if (nodes[dep].k === SLOT && refreshSlot(dep)) {
627
+ if (kinds[dep] === KIND_SLOT && refreshSlot(dep)) {
484
628
  dependencyChanged = true;
485
629
  }
486
630
  }
487
631
  }
488
- const needsRecompute = !node.hasValue || node.forceRecompute || dependencyChanged;
632
+ const needsRecompute =
633
+ (f & F_HAS_VALUE) === 0 || (f & F_FORCE_RECOMPUTE) !== 0 || dependencyChanged;
489
634
  if (!needsRecompute) {
490
- node.dirty = false;
491
- node.forceRecompute = false;
635
+ // Clear dirty/forceRecompute; the finally clears inProgress.
636
+ flags[id] &= ~(F_DIRTY | F_FORCE_RECOMPUTE);
492
637
  return false;
493
638
  }
494
- return recomputeSlotNow(id, node);
639
+ return recomputeSlotNow(id, node, f);
495
640
  } finally {
496
- node.inProgress = false;
641
+ flags[id] &= ~F_IN_PROGRESS;
497
642
  }
498
643
  }
499
644
 
500
- function recomputeSlotNow(id, node) {
645
+ function recomputeSlotNow(id, node, f) {
501
646
  if (instrument) {
502
647
  counters.slotRecomputes++;
503
648
  }
@@ -520,15 +665,18 @@ function createContext(opts = {}) {
520
665
  } finally {
521
666
  trackingStack.pop();
522
667
  }
523
- const unchanged = node.memo && node.hasValue && defaultEqual(node.value, result);
524
- node.dirty = false;
525
- node.forceRecompute = false;
668
+ const isMemo = (f & F_MEMO) !== 0;
669
+ const hadValue = (f & F_HAS_VALUE) !== 0;
670
+ const unchanged = isMemo && hadValue && defaultEqual(node.value, result);
671
+ // Clear dirty/forceRecompute for the next cycle. Use `&=` (not assignment
672
+ // from `f`) so F_IN_PROGRESS — set by the caller and cleared by its finally
673
+ // — stays set for the duration of the recompute (cycle detection).
674
+ flags[id] &= ~(F_DIRTY | F_FORCE_RECOMPUTE);
526
675
  if (unchanged) {
527
676
  return false;
528
677
  }
529
- const hadValue = node.hasValue;
530
678
  node.value = result;
531
- node.hasValue = true;
679
+ flags[id] |= F_HAS_VALUE;
532
680
  if (hadValue) {
533
681
  invalidateDependentsNow(id);
534
682
  }
@@ -543,13 +691,14 @@ function createContext(opts = {}) {
543
691
  * active reactor, so no flush is owed).
544
692
  */
545
693
  function invalidateDependentsNow(id) {
546
- const node = nodes[id];
694
+ const kind = kinds[id];
547
695
  let roots;
548
- if (node.k === CELL || node.k === SLOT) {
549
- if (node.dependents === null) {
696
+ if (kind === KIND_CELL || kind === KIND_SLOT) {
697
+ const dependents = nodes[id].dependents;
698
+ if (dependents === null) {
550
699
  return false;
551
700
  }
552
- roots = node.dependents;
701
+ roots = dependents;
553
702
  } else {
554
703
  return false;
555
704
  }
@@ -574,15 +723,23 @@ function createContext(opts = {}) {
574
723
  while (stack.length > 0) {
575
724
  const id = stack.pop();
576
725
  const force = forceStack.pop();
577
- const node = nodes[id];
578
- if (node.k === SLOT) {
579
- const shouldPropagate = !node.dirty || (force && !node.forceRecompute);
580
- node.dirty = true;
726
+ const kind = kinds[id];
727
+ if (kind === KIND_SLOT) {
728
+ // #lzjsarenanodes: read+write the dirty/forceRecompute bits in the
729
+ // flags arena. shouldPropagate mirrors the original `!dirty || (force
730
+ // && !forceRecompute)` predicate; then we set dirty (and
731
+ // forceRecompute when forcing).
732
+ const f = flags[id];
733
+ const isDirty = (f & F_DIRTY) !== 0;
734
+ const isForceRecompute = (f & F_FORCE_RECOMPUTE) !== 0;
735
+ const shouldPropagate = !isDirty || (force && !isForceRecompute);
736
+ let newFlags = f | F_DIRTY;
581
737
  if (force) {
582
- node.forceRecompute = true;
738
+ newFlags |= F_FORCE_RECOMPUTE;
583
739
  }
740
+ flags[id] = newFlags;
584
741
  if (shouldPropagate) {
585
- const ddeps = node.dependents;
742
+ const ddeps = nodes[id].dependents;
586
743
  if (ddeps !== null) {
587
744
  for (let i = 0; i < ddeps.length; i++) {
588
745
  stack.push(ddeps[i]);
@@ -590,7 +747,7 @@ function createContext(opts = {}) {
590
747
  }
591
748
  }
592
749
  }
593
- } else if (node.k === EFFECT) {
750
+ } else if (kind === KIND_EFFECT) {
594
751
  effects.push([id, force]);
595
752
  }
596
753
  }
@@ -600,12 +757,11 @@ function createContext(opts = {}) {
600
757
  // -- Internals: effect scheduling / flush ------------------------------
601
758
 
602
759
  function scheduleEffect(id, force) {
603
- const node = nodes[id];
604
- if (node.k !== EFFECT) {
760
+ if (kinds[id] !== KIND_EFFECT) {
605
761
  return;
606
762
  }
607
763
  if (force) {
608
- node.forceRun = true;
764
+ flags[id] |= F_FORCE_RUN;
609
765
  }
610
766
  if (scheduledEffects.add(id)) {
611
767
  pendingEffects.push(id);
@@ -644,16 +800,16 @@ function createContext(opts = {}) {
644
800
  if (!effectShouldRun(id)) {
645
801
  return;
646
802
  }
647
- const node = nodes[id];
648
- if (node.k !== EFFECT) {
803
+ if (kinds[id] !== KIND_EFFECT) {
649
804
  return;
650
805
  }
806
+ const node = nodes[id];
651
807
  // #lzjslazyedges: clear in place (see recomputeSlotNow) to keep the field
652
808
  // monomorphic once allocated.
653
809
  const oldDeps = node.dependencies;
654
810
  const cleanup = node.cleanup;
655
811
  node.cleanup = null;
656
- node.forceRun = false;
812
+ flags[id] &= ~F_FORCE_RUN;
657
813
  if (oldDeps !== null) {
658
814
  for (let i = 0; i < oldDeps.length; i++) {
659
815
  removeDependentEdge(oldDeps[i], id);
@@ -670,27 +826,25 @@ function createContext(opts = {}) {
670
826
  } finally {
671
827
  trackingStack.pop();
672
828
  }
673
- const current = nodes[id];
674
- if (current.k === EFFECT) {
675
- current.cleanup = typeof nextCleanup === "function" ? nextCleanup : null;
829
+ if (kinds[id] === KIND_EFFECT) {
830
+ nodes[id].cleanup = typeof nextCleanup === "function" ? nextCleanup : null;
676
831
  } else if (typeof nextCleanup === "function") {
677
832
  nextCleanup();
678
833
  }
679
834
  }
680
835
 
681
836
  function effectShouldRun(id) {
682
- const node = nodes[id];
683
- if (node.k !== EFFECT) {
837
+ if (kinds[id] !== KIND_EFFECT) {
684
838
  return false;
685
839
  }
686
- if (node.forceRun) {
840
+ if ((flags[id] & F_FORCE_RUN) !== 0) {
687
841
  return true;
688
842
  }
689
- const deps = node.dependencies;
843
+ const deps = nodes[id].dependencies;
690
844
  if (deps !== null) {
691
845
  for (let i = 0; i < deps.length; i++) {
692
846
  const dep = deps[i];
693
- if (nodes[dep].k === SLOT && refreshSlot(dep)) {
847
+ if (kinds[dep] === KIND_SLOT && refreshSlot(dep)) {
694
848
  return true;
695
849
  }
696
850
  }
@@ -714,6 +868,8 @@ function createContext(opts = {}) {
714
868
  isEffectActive,
715
869
  disposeSignal,
716
870
  isSignalActive,
871
+ disposeSlot,
872
+ disposeCell,
717
873
  isSet,
718
874
  instrumentationSnapshot,
719
875
  resetInstrumentation,