@graphrefly/graphrefly 0.22.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.
Files changed (78) hide show
  1. package/dist/{chunk-QA3RP5NH.js → chunk-5DJTTKX3.js} +111 -17
  2. package/dist/chunk-5DJTTKX3.js.map +1 -0
  3. package/dist/{chunk-BLD3IFYF.js → chunk-5WGT55R4.js} +9 -7
  4. package/dist/{chunk-BLD3IFYF.js.map → chunk-5WGT55R4.js.map} +1 -1
  5. package/dist/{chunk-IR3KMOLX.js → chunk-AOCBDH4T.js} +3 -383
  6. package/dist/chunk-AOCBDH4T.js.map +1 -0
  7. package/dist/{chunk-TH6COGOP.js → chunk-H4RVA4VE.js} +2 -2
  8. package/dist/chunk-HWPIFSW2.js +36 -0
  9. package/dist/chunk-HWPIFSW2.js.map +1 -0
  10. package/dist/{chunk-MQBQOFDS.js → chunk-IPLKX3L2.js} +12 -31
  11. package/dist/chunk-IPLKX3L2.js.map +1 -0
  12. package/dist/{chunk-44HD4BTA.js → chunk-MW4VAKAO.js} +3 -3
  13. package/dist/chunk-PY4XCDLR.js +391 -0
  14. package/dist/chunk-PY4XCDLR.js.map +1 -0
  15. package/dist/{chunk-RHI3GHZW.js → chunk-QOWVNWOC.js} +3 -3
  16. package/dist/{chunk-EQUZ5NLD.js → chunk-TDEXAMGO.js} +11 -16
  17. package/dist/chunk-TDEXAMGO.js.map +1 -0
  18. package/dist/{chunk-NXC35KC5.js → chunk-XOFWRC73.js} +3 -3
  19. package/dist/compat/nestjs/index.cjs +110 -16
  20. package/dist/compat/nestjs/index.cjs.map +1 -1
  21. package/dist/compat/nestjs/index.d.cts +6 -6
  22. package/dist/compat/nestjs/index.d.ts +6 -6
  23. package/dist/compat/nestjs/index.js +9 -7
  24. package/dist/core/index.cjs +110 -16
  25. package/dist/core/index.cjs.map +1 -1
  26. package/dist/core/index.d.cts +3 -3
  27. package/dist/core/index.d.ts +3 -3
  28. package/dist/core/index.js +3 -3
  29. package/dist/extra/index.cjs +110 -16
  30. package/dist/extra/index.cjs.map +1 -1
  31. package/dist/extra/index.d.cts +4 -4
  32. package/dist/extra/index.d.ts +4 -4
  33. package/dist/extra/index.js +9 -7
  34. package/dist/graph/index.cjs +110 -16
  35. package/dist/graph/index.cjs.map +1 -1
  36. package/dist/graph/index.d.cts +5 -5
  37. package/dist/graph/index.d.ts +5 -5
  38. package/dist/graph/index.js +4 -4
  39. package/dist/{graph-DFr0diXB.d.ts → graph-B6NFqv3z.d.ts} +3 -3
  40. package/dist/{graph-ab1yPwIB.d.cts → graph-D-3JIQme.d.cts} +3 -3
  41. package/dist/{index-BvWfZCTt.d.cts → index-1z8vRTCt.d.cts} +3 -3
  42. package/dist/{index-Dy04P4W3.d.cts → index-AMWewNDe.d.cts} +2 -2
  43. package/dist/{index-C9z6rU9P.d.cts → index-BJB7t9gg.d.cts} +19 -15
  44. package/dist/{index-BbYZma8G.d.ts → index-BysCTzJz.d.ts} +3 -3
  45. package/dist/{index-HdJx_BjO.d.ts → index-C-TXEa7C.d.ts} +19 -15
  46. package/dist/{index-DsGxLfwL.d.ts → index-CYkjxu3s.d.ts} +2 -2
  47. package/dist/{index-BHm3Ba5q.d.ts → index-D7XgsUt7.d.ts} +2 -2
  48. package/dist/{index-D36MAQ3f.d.ts → index-DiobMNwE.d.ts} +3 -3
  49. package/dist/{index-DrJq9B1T.d.cts → index-J7Kc0oIQ.d.cts} +3 -3
  50. package/dist/{index-DLE1Sp-L.d.cts → index-b5BYtczN.d.cts} +2 -2
  51. package/dist/index.cjs +129 -46
  52. package/dist/index.cjs.map +1 -1
  53. package/dist/index.d.cts +15 -15
  54. package/dist/index.d.ts +15 -15
  55. package/dist/index.js +36 -42
  56. package/dist/index.js.map +1 -1
  57. package/dist/{meta-n3FoVWML.d.ts → meta-CnkLA_43.d.ts} +1 -1
  58. package/dist/{meta--fr9sxRM.d.cts → meta-DWbkoq1s.d.cts} +1 -1
  59. package/dist/{node-C5UD5MGq.d.cts → node-B-f-Lu-k.d.cts} +57 -13
  60. package/dist/{node-C5UD5MGq.d.ts → node-B-f-Lu-k.d.ts} +57 -13
  61. package/dist/{observable-DWydVy5b.d.cts → observable-DBnrwcar.d.cts} +1 -1
  62. package/dist/{observable-CQRBtEbq.d.ts → observable-uP-wy_uK.d.ts} +1 -1
  63. package/dist/patterns/reactive-layout/index.cjs +224 -129
  64. package/dist/patterns/reactive-layout/index.cjs.map +1 -1
  65. package/dist/patterns/reactive-layout/index.d.cts +5 -5
  66. package/dist/patterns/reactive-layout/index.d.ts +5 -5
  67. package/dist/patterns/reactive-layout/index.js +6 -4
  68. package/dist/{storage-Bew05Xy6.d.cts → storage-BuTdpCI1.d.cts} +1 -1
  69. package/dist/{storage-C9fZfMfM.d.ts → storage-F2X1U1x0.d.ts} +1 -1
  70. package/package.json +3 -2
  71. package/dist/chunk-EQUZ5NLD.js.map +0 -1
  72. package/dist/chunk-IR3KMOLX.js.map +0 -1
  73. package/dist/chunk-MQBQOFDS.js.map +0 -1
  74. package/dist/chunk-QA3RP5NH.js.map +0 -1
  75. /package/dist/{chunk-TH6COGOP.js.map → chunk-H4RVA4VE.js.map} +0 -0
  76. /package/dist/{chunk-44HD4BTA.js.map → chunk-MW4VAKAO.js.map} +0 -0
  77. /package/dist/{chunk-RHI3GHZW.js.map → chunk-QOWVNWOC.js.map} +0 -0
  78. /package/dist/{chunk-NXC35KC5.js.map → chunk-XOFWRC73.js.map} +0 -0
