@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 +7 -7
- package/README.md +8 -8
- package/package.json +1 -1
- package/src/reactive.d.ts +13 -0
- package/src/reactive.js +243 -87
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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.
|
|
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.
|
|
685
|
-
| reactive: Context + handles + defaultEqual | 2.
|
|
686
|
-
| state-machine: StateMachine |
|
|
687
|
-
| sem-tree: SemTree | 505 B ✓ |
|
|
688
|
-
| stable-id: contentHash | 152 B ✓ |
|
|
689
|
-
| collections: CellMap + CellTree + reconcileCollections | 1.65 KB ✓ | 1.
|
|
690
|
-
| index: PROTOCOL_ID + Snapshot (tree-shaken kitchen sink) | 2.41 KB ✓ | 2.
|
|
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.
|
|
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
|
|
21
|
-
// inlines the small monomorphic closures more aggressively
|
|
22
|
-
// methods touching `#private` fields, so the read/invalidate
|
|
23
|
-
// 2-8x faster than the prior class implementation (see
|
|
24
|
-
// .bench.mjs) while also shaving ~8% off the
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
408
|
-
if (
|
|
524
|
+
const id = handle.id;
|
|
525
|
+
if (kinds[id] !== KIND_SLOT) {
|
|
409
526
|
return false;
|
|
410
527
|
}
|
|
411
|
-
|
|
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
|
|
430
|
-
if (
|
|
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
|
|
440
|
-
if (
|
|
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
|
|
450
|
-
if (
|
|
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
|
-
|
|
465
|
-
if (node.k !== SLOT) {
|
|
606
|
+
if (kinds[id] !== KIND_SLOT) {
|
|
466
607
|
return false;
|
|
467
608
|
}
|
|
468
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
627
|
+
if (kinds[dep] === KIND_SLOT && refreshSlot(dep)) {
|
|
484
628
|
dependencyChanged = true;
|
|
485
629
|
}
|
|
486
630
|
}
|
|
487
631
|
}
|
|
488
|
-
const needsRecompute =
|
|
632
|
+
const needsRecompute =
|
|
633
|
+
(f & F_HAS_VALUE) === 0 || (f & F_FORCE_RECOMPUTE) !== 0 || dependencyChanged;
|
|
489
634
|
if (!needsRecompute) {
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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
|
|
524
|
-
|
|
525
|
-
node.
|
|
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
|
-
|
|
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
|
|
694
|
+
const kind = kinds[id];
|
|
547
695
|
let roots;
|
|
548
|
-
if (
|
|
549
|
-
|
|
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 =
|
|
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
|
|
578
|
-
if (
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
738
|
+
newFlags |= F_FORCE_RECOMPUTE;
|
|
583
739
|
}
|
|
740
|
+
flags[id] = newFlags;
|
|
584
741
|
if (shouldPropagate) {
|
|
585
|
-
const ddeps =
|
|
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 (
|
|
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
|
-
|
|
604
|
-
if (node.k !== EFFECT) {
|
|
760
|
+
if (kinds[id] !== KIND_EFFECT) {
|
|
605
761
|
return;
|
|
606
762
|
}
|
|
607
763
|
if (force) {
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
674
|
-
|
|
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
|
-
|
|
683
|
-
if (node.k !== EFFECT) {
|
|
837
|
+
if (kinds[id] !== KIND_EFFECT) {
|
|
684
838
|
return false;
|
|
685
839
|
}
|
|
686
|
-
if (
|
|
840
|
+
if ((flags[id] & F_FORCE_RUN) !== 0) {
|
|
687
841
|
return true;
|
|
688
842
|
}
|
|
689
|
-
const deps =
|
|
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 (
|
|
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,
|