@solidjs/signals 2.0.0-rc.7 → 2.0.0-rc.8

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 (117) hide show
  1. package/dist/dev-shared.js +5243 -0
  2. package/dist/dev.attribution.js +2333 -0
  3. package/dist/dev.js +1229 -6834
  4. package/dist/observe/affects.js +127 -0
  5. package/dist/observe/attribution.js +1 -0
  6. package/dist/observe/boundaries.js +586 -0
  7. package/dist/observe/core/action.js +167 -0
  8. package/dist/observe/core/async.js +736 -0
  9. package/dist/observe/core/attribution-hooks.js +60 -0
  10. package/dist/observe/core/attribution.js +2318 -0
  11. package/dist/{types-cjs/core/constants.d.cts → observe/core/constants.js} +129 -73
  12. package/dist/{types-cjs/core/context.d.cts → observe/core/context.js} +37 -12
  13. package/dist/observe/core/core.js +1289 -0
  14. package/dist/observe/core/dev.js +232 -0
  15. package/dist/observe/core/effect.js +179 -0
  16. package/dist/observe/core/error.js +80 -0
  17. package/dist/observe/core/external.js +98 -0
  18. package/dist/observe/core/graph.js +155 -0
  19. package/dist/observe/core/heap.js +147 -0
  20. package/dist/observe/core/invariants.js +43 -0
  21. package/dist/observe/core/lanes.js +168 -0
  22. package/dist/observe/core/optimistic.js +438 -0
  23. package/dist/observe/core/owner.js +308 -0
  24. package/dist/observe/core/scheduler.js +1190 -0
  25. package/dist/observe/core/verdict.js +551 -0
  26. package/dist/observe/index.js +55 -0
  27. package/dist/observe/map.js +482 -0
  28. package/dist/{types-cjs/signals.d.cts → observe/signals.js} +295 -317
  29. package/dist/observe/store/index.js +38 -0
  30. package/dist/observe/store/next/optimistic.js +651 -0
  31. package/dist/observe/store/next/projection.js +242 -0
  32. package/dist/observe/store/next/reconcile.js +373 -0
  33. package/dist/observe/store/next/store.js +2213 -0
  34. package/dist/observe/store/next/target.js +51 -0
  35. package/dist/observe/store/store.js +323 -0
  36. package/dist/observe/store/storePath.js +103 -0
  37. package/dist/observe/store/utils.js +210 -0
  38. package/dist/prod/attribution.js +32 -0
  39. package/dist/prod/core/action.js +52 -32
  40. package/dist/prod/core/async.js +171 -127
  41. package/dist/prod/core/constants.js +24 -1
  42. package/dist/prod/core/context.js +3 -3
  43. package/dist/prod/core/core.js +434 -291
  44. package/dist/prod/core/effect.js +54 -34
  45. package/dist/prod/core/external.js +2 -2
  46. package/dist/prod/core/graph.js +32 -32
  47. package/dist/prod/core/heap.js +49 -44
  48. package/dist/prod/core/lanes.js +45 -38
  49. package/dist/prod/core/optimistic.js +207 -73
  50. package/dist/prod/core/owner.js +43 -39
  51. package/dist/prod/core/scheduler.js +319 -190
  52. package/dist/prod/core/verdict.js +67 -67
  53. package/dist/prod/index.js +9 -3
  54. package/dist/prod/map.js +292 -141
  55. package/dist/prod/signals.js +7 -10
  56. package/dist/prod/store/next/optimistic.js +140 -125
  57. package/dist/prod/store/next/projection.js +3 -3
  58. package/dist/prod/store/next/reconcile.js +87 -84
  59. package/dist/prod/store/next/store.js +524 -280
  60. package/dist/prod/store/next/target.js +32 -10
  61. package/dist/prod/store/store.js +12 -14
  62. package/dist/prod/store/utils.js +36 -27
  63. package/dist/types/attribution.d.ts +15 -0
  64. package/dist/types/attribution.prod.d.ts +13 -0
  65. package/dist/types/core/async.d.ts +1 -1
  66. package/dist/types/core/attribution-hooks.d.ts +119 -6
  67. package/dist/types/core/attribution.d.ts +208 -53
  68. package/dist/types/core/constants.d.ts +23 -0
  69. package/dist/types/core/core.d.ts +7 -0
  70. package/dist/types/core/dev.d.ts +124 -26
  71. package/dist/types/core/effect.d.ts +8 -1
  72. package/dist/types/core/index.d.ts +2 -1
  73. package/dist/types/core/lanes.d.ts +8 -1
  74. package/dist/types/core/scheduler.d.ts +60 -0
  75. package/dist/types/core/types.d.ts +29 -9
  76. package/dist/types/index.d.ts +9 -2
  77. package/dist/types/map.d.ts +2 -0
  78. package/dist/types/store/index.d.ts +1 -0
  79. package/dist/types/store/next/store.d.ts +5 -0
  80. package/dist/types/store/next/target.d.ts +36 -9
  81. package/dist/types/store/utils.d.ts +7 -0
  82. package/package.json +21 -17
  83. package/dist/node.cjs +0 -10541
  84. package/dist/node.dev.cjs +0 -13724
  85. package/dist/types-cjs/affects.d.cts +0 -47
  86. package/dist/types-cjs/boundaries.d.cts +0 -175
  87. package/dist/types-cjs/core/action.d.cts +0 -64
  88. package/dist/types-cjs/core/async.d.cts +0 -23
  89. package/dist/types-cjs/core/attribution-hooks.d.cts +0 -127
  90. package/dist/types-cjs/core/attribution.d.cts +0 -495
  91. package/dist/types-cjs/core/core.d.cts +0 -185
  92. package/dist/types-cjs/core/dev.d.cts +0 -136
  93. package/dist/types-cjs/core/effect.d.cts +0 -30
  94. package/dist/types-cjs/core/error.d.cts +0 -56
  95. package/dist/types-cjs/core/external.d.cts +0 -15
  96. package/dist/types-cjs/core/graph.d.cts +0 -28
  97. package/dist/types-cjs/core/heap.d.cts +0 -24
  98. package/dist/types-cjs/core/index.d.cts +0 -13
  99. package/dist/types-cjs/core/invariants.d.cts +0 -59
  100. package/dist/types-cjs/core/lanes.d.cts +0 -54
  101. package/dist/types-cjs/core/optimistic.d.cts +0 -6
  102. package/dist/types-cjs/core/owner.d.cts +0 -124
  103. package/dist/types-cjs/core/scheduler.d.cts +0 -236
  104. package/dist/types-cjs/core/types.d.cts +0 -204
  105. package/dist/types-cjs/core/verdict.d.cts +0 -2
  106. package/dist/types-cjs/index.d.cts +0 -10
  107. package/dist/types-cjs/map.d.cts +0 -69
  108. package/dist/types-cjs/package.json +0 -3
  109. package/dist/types-cjs/store/index.d.cts +0 -18
  110. package/dist/types-cjs/store/next/optimistic.d.cts +0 -25
  111. package/dist/types-cjs/store/next/projection.d.cts +0 -8
  112. package/dist/types-cjs/store/next/reconcile.d.cts +0 -10
  113. package/dist/types-cjs/store/next/store.d.cts +0 -121
  114. package/dist/types-cjs/store/next/target.d.cts +0 -166
  115. package/dist/types-cjs/store/store.d.cts +0 -143
  116. package/dist/types-cjs/store/storePath.d.cts +0 -58
  117. package/dist/types-cjs/store/utils.d.cts +0 -74
