@solidjs/signals 2.0.0-rc.13 → 2.0.0-rc.14

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 (118) hide show
  1. package/dist/dev-shared.js +2000 -3768
  2. package/dist/dev.attribution.js +132 -79
  3. package/dist/dev.js +7226 -8488
  4. package/dist/observe/affects.js +125 -104
  5. package/dist/observe/boundaries.js +458 -665
  6. package/dist/observe/core/action.js +63 -66
  7. package/dist/observe/core/async.js +240 -383
  8. package/dist/observe/core/attribution-queries.js +1 -1
  9. package/dist/observe/core/attribution.js +240 -183
  10. package/dist/observe/core/constants.js +115 -187
  11. package/dist/observe/core/context.js +3 -3
  12. package/dist/observe/core/core.js +840 -1438
  13. package/dist/observe/core/dev.js +3 -3
  14. package/dist/observe/core/effect.js +75 -86
  15. package/dist/observe/core/external.js +5 -5
  16. package/dist/observe/core/graph.js +61 -70
  17. package/dist/observe/core/heap.js +75 -98
  18. package/dist/observe/core/invariants.js +1 -34
  19. package/dist/observe/core/lanes.js +704 -181
  20. package/dist/observe/core/owner.js +80 -101
  21. package/dist/observe/core/scheduler.js +708 -1351
  22. package/dist/observe/core/verdict.js +302 -621
  23. package/dist/observe/flatten.js +79 -0
  24. package/dist/observe/index.js +15 -13
  25. package/dist/observe/map.js +232 -235
  26. package/dist/observe/reveal.js +212 -0
  27. package/dist/observe/signals.js +133 -215
  28. package/dist/observe/store/affects.js +135 -0
  29. package/dist/observe/store/index.js +29 -24
  30. package/dist/observe/store/optimistic.js +538 -0
  31. package/dist/observe/store/projection.js +302 -0
  32. package/dist/observe/store/reconcile.js +257 -0
  33. package/dist/observe/store/store.js +1945 -277
  34. package/dist/observe/store/storePath.js +1 -1
  35. package/dist/{prod/store/next → observe/store}/target.js +17 -18
  36. package/dist/observe/store/types.js +73 -0
  37. package/dist/observe/store/utils.js +1 -13
  38. package/dist/prod/affects.js +127 -106
  39. package/dist/prod/boundaries.js +442 -654
  40. package/dist/prod/core/action.js +71 -74
  41. package/dist/prod/core/async.js +307 -442
  42. package/dist/prod/core/constants.js +112 -186
  43. package/dist/prod/core/context.js +3 -3
  44. package/dist/prod/core/core.js +852 -1458
  45. package/dist/prod/core/effect.js +88 -99
  46. package/dist/prod/core/external.js +5 -5
  47. package/dist/prod/core/graph.js +61 -70
  48. package/dist/prod/core/heap.js +75 -98
  49. package/dist/prod/core/invariants.js +1 -32
  50. package/dist/prod/core/lanes.js +666 -181
  51. package/dist/prod/core/owner.js +75 -96
  52. package/dist/prod/core/scheduler.js +677 -1332
  53. package/dist/prod/core/verdict.js +297 -621
  54. package/dist/prod/flatten.js +77 -0
  55. package/dist/prod/index.js +15 -13
  56. package/dist/prod/map.js +143 -146
  57. package/dist/prod/reveal.js +212 -0
  58. package/dist/prod/signals.js +144 -226
  59. package/dist/prod/store/affects.js +135 -0
  60. package/dist/prod/store/index.js +28 -19
  61. package/dist/prod/store/optimistic.js +533 -0
  62. package/dist/prod/store/projection.js +295 -0
  63. package/dist/prod/store/reconcile.js +257 -0
  64. package/dist/prod/store/store.js +1840 -272
  65. package/dist/prod/store/storePath.js +1 -1
  66. package/dist/{observe/store/next → prod/store}/target.js +13 -17
  67. package/dist/prod/store/types.js +73 -0
  68. package/dist/prod/store/utils.js +1 -11
  69. package/dist/types/affects.d.ts +11 -30
  70. package/dist/types/boundaries.d.ts +62 -176
  71. package/dist/types/carved.d.ts +1 -0
  72. package/dist/types/core/action.d.ts +5 -6
  73. package/dist/types/core/async.d.ts +10 -5
  74. package/dist/types/core/attribution-hooks.d.ts +20 -1
  75. package/dist/types/core/attribution.d.ts +20 -9
  76. package/dist/types/core/constants.d.ts +125 -180
  77. package/dist/types/core/core.d.ts +104 -264
  78. package/dist/types/core/dev.d.ts +2 -2
  79. package/dist/types/core/effect.d.ts +0 -7
  80. package/dist/types/core/graph.d.ts +7 -1
  81. package/dist/types/core/heap.d.ts +5 -7
  82. package/dist/types/core/index.d.ts +5 -5
  83. package/dist/types/core/invariants.d.ts +0 -29
  84. package/dist/types/core/lanes.d.ts +65 -87
  85. package/dist/types/core/owner.d.ts +3 -3
  86. package/dist/types/core/scheduler.d.ts +249 -331
  87. package/dist/types/core/types.d.ts +51 -85
  88. package/dist/types/core/verdict.d.ts +3 -0
  89. package/dist/types/flatten.d.ts +8 -0
  90. package/dist/types/index.d.ts +6 -4
  91. package/dist/types/reveal.d.ts +19 -0
  92. package/dist/types/signals.d.ts +35 -91
  93. package/dist/types/store/affects.d.ts +1 -0
  94. package/dist/types/store/index.d.ts +35 -15
  95. package/dist/types/store/optimistic.d.ts +23 -0
  96. package/dist/types/store/projection.d.ts +18 -0
  97. package/dist/types/store/reconcile.d.ts +3 -0
  98. package/dist/types/store/store.d.ts +165 -143
  99. package/dist/types/store/storePath.d.ts +1 -1
  100. package/dist/types/store/target.d.ts +151 -0
  101. package/dist/types/store/types.d.ts +84 -0
  102. package/package.json +1 -1
  103. package/dist/observe/core/optimistic.js +0 -669
  104. package/dist/observe/store/next/optimistic.js +0 -717
  105. package/dist/observe/store/next/projection.js +0 -266
  106. package/dist/observe/store/next/reconcile.js +0 -375
  107. package/dist/observe/store/next/store.js +0 -2608
  108. package/dist/prod/core/optimistic.js +0 -658
  109. package/dist/prod/store/next/optimistic.js +0 -710
  110. package/dist/prod/store/next/projection.js +0 -257
  111. package/dist/prod/store/next/reconcile.js +0 -373
  112. package/dist/prod/store/next/store.js +0 -2484
  113. package/dist/types/core/optimistic.d.ts +0 -6
  114. package/dist/types/store/next/optimistic.d.ts +0 -25
  115. package/dist/types/store/next/projection.d.ts +0 -27
  116. package/dist/types/store/next/reconcile.d.ts +0 -10
  117. package/dist/types/store/next/store.d.ts +0 -145
  118. package/dist/types/store/next/target.d.ts +0 -196
