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

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 (121) hide show
  1. package/dist/dev-shared.js +2059 -3768
  2. package/dist/dev.attribution.js +237 -101
  3. package/dist/dev.js +7466 -8497
  4. package/dist/observe/affects.js +128 -104
  5. package/dist/observe/boundaries.js +492 -665
  6. package/dist/observe/core/action.js +63 -66
  7. package/dist/observe/core/async.js +243 -379
  8. package/dist/observe/core/attribution-queries.js +1 -1
  9. package/dist/observe/core/attribution.js +394 -253
  10. package/dist/observe/core/constants.js +120 -187
  11. package/dist/observe/core/context.js +3 -3
  12. package/dist/observe/core/core.js +874 -1440
  13. package/dist/observe/core/dev.js +3 -3
  14. package/dist/observe/core/effect.js +73 -84
  15. package/dist/observe/core/external.js +5 -5
  16. package/dist/observe/core/graph.js +62 -71
  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 +750 -181
  20. package/dist/observe/core/owner.js +80 -101
  21. package/dist/observe/core/scheduler.js +719 -1356
  22. package/dist/observe/core/verdict.js +314 -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 +144 -216
  28. package/dist/observe/store/affects.js +142 -0
  29. package/dist/observe/store/hooks.js +13 -0
  30. package/dist/observe/store/index.js +27 -24
  31. package/dist/observe/store/optimistic.js +571 -0
  32. package/dist/observe/store/projection.js +308 -0
  33. package/dist/observe/store/reconcile.js +259 -0
  34. package/dist/observe/store/store.js +1988 -277
  35. package/dist/observe/store/storePath.js +1 -1
  36. package/dist/{prod/store/next → observe/store}/target.js +17 -18
  37. package/dist/observe/store/types.js +73 -0
  38. package/dist/observe/store/utils.js +1 -13
  39. package/dist/prod/affects.js +130 -106
  40. package/dist/prod/boundaries.js +476 -654
  41. package/dist/prod/core/action.js +71 -74
  42. package/dist/prod/core/async.js +306 -444
  43. package/dist/prod/core/constants.js +117 -186
  44. package/dist/prod/core/context.js +3 -3
  45. package/dist/prod/core/core.js +888 -1462
  46. package/dist/prod/core/effect.js +88 -99
  47. package/dist/prod/core/external.js +5 -5
  48. package/dist/prod/core/graph.js +62 -71
  49. package/dist/prod/core/heap.js +75 -98
  50. package/dist/prod/core/invariants.js +1 -32
  51. package/dist/prod/core/lanes.js +712 -181
  52. package/dist/prod/core/owner.js +75 -96
  53. package/dist/prod/core/scheduler.js +686 -1335
  54. package/dist/prod/core/verdict.js +309 -621
  55. package/dist/prod/flatten.js +77 -0
  56. package/dist/prod/index.js +15 -13
  57. package/dist/prod/map.js +142 -145
  58. package/dist/prod/reveal.js +212 -0
  59. package/dist/prod/signals.js +155 -227
  60. package/dist/prod/store/affects.js +142 -0
  61. package/dist/prod/store/hooks.js +13 -0
  62. package/dist/prod/store/index.js +26 -19
  63. package/dist/prod/store/optimistic.js +566 -0
  64. package/dist/prod/store/projection.js +301 -0
  65. package/dist/prod/store/reconcile.js +259 -0
  66. package/dist/prod/store/store.js +1883 -272
  67. package/dist/prod/store/storePath.js +1 -1
  68. package/dist/{observe/store/next → prod/store}/target.js +13 -17
  69. package/dist/prod/store/types.js +73 -0
  70. package/dist/prod/store/utils.js +1 -11
  71. package/dist/types/affects.d.ts +11 -30
  72. package/dist/types/boundaries.d.ts +66 -176
  73. package/dist/types/carved.d.ts +1 -0
  74. package/dist/types/core/action.d.ts +5 -6
  75. package/dist/types/core/async.d.ts +10 -5
  76. package/dist/types/core/attribution-hooks.d.ts +20 -1
  77. package/dist/types/core/attribution.d.ts +26 -11
  78. package/dist/types/core/constants.d.ts +130 -180
  79. package/dist/types/core/core.d.ts +134 -264
  80. package/dist/types/core/dev.d.ts +2 -2
  81. package/dist/types/core/effect.d.ts +0 -7
  82. package/dist/types/core/graph.d.ts +7 -1
  83. package/dist/types/core/heap.d.ts +5 -7
  84. package/dist/types/core/index.d.ts +5 -5
  85. package/dist/types/core/invariants.d.ts +0 -29
  86. package/dist/types/core/lanes.d.ts +74 -86
  87. package/dist/types/core/owner.d.ts +3 -3
  88. package/dist/types/core/scheduler.d.ts +255 -331
  89. package/dist/types/core/types.d.ts +45 -85
  90. package/dist/types/core/verdict.d.ts +3 -0
  91. package/dist/types/flatten.d.ts +8 -0
  92. package/dist/types/index.d.ts +6 -4
  93. package/dist/types/reveal.d.ts +19 -0
  94. package/dist/types/signals.d.ts +35 -91
  95. package/dist/types/store/affects.d.ts +12 -0
  96. package/dist/types/store/hooks.d.ts +48 -0
  97. package/dist/types/store/index.d.ts +34 -15
  98. package/dist/types/store/optimistic.d.ts +23 -0
  99. package/dist/types/store/projection.d.ts +18 -0
  100. package/dist/types/store/reconcile.d.ts +3 -0
  101. package/dist/types/store/store.d.ts +132 -145
  102. package/dist/types/store/storePath.d.ts +1 -1
  103. package/dist/types/store/target.d.ts +151 -0
  104. package/dist/types/store/types.d.ts +84 -0
  105. package/package.json +1 -1
  106. package/dist/observe/core/optimistic.js +0 -669
  107. package/dist/observe/store/next/optimistic.js +0 -717
  108. package/dist/observe/store/next/projection.js +0 -266
  109. package/dist/observe/store/next/reconcile.js +0 -375
  110. package/dist/observe/store/next/store.js +0 -2608
  111. package/dist/prod/core/optimistic.js +0 -658
  112. package/dist/prod/store/next/optimistic.js +0 -710
  113. package/dist/prod/store/next/projection.js +0 -257
  114. package/dist/prod/store/next/reconcile.js +0 -373
  115. package/dist/prod/store/next/store.js +0 -2484
  116. package/dist/types/core/optimistic.d.ts +0 -6
  117. package/dist/types/store/next/optimistic.d.ts +0 -25
  118. package/dist/types/store/next/projection.d.ts +0 -27
  119. package/dist/types/store/next/reconcile.d.ts +0 -10
  120. package/dist/types/store/next/store.d.ts +0 -145
  121. package/dist/types/store/next/target.d.ts +0 -196