@@ -0,0 +1,2333 @@
1
+ import {
2
+ aF as $REFRESH,
3
+ bp as setAttributionHooks,
4
+ b as NOT_PENDING,
5
+ bq as ownerPath,
6
+ al as reportDiagnostic,
7
+ am as emitDiagnostic,
8
+ br as isSuppressed,
9
+ bs as anyExcluded,
10
+ bt as isExcluded,
11
+ bu as GRAPH_SIZE_WARN_AT
12
+ } from "./dev-shared.js";
13
+
14
+ /**
15
+ * Under an owner the observer marked as its own (`OBSERVE.exclude`): the
16
+ * engine records nothing about the node. Cached per node once any exclusion
17
+ * exists; before that the answer is a flag read.
18
+ */
19
+ function excludedNode(el) {
20
+ if (!anyExcluded()) return false;
21
+ const node = el;
22
+ if (node._devExcluded === undefined) node._devExcluded = isExcluded(el);
23
+ return node._devExcluded;
24
+ }
25
+ let changeSeq = 0;
26
+ let runSeq = 0;
27
+ const defaultOptions = {
28
+ log: true,
29
+ stacks: false,
30
+ historyLimit: 200,
31
+ hotRuns: { count: 120, windowMs: 1000 },
32
+ wideDeps: 30,
33
+ hotTime: { budgetMs: 8, windowMs: 1000 },
34
+ unstableMemos: 4,
35
+ wideWrites: 250,
36
+ waterfalls: { minFlightMs: 50 },
37
+ holds: { infoMs: 100, warnMs: 200 },
38
+ longHolds: { infoMs: 500, warnMs: 1000 }
39
+ };
40
+ let options = { ...defaultOptions };
41
+ let history = [];
42
+ const recordListeners = {
43
+ rerun: new Set(),
44
+ interaction: new Set(),
45
+ hold: new Set(),
46
+ navigation: new Set()
47
+ };
48
+ function emitRecord(type, record) {
49
+ for (const listener of recordListeners[type]) listener(record);
50
+ }
51
+ function clearListeners() {
52
+ for (const type in recordListeners) recordListeners[type].clear();
53
+ }
54
+ const now = typeof performance !== "undefined" ? () => performance.now() : () => Date.now();
55
+ const frames = [];
56
+ const scopeCosts = new Map();
57
+ const writeCosts = new Map();
58
+ function rootsOf(causes, out) {
59
+ for (const c of causes) {
60
+ if (c.kind === "derived" && c.causes && c.causes.length > 0) rootsOf(c.causes, out);
61
+ else out.add(c.name);
62
+ }
63
+ }
64
+ function recordCosts(event) {
65
+ let scope = scopeCosts.get(event.node);
66
+ if (scope === undefined) {
67
+ scope = {
68
+ name: event.nodeName,
69
+ kind: event.nodeKind,
70
+ runs: 0,
71
+ selfMs: 0,
72
+ wastedMs: 0,
73
+ overlayMs: 0
74
+ };
75
+ scopeCosts.set(event.node, scope);
76
+ }
77
+ scope.runs++;
78
+ scope.selfMs += event.selfMs;
79
+ if (event.phase !== "plain") scope.overlayMs += event.selfMs;
80
+ else if (!event.changed && !event.held) scope.wastedMs += event.selfMs;
81
+ const roots = new Set();
82
+ rootsOf(event.causes, roots);
83
+ for (const name of roots) {
84
+ let write = writeCosts.get(name);
85
+ if (write === undefined) writeCosts.set(name, (write = { name, runs: 0, downstreamMs: 0 }));
86
+ write.runs++;
87
+ write.downstreamMs += event.selfMs;
88
+ }
89
+ }
90
+ function nodeName(node) {
91
+ return node._name ?? "anonymous";
92
+ }
93
+ function preview(v) {
94
+ if (v === null) return "null";
95
+ switch (typeof v) {
96
+ case "undefined":
97
+ return "undefined";
98
+ case "string":
99
+ return JSON.stringify(v.length > 40 ? v.slice(0, 40) + "…" : v);
100
+ case "number":
101
+ case "boolean":
102
+ case "bigint":
103
+ return String(v);
104
+ case "function":
105
+ return "[function]";
106
+ case "symbol":
107
+ return v.toString();
108
+ default:
109
+ return Array.isArray(v) ? `Array(${v.length})` : `[${v.constructor?.name ?? "object"}]`;
110
+ }
111
+ }
112
+ function captureStack() {
113
+ if (!options.stacks) return undefined;
114
+ const raw = new Error().stack?.split("\n") ?? [];
115
+ // Drop the message line and every frame inside the reactive core; the first
116
+ // remaining frames are the user code that performed the write.
117
+ return raw
118
+ .slice(1)
119
+ .filter(line => !/(?:^|[/\\])(?:packages[/\\])?signals[/\\](src|dist)[/\\]/.test(line))
120
+ .slice(0, 3)
121
+ .map(line => line.trim());
122
+ }
123
+ /** Sentinel for "no value transition to record" (refresh() stamps). */
124
+ const NO_VALUES = Symbol("no-values");
125
+ // --- Provenance -------------------------------------------------------------
126
+ //
127
+ // Who performed a write is not a graph fact — the graph only sees the write.
128
+ // The engine keeps an ambient answer: a stack of imperative frames the core
129
+ // announces (effect callbacks, action steps) and the interaction the web
130
+ // runtime declares around event dispatch. A write stamps the innermost frame;
131
+ // frames nested under an interaction carry it. Effects run in a later flush
132
+ // than the click that caused them, so their frame inherits the interaction
133
+ // from the run's cause chain instead (recorded at recomputeEnd).
134
+ const EXTERNAL_ORIGIN = { kind: "external" };
135
+ const originFrames = [];
136
+ /** Per action invocation (keyed by its iterator): the interaction its first step ran under. */
137
+ const actionInteractions = new WeakMap();
138
+ /**
139
+ * The interaction frame the core's `withInteraction` opened (via the
140
+ * `interactionStart`/`interactionEnd` hooks) for the duration of a handler.
141
+ * Frames nest strictly, so the enclosing one is kept on a stack to restore.
142
+ * The core pins the engine per frame: an `interactionEnd` can arrive after
143
+ * `disable()` cleared the stack, so popping an empty stack is tolerated.
144
+ */
145
+ let currentInteraction = null;
146
+ const interactionStack = [];
147
+ function interactionStart(ref) {
148
+ interactionStack.push(currentInteraction);
149
+ const origin = { kind: "interaction", name: ref.type, at: ref.at ?? now() };
150
+ if (ref.target) origin.target = ref.target;
151
+ currentInteraction = origin;
152
+ openInteraction(origin);
153
+ }
154
+ function interactionEnd() {
155
+ const closing = currentInteraction;
156
+ currentInteraction = interactionStack.length ? interactionStack.pop() : null;
157
+ if (closing !== null) closeInteraction(closing);
158
+ }
159
+ /** The interaction an origin runs under (itself, when it is one). */
160
+ function interactionOf(origin) {
161
+ if (origin === undefined) return undefined;
162
+ return origin.kind === "interaction" ? origin : origin.interaction;
163
+ }
164
+ /** The interaction a cause list traces back to — root writes only, derived links walked. */
165
+ function interactionIn(causes) {
166
+ for (const c of causes) {
167
+ const found =
168
+ c.kind === "derived"
169
+ ? c.causes !== undefined
170
+ ? interactionIn(c.causes)
171
+ : undefined
172
+ : interactionOf(c.origin);
173
+ if (found !== undefined) return found;
174
+ }
175
+ return undefined;
176
+ }
177
+ function currentOrigin() {
178
+ const frame = originFrames[originFrames.length - 1];
179
+ if (frame !== undefined) return frame;
180
+ return currentInteraction ?? EXTERNAL_ORIGIN;
181
+ }
182
+ const effectFrames = new WeakMap();
183
+ /** The interaction the innermost open frame runs under, else the ambient one. */
184
+ function enclosingInteraction() {
185
+ const top = originFrames[originFrames.length - 1];
186
+ return (top !== undefined ? interactionOf(top) : undefined) ?? currentInteraction ?? undefined;
187
+ }
188
+ function pushFrame(kind, name, interaction, effect) {
189
+ const frame = { kind };
190
+ if (name) frame.name = name;
191
+ const under = interaction ?? currentInteraction ?? undefined;
192
+ if (under !== undefined) frame.interaction = under;
193
+ if (effect !== undefined) {
194
+ const node = effect;
195
+ if (node._devRunSeq !== undefined) frame.run = node._devRunSeq;
196
+ effectFrames.set(frame, { node: effect, causes: node._devRunCauses });
197
+ }
198
+ originFrames.push(frame);
199
+ }
200
+ function popFrame(kind) {
201
+ // Frames are strictly nested; a mismatch means enable() landed mid-frame
202
+ // (the opener never pushed) — leave the stack alone rather than pop a stranger.
203
+ const top = originFrames[originFrames.length - 1];
204
+ if (top !== undefined && top.kind === kind) originFrames.pop();
205
+ }
206
+ /**
207
+ * `withOrigin` opened a declared frame. The frame object IS the origin every
208
+ * write inside stamps, and the key the navigation record hangs off (see
209
+ * "Navigations" below), so a hold or re-run that later resolves a write's
210
+ * origin lands on the same record. It runs under the interaction of the frame
211
+ * it opened inside — a `navigate()` from an action step, whose ambient
212
+ * interaction is long gone but whose frame remembers it — else the ambient
213
+ * one (a link click's handler).
214
+ */
215
+ function originStart(ref) {
216
+ // A redirect hop re-enters the pending navigation's frame — the same object,
217
+ // so its writes stamp the same origin and replace the pending write without
218
+ // superseding it (see "Navigations").
219
+ if (ref.redirect !== undefined && ref.redirect > 0) {
220
+ const pending = lastOpenNavigation();
221
+ if (pending !== undefined) {
222
+ redirectNavigation(pending, ref);
223
+ originFrames.push(pending.event.origin);
224
+ return;
225
+ }
226
+ }
227
+ const frame = { kind: ref.kind, at: ref.at ?? now() };
228
+ if (ref.from !== undefined) frame.from = ref.from;
229
+ const under = enclosingInteraction();
230
+ if (under !== undefined) frame.interaction = under;
231
+ originFrames.push(frame);
232
+ openNavigation(frame, ref);
233
+ }
234
+ function originEnd() {
235
+ const top = originFrames[originFrames.length - 1];
236
+ popFrame("navigation");
237
+ if (top !== undefined && top.kind === "navigation") closeNavigation(top);
238
+ }
239
+ /** `click on button#next "Next →"`, `effect "syncTitle"`, `action "save"`, `navigation to /users/:id`, … */
240
+ function formatOrigin(origin) {
241
+ switch (origin.kind) {
242
+ case "interaction":
243
+ return `${origin.name} on ${origin.target ?? "an element"}`;
244
+ case "effect":
245
+ return `effect${origin.name ? ` "${origin.name}"` : ""}`;
246
+ case "action":
247
+ return `action${origin.name ? ` "${origin.name}"` : ""}`;
248
+ case "async":
249
+ return `async landing${origin.name ? ` on "${origin.name}"` : ""}`;
250
+ case "navigation": {
251
+ // The route pattern is the name consumers group by; the concrete path
252
+ // follows when it adds information, then the destinations a redirect
253
+ // chain abandoned on the way.
254
+ const name = origin.name ?? origin.to;
255
+ if (name === undefined) return "navigation";
256
+ const notes = [];
257
+ if (origin.to !== undefined && origin.to !== name) notes.push(origin.to);
258
+ const redirects = navStates.get(origin)?.event.redirects;
259
+ if (redirects !== undefined)
260
+ notes.push(
261
+ `redirected from ${redirects.map(hop => hop.to ?? hop.name ?? "?").join(" → ")}`
262
+ );
263
+ return `navigation to ${name}${notes.length > 0 ? ` (${notes.join(", ")})` : ""}`;
264
+ }
265
+ default:
266
+ return "outside the reactive system";
267
+ }
268
+ }
269
+ /** Record a root change (setSignal / refresh / async landing) on the node. */
270
+ /** Live subscriber count, walked on demand — the core keeps no counter. */
271
+ function countSubscribers(node) {
272
+ let n = 0;
273
+ for (let s = node._subs; s !== null; s = s._nextSub) n++;
274
+ return n;
275
+ }
276
+ /**
277
+ * Written-fan-out warning — the engine's lower-bar sibling of the always-on
278
+ * HUGE_FAN_OUT (see dev.ts): a committed root invalidation reaching hundreds
279
+ * of subscribers re-runs all of them this flush. Counts the subscriber list
280
+ * itself (an engine-only walk, on the write; the core keeps no per-node
281
+ * count — a live `_subCount` was a post-construction field that forked node
282
+ * shapes). Once per node; re-warns only when the subscriber count has
283
+ * doubled since the last warning. Stops at GRAPH_SIZE_WARN_AT, where
284
+ * HUGE_FAN_OUT takes over, so the two never fire for the same write.
285
+ */
286
+ function checkWideWrite(node, kind) {
287
+ const limit = options.wideWrites;
288
+ if (typeof limit !== "number") return;
289
+ const subs = countSubscribers(node);
290
+ const attributed = node;
291
+ if (subs < limit || subs >= GRAPH_SIZE_WARN_AT) return;
292
+ if (subs < (attributed._devWideWriteWarnedAt ?? 0) * 2) return;
293
+ attributed._devWideWriteWarnedAt = subs;
294
+ const verb =
295
+ kind === "refresh" ? "refresh of" : kind === "async" ? "async landing on" : "write to";
296
+ const message =
297
+ `[WIDE_WRITE] ${verb} "${nodeName(node)}" reached ${subs} subscribers — every one ` +
298
+ `re-runs this flush. If consumers ask keyed questions of this value (for example every ` +
299
+ `row comparing against one selected id), invert it: keep the answer in a store used as a ` +
300
+ `map keyed by id, so each consumer reads its own key and only the keys that flipped update.`;
301
+ reportDiagnostic(
302
+ emitDiagnostic(
303
+ {
304
+ code: "WIDE_WRITE",
305
+ kind: "perf",
306
+ severity: "warn",
307
+ message,
308
+ nodeName: nodeName(node),
309
+ data: { subscribers: subs, write: kind }
310
+ },
311
+ node
312
+ )
313
+ );
314
+ }
315
+ function stampWrite(node, kind, prev = NO_VALUES, value = NO_VALUES) {
316
+ const record = { seq: ++changeSeq, kind, name: nodeName(node) };
317
+ if (value !== NO_VALUES) {
318
+ record.prev = prev === NO_VALUES ? undefined : preview(prev);
319
+ record.value = preview(value);
320
+ }
321
+ record.origin = kind === "async" ? asyncOrigin(node) : currentOrigin();
322
+ record.at = now();
323
+ record.stack = captureStack();
324
+ const prior = node._devChange;
325
+ node._devChange = record;
326
+ noteNavigationWrite(prior, record);
327
+ noteInteractionWrite(record.origin);
328
+ if (kind === "write") trackEffectWrite(node, record, value);
329
+ // stampWrite is the single funnel for committed root invalidations (sync
330
+ // writes, refresh(), async landings), which makes it the one place the
331
+ // written-fan-out check needs to live.
332
+ checkWideWrite(node, kind);
333
+ }
334
+ /** Record a derived change (memo produced a new value) with its causes. */
335
+ function stampDerived(node, causes) {
336
+ node._devChange = {
337
+ seq: ++changeSeq,
338
+ kind: "derived",
339
+ name: nodeName(node),
340
+ causes
341
+ };
342
+ }
343
+ /**
344
+ * Collect the deps whose committed change is newer than this node's previous
345
+ * run. Called at recompute entry, while `_deps` still holds the previous
346
+ * run's links. A refresh() stamp on the node itself also counts — that is a
347
+ * self-invalidation, not a dep change.
348
+ */
349
+ function collectCauses(el) {
350
+ const seen = el._devSeenSeq ?? 0;
351
+ const causes = [];
352
+ const self = el._devChange;
353
+ if (self !== undefined && self.seq > seen && self.kind === "refresh") causes.push(self);
354
+ for (let l = el._deps; l !== null; l = l._nextDep) {
355
+ const change = l._dep._devChange;
356
+ if (change !== undefined && change.seq > seen) causes.push(change);
357
+ }
358
+ return causes;
359
+ }
360
+ /** Advance the node's seen-cursor to the present. Call after every run. */
361
+ function markSeen(el) {
362
+ el._devSeenSeq = changeSeq;
363
+ }
364
+ /** Snapshot the node's current dep identities (call before a run replaces them). */
365
+ function captureDeps(el) {
366
+ const deps = [];
367
+ for (let l = el._deps; l !== null; l = l._nextDep) deps.push(l._dep);
368
+ return deps;
369
+ }
370
+ /**
371
+ * Wide-scope warning — the coarse-read / helper-leak signature: one scope
372
+ * subscribed to dozens of sources re-runs when ANY of them change. Fired from
373
+ * recordRerun for re-runs and directly from recompute for creation runs (a
374
+ * memo can be born too wide). Re-warns only on 50% further growth.
375
+ */
376
+ function checkDepWidth(el) {
377
+ const limit = options.wideDeps;
378
+ if (limit === false) return;
379
+ let count = 0;
380
+ const names = [];
381
+ for (let l = el._deps; l !== null; l = l._nextDep) {
382
+ count++;
383
+ if (names.length < 12) names.push(nodeName(l._dep));
384
+ }
385
+ const node = el;
386
+ if (count < limit || count < (node._devWideWarnedAt ?? 0) * 1.5) return;
387
+ node._devWideWarnedAt = count;
388
+ const kind = el._type ? "effect" : "memo";
389
+ const message =
390
+ `[WIDE_SCOPE_DEPS] ${kind} "${nodeName(el)}" is subscribed to ${count} sources — ` +
391
+ `it re-runs when any of them change. Narrow its reads or split it into smaller memos. ` +
392
+ `Sources: ${names.join(", ")}${count > names.length ? ", …" : ""}`;
393
+ reportDiagnostic(
394
+ emitDiagnostic(
395
+ {
396
+ code: "WIDE_SCOPE_DEPS",
397
+ kind: "perf",
398
+ severity: "warn",
399
+ message,
400
+ nodeName: nodeName(el),
401
+ data: { depCount: count, deps: names }
402
+ },
403
+ el
404
+ )
405
+ );
406
+ }
407
+ const hotCauses = new Map();
408
+ const HOT_FANOUT_FIRST_MILESTONE = 5;
409
+ /**
410
+ * Hot-scope warning — flags a scope that re-ran more than `count` times
411
+ * inside one `windowMs` window. Warned once per window, with the most recent
412
+ * cause chain named so the leaking signal is identified in the message.
413
+ * Fan-out spam is folded per root cause (see HotCauseWindow above).
414
+ */
415
+ function checkHotRuns(el, event) {
416
+ const cfg = options.hotRuns;
417
+ if (cfg === false) return;
418
+ const node = el;
419
+ const now = Date.now();
420
+ if (node._devWinStart === undefined || now - node._devWinStart > cfg.windowMs) {
421
+ node._devWinStart = now;
422
+ node._devWinCount = 0;
423
+ node._devHotWarned = false;
424
+ }
425
+ node._devWinCount = (node._devWinCount ?? 0) + 1;
426
+ if (node._devHotWarned || node._devWinCount < cfg.count) return;
427
+ node._devHotWarned = true;
428
+ // Root-cause key: the set of originating writes behind this scope's latest
429
+ // re-run. Scopes hot from the SAME roots share one aggregation window.
430
+ const roots = new Set();
431
+ rootsOf(event.causes, roots);
432
+ const causeKey = roots.size > 0 ? [...roots].sort().join(", ") : "(untracked)";
433
+ let window = hotCauses.get(causeKey);
434
+ if (window === undefined || now - window.winStart > cfg.windowMs) {
435
+ window = { winStart: now, scopes: 0, runs: 0, nextMilestone: HOT_FANOUT_FIRST_MILESTONE };
436
+ hotCauses.set(causeKey, window);
437
+ }
438
+ window.scopes++;
439
+ window.runs += node._devWinCount;
440
+ if (window.scopes === 1) {
441
+ const rootCause = event.causes.map(c => `"${c.name}" (${c.kind})`).join(", ");
442
+ const message =
443
+ `[HOT_SCOPE_RERUNS] ${event.nodeKind} "${event.nodeName}" re-ran ${node._devWinCount} times ` +
444
+ `in ${Math.max(1, now - node._devWinStart)}ms — a hot signal is likely leaking into this ` +
445
+ `scope. Latest cause: ${rootCause || "(untracked pull)"}`;
446
+ reportDiagnostic(
447
+ emitDiagnostic(
448
+ {
449
+ code: "HOT_SCOPE_RERUNS",
450
+ kind: "perf",
451
+ severity: "warn",
452
+ message,
453
+ nodeName: event.nodeName,
454
+ data: {
455
+ runs: node._devWinCount,
456
+ windowMs: cfg.windowMs,
457
+ causes: event.causes.map(c => c.name)
458
+ }
459
+ },
460
+ el
461
+ )
462
+ );
463
+ return;
464
+ }
465
+ // Additional scopes hot from the same cause: silent until a milestone —
466
+ // the culprit is the cause, and it has already been named once.
467
+ if (window.scopes < window.nextMilestone) return;
468
+ window.nextMilestone *= 10;
469
+ const message =
470
+ `[HOT_SCOPE_FANOUT] ${window.scopes} scopes have gone hot (${window.runs} re-runs) within ` +
471
+ `${cfg.windowMs}ms, all driven by ${causeKey} — one hot cause is re-running a large part ` +
472
+ `of the graph. Per-scope warnings are suppressed; fix the cause. If consumers ask keyed ` +
473
+ `questions of it, invert it: a store used as a map keyed by id, one key per consumer.`;
474
+ // The subject is the shared CAUSE, not this victim scope — no single owner
475
+ // path locates it, so the event carries none.
476
+ reportDiagnostic(
477
+ emitDiagnostic(
478
+ {
479
+ code: "HOT_SCOPE_FANOUT",
480
+ kind: "perf",
481
+ severity: "warn",
482
+ message,
483
+ nodeName: causeKey,
484
+ data: { cause: causeKey, scopes: window.scopes, runs: window.runs, windowMs: cfg.windowMs }
485
+ },
486
+ null
487
+ )
488
+ );
489
+ }
490
+ /**
491
+ * Time-budget warning — the counterpart of checkHotRuns for the
492
+ * few-but-expensive scope: warns when one scope's summed self-time within a
493
+ * window exceeds the budget. Warned once per window.
494
+ */
495
+ function checkHotTime(el, event) {
496
+ const cfg = options.hotTime;
497
+ if (cfg === false) return;
498
+ const node = el;
499
+ const at = now();
500
+ if (node._devTimeWinStart === undefined || at - node._devTimeWinStart > cfg.windowMs) {
501
+ node._devTimeWinStart = at;
502
+ node._devTimeWinMs = 0;
503
+ node._devTimeWarned = false;
504
+ }
505
+ node._devTimeWinMs = (node._devTimeWinMs ?? 0) + event.selfMs;
506
+ if (node._devTimeWarned || node._devTimeWinMs < cfg.budgetMs) return;
507
+ node._devTimeWarned = true;
508
+ const rootCause = event.causes.map(c => `"${c.name}" (${c.kind})`).join(", ");
509
+ const message =
510
+ `[HOT_SCOPE_TIME] ${event.nodeKind} "${event.nodeName}" spent ` +
511
+ `${node._devTimeWinMs.toFixed(1)}ms of compute inside one ${cfg.windowMs}ms window ` +
512
+ `(budget ${cfg.budgetMs}ms). Latest cause: ${rootCause || "(untracked pull)"}`;
513
+ reportDiagnostic(
514
+ emitDiagnostic(
515
+ {
516
+ code: "HOT_SCOPE_TIME",
517
+ kind: "perf",
518
+ severity: "warn",
519
+ message,
520
+ nodeName: event.nodeName,
521
+ data: {
522
+ spentMs: node._devTimeWinMs,
523
+ budgetMs: cfg.budgetMs,
524
+ windowMs: cfg.windowMs,
525
+ causes: event.causes.map(c => c.name)
526
+ }
527
+ },
528
+ el
529
+ )
530
+ );
531
+ }
532
+ function recordRerun(el, frame, timing, changed, phase, held) {
533
+ const causes = frame.causes;
534
+ const prevDeps = frame.prevDeps;
535
+ const node = el;
536
+ const prevCauses = node._devRunCauses;
537
+ if (excludedNode(el)) {
538
+ // The observer's own computation: keep the per-node bookkeeping its
539
+ // effect phase reads (see effectRunStart) and record nothing.
540
+ node._devRunInteraction = frame.interaction;
541
+ node._devRunSeq = undefined;
542
+ node._devRunCauses = causes;
543
+ return;
544
+ }
545
+ // Subscription diff: `prevDeps` was captured at run entry; `_deps` now
546
+ // holds the fresh set. A changed set is the "helper edit changed distant
547
+ // call sites" signal — surfaced per-event and in the console format.
548
+ const newDeps = captureDeps(el);
549
+ const prevSet = new Set(prevDeps);
550
+ const newSet = new Set(newDeps);
551
+ const depsAdded = [];
552
+ const depsRemoved = [];
553
+ for (const d of newDeps) if (!prevSet.has(d)) depsAdded.push(nodeName(d));
554
+ for (const d of prevDeps) if (!newSet.has(d)) depsRemoved.push(nodeName(d));
555
+ const event = {
556
+ run: ++runSeq,
557
+ at: frame.start,
558
+ nodeRuns: (node._devRunCount = (node._devRunCount ?? 0) + 1),
559
+ nodeKind: el._type ? "effect" : "memo",
560
+ nodeName: nodeName(el),
561
+ node: el,
562
+ causes,
563
+ depCount: newDeps.length,
564
+ depsAdded,
565
+ depsRemoved,
566
+ selfMs: timing.selfMs,
567
+ totalMs: timing.totalMs,
568
+ changed,
569
+ phase,
570
+ held
571
+ };
572
+ const interaction = frame.interaction;
573
+ if (interaction !== undefined) event.interaction = interaction;
574
+ // The effect phase runs later in the flush with no cause list of its own:
575
+ // it inherits this run's interaction and is joined to this run's causes
576
+ // (see effectRunStart / pushFrame).
577
+ node._devRunInteraction = interaction;
578
+ node._devRunSeq = event.run;
579
+ node._devRunCauses = causes;
580
+ history.push(event);
581
+ if (history.length > options.historyLimit) history.shift();
582
+ recordCosts(event);
583
+ recordFeedbackRun(event);
584
+ noteInteractionRun(interaction, timing.selfMs, false);
585
+ if (event.nodeKind === "effect") checkEffectCycle(el, causes);
586
+ checkRelayTear(el, causes, prevCauses);
587
+ checkHotRuns(el, event);
588
+ checkHotTime(el, event);
589
+ checkDepWidth(el);
590
+ emitRecord("rerun", event);
591
+ if (options.log) logRerun(event);
592
+ }
593
+ function formatCause(cause, depth, out) {
594
+ const pad = " ".repeat(depth + 1);
595
+ let line = `${pad}← ${cause.kind === "derived" ? "memo" : "signal"} "${cause.name}" ${cause.kind === "derived" ? "changed" : cause.kind} (#${cause.seq})`;
596
+ if (cause.prev !== undefined) line += ` ${cause.prev} → ${cause.value}`;
597
+ if (cause.origin !== undefined && cause.origin.kind !== "external") {
598
+ line += ` — ${formatOrigin(cause.origin)}`;
599
+ const under = cause.origin.interaction;
600
+ if (under !== undefined) line += ` (under ${formatOrigin(under)})`;
601
+ }
602
+ out.push(line);
603
+ if (cause.stack) for (const frame of cause.stack) out.push(`${pad} ${frame}`);
604
+ if (cause.causes && depth < 10) {
605
+ for (const upstream of cause.causes) formatCause(upstream, depth + 1, out);
606
+ }
607
+ }
608
+ function formatRerun(event) {
609
+ const out = [
610
+ `[why-run] ${event.nodeKind} "${event.nodeName}" ran (run ${event.nodeRuns}, ` +
611
+ `${event.selfMs.toFixed(2)}ms${event.changed ? "" : ", unchanged"}` +
612
+ `${event.phase === "plain" ? "" : `, ${event.phase}`}${event.held ? ", held" : ""})` +
613
+ (event.causes.length === 0 ? " — no tracked cause (pull or retry)" : "")
614
+ ];
615
+ for (const cause of event.causes) formatCause(cause, 0, out);
616
+ if (event.depsAdded.length > 0 || event.depsRemoved.length > 0) {
617
+ const delta = [
618
+ ...event.depsAdded.map(n => `+"${n}"`),
619
+ ...event.depsRemoved.map(n => `-"${n}"`)
620
+ ].join(" ");
621
+ out.push(` deps changed: ${delta} (${event.depCount} total)`);
622
+ }
623
+ return out.join("\n");
624
+ }
625
+ /**
626
+ * Console face of a re-run: the headline as a collapsed group with the
627
+ * why-chain and dep delta inside, so a busy console stays scannable (one line
628
+ * per run, evidence a click away). Consoles without grouping get the text.
629
+ */
630
+ function logRerun(event) {
631
+ const text = formatRerun(event);
632
+ const nl = text.indexOf("\n");
633
+ if (nl === -1 || typeof console.groupCollapsed !== "function") {
634
+ console.log(text);
635
+ return;
636
+ }
637
+ console.groupCollapsed(text.slice(0, nl));
638
+ console.log(text.slice(nl + 1));
639
+ console.groupEnd();
640
+ }
641
+ /**
642
+ * Values eligible for the unstable-output check: plain objects and arrays
643
+ * only. Promises, iterators, Dates, Maps, class instances etc. all have no
644
+ * (or unrepresentative) own enumerable keys, so a shallow compare would
645
+ * false-positive on them — a fresh Promise is a genuinely new value.
646
+ */
647
+ function isPlainShape(v) {
648
+ if (v === null || typeof v !== "object") return false;
649
+ if (Array.isArray(v)) return true;
650
+ const proto = Object.getPrototypeOf(v);
651
+ return proto === Object.prototype || proto === null;
652
+ }
653
+ /** Shallow structural equivalence, capped so hot paths stay cheap. */
654
+ const UNSTABLE_KEY_CAP = 64;
655
+ function shallowEquivalent(a, b) {
656
+ const aArr = Array.isArray(a);
657
+ if (aArr !== Array.isArray(b)) return false;
658
+ if (aArr) {
659
+ const arrA = a;
660
+ const arrB = b;
661
+ if (arrA.length !== arrB.length || arrA.length > UNSTABLE_KEY_CAP) return false;
662
+ for (let i = 0; i < arrA.length; i++) if (arrA[i] !== arrB[i]) return false;
663
+ return true;
664
+ }
665
+ const keys = Object.keys(a);
666
+ if (keys.length > UNSTABLE_KEY_CAP || keys.length !== Object.keys(b).length) return false;
667
+ for (const key of keys) {
668
+ if (!(key in b) || a[key] !== b[key]) return false;
669
+ }
670
+ return true;
671
+ }
672
+ /**
673
+ * Unstable-output warning — the fan-out amplifier signature: a memo whose
674
+ * committed value is referentially new but structurally identical run after
675
+ * run has an equality gate that never closes, so ALL its subscribers re-run
676
+ * on EVERY upstream change. Checked only on plain (non-overlay) changed runs;
677
+ * a genuinely different value (or a non-plain shape) resets the streak.
678
+ */
679
+ function checkUnstableOutput(el, prevValue, newValue) {
680
+ const limit = options.unstableMemos;
681
+ // typeof guard: an explicit `unstableMemos: undefined` in enable() options
682
+ // clobbers the default through the spread — treat any non-number as off.
683
+ if (typeof limit !== "number") return;
684
+ const node = el;
685
+ if (
686
+ prevValue === newValue || // paranoia: changed runs should never hit this
687
+ !isPlainShape(prevValue) ||
688
+ !isPlainShape(newValue) ||
689
+ !shallowEquivalent(prevValue, newValue)
690
+ ) {
691
+ node._devUnstableRuns = 0;
692
+ node._devUnstableWarned = false;
693
+ return;
694
+ }
695
+ node._devUnstableRuns = (node._devUnstableRuns ?? 0) + 1;
696
+ if (node._devUnstableWarned || node._devUnstableRuns < limit) return;
697
+ node._devUnstableWarned = true;
698
+ const shape = Array.isArray(newValue) ? "array" : "object";
699
+ const message =
700
+ `[UNSTABLE_MEMO_OUTPUT] memo "${nodeName(el)}" produced a new-but-equivalent ${shape} on ` +
701
+ `${node._devUnstableRuns} consecutive runs — its equality gate never closes, so every ` +
702
+ `subscriber re-runs on every upstream change. Return stable references or pass an ` +
703
+ `\`equals\` option.`;
704
+ reportDiagnostic(
705
+ emitDiagnostic(
706
+ {
707
+ code: "UNSTABLE_MEMO_OUTPUT",
708
+ kind: "perf",
709
+ severity: "warn",
710
+ message,
711
+ nodeName: nodeName(el),
712
+ data: { runs: node._devUnstableRuns, shape }
713
+ },
714
+ el
715
+ )
716
+ );
717
+ }
718
+ const EFFECT_CYCLE_MAX_HOPS = 6;
719
+ const reportedCycles = new Set();
720
+ let nextDevId = 0;
721
+ const devIds = new WeakMap();
722
+ function devId(node) {
723
+ let id = devIds.get(node);
724
+ if (id === undefined) devIds.set(node, (id = ++nextDevId));
725
+ return id;
726
+ }
727
+ function rootWrites(causes, out) {
728
+ for (const c of causes) {
729
+ if (c.kind === "derived") {
730
+ if (c.causes !== undefined) rootWrites(c.causes, out);
731
+ } else out.push(c);
732
+ }
733
+ }
734
+ /**
735
+ * The effect writes leading from an earlier run of `target` to the run whose
736
+ * `causes` these are, in causal order (target's own write first), or null.
737
+ */
738
+ function findEffectCycle(target, causes, visited, hops) {
739
+ const roots = [];
740
+ rootWrites(causes, roots);
741
+ for (const write of roots) {
742
+ const origin = write.origin;
743
+ if (origin === undefined || origin.kind !== "effect") continue;
744
+ const info = effectFrames.get(origin);
745
+ if (info === undefined) continue;
746
+ if (info.node === target) return [{ effect: info.node, write }];
747
+ if (hops >= EFFECT_CYCLE_MAX_HOPS || visited.has(info.node) || info.causes === undefined)
748
+ continue;
749
+ visited.add(info.node);
750
+ const rest = findEffectCycle(target, info.causes, visited, hops + 1);
751
+ if (rest !== null) {
752
+ rest.push({ effect: info.node, write });
753
+ return rest;
754
+ }
755
+ }
756
+ return null;
757
+ }
758
+ /** Names of the memos between a direct cause of a run and `write`, root-first. */
759
+ function derivedPath(causes, write, path) {
760
+ for (const c of causes) {
761
+ if (c === write) return true;
762
+ if (c.kind === "derived" && c.causes !== undefined) {
763
+ path.unshift(c.name);
764
+ if (derivedPath(c.causes, write, path)) return true;
765
+ path.shift();
766
+ }
767
+ }
768
+ return false;
769
+ }
770
+ function describeWrite(write) {
771
+ if (write.kind === "refresh") return `refreshed "${write.name}"`;
772
+ const values = write.prev !== undefined ? ` (${write.prev} → ${write.value})` : "";
773
+ return `wrote "${write.name}"${values}`;
774
+ }
775
+ function checkEffectCycle(el, causes) {
776
+ const links = findEffectCycle(el, causes, new Set([el]), 0);
777
+ if (links === null) return;
778
+ const key = links
779
+ .map(link => devId(link.effect))
780
+ .sort((a, b) => a - b)
781
+ .join(",");
782
+ if (reportedCycles.has(key)) return;
783
+ reportedCycles.add(key);
784
+ const flushes = links.length + 1;
785
+ let message;
786
+ if (links.length === 1) {
787
+ const [{ write }] = links;
788
+ const path = [];
789
+ derivedPath(causes, write, path);
790
+ const via = path.length > 0 ? ` through ${path.map(n => `memo "${n}"`).join(" → ")}` : "";
791
+ message =
792
+ `[EFFECT_WRITES_OWN_SOURCE] effect "${nodeName(el)}" re-ran because of its own write: it ` +
793
+ `${describeWrite(write)}, which fed back into its inputs${via}. Two flushes to settle, ` +
794
+ `and the screen rendered the pre-write value in between. The written value is a function ` +
795
+ `of what the effect reads — compute it in a memo (or normalize where the source is ` +
796
+ `written) instead of correcting it after the fact.`;
797
+ } else {
798
+ const names = links.map(link => `"${nodeName(link.effect)}"`);
799
+ const steps = links
800
+ .map(
801
+ (link, i) => `effect ${names[i]}${i > 0 ? " re-ran and" : ""} ${describeWrite(link.write)}`
802
+ )
803
+ .join("; ");
804
+ message =
805
+ `[EFFECT_WRITES_OWN_SOURCE] effects ${[...names, names[0]].join(" → ")} relay writes in a ` +
806
+ `cycle: ${steps}; which fed back into effect ${names[0]}'s inputs — ${flushes} flushes to ` +
807
+ `settle after each change, each rendering an intermediate state. Every relayed value is a ` +
808
+ `function of the original inputs: derive them in memos and drop the writes.`;
809
+ }
810
+ const severity = links.length === 1 ? "warn" : "info";
811
+ const entry = emitDiagnostic(
812
+ {
813
+ code: "EFFECT_WRITES_OWN_SOURCE",
814
+ kind: "perf",
815
+ severity,
816
+ message,
817
+ nodeName: nodeName(el),
818
+ data: {
819
+ effects: links.map(link => nodeName(link.effect)),
820
+ writes: links.map(link => ({
821
+ effect: nodeName(link.effect),
822
+ kind: link.write.kind,
823
+ name: link.write.name,
824
+ prev: link.write.prev,
825
+ value: link.write.value
826
+ })),
827
+ flushes
828
+ }
829
+ },
830
+ el
831
+ );
832
+ if (severity === "warn") reportDiagnostic(entry);
833
+ }
834
+ const RELAY_WARN_AT = 3;
835
+ const relays = new Map();
836
+ const copyWrites = new WeakSet();
837
+ const copyReported = new WeakSet();
838
+ /** The node each root write record was stamped on (records are serializable and cannot hold it). */
839
+ const recordNodes = new WeakMap();
840
+ /**
841
+ * Write-side bookkeeping for the relay heuristics: who has written this
842
+ * signal, and whether an effect just copied its compute output into it.
843
+ */
844
+ function trackEffectWrite(node, record, value) {
845
+ const n = node;
846
+ const origin = record.origin;
847
+ const info =
848
+ origin !== undefined && origin.kind === "effect" ? effectFrames.get(origin) : undefined;
849
+ const writer = info === undefined ? 0 : devId(info.node);
850
+ recordNodes.set(record, node);
851
+ n._devSoleWriter = n._devSoleWriter === undefined || n._devSoleWriter === writer ? writer : null;
852
+ if (info !== undefined && value !== undefined && value === info.node._value) {
853
+ copyWrites.add(record);
854
+ n._devCopyRuns = n._devCopyFrom === writer ? (n._devCopyRuns ?? 0) + 1 : 1;
855
+ n._devCopyFrom = writer;
856
+ if (n._devCopyRuns >= 2 && n._devSoleWriter === writer) checkCopyEffect(info.node, node);
857
+ } else n._devCopyRuns = 0;
858
+ }
859
+ /** The effect's source whose current value the compute output is, if any (the prop-to-state port). */
860
+ function passthroughSource(effect) {
861
+ for (let l = effect._deps; l !== null; l = l._nextDep)
862
+ if (l._dep._value === effect._value) return nodeName(l._dep);
863
+ return undefined;
864
+ }
865
+ /** The repair for a write that is the effect's compute output. */
866
+ function copyRepair(effect, target) {
867
+ const source = passthroughSource(effect);
868
+ return source !== undefined
869
+ ? `The written value is "${source}" itself: read "${source}" where "${target}" is read ` +
870
+ `(or createMemo it if a stable derivation is needed) and delete the effect.`
871
+ : `The written value is the effect's compute output — by contract a pure function of ` +
872
+ `what it tracks: make "${target}" a memo of that computation and delete the effect.`;
873
+ }
874
+ function checkCopyEffect(effect, target) {
875
+ if (copyReported.has(target)) return;
876
+ copyReported.add(target);
877
+ const name = nodeName(target);
878
+ const message =
879
+ `[EFFECT_RELAY_TEAR] effect "${nodeName(effect)}" writes its compute output into ` +
880
+ `"${name}" on every run, and nothing else writes "${name}" — it is derived state kept ` +
881
+ `one flush late: everything reading it paints a frame behind everything reading the ` +
882
+ `source. ${copyRepair(effect, name)}`;
883
+ reportDiagnostic(
884
+ emitDiagnostic(
885
+ {
886
+ code: "EFFECT_RELAY_TEAR",
887
+ kind: "perf",
888
+ severity: "warn",
889
+ message,
890
+ nodeName: nodeName(effect),
891
+ data: {
892
+ relay: nodeName(effect),
893
+ wrote: name,
894
+ copy: true,
895
+ passthrough: passthroughSource(effect) ?? null,
896
+ soleWriter: true
897
+ }
898
+ },
899
+ effect
900
+ )
901
+ );
902
+ }
903
+ /**
904
+ * `victim` re-ran with `causes`; its previous run had `prevCauses`. A tear is
905
+ * a re-run whose root writes ALL came from effects (no independent outside
906
+ * cause) and at least one of which was made by a run that shares a root
907
+ * write with the victim's previous run.
908
+ */
909
+ function checkRelayTear(victim, causes, prevCauses) {
910
+ if (prevCauses === undefined || causes.length === 0) return;
911
+ const roots = [];
912
+ rootWrites(causes, roots);
913
+ if (roots.length === 0) return;
914
+ let relay;
915
+ let write;
916
+ let shared;
917
+ for (const root of roots) {
918
+ const origin = root.origin;
919
+ if (origin === undefined || origin.kind !== "effect") return;
920
+ const info = effectFrames.get(origin);
921
+ // Own-source cycles are EFFECT_WRITES_OWN_SOURCE's; a create-run relay
922
+ // is initial sync, not a tear for one change.
923
+ if (info === undefined || info.node === victim || info.causes === undefined) return;
924
+ if (shared === undefined) {
925
+ const relayRoots = [];
926
+ rootWrites(info.causes, relayRoots);
927
+ const prevRoots = [];
928
+ rootWrites(prevCauses, prevRoots);
929
+ const hit = relayRoots.find(r => prevRoots.includes(r));
930
+ if (hit !== undefined) {
931
+ shared = hit;
932
+ relay = info;
933
+ write = root;
934
+ }
935
+ }
936
+ }
937
+ if (shared === undefined || relay === undefined || write === undefined) return;
938
+ const key = `${devId(relay.node)}:${write.name}`;
939
+ let state = relays.get(key);
940
+ if (state === undefined) relays.set(key, (state = { count: 0, warned: false }));
941
+ state.count++;
942
+ const copy = copyWrites.has(write);
943
+ const target = recordNodes.get(write);
944
+ const soleWriter = target !== undefined && target._devSoleWriter === devId(relay.node);
945
+ // Derivable outright: the value is the compute output and nothing else
946
+ // writes the signal. A copy INTO a signal that has other writers is the
947
+ // "reset editable state from a source" shape — the tear is real, but a memo
948
+ // is not the answer, so it stays advisory like any other non-derivable tear.
949
+ const derivable = copy && soleWriter;
950
+ const severity = derivable || state.count >= RELAY_WARN_AT ? "warn" : "info";
951
+ // First sighting always reports (advisory); afterwards only the escalation.
952
+ if (state.count > 1 && (severity !== "warn" || state.warned)) return;
953
+ // One verdict per derivable signal: the copy report (checkCopyEffect) and
954
+ // the tear report carry the same repair.
955
+ if (derivable && target !== undefined) {
956
+ if (copyReported.has(target)) return;
957
+ copyReported.add(target);
958
+ }
959
+ if (severity === "warn") state.warned = true;
960
+ const victimKind = victim._type ? "effect" : "memo";
961
+ const relayName = nodeName(relay.node);
962
+ const repair = derivable
963
+ ? copyRepair(relay.node, write.name)
964
+ : copy
965
+ ? `The written value is the effect's compute output, but "${write.name}" has other ` +
966
+ `writers — editable state reset from a source. If the reset is the intent, the tear ` +
967
+ `is its cost; if "${write.name}" only ever mirrors the source, drop the local copy ` +
968
+ `and read the source.`
969
+ : soleWriter
970
+ ? `Nothing else writes "${write.name}" — it is derived state: make it a memo over what ` +
971
+ `the effect reads and every reader gets it in the same flush.`
972
+ : `If "${write.name}" is computed from what the effect reads, make it a memo so readers ` +
973
+ `get it in the same flush; if the write reads something outside the graph (layout, ` +
974
+ `time), the tear is the cost of measuring.`;
975
+ const message =
976
+ `[EFFECT_RELAY_TEAR] ${victimKind} "${nodeName(victim)}" ran twice for one write of ` +
977
+ `"${shared.name}": once in the flush where "${shared.name}" changed, and again after ` +
978
+ `effect "${relayName}" relayed it by writing "${write.name}" — the first frame showed the ` +
979
+ `new "${shared.name}" with the stale "${write.name}"` +
980
+ (state.count > 1 ? ` (${state.count} times so far)` : "") +
981
+ `. ${repair}`;
982
+ const entry = emitDiagnostic(
983
+ {
984
+ code: "EFFECT_RELAY_TEAR",
985
+ kind: "perf",
986
+ severity,
987
+ message,
988
+ nodeName: nodeName(victim),
989
+ data: {
990
+ victim: nodeName(victim),
991
+ root: shared.name,
992
+ relay: relayName,
993
+ wrote: write.name,
994
+ copy,
995
+ passthrough: copy ? (passthroughSource(relay.node) ?? null) : null,
996
+ soleWriter,
997
+ occurrences: state.count
998
+ }
999
+ },
1000
+ victim
1001
+ );
1002
+ if (severity === "warn") reportDiagnostic(entry);
1003
+ }
1004
+ // --- Immutable updates in stores ---------------------------------------------
1005
+ //
1006
+ // `draft.user = { ...draft.user, name }` / `draft.items = [...draft.items,
1007
+ // x]` / `draft.items = draft.items.filter(...)` — the React habit of
1008
+ // producing a fresh container to change one leaf. The store tracks leaves, so
1009
+ // a fresh container is pure cost: every reader of `user` (any path below it)
1010
+ // re-runs for the one leaf that moved, where a draft mutation would re-run
1011
+ // only the readers of `name`. The store's notify sees both containers at the
1012
+ // write and reports a leaf census (identity on unwrapped values, capped); the
1013
+ // verdict is the whole detector: a replacement whose leaves are mostly the
1014
+ // SAME values is a spread-copy, and one whose leaves are mostly different is
1015
+ // new data (reconcile's job — and UNSTABLE_LIST_IDENTITY's, downstream). Once
1016
+ // per store path.
1017
+ const immutableReported = new Set();
1018
+ function checkImmutableUpdate(path, isArray, total, same, prevTotal) {
1019
+ if (immutableReported.has(path) || total < 2) return;
1020
+ // Push/filter/splice copies change the length by a little; a wholesale
1021
+ // resize is a different operation even if some items survive.
1022
+ if (isArray && Math.abs(prevTotal - total) > Math.max(1, total >> 2)) return;
1023
+ // At least half the leaves carried over unchanged, and at least one did.
1024
+ if (same === 0 || same * 2 < total) return;
1025
+ const changed = total - same;
1026
+ const shape = isArray ? "array" : "object";
1027
+ const repair = isArray
1028
+ ? `mutate the draft in place (push/splice/index assignment) so only the touched ` +
1029
+ `indices notify`
1030
+ : `assign the leaf on the draft (\`${path}.<key> = …\`) so only readers of that key re-run`;
1031
+ const message =
1032
+ `[IMMUTABLE_UPDATE_IN_STORE] "${path}" was replaced with a fresh ${shape} whose ` +
1033
+ `${isArray ? "items" : "leaves"} are mostly the same values (${same} of ${total} unchanged` +
1034
+ `${changed > 0 ? `, ${changed} changed` : ""}) — a spread-copy update. The store already ` +
1035
+ `tracks ${isArray ? "items" : "leaves"}; a new container makes every reader of "${path}" ` +
1036
+ `re-run for the ${changed === 1 ? "one that" : "few that"} moved. Instead, ${repair}. For ` +
1037
+ `data arriving from outside (a fetch result), merge it with reconcile(data, key)(${path}).`;
1038
+ const entry = emitDiagnostic({
1039
+ code: "IMMUTABLE_UPDATE_IN_STORE",
1040
+ kind: "perf",
1041
+ severity: "warn",
1042
+ message,
1043
+ nodeName: path,
1044
+ data: { path, shape, total, unchanged: same, changed }
1045
+ });
1046
+ // Paths are not unique across stores: a write under an excluded owner
1047
+ // (an adapter's own "store.list") must not spend the app's once-per-path slot.
1048
+ if (isSuppressed(entry)) return;
1049
+ immutableReported.add(path);
1050
+ reportDiagnostic(entry);
1051
+ }
1052
+ // --- Unstable list identity -----------------------------------------------------
1053
+ //
1054
+ // `<For>` keyed by identity (the default) treats every new object as a new
1055
+ // row. When a re-fetch hands back fresh objects for the same records, or a
1056
+ // spread-copy rebuilds the array, most rows are disposed and recreated —
1057
+ // DOM, state, focus, and all — for data that did not change. mapArray knows
1058
+ // exactly which items exited and entered; pairing them (by `id` when the
1059
+ // items carry one, else by position) and sampling shallow equivalence turns
1060
+ // that into a verdict: churn that replaced equivalent records is unstable
1061
+ // identity, not a new list. A key function that still churns has the same
1062
+ // disease one level up (its keys are not stable). Once per list.
1063
+ const LIST_CHURN_SAMPLE = 8;
1064
+ const listIdentityWarned = new WeakSet();
1065
+ function recordId(item) {
1066
+ if (item === null || typeof item !== "object") return undefined;
1067
+ const o = item;
1068
+ return o.id ?? o.key ?? o._id ?? undefined;
1069
+ }
1070
+ function checkListIdentity(el, removed, created, newLen, keyed) {
1071
+ if (listIdentityWarned.has(el)) return;
1072
+ // Most of the list turned over, and the turnover was a swap (rows out ≈ rows in).
1073
+ if (created.length < 2 || created.length * 2 < newLen) return;
1074
+ if (Math.abs(removed.length - created.length) > Math.max(1, created.length >> 2)) return;
1075
+ // Pair exited with entered: by record id when present, else by position.
1076
+ const byId = new Map();
1077
+ for (const item of removed) {
1078
+ const id = recordId(item);
1079
+ if (id !== undefined) byId.set(id, item);
1080
+ }
1081
+ let sampled = 0;
1082
+ let equivalent = 0;
1083
+ const step = Math.max(1, Math.floor(created.length / LIST_CHURN_SAMPLE));
1084
+ for (let i = 0; i < created.length && sampled < LIST_CHURN_SAMPLE; i += step) {
1085
+ const item = created[i];
1086
+ const id = recordId(item);
1087
+ const prev = id !== undefined ? byId.get(id) : removed[i];
1088
+ if (prev === undefined || !isPlainShape(prev) || !isPlainShape(item)) continue;
1089
+ sampled++;
1090
+ if (shallowEquivalent(prev, item)) equivalent++;
1091
+ }
1092
+ if (sampled === 0 || equivalent * 2 < sampled) return;
1093
+ listIdentityWarned.add(el);
1094
+ const name = nodeName(el);
1095
+ const repair = keyed
1096
+ ? `The key function returned different keys for equivalent records — return a stable ` +
1097
+ `field (\`keyed: item => item.id\`), not the object or a computed value that changes ` +
1098
+ `with the fetch.`
1099
+ : `Key the list by a stable field (\`keyed: item => item.id\`), or merge the data into ` +
1100
+ `a store with reconcile(data, "id") so the same records keep the same identity.`;
1101
+ const message =
1102
+ `[UNSTABLE_LIST_IDENTITY] list "${name}" recreated ${created.length} of ${newLen} rows on ` +
1103
+ `an update where the entering items are equivalent to the ones they replaced ` +
1104
+ `(${equivalent} of ${sampled} sampled pairs identical field-for-field) — fresh objects ` +
1105
+ `for the same records, so identity keying threw away every row's DOM and state and ` +
1106
+ `rebuilt it. ${repair}`;
1107
+ reportDiagnostic(
1108
+ emitDiagnostic(
1109
+ {
1110
+ code: "UNSTABLE_LIST_IDENTITY",
1111
+ kind: "perf",
1112
+ severity: "warn",
1113
+ message,
1114
+ nodeName: name,
1115
+ data: {
1116
+ removed: removed.length,
1117
+ created: created.length,
1118
+ length: newLen,
1119
+ sampled,
1120
+ equivalent,
1121
+ keyed
1122
+ }
1123
+ },
1124
+ el
1125
+ )
1126
+ );
1127
+ }
1128
+ /** Provenance of an async landing: the flight, under the interaction that started it. */
1129
+ function asyncOrigin(el) {
1130
+ const origin = { kind: "async", name: nodeName(el) };
1131
+ const interaction = liveFlights.get(el)?.interaction;
1132
+ if (interaction !== undefined) origin.interaction = interaction;
1133
+ return origin;
1134
+ }
1135
+ // WeakMaps: an errored/abandoned flight must not leak its node or block GC.
1136
+ const liveFlights = new WeakMap();
1137
+ const landedFlights = new WeakMap();
1138
+ /**
1139
+ * Flight-object identity → earliest known start. Fed by markFlight() (the
1140
+ * cooperative preload/cache declaration — populated even while attribution
1141
+ * is disabled, so navigation-time marks survive a later enable()) and by
1142
+ * first sightings at registration.
1143
+ */
1144
+ const flightOrigins = new WeakMap();
1145
+ let waterfallLog = [];
1146
+ /** Deepest landed-flight cause reachable through a cause list (derived links included). */
1147
+ function flightCauseIn(causes) {
1148
+ let best = null;
1149
+ for (const c of causes) {
1150
+ let found = null;
1151
+ if (c.kind === "async") found = landedFlights.get(c) ?? null;
1152
+ else if (c.kind === "derived" && c.causes) found = flightCauseIn(c.causes);
1153
+ if (found !== null && (best === null || found.chain.length > best.chain.length)) best = found;
1154
+ }
1155
+ return best;
1156
+ }
1157
+ function trackFlightStart(el, flight) {
1158
+ const at = now();
1159
+ const origin = flightOrigins.get(flight) ?? at;
1160
+ if (origin === at) flightOrigins.set(flight, at);
1161
+ // Census: a flight still in the air when the node starts another was
1162
+ // superseded — its answer will be discarded.
1163
+ const stats = flightBucket(el);
1164
+ stats.flights++;
1165
+ if (liveFlights.has(el)) stats.abandoned++;
1166
+ // Nearest enclosing frame with causes: create runs carry null (a node born
1167
+ // inside a parent's recompute inherits the parent's causality — the
1168
+ // boundary-reveal case, and the lazy sibling whose first pull is gated
1169
+ // behind an earlier not-ready read), so walk down to the first re-run frame.
1170
+ let causes = null;
1171
+ for (let i = frames.length - 1; i >= 0; i--) {
1172
+ if (frames[i].causes !== null) {
1173
+ causes = frames[i].causes;
1174
+ break;
1175
+ }
1176
+ }
1177
+ const live = { origin, startSeq: changeSeq, chain: [] };
1178
+ // Provenance: the flight belongs to whatever interaction caused the
1179
+ // recompute that started it (a create run under a click's handler — a
1180
+ // freshly mounted async node — inherits the ambient interaction instead).
1181
+ const interaction = causes !== null ? interactionIn(causes) : (currentInteraction ?? undefined);
1182
+ if (interaction !== undefined) live.interaction = interaction;
1183
+ if (options.waterfalls !== false && causes !== null) {
1184
+ let parent = flightCauseIn(causes);
1185
+ // The sequentiality test. A marked/previously-seen flight whose origin
1186
+ // predates the upstream landing was in the air alongside it: parallel.
1187
+ if (parent !== null && origin < parent.landedAt) parent = null;
1188
+ if (parent !== null) live.chain = [...parent.chain, { name: parent.name, ms: parent.ms }];
1189
+ }
1190
+ liveFlights.set(el, live);
1191
+ }
1192
+ /**
1193
+ * Flight landed (whether or not the value committed — the wall time was
1194
+ * spent either way). Attach the measurement to the landing's fresh "async"
1195
+ * stamp so downstream flights can chain through it, then judge the chain.
1196
+ */
1197
+ function finalizeFlight(el) {
1198
+ const flight = liveFlights.get(el);
1199
+ if (flight === undefined) return;
1200
+ liveFlights.delete(el);
1201
+ const landedAt = now();
1202
+ const ms = landedAt - flight.origin;
1203
+ const stats = flightBucket(el);
1204
+ stats.landed++;
1205
+ stats.landedMs += ms;
1206
+ if (ms > stats.worstMs) stats.worstMs = ms;
1207
+ const record = el._devChange;
1208
+ // Only a stamp this landing produced may carry the measurement — a stale
1209
+ // async record from a previous landing must not be re-labeled.
1210
+ if (record !== undefined && record.kind === "async" && record.seq > flight.startSeq)
1211
+ landedFlights.set(record, { name: nodeName(el), ms, chain: flight.chain, landedAt });
1212
+ checkWaterfall(el, flight.chain, ms);
1213
+ }
1214
+ function checkWaterfall(el, chain, ms) {
1215
+ const cfg = options.waterfalls;
1216
+ if (cfg === false) return;
1217
+ if (chain.length > 0) {
1218
+ waterfallLog.push({
1219
+ chain: [...chain, { name: nodeName(el), ms }],
1220
+ sequentialMs: chain.reduce((sum, l) => sum + l.ms, ms)
1221
+ });
1222
+ if (waterfallLog.length > options.historyLimit) waterfallLog.shift();
1223
+ }
1224
+ // The verdict: trailing run of links that were each a real wait. A fast
1225
+ // tail (settled preload/cache hit) or a fast upstream breaks the sequence.
1226
+ if (ms < cfg.minFlightMs) return;
1227
+ let seq = 1;
1228
+ let totalMs = ms;
1229
+ for (let i = chain.length - 1; i >= 0 && chain[i].ms >= cfg.minFlightMs; i--) {
1230
+ seq++;
1231
+ totalMs += chain[i].ms;
1232
+ }
1233
+ if (seq < 2) return;
1234
+ const node = el;
1235
+ if ((node._devWaterfallWarnedAt ?? 0) >= seq) return;
1236
+ node._devWaterfallWarnedAt = seq;
1237
+ const links = [...chain.slice(chain.length - (seq - 1)), { name: nodeName(el), ms }];
1238
+ const path = links.map(l => `"${l.name}" (${l.ms.toFixed(0)}ms)`).join(" → ");
1239
+ const message =
1240
+ `[ASYNC_WATERFALL] ${seq} sequential async flights — ${path} — ` +
1241
+ `${totalMs.toFixed(0)}ms serialized: each began only after the previous resolved ` +
1242
+ `(as far as this graph can see). If a later request doesn't need the earlier ` +
1243
+ `response, derive both from the same inputs so they start together; if the ` +
1244
+ `dependency is intrinsic, preload the dependent data or join the requests ` +
1245
+ `server-side. If this work WAS already started elsewhere (a preloader or request ` +
1246
+ `cache), have that layer stamp its promises with attribution.markFlight() ` +
1247
+ `from "@solidjs/signals/attribution".`;
1248
+ // Depth 2 is advisory-only (structured consumers see it; the console does
1249
+ // not): a 2-chain can be an intrinsic data dependency or an unmarked
1250
+ // preload. A 3+ chain that survived the origin test is near-certainly
1251
+ // structural — that one earns the console.
1252
+ const severity = seq > 2 ? "warn" : "info";
1253
+ const entry = emitDiagnostic(
1254
+ {
1255
+ code: "ASYNC_WATERFALL",
1256
+ kind: "perf",
1257
+ severity,
1258
+ message,
1259
+ nodeName: nodeName(el),
1260
+ data: { chain: links.map(l => ({ name: l.name, ms: l.ms })), sequentialMs: totalMs }
1261
+ },
1262
+ el
1263
+ );
1264
+ if (severity === "warn") reportDiagnostic(entry);
1265
+ }
1266
+ const holdStates = new WeakMap();
1267
+ let activeHold = null;
1268
+ let holdLog = [];
1269
+ /** Companions are optimistic nodes too; `_parentSource` marks them. */
1270
+ function isCompanion(node) {
1271
+ return !!node._x && node._x._parentSource !== undefined;
1272
+ }
1273
+ const HOLD_CENSUS_CAP = 10_000;
1274
+ function acknowledge(state, kind, source, reader) {
1275
+ const key = `${kind}:${source}`;
1276
+ const existing = state.acknowledgements.get(key);
1277
+ const path = reader !== null ? ownerPath(reader) : undefined;
1278
+ // A later census (a merged transition, the settle pass) may find the
1279
+ // reader an earlier one missed.
1280
+ if (existing === undefined)
1281
+ state.acknowledgements.set(
1282
+ key,
1283
+ path !== undefined ? { kind, source, reader: path } : { kind, source }
1284
+ );
1285
+ else if (path !== undefined) existing.reader ??= path;
1286
+ }
1287
+ function censusRegistrations(t, state) {
1288
+ const budget = { left: HOLD_CENSUS_CAP };
1289
+ for (const node of t._optimisticNodes)
1290
+ if (!isCompanion(node))
1291
+ acknowledge(state, "optimistic", nodeName(node), reachesEffect(node, budget));
1292
+ for (const store of t._optimisticStores)
1293
+ acknowledge(state, "optimistic", store?._name ?? "store", null);
1294
+ for (const node of t._affectsNodes)
1295
+ acknowledge(state, "affects", nodeName(node), reachesEffect(node, budget));
1296
+ }
1297
+ /**
1298
+ * Does anything that paints read `companion` — an effect, through however many
1299
+ * memos? A subscriber alone is not acknowledgement: memos compute eagerly, so a
1300
+ * router's `createMemo(() => isPending(location))` subscribes to the companion
1301
+ * whether or not the app ever renders the memo. Only an effect is the screen.
1302
+ * Returns the first effect found (the reader), or null.
1303
+ */
1304
+ function reachesEffect(companion, budget) {
1305
+ const seen = new Set([companion]);
1306
+ const stack = [companion];
1307
+ while (stack.length > 0 && budget.left-- > 0) {
1308
+ const node = stack.pop();
1309
+ for (let s = node._subs; s !== null; s = s._nextSub) {
1310
+ const sub = s._sub;
1311
+ if (sub._type) return sub;
1312
+ if (!seen.has(sub)) {
1313
+ seen.add(sub);
1314
+ stack.push(sub);
1315
+ }
1316
+ }
1317
+ }
1318
+ return null;
1319
+ }
1320
+ /** Companions an effect reads, anywhere downstream of the hold's nodes. */
1321
+ function censusCompanions(roots, state) {
1322
+ const visited = new Set();
1323
+ const stack = [...roots];
1324
+ const budget = { left: HOLD_CENSUS_CAP };
1325
+ while (stack.length > 0 && visited.size < HOLD_CENSUS_CAP) {
1326
+ const node = stack.pop();
1327
+ if (visited.has(node)) continue;
1328
+ visited.add(node);
1329
+ const x = node._x;
1330
+ if (x) {
1331
+ let reader;
1332
+ if (x._pendingSignal !== undefined && (reader = reachesEffect(x._pendingSignal, budget)))
1333
+ acknowledge(state, "isPending", nodeName(node), reader);
1334
+ if (
1335
+ x._latestValueComputed !== undefined &&
1336
+ (reader = reachesEffect(x._latestValueComputed, budget))
1337
+ )
1338
+ acknowledge(state, "latest", nodeName(node), reader);
1339
+ for (let child = x._child ?? null; child !== null; child = child._nextChild ?? null)
1340
+ stack.push(child);
1341
+ }
1342
+ for (let s = node._subs; s !== null; s = s._nextSub) stack.push(s._sub);
1343
+ }
1344
+ }
1345
+ function holdState(t) {
1346
+ let state = holdStates.get(t);
1347
+ if (state === undefined) {
1348
+ state = {
1349
+ start: now(),
1350
+ flushes: 0,
1351
+ blockers: new Set(),
1352
+ acknowledgements: new Map(),
1353
+ painted: 0,
1354
+ action: false
1355
+ };
1356
+ holdStates.set(t, state);
1357
+ }
1358
+ return state;
1359
+ }
1360
+ function trackHoldStart(t) {
1361
+ // Navigations and interactions learn they are held regardless of hold
1362
+ // tracking: their settle must wait for the transition either way (see flushEnd).
1363
+ markNavigationsHeld(t);
1364
+ markInteractionsHeld(t);
1365
+ if (options.holds === false) return;
1366
+ const state = holdState(t);
1367
+ state.flushes++;
1368
+ if (t._actions.length > 0) state.action = true;
1369
+ for (const [source, reporters] of t._asyncReporters)
1370
+ if (reporters.size > 0) state.blockers.add(source);
1371
+ censusRegistrations(t, state);
1372
+ activeHold = state;
1373
+ }
1374
+ function trackHoldMerge(target, outgoing) {
1375
+ mergeInteractionsHeld(target, outgoing);
1376
+ const from = holdStates.get(outgoing);
1377
+ if (from === undefined) return;
1378
+ holdStates.delete(outgoing);
1379
+ const into = holdState(target);
1380
+ if (from.start < into.start) into.start = from.start;
1381
+ into.flushes += from.flushes;
1382
+ into.painted += from.painted;
1383
+ into.action ||= from.action;
1384
+ for (const b of from.blockers) into.blockers.add(b);
1385
+ for (const [key, a] of from.acknowledgements)
1386
+ if (!into.acknowledgements.has(key)) into.acknowledgements.set(key, a);
1387
+ }
1388
+ function trackHoldSettled(t) {
1389
+ const state = holdStates.get(t);
1390
+ if (state === undefined) {
1391
+ // No hold was recorded (the transition completed in its first flush, or
1392
+ // hold tracking is off): navigations and interactions staged in it still
1393
+ // settle here.
1394
+ settleNavigations(t, undefined);
1395
+ settleInteractionsHeld(t, undefined);
1396
+ return;
1397
+ }
1398
+ holdStates.delete(t);
1399
+ // Root writes only: a memo in _pendingNodes is a derived hold, and the
1400
+ // question is whether the USER's input went unanswered.
1401
+ const heldWrites = [];
1402
+ let subject = null;
1403
+ let interaction;
1404
+ let origin;
1405
+ let lastJoinAt = -Infinity;
1406
+ for (const node of t._pendingNodes) {
1407
+ if (typeof node._fn === "function" || isCompanion(node)) continue;
1408
+ const change = node._devChange;
1409
+ if (change === undefined || change.kind !== "write") continue;
1410
+ if (subject === null) subject = node;
1411
+ const held = { name: nodeName(node), prev: change.prev, value: change.value };
1412
+ if (change.origin !== undefined) held.origin = change.origin;
1413
+ heldWrites.push(held);
1414
+ // Earliest interaction among the held writes: the user has been waiting
1415
+ // since the first thing they did that this transaction is holding.
1416
+ const under = interactionOf(change.origin);
1417
+ if (under !== undefined && (interaction === undefined || under.at < interaction.at))
1418
+ interaction = under;
1419
+ // The declared frame the writes belong to — earliest navigation, by the
1420
+ // same reasoning.
1421
+ if (
1422
+ change.origin?.kind === "navigation" &&
1423
+ (origin === undefined || change.origin.at < origin.at)
1424
+ )
1425
+ origin = change.origin;
1426
+ // Latest write: a signal written twice while held carries the later
1427
+ // stamp, so this is the user's final input, not their first.
1428
+ if (change.at !== undefined && change.at > lastJoinAt) lastJoinAt = change.at;
1429
+ }
1430
+ if (heldWrites.length === 0) {
1431
+ settleNavigations(t, undefined);
1432
+ settleInteractionsHeld(t, undefined);
1433
+ return;
1434
+ }
1435
+ censusRegistrations(t, state);
1436
+ censusCompanions([...t._pendingNodes, ...state.blockers], state);
1437
+ const end = now();
1438
+ // The hold began no later than its first parked flush; an interaction stamp
1439
+ // reaches further back (dispatch). A node rewritten mid-hold keeps only its
1440
+ // latest record, so the surviving interaction may be a later one — the
1441
+ // flush clock keeps the first wait from being forgotten.
1442
+ const at = Math.min(state.start, interaction !== undefined ? interaction.at : Infinity);
1443
+ const holdMs = end - at;
1444
+ const event = {
1445
+ at,
1446
+ holdMs,
1447
+ tailMs: lastJoinAt === -Infinity ? holdMs : Math.min(holdMs, end - lastJoinAt),
1448
+ flushes: state.flushes,
1449
+ heldWrites,
1450
+ blockers: [...state.blockers].map(nodeName),
1451
+ acknowledgements: [...state.acknowledgements.values()],
1452
+ paintedDuringHold: state.painted,
1453
+ action: state.action
1454
+ };
1455
+ if (interaction !== undefined) event.interaction = interaction;
1456
+ if (origin !== undefined) event.origin = origin;
1457
+ holdLog.push(event);
1458
+ if (holdLog.length > options.historyLimit) holdLog.shift();
1459
+ // Bottom-up delivery: the hold, then the navigations it held, then the
1460
+ // interactions those belong to — each record complete when its parent is.
1461
+ emitRecord("hold", event);
1462
+ settleNavigations(t, event);
1463
+ settleInteractionsHeld(t, event);
1464
+ recordFeedbackHold(event);
1465
+ if (isSilentHold(event)) checkSilentHold(event, subject);
1466
+ else checkLongHold(event, subject);
1467
+ }
1468
+ /** `isLongHold` — the tail outlasted `longHolds.infoMs`. */
1469
+ function isLongHold(event) {
1470
+ const cfg = options.longHolds;
1471
+ return cfg !== false && cfg !== undefined && event.tailMs >= cfg.infoMs;
1472
+ }
1473
+ function describeHeldWrites(event) {
1474
+ return event.heldWrites
1475
+ .map(w => (w.prev !== undefined ? `"${w.name}" (${w.prev} → ${w.value})` : `"${w.name}"`))
1476
+ .join(", ");
1477
+ }
1478
+ function describeBlockers(event, lead) {
1479
+ return event.blockers.length > 0
1480
+ ? ` ${lead} ${event.blockers.map(b => `"${b}"`).join(", ")}`
1481
+ : "";
1482
+ }
1483
+ /**
1484
+ * The boundary repair, shared by LONG_HOLD and a long SILENT_HOLD: a wait
1485
+ * this long should show a fallback, not a stale screen. A `Loading` boundary
1486
+ * lifts the write out of the hold only when it has not revealed yet or its
1487
+ * `on` prop changed — a revealed boundary with no `on` IS the stale screen.
1488
+ */
1489
+ function boundaryRepair(event) {
1490
+ const key = event.heldWrites[0]?.name ?? "key";
1491
+ return (
1492
+ `A wait this long is past what a stale screen should carry: show a fallback instead. Put ` +
1493
+ `the reader behind a Loading boundary keyed on what changed — <Loading on={${key}()} ` +
1494
+ `fallback={…}> — so the write commits at once and the fallback shows where the data lands; ` +
1495
+ `a boundary that has already revealed keeps the old content unless \`on\` changes. If the ` +
1496
+ `data itself is the problem, preload it or cache it so the wait never gets this long.`
1497
+ );
1498
+ }
1499
+ function holdData(event) {
1500
+ const data = {
1501
+ holdMs: event.holdMs,
1502
+ tailMs: event.tailMs,
1503
+ flushes: event.flushes,
1504
+ heldWrites: event.heldWrites.map(w => w.name),
1505
+ blockers: event.blockers,
1506
+ action: event.action
1507
+ };
1508
+ if (event.interaction !== undefined)
1509
+ data.interaction = { type: event.interaction.name, target: event.interaction.target };
1510
+ if (event.origin?.kind === "navigation") data.navigation = navigationData(event.origin);
1511
+ return data;
1512
+ }
1513
+ /**
1514
+ * Who the verdict sentence starts from: the interaction, with the navigation
1515
+ * it performed in parentheses — `click on a.nav (navigation to /users/:id)`;
1516
+ * the navigation alone when nothing user-dispatched is known (a redirect);
1517
+ * empty when neither is.
1518
+ */
1519
+ function holdActor(event) {
1520
+ const via = event.origin !== undefined ? formatOrigin(event.origin) : "";
1521
+ if (event.interaction !== undefined)
1522
+ return `${formatOrigin(event.interaction)}${via ? ` (${via})` : ""}`;
1523
+ return via;
1524
+ }
1525
+ function checkSilentHold(event, subject) {
1526
+ const cfg = options.holds;
1527
+ if (cfg === false) return;
1528
+ if (event.holdMs < cfg.infoMs) return;
1529
+ const ms = event.holdMs.toFixed(0);
1530
+ const writes = describeHeldWrites(event);
1531
+ const waitedOn = describeBlockers(event, "waiting on");
1532
+ // With the interaction (or navigation) stamped the sentence starts from
1533
+ // what the user did; without it, from the writes.
1534
+ const actor = holdActor(event);
1535
+ const who = actor ? `${actor} ` : "";
1536
+ let message = event.action
1537
+ ? `[SILENT_HOLD] ${who}${who ? "started an action that" : "an action"} held ${writes} for ` +
1538
+ `${ms}ms${waitedOn} and the screen showed nothing for the whole round-trip: no optimistic ` +
1539
+ `value, no isPending() reader, no affects() mark, and no effect ran while it was held. ` +
1540
+ `Pair the action with a createOptimistic/createOptimisticStore write for the expected ` +
1541
+ `outcome (it reverts on failure), or co-write a createOptimistic(false) "saving" flag ` +
1542
+ `the UI reads.`
1543
+ : `[SILENT_HOLD] ${who}${who ? "wrote" : "writes to"} ${writes}${who ? "; the write was" : " were"} ` +
1544
+ `held ${ms}ms${waitedOn} and the screen showed nothing for the wait: no ` +
1545
+ `isPending()/latest() reader downstream, no optimistic value, no affects() mark, and no ` +
1546
+ `effect ran while it was held — the interaction was dead for ${ms}ms. Show the wait: ` +
1547
+ `read isPending(() => ${event.blockers[0] ?? "source"}()) to render a busy state, or ` +
1548
+ `latest(${event.heldWrites[0].name}) to reveal the new input immediately while the data ` +
1549
+ `catches up. The hold itself is correct — do not "fix" this by moving the write off the ` +
1550
+ `async path.`;
1551
+ const long = isLongHold(event);
1552
+ if (long) message += ` ${boundaryRepair(event)}`;
1553
+ const severity = event.holdMs >= cfg.warnMs ? "warn" : "info";
1554
+ const data = holdData(event);
1555
+ data.long = long;
1556
+ const entry = emitDiagnostic(
1557
+ {
1558
+ code: "SILENT_HOLD",
1559
+ kind: "responsiveness",
1560
+ severity,
1561
+ message,
1562
+ nodeName: nodeName(subject),
1563
+ data
1564
+ },
1565
+ subject
1566
+ );
1567
+ if (severity === "warn") reportDiagnostic(entry);
1568
+ }
1569
+ function checkLongHold(event, subject) {
1570
+ const cfg = options.longHolds;
1571
+ if (cfg === false || cfg === undefined) return;
1572
+ if (event.tailMs < cfg.infoMs) return;
1573
+ const tail = event.tailMs.toFixed(0);
1574
+ const writes = describeHeldWrites(event);
1575
+ const waitedOn = describeBlockers(event, "waiting on");
1576
+ const actor = holdActor(event);
1577
+ const who = actor ? `${actor} ` : "";
1578
+ const answered =
1579
+ event.acknowledgements.length > 0
1580
+ ? `${event.acknowledgements.map(a => `"${a.kind}:${a.source}"`).join(", ")} said it was pending`
1581
+ : `an effect painted meanwhile`;
1582
+ const sinceLast =
1583
+ event.tailMs < event.holdMs - 1
1584
+ ? ` after the last input (${event.holdMs.toFixed(0)}ms in all)`
1585
+ : "";
1586
+ const message =
1587
+ `[LONG_HOLD] ${who}${who ? "wrote" : "writes to"} ${writes}; the screen kept the old ` +
1588
+ `content for ${tail}ms${sinceLast}${waitedOn} — ${answered}, but the hold ran on well past ` +
1589
+ `the point where "loading" over stale content reads as broken. ${boundaryRepair(event)}`;
1590
+ const severity = event.tailMs >= cfg.warnMs ? "warn" : "info";
1591
+ const data = holdData(event);
1592
+ data.acknowledgements = event.acknowledgements;
1593
+ const entry = emitDiagnostic(
1594
+ {
1595
+ code: "LONG_HOLD",
1596
+ kind: "responsiveness",
1597
+ severity,
1598
+ message,
1599
+ nodeName: nodeName(subject),
1600
+ data
1601
+ },
1602
+ subject
1603
+ );
1604
+ if (severity === "warn") reportDiagnostic(entry);
1605
+ }
1606
+ const navStates = new WeakMap();
1607
+ /** Opened, not yet settled. */
1608
+ const openNavs = new Set();
1609
+ let navigationLog = [];
1610
+ /** Drains completed since enable() — the clock `writeDrain` reads. */
1611
+ let drainSeq = 0;
1612
+ /**
1613
+ * Copy what the router currently says onto the frame (what writes stamped —
1614
+ * `formatOrigin` reads it) and the event. Called when the frame opens, when a
1615
+ * redirect re-describes it, and when the record settles, so a description
1616
+ * refined during the hold is what every consumer ends up reading.
1617
+ */
1618
+ function syncNavigation(state) {
1619
+ const { event, ref } = state;
1620
+ const frame = event.origin;
1621
+ if (ref.name === undefined) {
1622
+ delete frame.name;
1623
+ delete event.name;
1624
+ } else frame.name = event.name = ref.name;
1625
+ if (ref.to === undefined) {
1626
+ delete frame.to;
1627
+ delete event.to;
1628
+ } else frame.to = event.to = ref.to;
1629
+ if (ref.params === undefined) {
1630
+ delete frame.params;
1631
+ delete event.params;
1632
+ } else frame.params = event.params = ref.params;
1633
+ }
1634
+ function openNavigation(frame, ref) {
1635
+ const event = { at: frame.at, writes: 0, origin: frame };
1636
+ if (frame.from !== undefined) event.from = frame.from;
1637
+ if (frame.interaction !== undefined) event.interaction = frame.interaction;
1638
+ const state = {
1639
+ event,
1640
+ ref,
1641
+ open: 1,
1642
+ held: false,
1643
+ writeDrain: drainSeq
1644
+ };
1645
+ syncNavigation(state);
1646
+ navStates.set(frame, state);
1647
+ openNavs.add(state);
1648
+ navigationLog.push(event);
1649
+ if (navigationLog.length > options.historyLimit) navigationLog.shift();
1650
+ noteInteractionNavigation(event);
1651
+ }
1652
+ /** The navigation a redirect hop folds onto: the most recently opened one still pending. */
1653
+ function lastOpenNavigation() {
1654
+ let last;
1655
+ for (const state of openNavs) last = state;
1656
+ return last;
1657
+ }
1658
+ /** A redirect re-describes `state`: the current destination becomes a hop it abandoned. */
1659
+ function redirectNavigation(state, ref) {
1660
+ const event = state.event;
1661
+ // As the router last described the destination being left behind.
1662
+ syncNavigation(state);
1663
+ const hop = { at: ref.at ?? now() };
1664
+ if (event.name !== undefined) hop.name = event.name;
1665
+ if (event.to !== undefined) hop.to = event.to;
1666
+ if (event.params !== undefined) hop.params = event.params;
1667
+ (event.redirects ??= []).push(hop);
1668
+ state.ref = ref;
1669
+ state.open++;
1670
+ syncNavigation(state);
1671
+ }
1672
+ function closeNavigation(frame) {
1673
+ const state = navStates.get(frame);
1674
+ if (state === undefined || --state.open > 0) return;
1675
+ syncNavigation(state);
1676
+ // Nothing to wait for: no write survived the equality gate (navigating to
1677
+ // where we already are), or a drain inside the frame already committed
1678
+ // them (`flush(() => setLocation(…))`) with no hold.
1679
+ if (state.event.writes === 0 || (!state.held && drainSeq > state.writeDrain))
1680
+ settleNavigation(state, "committed");
1681
+ }
1682
+ /** stampWrite: the record replacing `prior` on a node was just stamped. */
1683
+ function noteNavigationWrite(prior, record) {
1684
+ const origin = record.origin;
1685
+ if (origin.kind === "navigation") {
1686
+ const state = navStates.get(origin);
1687
+ if (state !== undefined) {
1688
+ state.event.writes++;
1689
+ state.writeDrain = drainSeq;
1690
+ }
1691
+ }
1692
+ // The node now carries a different frame's record: whatever `prior`'s
1693
+ // navigation was waiting to show on it will never land as that navigation.
1694
+ const before = prior?.origin;
1695
+ if (before !== undefined && before !== origin && before.kind === "navigation") {
1696
+ const state = navStates.get(before);
1697
+ if (state !== undefined && openNavs.has(state)) settleNavigation(state, "superseded");
1698
+ }
1699
+ }
1700
+ /** holdStart: `t`'s staged writes are parked — their navigations settle with `t`. */
1701
+ function markNavigationsHeld(t) {
1702
+ if (openNavs.size === 0) return;
1703
+ for (const node of t._pendingNodes) {
1704
+ const origin = node._devChange?.origin;
1705
+ if (origin?.kind !== "navigation") continue;
1706
+ const state = navStates.get(origin);
1707
+ if (state !== undefined) state.held = true;
1708
+ }
1709
+ }
1710
+ /** transitionSettled: `t` commits — the navigations whose writes it staged are done. */
1711
+ function settleNavigations(t, hold) {
1712
+ if (openNavs.size === 0) return;
1713
+ for (const node of t._pendingNodes) {
1714
+ const origin = node._devChange?.origin;
1715
+ if (origin?.kind !== "navigation") continue;
1716
+ const state = navStates.get(origin);
1717
+ if (state === undefined || !openNavs.has(state)) continue;
1718
+ // A navigation whose frame is still open when its transition commits
1719
+ // (`until()` inside the frame) settles here too: its writes are through.
1720
+ settleNavigation(state, state.held ? "held" : "committed", hold);
1721
+ }
1722
+ }
1723
+ /** flushEnd: every open, closed, unheld navigation's (and interaction's) writes just committed. */
1724
+ function trackFlushEnd() {
1725
+ drainSeq++;
1726
+ for (const state of openNavs)
1727
+ if (state.open === 0 && !state.held) settleNavigation(state, "committed");
1728
+ for (const state of openInteractions) maybeSettleInteraction(state);
1729
+ }
1730
+ function settleNavigation(state, outcome, hold) {
1731
+ if (!openNavs.has(state)) return;
1732
+ openNavs.delete(state);
1733
+ syncNavigation(state);
1734
+ const event = state.event;
1735
+ event.settledMs = now() - event.at;
1736
+ event.outcome = outcome;
1737
+ if (hold !== undefined) event.hold = hold;
1738
+ recordFeedbackNavigation(event);
1739
+ emitRecord("navigation", event);
1740
+ // The interaction that performed it may have been waiting only on this.
1741
+ const under = openInteractionOf(event.interaction);
1742
+ if (under !== undefined) maybeSettleInteraction(under);
1743
+ }
1744
+ const interactionStates = new WeakMap();
1745
+ /** Opened, not yet settled. */
1746
+ const openInteractions = new Set();
1747
+ let interactionLog = [];
1748
+ function openInteraction(frame) {
1749
+ const event = {
1750
+ name: frame.name,
1751
+ at: frame.at,
1752
+ handlerMs: 0,
1753
+ writes: 0,
1754
+ runs: 0,
1755
+ created: 0,
1756
+ runMs: 0,
1757
+ holds: [],
1758
+ navigations: [],
1759
+ origin: frame
1760
+ };
1761
+ if (frame.target !== undefined) event.target = frame.target;
1762
+ const state = {
1763
+ event,
1764
+ open: true,
1765
+ writeDrain: drainSeq,
1766
+ heldIn: new Set(),
1767
+ held: false
1768
+ };
1769
+ interactionStates.set(frame, state);
1770
+ openInteractions.add(state);
1771
+ interactionLog.push(event);
1772
+ if (interactionLog.length > options.historyLimit) interactionLog.shift();
1773
+ }
1774
+ /** interactionEnd: the handler returned. */
1775
+ function closeInteraction(frame) {
1776
+ const state = interactionStates.get(frame);
1777
+ if (state === undefined) return;
1778
+ state.open = false;
1779
+ const end = now();
1780
+ state.event.handlerMs = end - state.event.at;
1781
+ maybeSettleInteraction(state, end);
1782
+ }
1783
+ /** The open record a frame runs under, if any. */
1784
+ function openInteractionOf(origin) {
1785
+ const interaction = interactionOf(origin);
1786
+ if (interaction === undefined) return undefined;
1787
+ const state = interactionStates.get(interaction);
1788
+ return state !== undefined && openInteractions.has(state) ? state : undefined;
1789
+ }
1790
+ /** stampWrite: a root write stamped `origin`. */
1791
+ function noteInteractionWrite(origin) {
1792
+ const state = openInteractionOf(origin);
1793
+ if (state === undefined) return;
1794
+ state.event.writes++;
1795
+ state.writeDrain = drainSeq;
1796
+ }
1797
+ /** recordRerun / a create run: work attributed to the interaction. */
1798
+ function noteInteractionRun(interaction, selfMs, created) {
1799
+ const state = openInteractionOf(interaction);
1800
+ if (state === undefined) return;
1801
+ state.event[created ? "created" : "runs"]++;
1802
+ state.event.runMs += selfMs;
1803
+ }
1804
+ /** openNavigation: a navigation frame opened under the interaction. */
1805
+ function noteInteractionNavigation(event) {
1806
+ const state = openInteractionOf(event.interaction);
1807
+ if (state !== undefined) state.event.navigations.push(event);
1808
+ }
1809
+ /** holdStart: `t` parked writes — the interactions that performed them wait for `t`. */
1810
+ function markInteractionsHeld(t) {
1811
+ if (openInteractions.size === 0) return;
1812
+ for (const node of t._pendingNodes) {
1813
+ const state = openInteractionOf(node._devChange?.origin);
1814
+ if (state !== undefined) {
1815
+ state.heldIn.add(t);
1816
+ state.held = true;
1817
+ }
1818
+ }
1819
+ }
1820
+ /** transitionMerged: whoever waited for `outgoing` now waits for `target`. */
1821
+ function mergeInteractionsHeld(target, outgoing) {
1822
+ for (const state of openInteractions) if (state.heldIn.delete(outgoing)) state.heldIn.add(target);
1823
+ }
1824
+ /** transitionSettled: `t` committed — its holders' writes are through. */
1825
+ function settleInteractionsHeld(t, hold) {
1826
+ if (openInteractions.size === 0) return;
1827
+ for (const state of openInteractions) {
1828
+ if (!state.heldIn.delete(t)) continue;
1829
+ if (hold !== undefined) state.event.holds.push(hold);
1830
+ maybeSettleInteraction(state);
1831
+ }
1832
+ }
1833
+ function maybeSettleInteraction(state, end = now()) {
1834
+ const event = state.event;
1835
+ if (state.open || state.heldIn.size > 0) return;
1836
+ // A drain must have committed the last write (the handler's, or a redirect
1837
+ // hop's after the click's own drain) — the handler returning is not the
1838
+ // screen having it.
1839
+ if (event.writes > 0 && drainSeq <= state.writeDrain) return;
1840
+ for (const nav of event.navigations) if (nav.outcome === undefined) return;
1841
+ openInteractions.delete(state);
1842
+ event.settledMs = end - event.at;
1843
+ event.outcome = event.writes === 0 ? "idle" : state.held ? "held" : "committed";
1844
+ emitRecord("interaction", event);
1845
+ }
1846
+ /** The serializable face of a navigation origin for diagnostic `data`. */
1847
+ function navigationData(origin) {
1848
+ const data = {};
1849
+ if (origin.name !== undefined) data.name = origin.name;
1850
+ if (origin.to !== undefined) data.to = origin.to;
1851
+ if (origin.from !== undefined) data.from = origin.from;
1852
+ if (origin.params !== undefined) data.params = origin.params;
1853
+ return data;
1854
+ }
1855
+ const feedbackSources = new Map();
1856
+ const feedbackInteractions = new Map();
1857
+ const feedbackNavigations = new Map();
1858
+ const flightStats = new Map();
1859
+ const fallbackStats = new Map();
1860
+ const FALLBACK_FLASH_MS = 150;
1861
+ function flightBucket(el) {
1862
+ let row = flightStats.get(el);
1863
+ if (row === undefined) {
1864
+ row = { source: nodeName(el), flights: 0, landed: 0, abandoned: 0, landedMs: 0, worstMs: 0 };
1865
+ flightStats.set(el, row);
1866
+ }
1867
+ return row;
1868
+ }
1869
+ function trackFallback(boundary, tree, shown) {
1870
+ let bucket = fallbackStats.get(boundary);
1871
+ if (bucket === undefined) {
1872
+ bucket = {
1873
+ row: { boundary: "boundary", shows: 0, shownMs: 0, worstMs: 0, flashes: 0 },
1874
+ shownAt: null
1875
+ };
1876
+ fallbackStats.set(boundary, bucket);
1877
+ }
1878
+ // The first show can fire before the subtree exists; name on first sight.
1879
+ if (bucket.row.boundary === "boundary" && tree !== undefined) {
1880
+ const path = ownerPath(tree);
1881
+ if (path !== undefined) bucket.row.boundary = path.join(" › ");
1882
+ }
1883
+ if (shown) {
1884
+ if (bucket.shownAt === null) {
1885
+ bucket.shownAt = now();
1886
+ bucket.row.shows++;
1887
+ }
1888
+ return;
1889
+ }
1890
+ if (bucket.shownAt === null) return;
1891
+ const ms = now() - bucket.shownAt;
1892
+ bucket.shownAt = null;
1893
+ bucket.row.shownMs += ms;
1894
+ if (ms > bucket.row.worstMs) bucket.row.worstMs = ms;
1895
+ if (ms < FALLBACK_FLASH_MS) bucket.row.flashes++;
1896
+ }
1897
+ /** No affordance answered and nothing painted while held. */
1898
+ function isSilentHold(event) {
1899
+ return event.paintedDuringHold === 0 && event.acknowledgements.length === 0;
1900
+ }
1901
+ function interactionBucket(interaction) {
1902
+ const key = formatOrigin(interaction);
1903
+ let bucket = feedbackInteractions.get(key);
1904
+ if (bucket === undefined) {
1905
+ bucket = {
1906
+ row: {
1907
+ interaction: key,
1908
+ dispatches: 0,
1909
+ runs: 0,
1910
+ selfMs: 0,
1911
+ worstDispatchMs: 0,
1912
+ holds: 0,
1913
+ heldMs: 0,
1914
+ silentMs: 0,
1915
+ worstHoldMs: 0
1916
+ },
1917
+ dispatches: new Map()
1918
+ };
1919
+ feedbackInteractions.set(key, bucket);
1920
+ }
1921
+ const at = interaction.at ?? 0;
1922
+ if (!bucket.dispatches.has(at)) {
1923
+ bucket.dispatches.set(at, 0);
1924
+ bucket.row.dispatches++;
1925
+ }
1926
+ return bucket;
1927
+ }
1928
+ function recordFeedbackRun(event) {
1929
+ if (event.interaction === undefined) return;
1930
+ const bucket = interactionBucket(event.interaction);
1931
+ bucket.row.runs++;
1932
+ bucket.row.selfMs += event.selfMs;
1933
+ const at = event.interaction.at ?? 0;
1934
+ const dispatchMs = bucket.dispatches.get(at) + event.selfMs;
1935
+ bucket.dispatches.set(at, dispatchMs);
1936
+ if (dispatchMs > bucket.row.worstDispatchMs) bucket.row.worstDispatchMs = dispatchMs;
1937
+ }
1938
+ function recordFeedbackHold(event) {
1939
+ const sources = [...event.blockers].sort();
1940
+ const key = sources.join("\u0000");
1941
+ let bucket = feedbackSources.get(key);
1942
+ if (bucket === undefined) {
1943
+ bucket = {
1944
+ row: {
1945
+ sources,
1946
+ holds: 0,
1947
+ heldMs: 0,
1948
+ worstMs: 0,
1949
+ silent: 0,
1950
+ silentMs: 0,
1951
+ latestOnly: 0,
1952
+ long: 0,
1953
+ longMs: 0,
1954
+ acknowledgedBy: [],
1955
+ interactions: [],
1956
+ writes: [],
1957
+ actions: 0
1958
+ },
1959
+ acks: new Map(),
1960
+ interactions: new Map(),
1961
+ writes: new Set()
1962
+ };
1963
+ feedbackSources.set(key, bucket);
1964
+ }
1965
+ const row = bucket.row;
1966
+ const silent = isSilentHold(event);
1967
+ row.holds++;
1968
+ row.heldMs += event.holdMs;
1969
+ if (event.holdMs > row.worstMs) row.worstMs = event.holdMs;
1970
+ if (silent) {
1971
+ row.silent++;
1972
+ row.silentMs += event.holdMs;
1973
+ } else if (
1974
+ event.acknowledgements.length > 0 &&
1975
+ event.acknowledgements.every(a => a.kind === "latest")
1976
+ )
1977
+ row.latestOnly++;
1978
+ if (isLongHold(event)) {
1979
+ row.long++;
1980
+ row.longMs += event.tailMs;
1981
+ }
1982
+ if (event.action) row.actions++;
1983
+ for (const a of event.acknowledgements) {
1984
+ const by = `${a.kind}:${a.source}`;
1985
+ bucket.acks.set(by, (bucket.acks.get(by) ?? 0) + 1);
1986
+ }
1987
+ for (const w of event.heldWrites) bucket.writes.add(w.name);
1988
+ if (event.interaction !== undefined) {
1989
+ const key = formatOrigin(event.interaction);
1990
+ bucket.interactions.set(key, (bucket.interactions.get(key) ?? 0) + 1);
1991
+ const ib = interactionBucket(event.interaction);
1992
+ ib.row.holds++;
1993
+ ib.row.heldMs += event.holdMs;
1994
+ if (silent) ib.row.silentMs += event.holdMs;
1995
+ if (event.holdMs > ib.row.worstHoldMs) ib.row.worstHoldMs = event.holdMs;
1996
+ }
1997
+ }
1998
+ function recordFeedbackNavigation(event) {
1999
+ const name = event.name ?? event.to;
2000
+ if (name === undefined) return;
2001
+ let row = feedbackNavigations.get(name);
2002
+ if (row === undefined) {
2003
+ row = {
2004
+ name,
2005
+ navigations: 0,
2006
+ settledMs: 0,
2007
+ worstMs: 0,
2008
+ held: 0,
2009
+ heldMs: 0,
2010
+ silent: 0,
2011
+ superseded: 0,
2012
+ redirected: 0
2013
+ };
2014
+ feedbackNavigations.set(name, row);
2015
+ }
2016
+ row.navigations++;
2017
+ if (event.redirects !== undefined) row.redirected++;
2018
+ if (event.outcome === "superseded") {
2019
+ row.superseded++;
2020
+ return;
2021
+ }
2022
+ const ms = event.settledMs;
2023
+ row.settledMs += ms;
2024
+ if (ms > row.worstMs) row.worstMs = ms;
2025
+ if (event.outcome === "held") {
2026
+ row.held++;
2027
+ row.heldMs += event.hold?.holdMs ?? ms;
2028
+ if (event.hold !== undefined && isSilentHold(event.hold)) row.silent++;
2029
+ }
2030
+ }
2031
+ function rankedCounts(counts, key) {
2032
+ return [...counts].sort((a, b) => b[1] - a[1]).map(([name, holds]) => ({ [key]: name, holds }));
2033
+ }
2034
+ function feedbackTables() {
2035
+ const navigations = [...feedbackNavigations.values()]
2036
+ .map(row => ({ ...row }))
2037
+ .sort((a, b) => b.heldMs - a.heldMs || b.settledMs - a.settledMs);
2038
+ const flights = [...flightStats.values()]
2039
+ .map(row => ({ ...row }))
2040
+ .sort((a, b) => b.abandoned - a.abandoned || b.flights - a.flights);
2041
+ const fallbacks = [...fallbackStats.values()]
2042
+ .map(bucket => ({ ...bucket.row }))
2043
+ .sort((a, b) => b.flashes - a.flashes || b.shownMs - a.shownMs);
2044
+ const sources = [...feedbackSources.values()]
2045
+ .map(bucket => ({
2046
+ ...bucket.row,
2047
+ acknowledgedBy: rankedCounts(bucket.acks, "by"),
2048
+ interactions: rankedCounts(bucket.interactions, "interaction"),
2049
+ writes: [...bucket.writes]
2050
+ }))
2051
+ .sort((a, b) => b.silentMs - a.silentMs || b.heldMs - a.heldMs);
2052
+ // Ranked by the total time the user spent on it: held plus synchronous work.
2053
+ const interactions = [...feedbackInteractions.values()]
2054
+ .map(bucket => ({ ...bucket.row }))
2055
+ .sort((a, b) => b.heldMs + b.selfMs - (a.heldMs + a.selfMs));
2056
+ return { sources, interactions, navigations, flights, fallbacks };
2057
+ }
2058
+ // The engine's implementation of the core's dev hook points. Installed by
2059
+ // enable(), uninstalled by disable() — while uninstalled the core pays one
2060
+ // null check per site and nothing else.
2061
+ let asyncStartSeq = 0;
2062
+ let asyncStartTime = 0;
2063
+ let asyncStartValue;
2064
+ const engineHooks = {
2065
+ interactionStart,
2066
+ interactionEnd,
2067
+ originStart,
2068
+ originEnd,
2069
+ flushEnd() {
2070
+ trackFlushEnd();
2071
+ },
2072
+ recomputeStart(el, create) {
2073
+ const causes = create ? null : collectCauses(el);
2074
+ frames.push({
2075
+ start: now(),
2076
+ childMs: 0,
2077
+ causes,
2078
+ prevDeps: create ? null : captureDeps(el),
2079
+ // Mirror recompute's own prev-value resolution: an earlier run in the
2080
+ // same flush may still be holding in _pendingValue.
2081
+ prevValue: el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value,
2082
+ // A create run inherits the interaction of whatever is building it: the
2083
+ // enclosing recompute (a parent's fn creating children) or, at the top
2084
+ // of the recompute stack, the effect callback / handler frame.
2085
+ interaction:
2086
+ causes !== null
2087
+ ? interactionIn(causes)
2088
+ : frames.length > 0
2089
+ ? frames[frames.length - 1].interaction
2090
+ : enclosingInteraction()
2091
+ });
2092
+ },
2093
+ derivedChanged(el) {
2094
+ const frame = frames[frames.length - 1];
2095
+ stampDerived(el, frame !== undefined && frame.causes !== null ? frame.causes : []);
2096
+ },
2097
+ recomputeEnd(el, _create, changed, optimistic, transition, held) {
2098
+ const frame = frames.pop();
2099
+ // enable() can land mid-recompute: no opening frame, nothing to report.
2100
+ if (frame === undefined) return;
2101
+ const totalMs = now() - frame.start;
2102
+ if (frames.length > 0) frames[frames.length - 1].childMs += totalMs;
2103
+ const selfMs = Math.max(0, totalMs - frame.childMs);
2104
+ // Effect-output honesty: effects run with `_equals: false`, so core
2105
+ // reports EVERY effect recompute as changed — which made effect waste
2106
+ // invisible to costs() (and compiled JSX bindings are effects: the
2107
+ // fan-out waste a naive selected-row produces is all effects). The
2108
+ // engine re-derives the fact from its own snapshot: an identical
2109
+ // committed compute output is an unchanged run. `undefined` outputs are
2110
+ // exempt — a side-effect-only compute's work IS its effect phase, and
2111
+ // identity of `undefined` proves nothing.
2112
+ if (changed && frame.causes !== null && el._type) {
2113
+ const committed = el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value;
2114
+ if (committed !== undefined && committed === frame.prevValue) changed = false;
2115
+ }
2116
+ // Unstable-output check: memos only, non-create, plain runs with a
2117
+ // committed change. The fresh value sits in `_pendingValue` for held
2118
+ // plain-flush memo commits and in `_value` for direct ones. Overlay runs
2119
+ // are excluded — an optimistic re-derive legitimately produces fresh
2120
+ // equivalents while the lane settles.
2121
+ if (frame.causes !== null && changed && !optimistic && !transition && !el._type)
2122
+ checkUnstableOutput(
2123
+ el,
2124
+ frame.prevValue,
2125
+ el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value
2126
+ );
2127
+ if (frame.causes !== null)
2128
+ recordRerun(
2129
+ el,
2130
+ frame,
2131
+ { selfMs, totalMs },
2132
+ changed,
2133
+ optimistic ? "optimistic" : transition ? "held" : "plain",
2134
+ held
2135
+ );
2136
+ else if (!excludedNode(el)) {
2137
+ // Creation runs still get the wide-scope check: a memo can be born with
2138
+ // its coarse-read problem already in place — and their time is charged
2139
+ // to the interaction building them (the interaction record's `created`).
2140
+ checkDepWidth(el);
2141
+ noteInteractionRun(frame.interaction, selfMs, true);
2142
+ }
2143
+ markSeen(el);
2144
+ },
2145
+ write(el, prev, value) {
2146
+ stampWrite(el, "write", prev, value);
2147
+ },
2148
+ refreshed(el) {
2149
+ stampWrite(el, "refresh");
2150
+ },
2151
+ flightStart(el, flight) {
2152
+ trackFlightStart(el, flight);
2153
+ },
2154
+ asyncStart(el) {
2155
+ asyncStartSeq = el._devChange?.seq ?? 0;
2156
+ asyncStartTime = el._time;
2157
+ asyncStartValue = el._value;
2158
+ },
2159
+ asyncEnd(el, prev, value, direct) {
2160
+ if (direct) {
2161
+ // Core calls this unconditionally (hook calls cannot live inside its
2162
+ // try blocks — see attribution-hooks.ts), so committed-ness is detected
2163
+ // here against the asyncStart snapshot: a direct commit moves `_value`
2164
+ // (or `_time`, for a same-reference commit under `equals: false`), and
2165
+ // a transition hold parks the value in `_pendingValue`. A landing the
2166
+ // equality gate swallowed moves none of them and must leave no stamp.
2167
+ const committed =
2168
+ el._value !== asyncStartValue || el._time !== asyncStartTime || el._pendingValue === value;
2169
+ if (committed) stampWrite(el, "async", prev === undefined ? NO_VALUES : prev, value);
2170
+ // Flight over either way — an equality-swallowed landing still spent
2171
+ // the wall time (finalizeFlight only chains through a fresh stamp).
2172
+ finalizeFlight(el);
2173
+ return;
2174
+ }
2175
+ // Landed through setSignal: reclassify its "write" stamp as an async
2176
+ // landing — but only if it actually stamped (the value changed) since
2177
+ // asyncStart; a no-change landing must leave no fresh stamp behind.
2178
+ const change = el._devChange;
2179
+ if (change !== undefined && change.seq > asyncStartSeq && change.kind === "write")
2180
+ stampWrite(el, "async", NO_VALUES, value);
2181
+ finalizeFlight(el);
2182
+ },
2183
+ effectRunStart(el) {
2184
+ pushFrame("effect", nodeName(el), el._devRunInteraction, el);
2185
+ },
2186
+ effectRunEnd() {
2187
+ popFrame("effect");
2188
+ if (activeHold !== null) activeHold.painted++;
2189
+ },
2190
+ actionStepStart(it, name) {
2191
+ // Steps after a yield resume from a promise callback with no ambient
2192
+ // interaction; the one that started the action (its first step) is the
2193
+ // action's interaction for every step.
2194
+ let interaction = actionInteractions.get(it);
2195
+ if (interaction === undefined && !actionInteractions.has(it)) {
2196
+ interaction = currentInteraction ?? undefined;
2197
+ actionInteractions.set(it, interaction);
2198
+ }
2199
+ pushFrame("action", name, interaction);
2200
+ },
2201
+ actionStepEnd() {
2202
+ popFrame("action");
2203
+ },
2204
+ holdStart(t) {
2205
+ trackHoldStart(t);
2206
+ },
2207
+ holdEnd() {
2208
+ activeHold = null;
2209
+ },
2210
+ transitionSettled(t) {
2211
+ trackHoldSettled(t);
2212
+ },
2213
+ transitionMerged(target, outgoing) {
2214
+ trackHoldMerge(target, outgoing);
2215
+ },
2216
+ storeReplaced(path, isArray, total, unchanged, prevTotal) {
2217
+ checkImmutableUpdate(path, isArray, total, unchanged, prevTotal);
2218
+ },
2219
+ listChurn(el, removed, created, newLen, keyed) {
2220
+ checkListIdentity(el, removed, created, newLen, keyed);
2221
+ },
2222
+ boundaryFallback(boundary, tree, shown) {
2223
+ trackFallback(boundary, tree, shown);
2224
+ }
2225
+ };
2226
+ const attribution = {
2227
+ enable(opts) {
2228
+ options = { ...defaultOptions, ...opts };
2229
+ frames.length = 0;
2230
+ scopeCosts.clear();
2231
+ writeCosts.clear();
2232
+ waterfallLog = [];
2233
+ holdLog = [];
2234
+ activeHold = null;
2235
+ feedbackSources.clear();
2236
+ feedbackInteractions.clear();
2237
+ feedbackNavigations.clear();
2238
+ navigationLog = [];
2239
+ openNavs.clear();
2240
+ interactionLog = [];
2241
+ openInteractions.clear();
2242
+ drainSeq = 0;
2243
+ reportedCycles.clear();
2244
+ relays.clear();
2245
+ immutableReported.clear();
2246
+ flightStats.clear();
2247
+ fallbackStats.clear();
2248
+ originFrames.length = 0;
2249
+ interactionStack.length = 0;
2250
+ currentInteraction = null;
2251
+ hotCauses.clear();
2252
+ setAttributionHooks(engineHooks);
2253
+ },
2254
+ disable() {
2255
+ clearListeners();
2256
+ history = [];
2257
+ frames.length = 0;
2258
+ scopeCosts.clear();
2259
+ writeCosts.clear();
2260
+ waterfallLog = [];
2261
+ holdLog = [];
2262
+ activeHold = null;
2263
+ feedbackSources.clear();
2264
+ feedbackInteractions.clear();
2265
+ feedbackNavigations.clear();
2266
+ navigationLog = [];
2267
+ openNavs.clear();
2268
+ interactionLog = [];
2269
+ openInteractions.clear();
2270
+ drainSeq = 0;
2271
+ reportedCycles.clear();
2272
+ relays.clear();
2273
+ immutableReported.clear();
2274
+ flightStats.clear();
2275
+ fallbackStats.clear();
2276
+ originFrames.length = 0;
2277
+ interactionStack.length = 0;
2278
+ currentInteraction = null;
2279
+ hotCauses.clear();
2280
+ setAttributionHooks(null);
2281
+ },
2282
+ subscribe(typeOrListener, listener) {
2283
+ const type = typeof typeOrListener === "string" ? typeOrListener : "rerun";
2284
+ const fn = typeof typeOrListener === "string" ? listener : typeOrListener;
2285
+ const set = recordListeners[type];
2286
+ set.add(fn);
2287
+ return () => set.delete(fn);
2288
+ },
2289
+ history() {
2290
+ return history;
2291
+ },
2292
+ why(target) {
2293
+ const node = target?.[$REFRESH] ?? target;
2294
+ return history.filter(event => event.node === node);
2295
+ },
2296
+ subscriptions(target) {
2297
+ const node = target?.[$REFRESH] ?? target;
2298
+ const names = [];
2299
+ for (let l = node?._deps ?? null; l !== null; l = l._nextDep) names.push(nodeName(l._dep));
2300
+ return names;
2301
+ },
2302
+ costs() {
2303
+ return {
2304
+ scopes: [...scopeCosts.values()].sort((a, b) => b.selfMs - a.selfMs),
2305
+ writes: [...writeCosts.values()].sort((a, b) => b.downstreamMs - a.downstreamMs)
2306
+ };
2307
+ },
2308
+ waterfalls() {
2309
+ return waterfallLog;
2310
+ },
2311
+ holds() {
2312
+ return holdLog;
2313
+ },
2314
+ navigations() {
2315
+ return navigationLog;
2316
+ },
2317
+ interactions() {
2318
+ return interactionLog;
2319
+ },
2320
+ feedback() {
2321
+ return feedbackTables();
2322
+ },
2323
+ markFlight(flight, startedAt = now()) {
2324
+ // Earliest wins: re-marking (a cache re-serving the same promise) must
2325
+ // not move the origin later.
2326
+ const existing = flightOrigins.get(flight);
2327
+ if (existing === undefined || startedAt < existing) flightOrigins.set(flight, startedAt);
2328
+ },
2329
+ format: formatRerun,
2330
+ formatOrigin
2331
+ };
2332
+
2333
+ export { attribution };