@@ -316,6 +316,109 @@ var ImageSizeAdapter = class {
316
316
  }
317
317
  };
318
318
 
319
+ // src/core/clock.ts
320
+ function monotonicNs() {
321
+ return Math.trunc(performance.now() * 1e6);
322
+ }
323
+ function wallClockNs() {
324
+ return Date.now() * 1e6;
325
+ }
326
+
327
+ // src/graph/codec.ts
328
+ var JsonCodec = {
329
+ name: "json",
330
+ version: 1,
331
+ contentType: "application/json",
332
+ encode(snapshot) {
333
+ const json = JSON.stringify(snapshot);
334
+ return new TextEncoder().encode(json);
335
+ },
336
+ decode(buffer, _codecVersion) {
337
+ const json = new TextDecoder().decode(buffer);
338
+ return JSON.parse(json);
339
+ }
340
+ };
341
+ var ENVELOPE_VERSION = 1;
342
+ var ENVELOPE_MIN_LEN = 4;
343
+ function encodeEnvelope(codec, payload) {
344
+ const nameBytes = new TextEncoder().encode(codec.name);
345
+ if (nameBytes.length === 0 || nameBytes.length > 255) {
346
+ throw new Error(
347
+ `encodeEnvelope: codec name "${codec.name}" encodes to ${nameBytes.length} bytes (must be 1\u2013255)`
348
+ );
349
+ }
350
+ const cv = codec.version;
351
+ if (!Number.isInteger(cv) || cv < 0 || cv > 65535) {
352
+ throw new Error(
353
+ `encodeEnvelope: codec.version ${cv} out of u16 range (expected integer 0\u201365535)`
354
+ );
355
+ }
356
+ const totalLen = 1 + 1 + nameBytes.length + 2 + payload.length;
357
+ if (totalLen > 4294967295) {
358
+ throw new Error(
359
+ `encodeEnvelope: total envelope size ${totalLen} exceeds 2^32-1 bytes (payload ${payload.length} bytes)`
360
+ );
361
+ }
362
+ const out = new Uint8Array(totalLen);
363
+ let i = 0;
364
+ out[i++] = ENVELOPE_VERSION;
365
+ out[i++] = nameBytes.length;
366
+ out.set(nameBytes, i);
367
+ i += nameBytes.length;
368
+ out[i++] = cv >>> 8 & 255;
369
+ out[i++] = cv & 255;
370
+ out.set(payload, i);
371
+ return out;
372
+ }
373
+ function decodeEnvelope(bytes, config) {
374
+ if (bytes.length < ENVELOPE_MIN_LEN) {
375
+ throw new Error(`decodeEnvelope: bytes too short (${bytes.length} < ${ENVELOPE_MIN_LEN})`);
376
+ }
377
+ let i = 0;
378
+ const envVersion = bytes[i++];
379
+ if (envVersion !== ENVELOPE_VERSION) {
380
+ throw new Error(
381
+ `decodeEnvelope: unsupported envelope version ${envVersion} (expected ${ENVELOPE_VERSION})`
382
+ );
383
+ }
384
+ const nameLen = bytes[i++];
385
+ if (nameLen === 0) {
386
+ throw new Error("decodeEnvelope: name_len must be >= 1");
387
+ }
388
+ if (i + nameLen + 2 > bytes.length) {
389
+ throw new Error(
390
+ `decodeEnvelope: envelope truncated (need ${i + nameLen + 2} bytes, have ${bytes.length})`
391
+ );
392
+ }
393
+ const name = new TextDecoder().decode(bytes.subarray(i, i + nameLen));
394
+ i += nameLen;
395
+ const codecVersion = (bytes[i] << 8 | bytes[i + 1]) >>> 0;
396
+ i += 2;
397
+ const payload = bytes.subarray(i);
398
+ const codec = config.lookupCodec(name);
399
+ if (codec == null) {
400
+ throw new Error(
401
+ `decodeEnvelope: codec "${name}" not registered (envelope codec_v=${codecVersion})`
402
+ );
403
+ }
404
+ return { codec, codecVersion, payload };
405
+ }
406
+ function registerBuiltinCodecs(config) {
407
+ config.registerCodec(JsonCodec);
408
+ }
409
+
410
+ // src/core/actor.ts
411
+ var DEFAULT_ACTOR = { type: "system", id: "" };
412
+ function normalizeActor(actor) {
413
+ if (actor == null) return DEFAULT_ACTOR;
414
+ const { type, id, ...rest } = actor;
415
+ return {
416
+ type: type ?? "system",
417
+ id: id ?? "",
418
+ ...rest
419
+ };
420
+ }
421
+
319
422
  // src/core/batch.ts