@@ -1,18 +1,24 @@
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,
2
+ bv as OBSERVE,
3
+ bF as setAttributionHooks,
4
+ bG as liveRootOwners,
5
+ N as NOT_PENDING,
6
+ ap as emitDiagnostic,
7
+ bH as ownerPath,
8
+ aD as reportDiagnostic,
9
+ bI as isSuppressed,
10
+ bJ as blockersOf,
11
+ bK as CONFIG_PLUMBING,
12
+ bL as anyExcluded,
13
+ bM as isExcluded,
14
+ bN as CONFIG_WIDE,
15
+ a as CONFIG_OVERRIDE,
16
+ C as CONFIG_GUESS,
17
+ G as GlobalQueue,
18
+ b as CONFIG_VERDICT,
19
+ bO as GRAPH_SIZE_WARN_AT,
20
+ bP as noteFanOut,
21
+ bQ as CONFIG_SUPERSEDES,
16
22
  aV as $REFRESH
17
23
  } from "./dev-shared.js";
18
24
 
@@ -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
@@ -1889,8 +1913,17 @@ function checkOptimisticRevert(el, shown, truth, how) {
1889
1913
  );
1890
1914
  }
1891
1915
  // --- Graph growth -----------------------------------------------------------------
1892
- /** Per route: the graph's size at its last `visits` settles, oldest first. */
1916
+ /**
1917
+ * Per route: at its last `visits` compared settles, oldest first, the
1918
+ * graph's size and the part of it that was already alive when that
1919
+ * navigation opened.
1920
+ */
1893
1921
  const routeCounts = new Map();