@@ -1,19 +1,25 @@
1
1
  import {
2
- bL as OBSERVE,
3
- bV as setAttributionHooks,
4
- bW as liveRootOwners,
5
- b as NOT_PENDING,
6
- ay as emitDiagnostic,
7
- bX as ownerPath,
8
- ax as reportDiagnostic,
9
- bY as isSuppressed,
10
- bZ as CONFIG_PLUMBING,
11
- b_ as anyExcluded,
12
- b$ as isExcluded,
13
- q as CONFIG_DERIVED_OVERRIDE,
14
- c0 as GRAPH_SIZE_WARN_AT,
15
- c1 as noteFanOut,
16
- aV as $REFRESH
2
+ br as OBSERVE,
3
+ bB as setAttributionHooks,
4
+ bC as liveRootOwners,
5
+ N as NOT_PENDING,
6
+ an as emitDiagnostic,
7
+ bD as ownerPath,
8
+ aB as reportDiagnostic,
9
+ bE as isSuppressed,
10
+ bF as blockersOf,
11
+ bG as CONFIG_PLUMBING,
12
+ bH as anyExcluded,
13
+ bI as isExcluded,
14
+ bJ as CONFIG_WIDE,
15
+ a as CONFIG_OVERRIDE,
16
+ C as CONFIG_GUESS,
17
+ G as GlobalQueue,
18
+ b as CONFIG_VERDICT,
19
+ bK as GRAPH_SIZE_WARN_AT,
20
+ bL as noteFanOut,
21
+ bM as CONFIG_SUPERSEDES,
22
+ aT as $REFRESH
17
23
  } from "./dev-shared.js";