320
423
  var MAX_DRAIN_ITERATIONS = 1e3;
321
424
  var batchDepth = 0;
@@ -323,9 +426,20 @@ var flushInProgress = false;
323
426
  var drainPhase2 = [];
324
427
  var drainPhase3 = [];
325
428
  var drainPhase4 = [];
429
+ var flushHooks = [];
326
430
  function isBatching() {
327
431
  return batchDepth > 0 || flushInProgress;
328
432
  }
433
+ function isExplicitlyBatching() {
434
+ return batchDepth > 0;
435
+ }
436
+ function registerBatchFlushHook(hook) {
437
+ if (batchDepth > 0) {
438
+ flushHooks.push(hook);
439
+ } else {
440
+ hook();
441
+ }
442
+ }
329
443
  function batch(fn) {
330
444
  batchDepth += 1;
331
445
  let threw = false;
@@ -339,6 +453,13 @@ function batch(fn) {
339
453
  if (batchDepth === 0) {
340
454
  if (threw) {
341
455
  if (!flushInProgress) {
456
+ const hooks = flushHooks.splice(0);
457
+ for (const h of hooks) {
458
+ try {
459
+ h();
460
+ } catch {
461
+ }
462
+ }
342
463
  drainPhase2.length = 0;
343
464
  drainPhase3.length = 0;
344
465
  drainPhase4.length = 0;
@@ -355,7 +476,18 @@ function drainPending() {
355
476
  const errors = [];
356
477
  let iterations = 0;
357
478
  try {
358
- while (drainPhase2.length > 0 || drainPhase3.length > 0 || drainPhase4.length > 0) {
479
+ while (drainPhase2.length > 0 || drainPhase3.length > 0 || drainPhase4.length > 0 || ownsFlush && flushHooks.length > 0) {
480
+ if (ownsFlush && flushHooks.length > 0) {
481
+ const hooks = flushHooks.splice(0);
482
+ for (const h of hooks) {
483
+ try {
484
+ h();
485
+ } catch (e) {
486
+ errors.push(e);
487
+ }
488
+ }
489
+ continue;
490
+ }
359
491
  iterations += 1;
360
492
  if (iterations > MAX_DRAIN_ITERATIONS) {
361
493
  drainPhase2.length = 0;
@@ -428,14 +560,6 @@ function downWithBatch(sink, messages, tierOf) {
428
560
  }
429
561
  }
430
562
 
431
- // src/core/clock.ts
432
- function monotonicNs() {
433
- return Math.trunc(performance.now() * 1e6);
434
- }
435
- function wallClockNs() {
436
- return Date.now() * 1e6;
437
- }
438
-
439
563
  // src/core/messages.ts
440
564
  var START = /* @__PURE__ */ Symbol.for("graphrefly/START");
441
565
  var DATA = /* @__PURE__ */ Symbol.for("graphrefly/DATA");
@@ -459,101 +583,6 @@ var INVALIDATE_ONLY_BATCH = Object.freeze([INVALIDATE_MSG]);
459
583
  var COMPLETE_ONLY_BATCH = Object.freeze([COMPLETE_MSG]);
460
584
  var TEARDOWN_ONLY_BATCH = Object.freeze([TEARDOWN_MSG]);
461
585
 
462
- // src/graph/codec.ts
463
- var JsonCodec = {
464
- name: "json",
465
- version: 1,
466
- contentType: "application/json",
467
- encode(snapshot) {
468
- const json = JSON.stringify(snapshot);
469
- return new TextEncoder().encode(json);
470
- },
471
- decode(buffer, _codecVersion) {
472
- const json = new TextDecoder().decode(buffer);
473
- return JSON.parse(json);
474
- }
475
- };
476
- var ENVELOPE_VERSION = 1;
477
- var ENVELOPE_MIN_LEN = 4;
478
- function encodeEnvelope(codec, payload) {
479
- const nameBytes = new TextEncoder().encode(codec.name);
480
- if (nameBytes.length === 0 || nameBytes.length > 255) {
481
- throw new Error(
482
- `encodeEnvelope: codec name "${codec.name}" encodes to ${nameBytes.length} bytes (must be 1\u2013255)`
483
- );
484
- }
485
- const cv = codec.version;
486
- if (!Number.isInteger(cv) || cv < 0 || cv > 65535) {
487
- throw new Error(
488
- `encodeEnvelope: codec.version ${cv} out of u16 range (expected integer 0\u201365535)`
489
- );
490
- }
491
- const totalLen = 1 + 1 + nameBytes.length + 2 + payload.length;
492
- if (totalLen > 4294967295) {
493
- throw new Error(
494
- `encodeEnvelope: total envelope size ${totalLen} exceeds 2^32-1 bytes (payload ${payload.length} bytes)`
495
- );
496
- }
497
- const out = new Uint8Array(totalLen);
498
- let i = 0;
499
- out[i++] = ENVELOPE_VERSION;
500
- out[i++] = nameBytes.length;
501
- out.set(nameBytes, i);
502
- i += nameBytes.length;
503
- out[i++] = cv >>> 8 & 255;
504
- out[i++] = cv & 255;
505
- out.set(payload, i);
506
- return out;
507
- }
508
- function decodeEnvelope(bytes, config) {
509
- if (bytes.length < ENVELOPE_MIN_LEN) {
510
- throw new Error(`decodeEnvelope: bytes too short (${bytes.length} < ${ENVELOPE_MIN_LEN})`);
511
- }
512
- let i = 0;
513
- const envVersion = bytes[i++];
514
- if (envVersion !== ENVELOPE_VERSION) {
515
- throw new Error(
516
- `decodeEnvelope: unsupported envelope version ${envVersion} (expected ${ENVELOPE_VERSION})`
517
- );
518
- }
519
- const nameLen = bytes[i++];
520
- if (nameLen === 0) {
521
- throw new Error("decodeEnvelope: name_len must be >= 1");
522
- }
523
- if (i + nameLen + 2 > bytes.length) {
524
- throw new Error(
525
- `decodeEnvelope: envelope truncated (need ${i + nameLen + 2} bytes, have ${bytes.length})`
526
- );
527
- }
528
- const name = new TextDecoder().decode(bytes.subarray(i, i + nameLen));
529
- i += nameLen;
530
- const codecVersion = (bytes[i] << 8 | bytes[i + 1]) >>> 0;
531
- i += 2;
532
- const payload = bytes.subarray(i);
533
- const codec = config.lookupCodec(name);
534
- if (codec == null) {
535
- throw new Error(
536
- `decodeEnvelope: codec "${name}" not registered (envelope codec_v=${codecVersion})`
537
- );
538
- }
539
- return { codec, codecVersion, payload };
540
- }
541
- function registerBuiltinCodecs(config) {
542
- config.registerCodec(JsonCodec);
543
- }
544
-
545
- // src/core/actor.ts
546
- var DEFAULT_ACTOR = { type: "system", id: "" };
547
- function normalizeActor(actor) {
548
- if (actor == null) return DEFAULT_ACTOR;
549
- const { type, id, ...rest } = actor;
550
- return {
551
- type: type ?? "system",
552
- id: id ?? "",
553
- ...rest
554
- };
555
- }
556
-
557
586
  // src/core/config.ts
558
587
  var GraphReFlyConfig = class {
559
588
  _messageTypes = /* @__PURE__ */ new Map();
@@ -1088,6 +1117,22 @@ var NodeImpl = class _NodeImpl {
1088
1117
  * treats `0` as "wave settled" — O(1) check for full dep settlement.
1089
1118
  */
1090
1119
  _dirtyDepCount = 0;
1120
+ // --- Per-batch emit accumulator (Bug 2: K+1 fan-in fix) ---
1121
+ /**
1122
+ * Inside an explicit `batch(() => ...)` scope, every `_emit` accumulates
1123
+ * its already-framed messages here instead of dispatching synchronously.
1124
+ * At batch end, `_flushBatchPending` runs (registered via
1125
+ * `registerBatchFlushHook`) and delivers the whole accumulated batch as
1126
+ * one `downWithBatch` call — collapsing what would otherwise be K
1127
+ * separate sink invocations into one. This is the fix for the diamond
1128
+ * fan-in K+1 over-fire.
1129
+ *
1130
+ * `null` outside batch (or after flush). Only ever appended to within
1131
+ * a single explicit batch lifetime; reset to `null` on flush. State
1132
+ * updates (cache, version, status) still happen per-emit via
1133
+ * `_updateState` — only the downstream delivery is coalesced.
1134
+ */
1135
+ _batchPendingMessages = null;
1091
1136
  // --- PAUSE/RESUME lock tracking (C0) ---
1092
1137
  /**
1093
1138
  * Set of active pause locks held against this node. Every `[PAUSE, lockId]`
@@ -1465,7 +1510,10 @@ var NodeImpl = class _NodeImpl {
1465
1510
  dep.unsub = noopUnsub;
1466
1511
  dep.unsub = dep.node.subscribe((msgs) => {
1467
1512
  if (dep.unsub === null) return;
1513
+ const tierOf = this._config.tierOf;
1514
+ let sawSettlement = false;
1468
1515
  for (const m of msgs) {
1516
+ if (tierOf(m[0]) >= 3) sawSettlement = true;
1469
1517
  this._config.onMessage(
1470
1518
  this,
1471
1519
  m,
@@ -1473,6 +1521,7 @@ var NodeImpl = class _NodeImpl {
1473
1521
  this._actions
1474
1522
  );
1475
1523
  }
1524
+ if (sawSettlement) this._maybeRunFnOnSettlement();
1476
1525
  });
1477
1526
  subscribedCount++;
1478
1527
  }
@@ -1527,7 +1576,10 @@ var NodeImpl = class _NodeImpl {
1527
1576
  try {
1528
1577
  record.unsub = depNode.subscribe((msgs) => {
1529
1578
  if (record.unsub === null) return;
1579
+ const tierOf = this._config.tierOf;
1580
+ let sawSettlement = false;
1530
1581
  for (const m of msgs) {
1582
+ if (tierOf(m[0]) >= 3) sawSettlement = true;
1531
1583
  this._config.onMessage(
1532
1584
  this,
1533
1585
  m,
@@ -1535,6 +1587,7 @@ var NodeImpl = class _NodeImpl {
1535
1587
  this._actions
1536
1588
  );
1537
1589
  }
1590
+ if (sawSettlement) this._maybeRunFnOnSettlement();
1538
1591
  });
1539
1592
  } catch (err) {
1540
1593
  record.unsub = null;
@@ -1656,7 +1709,6 @@ var NodeImpl = class _NodeImpl {
1656
1709
  }
1657
1710
  return;
1658
1711
  }
1659
- this._maybeRunFnOnSettlement();
1660
1712
  }
1661
1713
  // --- Centralized dep-state transitions (A3 settlement counters) ---
1662
1714
  //
@@ -1902,37 +1954,35 @@ var NodeImpl = class _NodeImpl {
1902
1954
  // --- Emit pipeline ---
1903
1955
  /**
1904
1956
  * @internal The unified dispatch waist — one call = one wave.
1957
+ * See `GRAPHREFLY-SPEC.md` §1.3.1 for protocol context — the stages
1958
+ * below are the implementation order.
1905
1959
  *
1906
1960
  * Pipeline stages, in order:
1907
1961
  *
1908
- * 1. Early-return on empty batch.
1909
- * 2. Terminal filter — post-COMPLETE/ERROR only TEARDOWN/INVALIDATE
1962
+ * 1. Terminal filter — post-COMPLETE/ERROR only TEARDOWN/INVALIDATE
1910
1963
  * still propagate so graph teardown and cache-clear still work.
1911
- * 3. Tier sort (stable) — the batch can be in any order when it
1964
+ * 2. Tier sort (stable) — the batch can be in any order when it
1912
1965
  * arrives; the walker downstream (`downWithBatch`) assumes
1913
1966
  * ascending tier monotone, and so does `_updateState`'s tier-3
1914
1967
  * slice walk. This is the single source of truth for ordering.
1915
- * 4. Synthetic DIRTY prefix — if a tier-3 payload is present, no
1968
+ * 3. Synthetic DIRTY prefix — if a tier-3 payload is present, no
1916
1969
  * DIRTY is already in the batch, and the node isn't already in
1917
1970
  * `"dirty"` status, prepend `[DIRTY]` after any tier-0 START
1918
1971
  * entries. Guarantees spec §1.3.1 (DIRTY precedes DATA within
1919
1972
  * the same batch) uniformly across every entry point.
1920
- * 5. PAUSE/RESUME lock bookkeeping (C0) — update `_pauseLocks`,
1973
+ * 4. PAUSE/RESUME lock bookkeeping (C0) — update `_pauseLocks`,
1921
1974
  * derive `_paused`, filter unknown-lockId RESUME, replay
1922
1975
  * bufferAll buffer on final lock release.
1923
- * 6. Meta TEARDOWN fan-out — notify meta children before
1976
+ * 5. Meta TEARDOWN fan-out — notify meta children before
1924
1977
  * `_updateState`'s TEARDOWN branch calls `_deactivate`. Hoisted
1925
1978
  * out of the walk to keep `_updateState` re-entrance-free.
1926
- * 7. `_updateState` — walk the batch in tier order, advancing
1979
+ * 6. `_updateState` — walk the batch in tier order, advancing
1927
1980
  * `_cached` / `_status` / `_versioning` and running equals
1928
1981
  * substitution on tier-3 DATA (§3.5.1). Returns
1929
1982
  * `{finalMessages, equalsError?}`.
1930
- * 8. `downWithBatch` dispatch (or bufferAll capture if paused with
1983
+ * 7. `downWithBatch` dispatch (or bufferAll capture if paused with
1931
1984
  * `pausable: "resumeAll"`).
1932
- * 9. Recursive ERROR emission if equals threw mid-walk.
1933
- *
1934
- * `node.down` / `node.emit` / `actions.down` / `actions.emit` all
1935
- * converge here — the unified `_emit` waist (spec §1.3.1).
1985
+ * 8. Recursive ERROR emission if equals threw mid-walk.
1936
1986
  */
1937
1987
  _emit(messages) {
1938
1988
  if (messages.length === 0) return;
@@ -2028,10 +2078,10 @@ var NodeImpl = class _NodeImpl {
2028
2078
  }
2029
2079
  }
2030
2080
  if (immediate.length > 0) {
2031
- downWithBatch(this._deliverToSinks, immediate, tierOf);
2081
+ this._dispatchOrAccumulate(immediate);
2032
2082
  }
2033
2083
  } else {
2034
- downWithBatch(this._deliverToSinks, finalMessages, this._config.tierOf);
2084
+ this._dispatchOrAccumulate(finalMessages);
2035
2085
  }
2036
2086
  }
2037
2087
  if (equalsError != null) {
@@ -2154,6 +2204,50 @@ var NodeImpl = class _NodeImpl {
2154
2204
  const snapshot = [...this._sinks];
2155
2205
  for (const sink of snapshot) sink(messages);
2156
2206
  };
2207
+ /**
2208
+ * @internal Dispatch entry point that respects the per-batch emit
2209
+ * accumulator (Bug 2). Inside an explicit `batch()` scope, append to
2210
+ * `_batchPendingMessages` and register a flush hook on first append.
2211
+ * Outside batch — or during a drain (where `flushInProgress` is true
2212
+ * but `batchDepth` is 0) — dispatch synchronously through `downWithBatch`.
2213
+ *
2214
+ * Per-emit state updates (`_frameBatch`, `_updateState`) have already
2215
+ * happened by the time we reach here; only the **downstream delivery**
2216
+ * is coalesced. Cache, version, and status are visible mid-batch on
2217
+ * the emitting node itself.
2218
+ */
2219
+ _dispatchOrAccumulate(messages) {
2220
+ if (isExplicitlyBatching()) {
2221
+ if (this._batchPendingMessages === null) {
2222
+ this._batchPendingMessages = [];
2223
+ registerBatchFlushHook(() => this._flushBatchPending());
2224
+ }
2225
+ for (const m of messages) this._batchPendingMessages.push(m);
2226
+ return;
2227
+ }
2228
+ downWithBatch(this._deliverToSinks, messages, this._config.tierOf);
2229
+ }
2230
+ /**
2231
+ * @internal Flushes the accumulated batch through `downWithBatch` and
2232
+ * clears the pending state. Idempotent — safe to call when pending is
2233
+ * already null or empty (e.g. on a `batch()` throw, where the hook
2234
+ * fires for cleanup but the drainPhase queues are wiped after).
2235
+ *
2236
+ * Critical: the accumulated batch is interleaved per-emit framings like
2237
+ * `[DIRTY, DATA(1), DIRTY, DATA(2)]` — non-monotone tier order. We must
2238
+ * re-frame to sort by tier before handing to `downWithBatch`, which
2239
+ * assumes pre-sorted input. `_frameBatch` also handles the synthetic
2240
+ * DIRTY prepend rule (no-op here — `hasDirty` is true since each
2241
+ * accumulated emit already carries its own DIRTY prefix).
2242
+ */
2243
+ _flushBatchPending() {
2244
+ const pending = this._batchPendingMessages;
2245
+ if (pending === null) return;
2246
+ this._batchPendingMessages = null;
2247
+ if (pending.length === 0) return;
2248
+ const framed = this._frameBatch(pending);
2249
+ downWithBatch(this._deliverToSinks, framed, this._config.tierOf);
2250
+ }
2157
2251
  };
2158
2252
  var isNodeArray = (value) => Array.isArray(value);
2159
2253
  var isNodeOptionsObject = (value) => typeof value === "object" && value != null && !Array.isArray(value);
@@ -4760,6 +4854,12 @@ function reachable(described, from, direction, options = {}) {
4760
4854
  return paths;
4761
4855
  }
4762
4856
 
4857
+ // src/patterns/_internal.ts
4858
+ function emitToMeta(metaNode, value) {
4859
+ if (metaNode == null) return;
4860
+ downWithBatch((msgs) => metaNode.down(msgs), [[DATA, value]], defaultConfig.tierOf);
4861
+ }
4862
+
4763
4863
  // src/patterns/reactive-layout/reactive-layout.ts
4764
4864
  function isCJK(s) {
4765
4865
  for (const ch of s) {
@@ -5216,13 +5316,9 @@ function reactiveLayout(opts) {
5216
5316
  const hitRate = lookups === 0 ? 1 : measureStats.hits / lookups;
5217
5317
  const meta = segmentsNode.meta;
5218
5318
  if (meta) {
5219
- const hr = hitRate;
5220
- const len = result.length;
5221
- const el = elapsed;
5222
- const tierOf = defaultConfig.tierOf;
5223
- downWithBatch((msgs) => meta["cache-hit-rate"]?.down(msgs), [[DATA, hr]], tierOf);
5224
- downWithBatch((msgs) => meta["segment-count"]?.down(msgs), [[DATA, len]], tierOf);
5225
- downWithBatch((msgs) => meta["layout-time-ns"]?.down(msgs), [[DATA, el]], tierOf);
5319
+ emitToMeta(meta["cache-hit-rate"], hitRate);
5320
+ emitToMeta(meta["segment-count"], result.length);
5321
+ emitToMeta(meta["layout-time-ns"], elapsed);
5226
5322
  }
5227
5323
  actions.emit(result);
5228
5324
  return () => {
@@ -5442,9 +5538,8 @@ function reactiveBlockLayout(opts) {
5442
5538
  const elapsed = monotonicNs() - t0;
5443
5539
  const meta = measuredBlocksNode.meta;
5444
5540
  if (meta) {
5445
- const tierOf = defaultConfig.tierOf;
5446
- downWithBatch((msgs) => meta["block-count"]?.down(msgs), [[DATA, result.length]], tierOf);
5447
- downWithBatch((msgs) => meta["layout-time-ns"]?.down(msgs), [[DATA, elapsed]], tierOf);
5541
+ emitToMeta(meta["block-count"], result.length);
5542
+ emitToMeta(meta["layout-time-ns"], elapsed);
5448
5543
  }
5449
5544
  actions.emit(result);
5450
5545
  return () => {