1922
+ /**
1923
+ * The route the app last arrived on: a navigation that stays on it (only
1924
+ * its params or query change) continues the visit and is not compared.
1925
+ */
1926
+ let visitRoute;
1894
1927
  /**
1895
1928
  * The live graph's size — a walk, not a counter: nothing is charged at node
1896
1929
  * creation, disposal, write or re-run; the engine asks at a navigation's
@@ -1905,26 +1938,42 @@ const routeCounts = new Map();
1905
1938
  * counted unless a subscription still reaches them.
1906
1939
  */
1907
1940
  function graphSize() {
1941
+ return walkGraph();
1942
+ }
1943
+ /**
1944
+ * The walk behind `graphSize()`. Given `before` alone, it adds every node it
1945
+ * meets to that set: what is alive as a navigation opens. Given `before` and
1946
+ * `survived`, it also counts into `survived` the nodes it meets that are in
1947
+ * the set, and an edge when both its ends are: what outlived the navigation.
1948
+ * Nothing is retained — the set is weak.
1949
+ */
1950
+ function walkGraph(before, survived) {
1908
1951
  const roots = liveRootOwners();
1909
- const size = { roots: roots.length, owners: 0, computations: 0, signals: 0, edges: 0 };
1952
+ const size = { roots: 0, owners: 0, computations: 0, signals: 0, edges: 0 };
1910
1953
  const seen = new Set();
1911
1954
  // A worklist, not recursion: a subscriber chain can be thousands deep.
1912
1955
  const work = [];
1913
1956
  const stack = [];
1957
+ const count = (node, key) => {
1958
+ size[key]++;
1959
+ if (survived !== undefined) {
1960
+ if (before.has(node)) survived[key]++;
1961
+ } else if (before !== undefined) before.add(node);
1962
+ };
1914
1963
  const meet = node => {
1915
1964
  if (seen.has(node)) return;
1916
1965
  seen.add(node);
1917
- if (typeof node._fn === "function") size.computations++;
1918
- else size.signals++;
1966
+ count(node, typeof node._fn === "function" ? "computations" : "signals");
1919
1967
  work.push(node);
1920
1968
  };
1921
1969
  for (const root of roots) {
1922
- size.owners++;
1970
+ count(root, "roots");
1971
+ count(root, "owners");
1923
1972
  // Iterative: a deep tree must not grow the call stack.
1924
1973
  let child = root._firstChild;
1925
1974
  for (;;) {
1926
1975
  while (child !== null) {
1927
- size.owners++;
1976
+ count(child, "owners");
1928
1977
  if (typeof child._fn === "function") meet(child);
1929
1978
  if (child._firstChild !== null) stack.push(child);
1930
1979
  child = child._nextSibling;
@@ -1943,10 +1992,20 @@ function graphSize() {
1943
1992
  for (let link = node._deps; link !== null; link = link._nextDep) {
1944
1993
  size.edges++;
1945
1994
  meet(link._dep);
1995
+ if (survived !== undefined && before.has(node) && before.has(link._dep)) survived.edges++;
1946
1996
  }
1947
1997
  }
1948
1998
  return size;
1949
1999
  }
2000
+ /**
2001
+ * openNavigation, while the growth check is on: what is alive as a
2002
+ * navigation opens, for its settle to tell apart from what it created.
2003
+ */
2004
+ function snapshotGraph() {
2005
+ const before = new WeakSet();
2006
+ walkGraph(before);
2007
+ return before;
2008
+ }
1950
2009
  /**
1951
2010
  * settleNavigation: the app has finished moving to a route — count the live
1952
2011
  * graph. One record per settle for a listener; the growth check compares
@@ -1955,43 +2014,77 @@ function graphSize() {
1955
2014
  * `createRoot` in an effect with no dispose, a subscription a component
1956
2015
  * registered outside its owner. Nothing here runs per node; the walk is
1957
2016
  * the cost, at navigation cadence.
2017
+ *
2018
+ * A count taken at an arrival also holds everything the visit just created,
2019
+ * so a page whose correctly owned content is bigger each visit climbs too.
2020
+ * What tells the two apart is what outlived the navigation: the nodes alive
2021
+ * when it opened (`NavState.before`) that are still alive at its settle. A
2022
+ * visit's own content is not among them; what an earlier visit left behind
2023
+ * is, whatever the content does. A series reports only when both climb.
1958
2024
  */
1959
2025
  const GRAPH_SERIES = ["owners", "computations", "signals", "edges"];
1960
- function trackGraph(navigation) {
2026
+ function trackGraph(state) {
2027
+ const navigation = state.event;
2028
+ const before = state.before;
2029
+ state.before = undefined;
1961
2030
  const cfg = options.graphGrowth;
1962
2031
  if (cfg === false && !records.observed("graph")) return;
1963
- const size = graphSize();
2032
+ const survived = before && {
2033
+ roots: 0,
2034
+ owners: 0,
2035
+ computations: 0,
2036
+ signals: 0,
2037
+ edges: 0
2038
+ };
2039
+ const size = walkGraph(before, survived);
1964
2040
  const route = navigation.name ?? navigation.to;
1965
2041
  const event = { at: now(), ...size, navigation };
1966
2042
  if (route !== undefined) event.route = route;
1967
2043
  records.emit("graph", event, undefined);
1968
2044
  if (cfg === false || route === undefined) return;
2045
+ // A navigation that stays on the route pattern continues the visit: the
2046
+ // page it kept outlived the move by design, so comparing it would read the
2047
+ // page itself as growth. Only arrivals that enter the route are compared —
2048
+ // a leak made during a visit is still alive when the next one starts. A
2049
+ // superseded navigation never showed, so the app is still where it was.
2050
+ const left = visitRoute;
2051
+ if (navigation.outcome !== "superseded") visitRoute = route;
2052
+ // The initial declaration is not a visit (it settles before route content
2053
+ // mounts — the same gate the feedback folds apply as `event.initial !== true`);
2054
+ // `initial` is `true | undefined`, set only by openNavigation, so truthiness is exact.
2055
+ // A navigation opened before the check was on has no snapshot to compare.
2056
+ if (navigation.initial || route === left || survived === undefined) return;
1969
2057
  let history = routeCounts.get(route);
1970
2058
  if (history === undefined) routeCounts.set(route, (history = []));
1971
- history.push(size);
2059
+ history.push({ size, survived });
1972
2060
  if (history.length > cfg.visits) history.shift();
1973
2061
  if (history.length < cfg.visits) return;
1974
- // Any series climbing on every visit to the ratio is growth; which ones
1975
- // did says what leaked — computations with owners flat is an ownerless
1976
- // effect per visit, edges alone is a subscription per visit to something
1977
- // long-lived, owners is an undisposed root.
1978
- const grew = GRAPH_SERIES.filter(key => {
1979
- for (let i = 1; i < history.length; i++)
1980
- if (history[i][key] <= history[i - 1][key]) return false;
1981
- return history[history.length - 1][key] >= history[0][key] * cfg.ratio;
1982
- });
2062
+ const totals = history.map(h => h.size);
2063
+ const kept = history.map(h => h.survived);
2064
+ const climbs = (series, key) => {
2065
+ for (let i = 1; i < series.length; i++) if (series[i][key] <= series[i - 1][key]) return false;
2066
+ return series[series.length - 1][key] >= series[0][key] * cfg.ratio;
2067
+ };
2068
+ // Any series climbing on every visit to the ratio — in the totals and in
2069
+ // what outlived each navigation — is growth; which ones did says what
2070
+ // leaked — computations with owners flat is an ownerless effect per visit,
2071
+ // edges alone is a subscription per visit to something long-lived, owners
2072
+ // is an undisposed root.
2073
+ const grew = GRAPH_SERIES.filter(key => climbs(totals, key) && climbs(kept, key));
1983
2074
  if (grew.length === 0) return;
1984
2075
  // The count is the whole graph's, so a leak shows at every route's settle;
1985
2076
  // the first route to complete its climb reports, naming the others seen.
1986
2077
  const routes = [...routeCounts.keys()];
1987
- const series = grew.map(key => `${key} ${history.map(h => h[key]).join(" → ")}`).join("; ");
2078
+ const join = (series, key) => series.map(h => h[key]).join(" → ");
2079
+ const series = grew.map(key => `${key} ${join(kept, key)} (of ${join(totals, key)})`).join("; ");
1988
2080
  const shape = grew.includes("owners")
1989
2081
  ? "a createRoot() in an effect or handler with no dispose, or a Portal or panel mounted per visit"
1990
2082
  : grew.includes("computations")
1991
2083
  ? "an effect or memo created with no owner (a module-level or callback createEffect) that only its sources keep alive"
1992
2084
  : "a subscription per visit to something long-lived — a global store or signal read by a computation that outlives the visit";
1993
2085
  const message =
1994
- `[GRAPH_GROWTH] the live graph grew on ${cfg.visits} consecutive visits to ${route}: ${series} ` +
2086
+ `[GRAPH_GROWTH] the live graph grew on ${cfg.visits} consecutive visits to ${route}, and so did ` +
2087
+ `the part that outlived each navigation there: ${series} ` +
1995
2088
  `(${size.roots} roots)${routes.length > 1 ? `, across visits to ${routes.join(", ")}` : ""}. Each ` +
1996
2089
  `visit left something behind that the next did not reclaim — the shape says ${shape}. Dispose ` +
1997
2090
  `what a visit creates (onCleanup, or return the disposer from onSettled) and own it under the ` +
@@ -1999,7 +2092,8 @@ function trackGraph(navigation) {
1999
2092
  const data = {
2000
2093
  route,
2001
2094
  grew,
2002
- history: history.map(h => ({ ...h })),
2095
+ history: totals,
2096
+ survived: kept,
2003
2097
  roots: size.roots,
2004
2098
  routes
2005
2099
  };
@@ -2019,11 +2113,14 @@ const abandonWindows = new WeakMap();
2019
2113
  * One source abandoning flight after flight inside a window: every input
2020
2114
  * asked again and the earlier answers were thrown away — the
2021
2115
  * request-per-keystroke signature `feedback().flights[].abandoned` counts,
2022
- * as a finding while it happens. Warned once per window per source.
2116
+ * as a finding while it happens. Warned once per window per source. A
2117
+ * framework node superseding its own flights by design (CONFIG_SUPERSEDES)
2118
+ * is not judged; its flights are still recorded.
2023
2119
  */
2024
2120
  function checkAbandonedFlights(el, abandoned) {
2025
2121
  const cfg = options.abandonedFlights;
2026
- if (cfg === false || excludedNode(el)) return;
2122
+ // Only dev sets CONFIG_SUPERSEDES (see constants.ts).
2123
+ if (cfg === false || el._config & CONFIG_SUPERSEDES || excludedNode(el)) return;
2027
2124
  const at = Date.now();
2028
2125
  let win = abandonWindows.get(el);
2029
2126
  if (win === undefined || at - win.start > cfg.windowMs) {
@@ -2129,6 +2226,14 @@ const openNavs = new Set();
2129
2226
  let navigationLog = [];
2130
2227
  /** Drains completed since enable() — the clock `writeDrain` reads. */
2131
2228
  let drainSeq = 0;
2229
+ /**
2230
+ * Navigations settled in a landing, waiting for their graph count: the hook
2231
+ * fires before the landing's commits dispose the frame parked for the page
2232
+ * left, which the walk would still reach through the sources both pages read
2233
+ * (#3989). `land()` runs only inside `flush()`'s drain, so `flushEnd` counts
2234
+ * them, after those commits — the instant a one-drain navigation is counted.
2235
+ */
2236
+ const landedGraphs = [];
2132
2237
  /**
2133
2238
  * Copy what the router currently says onto the frame (what writes stamped —
2134
2239
  * `formatOrigin` reads it) and the event. Called when the frame opens, when a
@@ -2163,6 +2268,8 @@ function openNavigation(frame, ref) {
2163
2268
  held: false,
2164
2269
  writeDrain: drainSeq
2165
2270
  };
2271
+ // The initial declaration is never compared (see `trackGraph`).
2272
+ if (options.graphGrowth !== false && ref.initial !== true) state.before = snapshotGraph();
2166
2273
  syncNavigation(state);
2167
2274
  navStates.set(frame, state);
2168
2275
  openNavs.add(state);
@@ -2221,7 +2328,7 @@ function noteNavigationWrite(prior, record) {
2221
2328
  /** holdStart: `t`'s staged writes are parked — their navigations settle with `t`. */
2222
2329
  function markNavigationsHeld(t) {
2223
2330
  if (openNavs.size === 0) return;
2224
- for (const node of t._pendingNodes) {
2331
+ for (const node of t._nodes) {
2225
2332
  const origin = node._devChange?.origin;
2226
2333
  if (origin?.kind !== "navigation") continue;
2227
2334
  const state = navStates.get(origin);
@@ -2231,14 +2338,14 @@ function markNavigationsHeld(t) {
2231
2338
  /** transitionSettled: `t` commits — the navigations whose writes it staged are done. */
2232
2339
  function settleNavigations(t, hold) {
2233
2340
  if (openNavs.size === 0) return;
2234
- for (const node of t._pendingNodes) {
2341
+ for (const node of t._nodes) {
2235
2342
  const origin = node._devChange?.origin;
2236
2343
  if (origin?.kind !== "navigation") continue;
2237
2344
  const state = navStates.get(origin);
2238
2345
  if (state === undefined || !openNavs.has(state)) continue;
2239
2346
  // A navigation whose frame is still open when its transition commits
2240
2347
  // (`until()` inside the frame) settles here too: its writes are through.
2241
- settleNavigation(state, state.held ? "held" : "committed", hold);
2348
+ settleNavigation(state, state.held ? "held" : "committed", hold, true);
2242
2349
  }
2243
2350
  }
2244
2351
  let openFlush = null;
@@ -2354,11 +2461,14 @@ function trackFlushEnd() {
2354
2461
  drainSeq++;
2355
2462
  // The swaps this drain committed have rendered.
2356
2463
  displayFallbacks();
2464
+ // Landed first: they settled before anything this drain settles below.
2465
+ for (let i = 0; i < landedGraphs.length; i++) trackGraph(landedGraphs[i]);
2466
+ landedGraphs.length = 0;
2357
2467
  for (const state of openNavs)
2358
2468
  if (state.open === 0 && !state.held) settleNavigation(state, "committed");
2359
2469
  for (const state of openInteractions) maybeSettleInteraction(state);
2360
2470
  }
2361
- function settleNavigation(state, outcome, hold) {
2471
+ function settleNavigation(state, outcome, hold, landed) {
2362
2472
  if (!openNavs.has(state)) return;
2363
2473
  openNavs.delete(state);
2364
2474
  syncNavigation(state);
@@ -2371,16 +2481,19 @@ function settleNavigation(state, outcome, hold) {
2371
2481
  // route's responsiveness in the feedback tables. Consumers get the record.
2372
2482
  if (event.initial !== true) for (const f of folds) f.navigation?.(event);
2373
2483
  records.emit("navigation", event, undefined);
2374
- trackGraph(event);
2484
+ if (landed) landedGraphs.push(state);
2485
+ else trackGraph(state);
2375
2486
  // The interaction that performed it may have been waiting only on this.
2376
2487
  const under = openInteractionOf(event.interaction);
2377
2488
  if (under !== undefined) maybeSettleInteraction(under);
2378
2489
  }
2379
2490
  const interactionStates = new WeakMap();
2491
+ /** `InteractionRef.event` → the record its frames join (while `joinable`). Weak: the Event is never held. */
2492
+ const eventInteractions = new WeakMap();
2380
2493
  /** Opened, not yet settled. */
2381
2494
  const openInteractions = new Set();
2382
2495
  let interactionLog = [];
2383
- function openInteraction(frame, opened) {
2496
+ function openInteraction(frame, opened, key) {
2384
2497
  const event = {
2385
2498
  name: frame.name,
2386
2499
  at: frame.at,
@@ -2406,13 +2519,24 @@ function openInteraction(frame, opened) {
2406
2519
  heldIn: new Set(),
2407
2520
  held: false,
2408
2521
  excludedWrites: 0,
2409
- awaiting: false,
2410
- actioned: false
2522
+ awaiting: 0,
2523
+ actioned: false,
2524
+ joinable: key !== undefined
2411
2525
  };
2412
2526
  interactionStates.set(frame, state);
2413
2527
  openInteractions.add(state);
2414
2528
  interactionLog.push(event);
2415
2529
  if (interactionLog.length > options.historyLimit) interactionLog.shift();
2530
+ if (key !== undefined) {
2531
+ eventInteractions.set(key, state);
2532
+ // A microtask is too early: native dispatch runs microtasks between
2533
+ // listeners. The callback must not close over `key`: a throttled timer
2534
+ // would hold the Event strongly long past its dispatch.
2535
+ setTimeout(() => {
2536
+ state.joinable = false;
2537
+ if (openInteractions.has(state)) maybeSettleInteraction(state, state.settleAt);
2538
+ });
2539
+ }
2416
2540
  }
2417
2541
  /**
2418
2542
  * How long the record waits on a handler's returned promise before settling
@@ -2438,17 +2562,17 @@ function closeInteraction(frame, returned) {
2438
2562
  ? returned
2439
2563
  : null;
2440
2564
  if (thenable !== null) {
2441
- state.awaiting = true;
2565
+ state.awaiting++;
2442
2566
  let done = false;
2443
2567
  let timer;
2444
2568
  const settle = () => {
2445
2569
  if (done) return;
2446
2570
  done = true;
2447
2571
  if (timer !== undefined) clearTimeout(timer);
2448
- if (!openInteractions.has(state)) return;
2449
- state.awaiting = false;
2572
+ if (!openInteractions.has(state) || --state.awaiting > 0) return;
2450
2573
  const at = now();
2451
- state.event.continuationMs = at - end;
2574
+ // From the last handler's return: the frames of one event share a record.
2575
+ state.event.continuationMs = at - (state.opened + state.event.handlerMs);
2452
2576
  checkUntrackedAsyncHandler(state);
2453
2577
  maybeSettleInteraction(state, at);
2454
2578
  };
@@ -2528,7 +2652,7 @@ function noteInteractionNavigation(event) {
2528
2652
  /** holdStart: `t` parked writes — the interactions that performed them wait for `t`. */
2529
2653
  function markInteractionsHeld(t) {
2530
2654
  if (openInteractions.size === 0) return;
2531
- for (const node of t._pendingNodes) {
2655
+ for (const node of t._nodes) {
2532
2656
  const state = openInteractionOf(node._devChange?.origin);
2533
2657
  if (state !== undefined) {
2534
2658
  state.heldIn.add(t);
@@ -2551,12 +2675,16 @@ function settleInteractionsHeld(t, hold) {
2551
2675
  }
2552
2676
  function maybeSettleInteraction(state, end = now()) {
2553
2677
  const event = state.event;
2554
- if (state.open || state.awaiting || state.heldIn.size > 0) return;
2678
+ if (state.open || state.awaiting > 0 || state.heldIn.size > 0) return;
2555
2679
  // A drain must have committed the last write (the handler's, or a redirect
2556
2680
  // hop's after the click's own drain) — the handler returning is not the
2557
2681
  // screen having it.
2558
2682
  if (event.writes > 0 && drainSeq <= state.writeDrain) return;
2559
2683
  for (const nav of event.navigations) if (nav.outcome === undefined) return;
2684
+ if (state.joinable) {
2685
+ state.settleAt = end;
2686
+ return;
2687
+ }
2560
2688
  openInteractions.delete(state);
2561
2689
  // Every write went to an excluded subject and nothing of the app's ran: the
2562
2690
  // click was on the observer's own UI (a devtools panel's button). Not a
@@ -2653,6 +2781,12 @@ const engineHooks = {
2653
2781
  frame.prevValue,
2654
2782
  el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value
2655
2783
  );
2784
+ // The same exemption for memos: a run that committed `undefined` did its
2785
+ // work as a side effect (a projection's draft writes or reconcile, a
2786
+ // write from inside a memo), so core's `undefined === undefined` gate
2787
+ // proves nothing. Reported as changed, so no consumer counts it as waste.
2788
+ if (!changed && (el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value) === undefined)
2789
+ changed = true;
2656
2790
  if (frame.causes !== null)
2657
2791
  recordRerun(
2658
2792
  el,
@@ -2832,8 +2966,10 @@ function resetTracking() {
2832
2966
  stagedFallbacks.delete(DRAIN);
2833
2967
  trackingGen++;
2834
2968
  openNavs.clear();
2969
+ landedGraphs.length = 0;
2835
2970
  openInteractions.clear();
2836
2971
  routeCounts.clear();
2972
+ visitRoute = undefined;
2837
2973
  drainSeq = 0;
2838
2974
  originFrames.length = 0;
2839
2975
  effectStack.length = 0;