18
24
 
19
25
  /** A fallback shown for less than this is a flash: feedback for a wait too short to need it. */
@@ -48,7 +54,7 @@ const defaultOptions = {
48
54
  stacks: false,
49
55
  historyLimit: 200,
50
56
  hotRuns: { count: 120, windowMs: 1000 },
51
- wideDeps: 30,
57
+ wideDeps: 200,
52
58
  hotTime: { budgetMs: 8, windowMs: 1000 },
53
59
  unstableMemos: 4,
54
60
  wastedRecompute: { minRuns: 5, ratio: 0.8, budgetMs: 2, windowMs: 1000 },
@@ -192,13 +198,20 @@ function targetLabel(target) {
192
198
  }
193
199
  function interactionStart(ref) {
194
200
  interactionStack.push(currentInteraction);
201
+ // No `ref.event`: the WeakMap answers undefined.
202
+ const joined = eventInteractions.get(ref.event);
203
+ if (joined?.joinable && openInteractions.has(joined)) {
204
+ joined.open = true;
205
+ currentInteraction = joined.event.origin;
206
+ return;
207
+ }
195
208
  // One clock read: the frame opens now; the interaction began at `ref.at`
196
209
  // when the runtime dated it (the event's own timestamp), else now too.
197
210
  const opened = now();
198
211
  const origin = { kind: "interaction", name: ref.type, at: ref.at ?? opened };
199
212
  if (ref.target) origin.target = targetLabel(ref.target);
200
213
  currentInteraction = origin;
201
- openInteraction(origin, opened);
214
+ openInteraction(origin, opened, ref.event);
202
215
  }
203
216
  function interactionEnd(returned) {
204
217
  const closing = currentInteraction;
@@ -385,12 +398,6 @@ function formatOrigin(origin) {
385
398
  }
386
399
  }
387
400
  /** Record a root change (setSignal / refresh / async landing) on the node. */
388
- /** Live subscriber count, walked on demand — the core keeps no counter. */
389
- function countSubscribers(node) {
390
- let n = 0;
391
- for (let s = node._subs; s !== null; s = s._nextSub) n++;
392
- return n;
393
- }
394
401
  /**
395
402
  * HUGE_FAN_OUT from the engine's lower `fanOut` threshold (see dev.ts): a
396
403
  * committed root invalidation reaching hundreds of subscribers re-runs all
@@ -399,12 +406,20 @@ function countSubscribers(node) {
399
406
  * was a post-construction field that forked node shapes). Stops at
400
407
  * GRAPH_SIZE_WARN_AT, where the always-on core check takes over, so the two
401
408
  * never fire for the same write; the once-per-node dedupe is the core's.
409
+ * HMR plumbing subscribers (the refresh memo of every mounted instance, on
410
+ * a component's registration signal) are not counted toward `fanOut`; the
411
+ * handover is on the full list, which is what the core counts.
402
412
  */
403
413
  function checkFanOut(node, kind) {
404
414
  const limit = options.fanOut;
405
415
  if (typeof limit !== "number") return;
406
- const subs = countSubscribers(node);
407
- if (subs < limit || subs >= GRAPH_SIZE_WARN_AT) return;
416
+ let all = 0;
417
+ let subs = 0;
418
+ for (let s = node._subs; s !== null; s = s._nextSub) {
419
+ all++;
420
+ if (!(s._sub._config & CONFIG_PLUMBING)) subs++;
421
+ }
422
+ if (subs < limit || all >= GRAPH_SIZE_WARN_AT) return;
408
423
  noteFanOut(node, subs, kind);
409
424
  }
410
425
  function stampWrite(node, kind, prev = NO_VALUES, value = NO_VALUES) {
@@ -483,18 +498,25 @@ function captureDeps(el) {
483
498
  }
484
499
  /**
485
500
  * Wide-scope warning — the coarse-read / helper-leak signature: one scope
486
- * subscribed to dozens of sources re-runs when ANY of them change. Fired from
501
+ * subscribed to hundreds of sources re-runs when ANY of them change. Fired from
487
502
  * recordRerun for re-runs and directly from recompute for creation runs (a
488
- * memo can be born too wide). Re-warns only on 50% further growth.
503
+ * memo can be born too wide). Re-warns only on 50% further growth. Counts
504
+ * what the scope's author can act on: HMR plumbing sources are not counted,
505
+ * and a framework scope wide by construction (CONFIG_WIDE) is not judged —
506
+ * the always-on HUGE_FAN_IN still is.
489
507
  */
490
508
  function checkDepWidth(el) {
491
509
  const limit = options.wideDeps;
492
- if (limit === false) return;
510
+ // Only dev sets CONFIG_WIDE (see constants.ts).
511
+ if (limit === false || el._config & CONFIG_WIDE) return;
493
512
  let count = 0;
494
513
  const names = [];
495
514
  for (let l = el._deps; l !== null; l = l._nextDep) {
496
- count++;
497
- if (names.length < 12) names.push(nodeName(l._dep));
515
+ // HMR plumbing is a dev-only hop the scope cannot read past: not a source.
516
+ if (!(l._dep._config & CONFIG_PLUMBING)) {
517
+ count++;
518
+ if (names.length < 12) names.push(nodeName(l._dep));
519
+ }
498
520
  if (l === el._depsTail) break; // the validated prefix, as captureDeps
499
521
  }
500
522
  const node = el;
@@ -650,7 +672,10 @@ function checkHotTime(el, selfMs, causes) {
650
672
  * names it while it happens, with the input that keeps triggering it. The
651
673
  * fix is upstream: an equality boundary on the part of the input the scope
652
674
  * depends on, or a narrower read. Plain runs only — a held or overlay run
653
- * may be replayed and is never blamed as waste.
675
+ * may be replayed and is never blamed as waste. A run that committed
676
+ * `undefined` arrives here as changed (see `recomputeEnd`): its work is a
677
+ * side effect (a projection's draft writes, a write from inside a memo), not
678
+ * its output.
654
679
  */
655
680
  function checkWastedRecompute(el, at, phase, changed, selfMs, causes) {
656
681
  const cfg = options.wastedRecompute;
@@ -851,8 +876,10 @@ function shallowEquivalent(a, b) {
851
876
  for (let i = 0; i < arrA.length; i++) if (arrA[i] !== arrB[i]) return false;
852
877
  return true;
853
878
  }
854
- const keys = Object.keys(a);
855
- if (keys.length > UNSTABLE_KEY_CAP || keys.length !== Object.keys(b).length) return false;
879
+ // Every own key, symbols included: a symbol-keyed box holding a fresh value
880
+ // each run (web's `dynamic` boxes its in-flight promise) is not equivalent.
881
+ const keys = Reflect.ownKeys(a);
882
+ if (keys.length > UNSTABLE_KEY_CAP || keys.length !== Reflect.ownKeys(b).length) return false;
856
883
  for (const key of keys) {
857
884
  if (!(key in b) || a[key] !== b[key]) return false;
858
885
  }
@@ -1486,14 +1513,11 @@ function checkWaterfall(el, chain, ms) {
1486
1513
  const holdStates = new WeakMap();
1487
1514
  let activeHold = null;
1488
1515
  let holdLog = [];
1489
- /** Companions are optimistic nodes too; `_parentSource` marks them. So is a
1490
- * memo carrying a DERIVED override (lanes stage, #3479) — a lane pass's
1491
- * result, not a write anyone made: neither is an acknowledgement. */
1516
+ /** A lane's derivation (L2: a node carrying the lane's value that is not a
1517
+ * written guess — lanes.ts) is a lane pass's result, not a write anyone
1518
+ * made: not an acknowledgement. */
1492
1519
  function isCompanion(node) {
1493
- return (
1494
- (!!node._x && node._x._parentSource !== undefined) ||
1495
- (node._config & CONFIG_DERIVED_OVERRIDE) !== 0
1496
- );
1520
+ return (node._config & (CONFIG_OVERRIDE | CONFIG_GUESS)) === CONFIG_OVERRIDE;
1497
1521
  }
1498
1522
  const HOLD_CENSUS_CAP = 10_000;
1499
1523
  function acknowledge(state, kind, source, reader) {
@@ -1509,15 +1533,17 @@ function acknowledge(state, kind, source, reader) {
1509
1533
  );
1510
1534
  else if (path !== undefined) existing.reader ??= path;
1511
1535
  }
1536
+ /** The declarations made against the hold: the guesses of its lanes
1537
+ * (lanes.ts `_laneGuesses`) and its `affects()` marks (`_marks`). */
1512
1538
  function censusRegistrations(t, state) {
1513
1539
  const budget = { left: HOLD_CENSUS_CAP };
1514
- for (const node of t._optimisticNodes)
1515
- if (!isCompanion(node))
1540
+ const guesses = GlobalQueue._laneGuesses?.(t);
1541
+ if (guesses !== undefined)
1542
+ for (const node of guesses)
1516
1543
  acknowledge(state, "optimistic", nodeName(node), reachesEffect(node, budget));
1517
- for (const store of t._optimisticStores)
1518
- acknowledge(state, "optimistic", store?._name ?? "store", null);
1519
- for (const node of t._affectsNodes)
1520
- acknowledge(state, "affects", nodeName(node), reachesEffect(node, budget));
1544
+ if (t._marks !== null)
1545
+ for (const node of t._marks)
1546
+ acknowledge(state, "affects", nodeName(node), reachesEffect(node, budget));
1521
1547
  }
1522
1548
  /**
1523
1549
  * Does anything that paints read `companion` — an effect, through however many
@@ -1542,7 +1568,10 @@ function reachesEffect(companion, budget) {
1542
1568
  }
1543
1569
  return null;
1544
1570
  }
1545
- /** Companions an effect reads, anywhere downstream of the hold's nodes. */
1571
+ /** Verdict readers an effect reads, anywhere downstream of the hold's nodes
1572
+ * (L2: a pass that entered an `isPending`/`latest` window is marked
1573
+ * CONFIG_VERDICT; the observe tier notes which windows — verdict.ts
1574
+ * `_devWindows`). The reader acknowledges the node it derives from. */
1546
1575
  function censusCompanions(roots, state) {
1547
1576
  const visited = new Set();
1548
1577
  const stack = [...roots];
@@ -1551,20 +1580,16 @@ function censusCompanions(roots, state) {
1551
1580
  const node = stack.pop();
1552
1581
  if (visited.has(node)) continue;
1553
1582
  visited.add(node);
1554
- const x = node._x;
1555
- if (x) {
1556
- let reader;
1557
- if (x._pendingSignal !== undefined && (reader = reachesEffect(x._pendingSignal, budget)))
1558
- acknowledge(state, "isPending", nodeName(node), reader);
1559
- if (
1560
- x._latestValueComputed !== undefined &&
1561
- (reader = reachesEffect(x._latestValueComputed, budget))
1562
- )
1563
- acknowledge(state, "latest", nodeName(node), reader);
1564
- for (let child = x._child ?? null; child !== null; child = child._nextChild ?? null)
1565
- stack.push(child);
1583
+ for (let s = node._subs; s !== null; s = s._nextSub) {
1584
+ const sub = s._sub;
1585
+ if (sub._config & CONFIG_VERDICT) {
1586
+ const windows = sub._devWindows ?? 0;
1587
+ const reader = sub._type ? sub : reachesEffect(sub, budget);
1588
+ if (windows & 1 && reader !== null) acknowledge(state, "isPending", nodeName(node), reader);
1589
+ if (windows & 2 && reader !== null) acknowledge(state, "latest", nodeName(node), reader);
1590
+ }
1591
+ stack.push(sub);
1566
1592
  }
1567
- for (let s = node._subs; s !== null; s = s._nextSub) stack.push(s._sub);
1568
1593
  }
1569
1594
  }
1570
1595
  function holdState(t) {
@@ -1590,9 +1615,8 @@ function trackHoldStart(t) {
1590
1615
  if (options.holds === false) return;
1591
1616
  const state = holdState(t);
1592
1617
  state.flushes++;
1593
- if (t._actions.length > 0) state.action = true;
1594
- for (const [source, reporters] of t._asyncReporters)
1595
- if (reporters.size > 0) state.blockers.add(source);
1618
+ if (t._open > 0) state.action = true;
1619
+ for (const source of blockersOf(t)) state.blockers.add(source);
1596
1620
  censusRegistrations(t, state);
1597
1621
  activeHold = state;
1598
1622
  }
@@ -1628,7 +1652,7 @@ function trackHoldSettled(t) {
1628
1652
  let interaction;
1629
1653
  let origin;
1630
1654
  let lastJoinAt = -Infinity;
1631
- for (const node of t._pendingNodes) {
1655
+ for (const node of t._nodes) {
1632
1656
  if (typeof node._fn === "function" || isCompanion(node)) continue;
1633
1657
  const change = node._devChange;
1634
1658
  if (change === undefined || change.kind !== "write") continue;
@@ -1658,7 +1682,7 @@ function trackHoldSettled(t) {
1658
1682
  return;
1659
1683
  }
1660
1684
  censusRegistrations(t, state);
1661
- censusCompanions([...t._pendingNodes, ...state.blockers], state);
1685
+ censusCompanions([...t._nodes, ...state.blockers], state);
1662
1686
  const end = now();
1663
1687
  // The hold began no later than its first parked flush; an interaction stamp
1664
1688
  // reaches further back (dispatch). A node rewritten mid-hold keeps only its
@@ -1965,7 +1989,10 @@ function trackGraph(navigation) {
1965
1989
  const event = { at: now(), ...size, navigation };
1966
1990
  if (route !== undefined) event.route = route;
1967
1991
  records.emit("graph", event, undefined);
1968
- if (cfg === false || route === undefined) return;
1992
+ // The initial declaration is not a visit (it settles before route content
1993
+ // mounts — the same gate the feedback folds apply as `event.initial !== true`);
1994
+ // `initial` is `true | undefined`, set only by openNavigation, so truthiness is exact.
1995
+ if (cfg === false || route === undefined || navigation.initial) return;
1969
1996
  let history = routeCounts.get(route);
1970
1997
  if (history === undefined) routeCounts.set(route, (history = []));
1971
1998
  history.push(size);
@@ -2019,11 +2046,14 @@ const abandonWindows = new WeakMap();
2019
2046
  * One source abandoning flight after flight inside a window: every input
2020
2047
  * asked again and the earlier answers were thrown away — the
2021
2048
  * request-per-keystroke signature `feedback().flights[].abandoned` counts,
2022
- * as a finding while it happens. Warned once per window per source.
2049
+ * as a finding while it happens. Warned once per window per source. A
2050
+ * framework node superseding its own flights by design (CONFIG_SUPERSEDES)
2051
+ * is not judged; its flights are still recorded.
2023
2052
  */
2024
2053
  function checkAbandonedFlights(el, abandoned) {
2025
2054
  const cfg = options.abandonedFlights;
2026
- if (cfg === false || excludedNode(el)) return;
2055
+ // Only dev sets CONFIG_SUPERSEDES (see constants.ts).
2056
+ if (cfg === false || el._config & CONFIG_SUPERSEDES || excludedNode(el)) return;
2027
2057
  const at = Date.now();
2028
2058
  let win = abandonWindows.get(el);
2029
2059
  if (win === undefined || at - win.start > cfg.windowMs) {
@@ -2221,7 +2251,7 @@ function noteNavigationWrite(prior, record) {
2221
2251
  /** holdStart: `t`'s staged writes are parked — their navigations settle with `t`. */
2222
2252
  function markNavigationsHeld(t) {
2223
2253
  if (openNavs.size === 0) return;
2224
- for (const node of t._pendingNodes) {
2254
+ for (const node of t._nodes) {
2225
2255
  const origin = node._devChange?.origin;
2226
2256
  if (origin?.kind !== "navigation") continue;
2227
2257
  const state = navStates.get(origin);
@@ -2231,7 +2261,7 @@ function markNavigationsHeld(t) {
2231
2261
  /** transitionSettled: `t` commits — the navigations whose writes it staged are done. */
2232
2262
  function settleNavigations(t, hold) {
2233
2263
  if (openNavs.size === 0) return;
2234
- for (const node of t._pendingNodes) {
2264
+ for (const node of t._nodes) {
2235
2265
  const origin = node._devChange?.origin;
2236
2266
  if (origin?.kind !== "navigation") continue;
2237
2267
  const state = navStates.get(origin);
@@ -2377,10 +2407,12 @@ function settleNavigation(state, outcome, hold) {
2377
2407
  if (under !== undefined) maybeSettleInteraction(under);
2378
2408
  }
2379
2409
  const interactionStates = new WeakMap();
2410
+ /** `InteractionRef.event` → the record its frames join (while `joinable`). Weak: the Event is never held. */
2411
+ const eventInteractions = new WeakMap();
2380
2412
  /** Opened, not yet settled. */
2381
2413
  const openInteractions = new Set();
2382
2414
  let interactionLog = [];
2383
- function openInteraction(frame, opened) {
2415
+ function openInteraction(frame, opened, key) {
2384
2416
  const event = {
2385
2417
  name: frame.name,
2386
2418
  at: frame.at,
@@ -2406,13 +2438,24 @@ function openInteraction(frame, opened) {
2406
2438
  heldIn: new Set(),
2407
2439
  held: false,
2408
2440
  excludedWrites: 0,
2409
- awaiting: false,
2410
- actioned: false
2441
+ awaiting: 0,
2442
+ actioned: false,
2443
+ joinable: key !== undefined
2411
2444
  };
2412
2445
  interactionStates.set(frame, state);
2413
2446
  openInteractions.add(state);
2414
2447
  interactionLog.push(event);
2415
2448
  if (interactionLog.length > options.historyLimit) interactionLog.shift();
2449
+ if (key !== undefined) {
2450
+ eventInteractions.set(key, state);
2451
+ // A microtask is too early: native dispatch runs microtasks between
2452
+ // listeners. The callback must not close over `key`: a throttled timer
2453
+ // would hold the Event strongly long past its dispatch.
2454
+ setTimeout(() => {
2455
+ state.joinable = false;
2456
+ if (openInteractions.has(state)) maybeSettleInteraction(state, state.settleAt);
2457
+ });
2458
+ }
2416
2459
  }
2417
2460
  /**
2418
2461
  * How long the record waits on a handler's returned promise before settling
@@ -2438,17 +2481,17 @@ function closeInteraction(frame, returned) {
2438
2481
  ? returned
2439
2482
  : null;
2440
2483
  if (thenable !== null) {
2441
- state.awaiting = true;
2484
+ state.awaiting++;
2442
2485
  let done = false;
2443
2486
  let timer;
2444
2487
  const settle = () => {
2445
2488
  if (done) return;
2446
2489
  done = true;
2447
2490
  if (timer !== undefined) clearTimeout(timer);
2448
- if (!openInteractions.has(state)) return;
2449
- state.awaiting = false;
2491
+ if (!openInteractions.has(state) || --state.awaiting > 0) return;
2450
2492
  const at = now();
2451
- state.event.continuationMs = at - end;
2493
+ // From the last handler's return: the frames of one event share a record.
2494
+ state.event.continuationMs = at - (state.opened + state.event.handlerMs);
2452
2495
  checkUntrackedAsyncHandler(state);
2453
2496
  maybeSettleInteraction(state, at);
2454
2497
  };
@@ -2528,7 +2571,7 @@ function noteInteractionNavigation(event) {
2528
2571
  /** holdStart: `t` parked writes — the interactions that performed them wait for `t`. */
2529
2572
  function markInteractionsHeld(t) {
2530
2573
  if (openInteractions.size === 0) return;
2531
- for (const node of t._pendingNodes) {
2574
+ for (const node of t._nodes) {
2532
2575
  const state = openInteractionOf(node._devChange?.origin);
2533
2576
  if (state !== undefined) {
2534
2577
  state.heldIn.add(t);
@@ -2551,12 +2594,16 @@ function settleInteractionsHeld(t, hold) {
2551
2594
  }
2552
2595
  function maybeSettleInteraction(state, end = now()) {
2553
2596
  const event = state.event;
2554
- if (state.open || state.awaiting || state.heldIn.size > 0) return;
2597
+ if (state.open || state.awaiting > 0 || state.heldIn.size > 0) return;
2555
2598
  // A drain must have committed the last write (the handler's, or a redirect
2556
2599
  // hop's after the click's own drain) — the handler returning is not the
2557
2600
  // screen having it.
2558
2601
  if (event.writes > 0 && drainSeq <= state.writeDrain) return;
2559
2602
  for (const nav of event.navigations) if (nav.outcome === undefined) return;
2603
+ if (state.joinable) {
2604
+ state.settleAt = end;
2605
+ return;
2606
+ }
2560
2607
  openInteractions.delete(state);
2561
2608
  // Every write went to an excluded subject and nothing of the app's ran: the
2562
2609
  // click was on the observer's own UI (a devtools panel's button). Not a
@@ -2653,6 +2700,12 @@ const engineHooks = {
2653
2700
  frame.prevValue,
2654
2701
  el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value
2655
2702
  );
2703
+ // The same exemption for memos: a run that committed `undefined` did its
2704
+ // work as a side effect (a projection's draft writes or reconcile, a
2705
+ // write from inside a memo), so core's `undefined === undefined` gate
2706
+ // proves nothing. Reported as changed, so no consumer counts it as waste.
2707
+ if (!changed && (el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value) === undefined)
2708
+ changed = true;
2656
2709
  if (frame.causes !== null)
2657
2710
  recordRerun(
2658
2711
  el,