@solidjs/signals 2.0.0-rc.1 → 2.0.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dev-shared.js +7138 -0
- package/dist/dev.attribution.js +3307 -0
- package/dist/dev.js +5691 -4491
- package/dist/observe/affects.js +129 -0
- package/dist/observe/attribution.js +7 -0
- package/dist/observe/boundaries.js +793 -0
- package/dist/observe/core/action.js +177 -0
- package/dist/observe/core/async.js +849 -0
- package/dist/observe/core/attribution-costs.js +66 -0
- package/dist/observe/core/attribution-feedback.js +280 -0
- package/dist/observe/core/attribution-hooks.js +83 -0
- package/dist/observe/core/attribution-queries.js +43 -0
- package/dist/observe/core/attribution.js +2904 -0
- package/dist/observe/core/constants.js +283 -0
- package/dist/{types-cjs/core/context.d.cts → observe/core/context.js} +37 -12
- package/dist/observe/core/core.js +1999 -0
- package/dist/observe/core/dev.js +343 -0
- package/dist/observe/core/effect.js +200 -0
- package/dist/observe/core/error-hooks.js +71 -0
- package/dist/observe/core/error.js +80 -0
- package/dist/observe/core/external.js +98 -0
- package/dist/observe/core/graph.js +155 -0
- package/dist/observe/core/heap.js +147 -0
- package/dist/observe/core/invariants.js +45 -0
- package/dist/observe/core/lanes.js +205 -0
- package/dist/observe/core/optimistic.js +627 -0
- package/dist/observe/core/owner.js +353 -0
- package/dist/observe/core/scheduler.js +1547 -0
- package/dist/observe/core/verdict.js +674 -0
- package/dist/observe/index.js +59 -0
- package/dist/observe/map.js +484 -0
- package/dist/observe/signals.js +731 -0
- package/dist/observe/store/index.js +44 -0
- package/dist/observe/store/next/optimistic.js +717 -0
- package/dist/observe/store/next/projection.js +266 -0
- package/dist/observe/store/next/reconcile.js +375 -0
- package/dist/observe/store/next/store.js +2480 -0
- package/dist/observe/store/next/target.js +51 -0
- package/dist/observe/store/store.js +331 -0
- package/dist/observe/store/storePath.js +103 -0
- package/dist/observe/store/utils.js +1070 -0
- package/dist/prod/affects.js +10 -9
- package/dist/prod/attribution.js +45 -0
- package/dist/prod/boundaries.js +388 -178
- package/dist/prod/core/action.js +65 -37
- package/dist/prod/core/async.js +378 -163
- package/dist/prod/core/attribution-hooks.js +3 -0
- package/dist/prod/core/constants.js +186 -1
- package/dist/prod/core/context.js +10 -16
- package/dist/prod/core/core.js +1546 -416
- package/dist/prod/core/dev.js +18 -1
- package/dist/prod/core/effect.js +119 -79
- package/dist/prod/core/error-hooks.js +71 -0
- package/dist/prod/core/error.js +13 -1
- package/dist/prod/core/external.js +4 -4
- package/dist/prod/core/graph.js +105 -54
- package/dist/prod/core/heap.js +72 -65
- package/dist/prod/core/invariants.js +3 -2
- package/dist/prod/core/lanes.js +117 -52
- package/dist/prod/core/optimistic.js +458 -104
- package/dist/prod/core/owner.js +131 -84
- package/dist/prod/core/scheduler.js +998 -241
- package/dist/prod/core/verdict.js +417 -112
- package/dist/prod/index.js +20 -8
- package/dist/prod/map.js +312 -151
- package/dist/prod/signals.js +363 -32
- package/dist/prod/store/index.js +2 -0
- package/dist/prod/store/next/optimistic.js +530 -197
- package/dist/prod/store/next/projection.js +142 -57
- package/dist/prod/store/next/reconcile.js +149 -103
- package/dist/prod/store/next/store.js +1470 -346
- package/dist/prod/store/next/target.js +40 -9
- package/dist/prod/store/store.js +23 -19
- package/dist/prod/store/utils.js +997 -130
- package/dist/types/attribution.d.ts +19 -0
- package/dist/types/attribution.prod.d.ts +22 -0
- package/dist/types/boundaries.d.ts +78 -5
- package/dist/types/core/action.d.ts +12 -5
- package/dist/types/core/async.d.ts +3 -1
- package/dist/types/core/attribution-costs.d.ts +35 -0
- package/dist/types/core/attribution-feedback.d.ts +133 -0
- package/dist/types/core/attribution-hooks.d.ts +304 -0
- package/dist/types/core/attribution-queries.d.ts +24 -0
- package/dist/types/core/attribution.d.ts +892 -0
- package/dist/types/core/constants.d.ts +179 -0
- package/dist/types/core/core.d.ts +299 -25
- package/dist/types/core/dev.d.ts +421 -8
- package/dist/types/core/effect.d.ts +8 -1
- package/dist/types/core/error-hooks.d.ts +71 -0
- package/dist/types/core/error.d.ts +9 -0
- package/dist/types/core/graph.d.ts +23 -0
- package/dist/types/core/heap.d.ts +5 -3
- package/dist/types/core/index.d.ts +6 -3
- package/dist/types/core/invariants.d.ts +5 -1
- package/dist/types/core/lanes.d.ts +51 -19
- package/dist/types/core/owner.d.ts +15 -3
- package/dist/types/core/scheduler.d.ts +248 -1
- package/dist/types/core/types.d.ts +124 -41
- package/dist/types/index.d.ts +18 -5
- package/dist/types/map.d.ts +2 -0
- package/dist/types/signals.d.ts +145 -13
- package/dist/types/store/index.d.ts +7 -6
- package/dist/types/store/next/optimistic.d.ts +18 -13
- package/dist/types/store/next/projection.d.ts +5 -2
- package/dist/types/store/next/reconcile.d.ts +7 -0
- package/dist/types/store/next/store.d.ts +83 -11
- package/dist/types/store/next/target.d.ts +108 -11
- package/dist/types/store/store.d.ts +20 -11
- package/dist/types/store/utils.d.ts +188 -0
- package/package.json +31 -26
- package/dist/node.cjs +0 -8137
- package/dist/types/store/optimistic.d.ts +0 -45
- package/dist/types/store/projection.d.ts +0 -70
- package/dist/types/store/reconcile.d.ts +0 -46
- package/dist/types-cjs/affects.d.cts +0 -47
- package/dist/types-cjs/boundaries.d.cts +0 -175
- package/dist/types-cjs/core/action.d.cts +0 -64
- package/dist/types-cjs/core/async.d.cts +0 -21
- package/dist/types-cjs/core/constants.d.cts +0 -71
- package/dist/types-cjs/core/core.d.cts +0 -156
- package/dist/types-cjs/core/dev.d.cts +0 -64
- package/dist/types-cjs/core/effect.d.cts +0 -30
- package/dist/types-cjs/core/error.d.cts +0 -47
- package/dist/types-cjs/core/external.d.cts +0 -15
- package/dist/types-cjs/core/graph.d.cts +0 -5
- package/dist/types-cjs/core/heap.d.cts +0 -22
- package/dist/types-cjs/core/index.d.cts +0 -13
- package/dist/types-cjs/core/invariants.d.cts +0 -59
- package/dist/types-cjs/core/lanes.d.cts +0 -56
- package/dist/types-cjs/core/optimistic.d.cts +0 -6
- package/dist/types-cjs/core/owner.d.cts +0 -124
- package/dist/types-cjs/core/scheduler.d.cts +0 -172
- package/dist/types-cjs/core/types.d.cts +0 -148
- package/dist/types-cjs/core/verdict.d.cts +0 -2
- package/dist/types-cjs/index.d.cts +0 -10
- package/dist/types-cjs/map.d.cts +0 -69
- package/dist/types-cjs/package.json +0 -3
- package/dist/types-cjs/signals.d.cts +0 -624
- package/dist/types-cjs/store/index.d.cts +0 -19
- package/dist/types-cjs/store/next/optimistic.d.cts +0 -20
- package/dist/types-cjs/store/next/projection.d.cts +0 -24
- package/dist/types-cjs/store/next/reconcile.d.cts +0 -3
- package/dist/types-cjs/store/next/store.d.cts +0 -71
- package/dist/types-cjs/store/next/target.d.cts +0 -99
- package/dist/types-cjs/store/optimistic.d.cts +0 -45
- package/dist/types-cjs/store/projection.d.cts +0 -70
- package/dist/types-cjs/store/reconcile.d.cts +0 -46
- package/dist/types-cjs/store/store.d.cts +0 -138
- package/dist/types-cjs/store/storePath.d.cts +0 -58
- package/dist/types-cjs/store/utils.d.cts +0 -74
|
@@ -0,0 +1,3307 @@
|
|
|
1
|
+
import {
|
|
2
|
+
bL as OBSERVE,
|
|
3
|
+
bV as setAttributionHooks,
|
|
4
|
+
bW as liveRootOwners,
|
|
5
|
+
b as NOT_PENDING,
|
|
6
|
+
ay as emitDiagnostic,
|
|
7
|
+
bX as ownerPath,
|
|
8
|
+
ax as reportDiagnostic,
|
|
9
|
+
bY as isSuppressed,
|
|
10
|
+
bZ as CONFIG_PLUMBING,
|
|
11
|
+
b_ as anyExcluded,
|
|
12
|
+
b$ as isExcluded,
|
|
13
|
+
q as CONFIG_DERIVED_OVERRIDE,
|
|
14
|
+
c0 as GRAPH_SIZE_WARN_AT,
|
|
15
|
+
c1 as noteFanOut,
|
|
16
|
+
aV as $REFRESH
|
|
17
|
+
} from "./dev-shared.js";
|
|
18
|
+
|
|
19
|
+
/** A fallback shown for less than this is a flash: feedback for a wait too short to need it. */
|
|
20
|
+
const FALLBACK_FLASH_MS = 150;
|
|
21
|
+
/**
|
|
22
|
+
* Under an owner the observer marked as its own (`OBSERVE.exclude`), or
|
|
23
|
+
* framework plumbing itself (`CONFIG_PLUMBING` — the HMR memo between a
|
|
24
|
+
* component's root and its body, which is nobody's node): the engine records
|
|
25
|
+
* nothing about the node. Plumbing is a bit read and excludes the node alone,
|
|
26
|
+
* not what it owns; the observer exclusion is cached per node once any
|
|
27
|
+
* exists, before that a flag read.
|
|
28
|
+
*/
|
|
29
|
+
function excludedNode(el) {
|
|
30
|
+
if ((el._config & CONFIG_PLUMBING) !== 0) return true;
|
|
31
|
+
if (!anyExcluded()) return false;
|
|
32
|
+
const node = el;
|
|
33
|
+
if (node._devExcluded === undefined) node._devExcluded = isExcluded(el);
|
|
34
|
+
return node._devExcluded;
|
|
35
|
+
}
|
|
36
|
+
let attributionActive = false;
|
|
37
|
+
let changeSeq = 0;
|
|
38
|
+
let runSeq = 0;
|
|
39
|
+
const defaultOptions = {
|
|
40
|
+
log: true,
|
|
41
|
+
// Tier-dependent (see `AttributionOptions.values`): dev output is for the
|
|
42
|
+
// developer at the console and shows everything; an observe build is a
|
|
43
|
+
// production artifact and carries no user data unless a holder asks. The
|
|
44
|
+
// literal folds per build — the observe engine ships `"none"` only.
|
|
45
|
+
values: "full",
|
|
46
|
+
checks: true,
|
|
47
|
+
stacks: false,
|
|
48
|
+
historyLimit: 200,
|
|
49
|
+
hotRuns: { count: 120, windowMs: 1000 },
|
|
50
|
+
wideDeps: 30,
|
|
51
|
+
hotTime: { budgetMs: 8, windowMs: 1000 },
|
|
52
|
+
unstableMemos: 4,
|
|
53
|
+
wastedRecompute: { minRuns: 5, ratio: 0.8, budgetMs: 2, windowMs: 1000 },
|
|
54
|
+
fanOut: 250,
|
|
55
|
+
waterfalls: { minFlightMs: 50 },
|
|
56
|
+
holds: { infoMs: 100, warnMs: 200 },
|
|
57
|
+
longHolds: { infoMs: 500, warnMs: 1000 },
|
|
58
|
+
graphGrowth: { visits: 3, ratio: 1.25 },
|
|
59
|
+
abandonedFlights: { count: 3, windowMs: 1000 },
|
|
60
|
+
fallbackFlashes: true,
|
|
61
|
+
optimisticReverts: true,
|
|
62
|
+
stackedHolds: { count: 3 }
|
|
63
|
+
};
|
|
64
|
+
let options = { ...defaultOptions };
|
|
65
|
+
let history = [];
|
|
66
|
+
/**
|
|
67
|
+
* The engine's records go out on the core's channel, `OBSERVE.records` —
|
|
68
|
+
* the one every runtime emits on — under the types `RecordTypes` declares
|
|
69
|
+
* for it (`rerun`, `create`, `effect`, `flush`, `flight`, `fallback`,
|
|
70
|
+
* `interaction`, `hold`, `navigation`, `graph`), each with the live node as
|
|
71
|
+
* `live` where the record has one. `records.observed(type)` is the
|
|
72
|
+
* pre-check the listener-gated records cost nothing without; the channel
|
|
73
|
+
* is process-wide and the subscriptions on it are the consumer's, so the
|
|
74
|
+
* engine neither holds nor clears listeners of its own.
|
|
75
|
+
*/
|
|
76
|
+
const records = OBSERVE.records;
|
|
77
|
+
/** @internal The engine's clock: `performance.now()` where it exists. */
|
|
78
|
+
const now = typeof performance !== "undefined" ? () => performance.now() : () => Date.now();
|
|
79
|
+
const frames = [];
|
|
80
|
+
const folds = [];
|
|
81
|
+
/** @internal */
|
|
82
|
+
function registerFold(hooks) {
|
|
83
|
+
folds.push(hooks);
|
|
84
|
+
}
|
|
85
|
+
/** @internal Root cause names of a cause chain — the writes/landings/refreshes the chain bottoms out in. */
|
|
86
|
+
function rootsOf(causes, out) {
|
|
87
|
+
for (const c of causes) {
|
|
88
|
+
if (c.kind === "derived" && c.causes && c.causes.length > 0) rootsOf(c.causes, out);
|
|
89
|
+
else out.add(c.name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function nodeName(node) {
|
|
93
|
+
return node._name ?? "anonymous";
|
|
94
|
+
}
|
|
95
|
+
/** The record vocabulary for a computation's kind: effects carry a `_type`, memos do not. */
|
|
96
|
+
function nodeKind(el) {
|
|
97
|
+
return el._type ? "effect" : "memo";
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Whether a re-run record has an audience — a listener on the channel, an
|
|
101
|
+
* imported fold (`costs`/`feedback`), or the console log. Read at recompute
|
|
102
|
+
* START (the subscription diff needs the deps as they were), so a listener
|
|
103
|
+
* arriving mid-run hears the next one. Without an audience the engine still
|
|
104
|
+
* runs every check and keeps every per-node fact; only the record — its
|
|
105
|
+
* cause list copy, dep diff, previews — is not built, and `history("rerun")`
|
|
106
|
+
* stays empty.
|
|
107
|
+
*/
|
|
108
|
+
function wantsRerun() {
|
|
109
|
+
return options.log || folds.length > 0 || records.observed("rerun");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A short preview of a written value for a record — only ever called under
|
|
113
|
+
* `values: "full"` (`stampWrite`, OPTIMISTIC_REVERTED): the other levels
|
|
114
|
+
* carry no previews, and the check is made at the call site so the value is
|
|
115
|
+
* not even looked at.
|
|
116
|
+
*/
|
|
117
|
+
function preview(v) {
|
|
118
|
+
if (v === null) return "null";
|
|
119
|
+
switch (typeof v) {
|
|
120
|
+
case "undefined":
|
|
121
|
+
return "undefined";
|
|
122
|
+
case "string":
|
|
123
|
+
return JSON.stringify(v.length > 40 ? v.slice(0, 40) + "…" : v);
|
|
124
|
+
case "number":
|
|
125
|
+
case "boolean":
|
|
126
|
+
case "bigint":
|
|
127
|
+
return String(v);
|
|
128
|
+
case "function":
|
|
129
|
+
return "[function]";
|
|
130
|
+
case "symbol":
|
|
131
|
+
return v.toString();
|
|
132
|
+
default:
|
|
133
|
+
return Array.isArray(v) ? `Array(${v.length})` : `[${v.constructor?.name ?? "object"}]`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function captureStack() {
|
|
137
|
+
if (!options.stacks) return undefined;
|
|
138
|
+
const raw = new Error().stack?.split("\n") ?? [];
|
|
139
|
+
// Drop the message line and every frame inside the reactive core; the first
|
|
140
|
+
// remaining frames are the user code that performed the write.
|
|
141
|
+
return raw
|
|
142
|
+
.slice(1)
|
|
143
|
+
.filter(line => !/(?:^|[/\\])(?:packages[/\\])?signals[/\\](src|dist)[/\\]/.test(line))
|
|
144
|
+
.slice(0, 3)
|
|
145
|
+
.map(line => line.trim());
|
|
146
|
+
}
|
|
147
|
+
/** Sentinel for "no value transition to record" (refresh() stamps). */
|
|
148
|
+
const NO_VALUES = Symbol("no-values");
|
|
149
|
+
// --- Provenance -------------------------------------------------------------
|
|
150
|
+
//
|
|
151
|
+
// Who performed a write is not a graph fact — the graph only sees the write.
|
|
152
|
+
// The engine keeps an ambient answer: a stack of imperative frames the core
|
|
153
|
+
// announces (effect callbacks, action steps) and the interaction the web
|
|
154
|
+
// runtime declares around event dispatch. A write stamps the innermost frame;
|
|
155
|
+
// frames nested under an interaction carry it. Effects run in a later flush
|
|
156
|
+
// than the click that caused them, so their frame inherits the interaction
|
|
157
|
+
// from the run's cause chain instead (recorded at recomputeEnd).
|
|
158
|
+
const EXTERNAL_ORIGIN = { kind: "external" };
|
|
159
|
+
const originFrames = [];
|
|
160
|
+
/** Per action invocation (keyed by its iterator): the interaction its first step ran under. */
|
|
161
|
+
const actionInteractions = new WeakMap();
|
|
162
|
+
/**
|
|
163
|
+
* The interaction frame the core's `withInteraction` opened (via the
|
|
164
|
+
* `interactionStart`/`interactionEnd` hooks) for the duration of a handler.
|
|
165
|
+
* Frames nest strictly, so the enclosing one is kept on a stack to restore.
|
|
166
|
+
* The core pins the engine per frame: an `interactionEnd` can arrive after
|
|
167
|
+
* `disable()` cleared the stack, so popping an empty stack is tolerated.
|
|
168
|
+
*/
|
|
169
|
+
let currentInteraction = null;
|
|
170
|
+
const interactionStack = [];
|
|
171
|
+
/**
|
|
172
|
+
* The element label an interaction record carries, from the one the runtime
|
|
173
|
+
* described (`tag`, then `#id` or `[name=…]`, then the element's text in
|
|
174
|
+
* quotes — `button#next "Next →"`), cut to what `options.values` allows: the
|
|
175
|
+
* text is the part after the first ` "`; under `"labels"` it stays on a
|
|
176
|
+
* `button` or an `a`, under `"none"` never. The ref's own string is never
|
|
177
|
+
* mutated — the runtime's object is the runtime's.
|
|
178
|
+
*/
|
|
179
|
+
function targetLabel(target) {
|
|
180
|
+
const level = options.values;
|
|
181
|
+
if (level === "full") return target;
|
|
182
|
+
const text = target.indexOf(' "');
|
|
183
|
+
if (text === -1) return target;
|
|
184
|
+
if (level === "labels") {
|
|
185
|
+
const head = target.slice(0, text);
|
|
186
|
+
const mark = head.search(/[#[]/);
|
|
187
|
+
const tag = mark === -1 ? head : head.slice(0, mark);
|
|
188
|
+
if (tag === "button" || tag === "a") return target;
|
|
189
|
+
}
|
|
190
|
+
return target.slice(0, text);
|
|
191
|
+
}
|
|
192
|
+
function interactionStart(ref) {
|
|
193
|
+
interactionStack.push(currentInteraction);
|
|
194
|
+
// One clock read: the frame opens now; the interaction began at `ref.at`
|
|
195
|
+
// when the runtime dated it (the event's own timestamp), else now too.
|
|
196
|
+
const opened = now();
|
|
197
|
+
const origin = { kind: "interaction", name: ref.type, at: ref.at ?? opened };
|
|
198
|
+
if (ref.target) origin.target = targetLabel(ref.target);
|
|
199
|
+
currentInteraction = origin;
|
|
200
|
+
openInteraction(origin, opened);
|
|
201
|
+
}
|
|
202
|
+
function interactionEnd(returned) {
|
|
203
|
+
const closing = currentInteraction;
|
|
204
|
+
currentInteraction = interactionStack.length ? interactionStack.pop() : null;
|
|
205
|
+
if (closing !== null) closeInteraction(closing, returned);
|
|
206
|
+
}
|
|
207
|
+
/** The interaction an origin runs under (itself, when it is one). */
|
|
208
|
+
function interactionOf(origin) {
|
|
209
|
+
if (origin === undefined) return undefined;
|
|
210
|
+
return origin.kind === "interaction" ? origin : origin.interaction;
|
|
211
|
+
}
|
|
212
|
+
/** The interaction a cause list traces back to — root writes only, derived links walked. */
|
|
213
|
+
function interactionIn(causes) {
|
|
214
|
+
for (const c of causes) {
|
|
215
|
+
const found =
|
|
216
|
+
c.kind === "derived"
|
|
217
|
+
? c.causes !== undefined
|
|
218
|
+
? interactionIn(c.causes)
|
|
219
|
+
: undefined
|
|
220
|
+
: interactionOf(c.origin);
|
|
221
|
+
if (found !== undefined) return found;
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
function currentOrigin() {
|
|
226
|
+
const frame = originFrames[originFrames.length - 1];
|
|
227
|
+
if (frame !== undefined) return frame;
|
|
228
|
+
return currentInteraction ?? EXTERNAL_ORIGIN;
|
|
229
|
+
}
|
|
230
|
+
/** The root origin a cause list traces back to — the stamp of the nearest root write, derived links walked. */
|
|
231
|
+
function originIn(causes) {
|
|
232
|
+
for (const c of causes) {
|
|
233
|
+
const found =
|
|
234
|
+
c.kind === "derived" ? (c.causes !== undefined ? originIn(c.causes) : undefined) : c.origin;
|
|
235
|
+
if (found !== undefined && found.kind !== "external") return found;
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The `currentOrigin` hook: what a runtime recording its own fact right now
|
|
241
|
+
* (a server-function call) should stamp it with. Inside a recompute the
|
|
242
|
+
* fact belongs to the change that caused the run — a `createAsync` calling
|
|
243
|
+
* the server on a navigation's write is the navigation's, and through it the
|
|
244
|
+
* click's — walked down past create runs the way `trackFlightStart` does,
|
|
245
|
+
* since a node born inside a parent's run inherits the parent's causality.
|
|
246
|
+
* Outside one — or when the causes were themselves external (a memo a
|
|
247
|
+
* handler pulls, stale from a timer's write) — it is the write's answer
|
|
248
|
+
* (`currentOrigin`), minus the external sentinel: "none known" is
|
|
249
|
+
* `undefined` on a record, as `HoldEvent.origin` has it. The objects
|
|
250
|
+
* returned are the engine's own frames, so the caller's record joins
|
|
251
|
+
* `InteractionEvent.origin` / `NavigationEvent.origin` by identity.
|
|
252
|
+
*/
|
|
253
|
+
function ambientOrigin() {
|
|
254
|
+
for (let i = frames.length - 1; i >= 0; i--) {
|
|
255
|
+
const causes = frames[i].causes;
|
|
256
|
+
if (causes !== null) {
|
|
257
|
+
const cause = originIn(causes);
|
|
258
|
+
if (cause !== undefined) return cause;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const origin = currentOrigin();
|
|
263
|
+
return origin === EXTERNAL_ORIGIN ? undefined : origin;
|
|
264
|
+
}
|
|
265
|
+
const effectFrames = new WeakMap();
|
|
266
|
+
/**
|
|
267
|
+
* The nodes of the open effect frames, innermost last — parallel to the
|
|
268
|
+
* `effect` entries of `originFrames`, so the frame on top resolves to its
|
|
269
|
+
* node without a map. A stack, not a single slot: a render effect created
|
|
270
|
+
* inside a callback runs its own callback synchronously.
|
|
271
|
+
*/
|
|
272
|
+
const effectStack = [];
|
|
273
|
+
/**
|
|
274
|
+
* A write just stamped `origin`. If it is the innermost effect frame (a
|
|
275
|
+
* root write's origin is always the innermost open frame, see `stampWrite`)
|
|
276
|
+
* and no earlier write registered it, register it now with the node on top
|
|
277
|
+
* of the effect stack. An origin that is an effect frame but not the top is
|
|
278
|
+
* inherited — reached through an earlier write's record — and that write
|
|
279
|
+
* registered it.
|
|
280
|
+
*/
|
|
281
|
+
function noteEffectOrigin(origin) {
|
|
282
|
+
if (origin.kind !== "effect" || effectFrames.has(origin)) return;
|
|
283
|
+
if (originFrames[originFrames.length - 1] !== origin) return;
|
|
284
|
+
const node = effectStack[effectStack.length - 1];
|
|
285
|
+
if (node !== undefined) effectFrames.set(origin, { node, causes: node._devRunCauses });
|
|
286
|
+
}
|
|
287
|
+
/** The interaction the innermost open frame runs under, else the ambient one. */
|
|
288
|
+
function enclosingInteraction() {
|
|
289
|
+
const top = originFrames[originFrames.length - 1];
|
|
290
|
+
return (top !== undefined ? interactionOf(top) : undefined) ?? currentInteraction ?? undefined;
|
|
291
|
+
}
|
|
292
|
+
function pushFrame(kind, name, interaction, effect) {
|
|
293
|
+
const frame = { kind };
|
|
294
|
+
if (name) frame.name = name;
|
|
295
|
+
const under = interaction ?? currentInteraction ?? undefined;
|
|
296
|
+
if (under !== undefined) frame.interaction = under;
|
|
297
|
+
if (effect !== undefined) {
|
|
298
|
+
const node = effect;
|
|
299
|
+
if (node._devRunSeq !== undefined) frame.run = node._devRunSeq;
|
|
300
|
+
// The frame → node map is filled by the first write inside the callback
|
|
301
|
+
// (noteEffectOrigin), not here: most callbacks never write.
|
|
302
|
+
effectStack.push(effect);
|
|
303
|
+
}
|
|
304
|
+
originFrames.push(frame);
|
|
305
|
+
}
|
|
306
|
+
function popFrame(kind) {
|
|
307
|
+
// Frames are strictly nested; a mismatch means enable() landed mid-frame
|
|
308
|
+
// (the opener never pushed) — leave the stack alone rather than pop a stranger.
|
|
309
|
+
const top = originFrames[originFrames.length - 1];
|
|
310
|
+
if (top !== undefined && top.kind === kind) {
|
|
311
|
+
originFrames.pop();
|
|
312
|
+
if (kind === "effect") effectStack.pop();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* `withOrigin` opened a declared frame. The frame object IS the origin every
|
|
317
|
+
* write inside stamps, and the key the navigation record hangs off (see
|
|
318
|
+
* "Navigations" below), so a hold or re-run that later resolves a write's
|
|
319
|
+
* origin lands on the same record. It runs under the interaction of the frame
|
|
320
|
+
* it opened inside — a `navigate()` from an action step, whose ambient
|
|
321
|
+
* interaction is long gone but whose frame remembers it — else the ambient
|
|
322
|
+
* one (a link click's handler).
|
|
323
|
+
*/
|
|
324
|
+
function originStart(ref) {
|
|
325
|
+
// A redirect hop re-enters the pending navigation's frame — the same object,
|
|
326
|
+
// so its writes stamp the same origin and replace the pending write without
|
|
327
|
+
// superseding it (see "Navigations").
|
|
328
|
+
if (ref.redirect !== undefined && ref.redirect > 0) {
|
|
329
|
+
const pending = lastOpenNavigation();
|
|
330
|
+
if (pending !== undefined) {
|
|
331
|
+
redirectNavigation(pending, ref);
|
|
332
|
+
originFrames.push(pending.event.origin);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const frame = { kind: ref.kind, at: ref.at ?? now() };
|
|
337
|
+
if (ref.from !== undefined) frame.from = ref.from;
|
|
338
|
+
const under = enclosingInteraction();
|
|
339
|
+
if (under !== undefined) frame.interaction = under;
|
|
340
|
+
originFrames.push(frame);
|
|
341
|
+
openNavigation(frame, ref);
|
|
342
|
+
}
|
|
343
|
+
function originEnd() {
|
|
344
|
+
const top = originFrames[originFrames.length - 1];
|
|
345
|
+
popFrame("navigation");
|
|
346
|
+
if (top !== undefined && top.kind === "navigation") closeNavigation(top);
|
|
347
|
+
}
|
|
348
|
+
/** `click on button#next "Next →"`, `effect "syncTitle"`, `action "save"`, `navigation to /users/:id`, … */
|
|
349
|
+
function formatOrigin(origin) {
|
|
350
|
+
switch (origin.kind) {
|
|
351
|
+
case "interaction":
|
|
352
|
+
return `${origin.name} on ${origin.target ?? "an element"}`;
|
|
353
|
+
case "effect":
|
|
354
|
+
return `effect${origin.name ? ` "${origin.name}"` : ""}`;
|
|
355
|
+
case "action":
|
|
356
|
+
return `action${origin.name ? ` "${origin.name}"` : ""}`;
|
|
357
|
+
case "async":
|
|
358
|
+
return `async landing${origin.name ? ` on "${origin.name}"` : ""}`;
|
|
359
|
+
case "navigation": {
|
|
360
|
+
// The route pattern is the name consumers group by; the concrete path
|
|
361
|
+
// follows when it adds information, then the destinations a redirect
|
|
362
|
+
// chain abandoned on the way.
|
|
363
|
+
const name = origin.name ?? origin.to;
|
|
364
|
+
if (name === undefined) return "navigation";
|
|
365
|
+
const notes = [];
|
|
366
|
+
if (origin.to !== undefined && origin.to !== name) notes.push(origin.to);
|
|
367
|
+
const redirects = navStates.get(origin)?.event.redirects;
|
|
368
|
+
if (redirects !== undefined)
|
|
369
|
+
notes.push(
|
|
370
|
+
`redirected from ${redirects.map(hop => hop.to ?? hop.name ?? "?").join(" → ")}`
|
|
371
|
+
);
|
|
372
|
+
return `navigation to ${name}${notes.length > 0 ? ` (${notes.join(", ")})` : ""}`;
|
|
373
|
+
}
|
|
374
|
+
default:
|
|
375
|
+
return "outside the reactive system";
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** Record a root change (setSignal / refresh / async landing) on the node. */
|
|
379
|
+
/** Live subscriber count, walked on demand — the core keeps no counter. */
|
|
380
|
+
function countSubscribers(node) {
|
|
381
|
+
let n = 0;
|
|
382
|
+
for (let s = node._subs; s !== null; s = s._nextSub) n++;
|
|
383
|
+
return n;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* HUGE_FAN_OUT from the engine's lower `fanOut` threshold (see dev.ts): a
|
|
387
|
+
* committed root invalidation reaching hundreds of subscribers re-runs all
|
|
388
|
+
* of them this flush. Counts the subscriber list itself (an engine-only
|
|
389
|
+
* walk, on the write; the core keeps no per-node count — a live `_subCount`
|
|
390
|
+
* was a post-construction field that forked node shapes). Stops at
|
|
391
|
+
* GRAPH_SIZE_WARN_AT, where the always-on core check takes over, so the two
|
|
392
|
+
* never fire for the same write; the once-per-node dedupe is the core's.
|
|
393
|
+
*/
|
|
394
|
+
function checkFanOut(node, kind) {
|
|
395
|
+
const limit = options.fanOut;
|
|
396
|
+
if (typeof limit !== "number") return;
|
|
397
|
+
const subs = countSubscribers(node);
|
|
398
|
+
if (subs < limit || subs >= GRAPH_SIZE_WARN_AT) return;
|
|
399
|
+
noteFanOut(node, subs, kind);
|
|
400
|
+
}
|
|
401
|
+
function stampWrite(node, kind, prev = NO_VALUES, value = NO_VALUES) {
|
|
402
|
+
const record = {
|
|
403
|
+
seq: ++changeSeq,
|
|
404
|
+
kind,
|
|
405
|
+
name: nodeName(node),
|
|
406
|
+
nodeId: devId(node)
|
|
407
|
+
};
|
|
408
|
+
// The previews are the record's one look at the written values: under
|
|
409
|
+
// any level but `"full"` they are not taken, so the record never carries
|
|
410
|
+
// them (nor does the `HeldWrite` copied from it, nor a sentence built on it).
|
|
411
|
+
if (value !== NO_VALUES && options.values === "full") {
|
|
412
|
+
record.prev = prev === NO_VALUES ? undefined : preview(prev);
|
|
413
|
+
record.value = preview(value);
|
|
414
|
+
}
|
|
415
|
+
record.origin = kind === "async" ? asyncOrigin(node) : currentOrigin();
|
|
416
|
+
record.at = now();
|
|
417
|
+
record.stack = captureStack();
|
|
418
|
+
const prior = node._devChange;
|
|
419
|
+
node._devChange = record;
|
|
420
|
+
noteNavigationWrite(prior, record);
|
|
421
|
+
noteInteractionWrite(record.origin, excludedNode(node));
|
|
422
|
+
if (kind === "write") trackEffectWrite(node, record, value);
|
|
423
|
+
// stampWrite is the single funnel for committed root invalidations (sync
|
|
424
|
+
// writes, refresh(), async landings), which makes it the one place the
|
|
425
|
+
// engine's fan-out check needs to live.
|
|
426
|
+
checkFanOut(node, kind);
|
|
427
|
+
}
|
|
428
|
+
/** Record a derived change (memo produced a new value) with its causes. */
|
|
429
|
+
function stampDerived(node, causes) {
|
|
430
|
+
node._devChange = {
|
|
431
|
+
seq: ++changeSeq,
|
|
432
|
+
kind: "derived",
|
|
433
|
+
name: nodeName(node),
|
|
434
|
+
nodeId: devId(node),
|
|
435
|
+
causes
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Collect the deps whose committed change is newer than this node's previous
|
|
440
|
+
* run. Called at recompute entry, while `_deps` still holds the previous
|
|
441
|
+
* run's links. A refresh() stamp on the node itself also counts — that is a
|
|
442
|
+
* self-invalidation, not a dep change.
|
|
443
|
+
*/
|
|
444
|
+
function collectCauses(el) {
|
|
445
|
+
const seen = el._devSeenSeq ?? 0;
|
|
446
|
+
const causes = [];
|
|
447
|
+
const self = el._devChange;
|
|
448
|
+
if (self !== undefined && self.seq > seen && self.kind === "refresh") causes.push(self);
|
|
449
|
+
for (let l = el._deps; l !== null; l = l._nextDep) {
|
|
450
|
+
const change = l._dep._devChange;
|
|
451
|
+
if (change !== undefined && change.seq > seen) causes.push(change);
|
|
452
|
+
}
|
|
453
|
+
return causes;
|
|
454
|
+
}
|
|
455
|
+
/** Advance the node's seen-cursor to the present. Call after every run. */
|
|
456
|
+
function markSeen(el) {
|
|
457
|
+
el._devSeenSeq = changeSeq;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Snapshot the dep identities of the node's last pass (call before a run
|
|
461
|
+
* replaces them, or after it to read the fresh set). The validated prefix
|
|
462
|
+
* [`_deps`..`_depsTail`] is that pass's set; links past the tail are a
|
|
463
|
+
* previous pass's, kept linked while the frame they fed is still the
|
|
464
|
+
* committed one (A30 — a staged memo pass, or an effect pass whose run is
|
|
465
|
+
* still owed) and not part of the subscription diff.
|
|
466
|
+
*/
|
|
467
|
+
function captureDeps(el) {
|
|
468
|
+
const deps = [];
|
|
469
|
+
for (let l = el._deps; l !== null; l = l._nextDep) {
|
|
470
|
+
deps.push(l._dep);
|
|
471
|
+
if (l === el._depsTail) break;
|
|
472
|
+
}
|
|
473
|
+
return deps;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Wide-scope warning — the coarse-read / helper-leak signature: one scope
|
|
477
|
+
* subscribed to dozens of sources re-runs when ANY of them change. Fired from
|
|
478
|
+
* recordRerun for re-runs and directly from recompute for creation runs (a
|
|
479
|
+
* memo can be born too wide). Re-warns only on 50% further growth.
|
|
480
|
+
*/
|
|
481
|
+
function checkDepWidth(el) {
|
|
482
|
+
const limit = options.wideDeps;
|
|
483
|
+
if (limit === false) return;
|
|
484
|
+
let count = 0;
|
|
485
|
+
const names = [];
|
|
486
|
+
for (let l = el._deps; l !== null; l = l._nextDep) {
|
|
487
|
+
count++;
|
|
488
|
+
if (names.length < 12) names.push(nodeName(l._dep));
|
|
489
|
+
if (l === el._depsTail) break; // the validated prefix, as captureDeps
|
|
490
|
+
}
|
|
491
|
+
const node = el;
|
|
492
|
+
if (count < limit || count < (node._devWideWarnedAt ?? 0) * 1.5) return;
|
|
493
|
+
node._devWideWarnedAt = count;
|
|
494
|
+
const message =
|
|
495
|
+
`[WIDE_SCOPE_DEPS] ${nodeKind(el)} "${nodeName(el)}" is subscribed to ${count} sources — ` +
|
|
496
|
+
`it re-runs when any of them change. Narrow its reads or split it into smaller memos. ` +
|
|
497
|
+
`Sources: ${names.join(", ")}${count > names.length ? ", …" : ""}`;
|
|
498
|
+
reportDiagnostic(
|
|
499
|
+
emitDiagnostic(
|
|
500
|
+
{
|
|
501
|
+
code: "WIDE_SCOPE_DEPS",
|
|
502
|
+
kind: "perf",
|
|
503
|
+
severity: "warn",
|
|
504
|
+
message,
|
|
505
|
+
nodeName: nodeName(el),
|
|
506
|
+
data: { depCount: count, deps: names }
|
|
507
|
+
},
|
|
508
|
+
el
|
|
509
|
+
)
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
const hotCauses = new Map();
|
|
513
|
+
const HOT_FANOUT_FIRST_MILESTONE = 5;
|
|
514
|
+
/**
|
|
515
|
+
* Hot-scope warning — flags a scope that re-ran more than `count` times
|
|
516
|
+
* inside one `windowMs` window. Warned once per window, with the most recent
|
|
517
|
+
* cause chain named so the leaking signal is identified in the message.
|
|
518
|
+
* Fan-out spam is folded per root cause (see HotCauseWindow above).
|
|
519
|
+
*/
|
|
520
|
+
function checkHotRuns(el, causes) {
|
|
521
|
+
const cfg = options.hotRuns;
|
|
522
|
+
if (cfg === false) return;
|
|
523
|
+
const node = el;
|
|
524
|
+
const now = Date.now();
|
|
525
|
+
if (node._devWinStart === undefined || now - node._devWinStart > cfg.windowMs) {
|
|
526
|
+
node._devWinStart = now;
|
|
527
|
+
node._devWinCount = 0;
|
|
528
|
+
node._devHotWarned = false;
|
|
529
|
+
}
|
|
530
|
+
node._devWinCount = (node._devWinCount ?? 0) + 1;
|
|
531
|
+
if (node._devHotWarned || node._devWinCount < cfg.count) return;
|
|
532
|
+
node._devHotWarned = true;
|
|
533
|
+
// Root-cause key: the set of originating writes behind this scope's latest
|
|
534
|
+
// re-run. Scopes hot from the SAME roots share one aggregation window.
|
|
535
|
+
const roots = new Set();
|
|
536
|
+
rootsOf(causes, roots);
|
|
537
|
+
const causeKey = roots.size > 0 ? [...roots].sort().join(", ") : "(untracked)";
|
|
538
|
+
let window = hotCauses.get(causeKey);
|
|
539
|
+
if (window === undefined || now - window.winStart > cfg.windowMs) {
|
|
540
|
+
window = { winStart: now, scopes: 0, runs: 0, nextMilestone: HOT_FANOUT_FIRST_MILESTONE };
|
|
541
|
+
hotCauses.set(causeKey, window);
|
|
542
|
+
}
|
|
543
|
+
window.scopes++;
|
|
544
|
+
window.runs += node._devWinCount;
|
|
545
|
+
if (window.scopes === 1) {
|
|
546
|
+
const rootCause = causes.map(c => `"${c.name}" (${c.kind})`).join(", ");
|
|
547
|
+
const message =
|
|
548
|
+
`[HOT_SCOPE_RERUNS] ${nodeKind(el)} "${nodeName(el)}" re-ran ${node._devWinCount} times ` +
|
|
549
|
+
`in ${Math.max(1, now - node._devWinStart)}ms — a hot signal is likely leaking into this ` +
|
|
550
|
+
`scope. Latest cause: ${rootCause || "(untracked pull)"}`;
|
|
551
|
+
reportDiagnostic(
|
|
552
|
+
emitDiagnostic(
|
|
553
|
+
{
|
|
554
|
+
code: "HOT_SCOPE_RERUNS",
|
|
555
|
+
kind: "perf",
|
|
556
|
+
severity: "warn",
|
|
557
|
+
message,
|
|
558
|
+
nodeName: nodeName(el),
|
|
559
|
+
data: {
|
|
560
|
+
runs: node._devWinCount,
|
|
561
|
+
windowMs: cfg.windowMs,
|
|
562
|
+
causes: causes.map(c => c.name)
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
el
|
|
566
|
+
)
|
|
567
|
+
);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
// Additional scopes hot from the same cause: silent until a milestone —
|
|
571
|
+
// the culprit is the cause, and it has already been named once.
|
|
572
|
+
if (window.scopes < window.nextMilestone) return;
|
|
573
|
+
window.nextMilestone *= 10;
|
|
574
|
+
const message =
|
|
575
|
+
`[HOT_SCOPE_FANOUT] ${window.scopes} scopes have gone hot (${window.runs} re-runs) within ` +
|
|
576
|
+
`${cfg.windowMs}ms, all driven by ${causeKey} — one hot cause is re-running a large part ` +
|
|
577
|
+
`of the graph. Per-scope warnings are suppressed; fix the cause. If consumers ask keyed ` +
|
|
578
|
+
`questions of it, invert it: a store used as a map keyed by id, one key per consumer.`;
|
|
579
|
+
// The subject is the shared CAUSE, not this victim scope — no single owner
|
|
580
|
+
// path locates it, so the event carries none.
|
|
581
|
+
reportDiagnostic(
|
|
582
|
+
emitDiagnostic(
|
|
583
|
+
{
|
|
584
|
+
code: "HOT_SCOPE_FANOUT",
|
|
585
|
+
kind: "perf",
|
|
586
|
+
severity: "warn",
|
|
587
|
+
message,
|
|
588
|
+
nodeName: causeKey,
|
|
589
|
+
data: { cause: causeKey, scopes: window.scopes, runs: window.runs, windowMs: cfg.windowMs }
|
|
590
|
+
},
|
|
591
|
+
null
|
|
592
|
+
)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Time-budget warning — the counterpart of checkHotRuns for the
|
|
597
|
+
* few-but-expensive scope: warns when one scope's summed self-time within a
|
|
598
|
+
* window exceeds the budget. Warned once per window.
|
|
599
|
+
*/
|
|
600
|
+
function checkHotTime(el, selfMs, causes) {
|
|
601
|
+
const cfg = options.hotTime;
|
|
602
|
+
if (cfg === false) return;
|
|
603
|
+
const node = el;
|
|
604
|
+
const at = now();
|
|
605
|
+
if (node._devTimeWinStart === undefined || at - node._devTimeWinStart > cfg.windowMs) {
|
|
606
|
+
node._devTimeWinStart = at;
|
|
607
|
+
node._devTimeWinMs = 0;
|
|
608
|
+
node._devTimeWarned = false;
|
|
609
|
+
}
|
|
610
|
+
node._devTimeWinMs = (node._devTimeWinMs ?? 0) + selfMs;
|
|
611
|
+
if (node._devTimeWarned || node._devTimeWinMs < cfg.budgetMs) return;
|
|
612
|
+
node._devTimeWarned = true;
|
|
613
|
+
const rootCause = causes.map(c => `"${c.name}" (${c.kind})`).join(", ");
|
|
614
|
+
const message =
|
|
615
|
+
`[HOT_SCOPE_TIME] ${nodeKind(el)} "${nodeName(el)}" spent ` +
|
|
616
|
+
`${node._devTimeWinMs.toFixed(1)}ms of compute inside one ${cfg.windowMs}ms window ` +
|
|
617
|
+
`(budget ${cfg.budgetMs}ms). Latest cause: ${rootCause || "(untracked pull)"}`;
|
|
618
|
+
reportDiagnostic(
|
|
619
|
+
emitDiagnostic(
|
|
620
|
+
{
|
|
621
|
+
code: "HOT_SCOPE_TIME",
|
|
622
|
+
kind: "perf",
|
|
623
|
+
severity: "warn",
|
|
624
|
+
message,
|
|
625
|
+
nodeName: nodeName(el),
|
|
626
|
+
data: {
|
|
627
|
+
spentMs: node._devTimeWinMs,
|
|
628
|
+
budgetMs: cfg.budgetMs,
|
|
629
|
+
windowMs: cfg.windowMs,
|
|
630
|
+
causes: causes.map(c => c.name)
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
el
|
|
634
|
+
)
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* A scope whose equality gate closes almost every time: it re-ran because
|
|
639
|
+
* an input changed, computed, compared equal to its last value and told
|
|
640
|
+
* nobody — the run was pure cost. `costs().wastedMs` sums this; the finding
|
|
641
|
+
* names it while it happens, with the input that keeps triggering it. The
|
|
642
|
+
* fix is upstream: an equality boundary on the part of the input the scope
|
|
643
|
+
* depends on, or a narrower read. Plain runs only — a held or overlay run
|
|
644
|
+
* may be replayed and is never blamed as waste.
|
|
645
|
+
*/
|
|
646
|
+
function checkWastedRecompute(el, at, phase, changed, selfMs, causes) {
|
|
647
|
+
const cfg = options.wastedRecompute;
|
|
648
|
+
if (cfg === false || phase !== "plain") return;
|
|
649
|
+
// This runs on every re-run: fields on the node (one property read each,
|
|
650
|
+
// like hotRuns) and the run's own `at` — no clock read, no map lookup.
|
|
651
|
+
const node = el;
|
|
652
|
+
if (node._devWasteWinStart === undefined || at - node._devWasteWinStart > cfg.windowMs) {
|
|
653
|
+
node._devWasteWinStart = at;
|
|
654
|
+
node._devWasteRuns = 0;
|
|
655
|
+
node._devWasted = 0;
|
|
656
|
+
node._devWastedMs = 0;
|
|
657
|
+
node._devWasteWarned = false;
|
|
658
|
+
}
|
|
659
|
+
node._devWasteRuns = node._devWasteRuns + 1;
|
|
660
|
+
if (!changed) {
|
|
661
|
+
node._devWasted = node._devWasted + 1;
|
|
662
|
+
node._devWastedMs = node._devWastedMs + selfMs;
|
|
663
|
+
}
|
|
664
|
+
if (
|
|
665
|
+
node._devWasteWarned ||
|
|
666
|
+
node._devWasteRuns < cfg.minRuns ||
|
|
667
|
+
node._devWasted / node._devWasteRuns < cfg.ratio ||
|
|
668
|
+
node._devWastedMs < cfg.budgetMs
|
|
669
|
+
)
|
|
670
|
+
return;
|
|
671
|
+
node._devWasteWarned = true;
|
|
672
|
+
const win = { runs: node._devWasteRuns, wasted: node._devWasted, wastedMs: node._devWastedMs };
|
|
673
|
+
const rootCause = causes.map(c => `"${c.name}" (${c.kind})`).join(", ");
|
|
674
|
+
const message =
|
|
675
|
+
`[WASTED_RECOMPUTE] ${nodeKind(el)} "${nodeName(el)}" re-ran ${win.runs} times in ` +
|
|
676
|
+
`${cfg.windowMs}ms and ${win.wasted} of those produced the same value — ` +
|
|
677
|
+
`${win.wastedMs.toFixed(1)}ms of compute the equality gate then discarded. Its inputs ` +
|
|
678
|
+
`change without changing its result: put an equality boundary upstream (a memo over the ` +
|
|
679
|
+
`part of the input it reads, or an \`equals\` on the source), or read a narrower slice ` +
|
|
680
|
+
`(the property, not the object). Latest cause: ${rootCause || "(untracked pull)"}`;
|
|
681
|
+
reportDiagnostic(
|
|
682
|
+
emitDiagnostic(
|
|
683
|
+
{
|
|
684
|
+
code: "WASTED_RECOMPUTE",
|
|
685
|
+
kind: "perf",
|
|
686
|
+
severity: "warn",
|
|
687
|
+
message,
|
|
688
|
+
nodeName: nodeName(el),
|
|
689
|
+
data: {
|
|
690
|
+
runs: win.runs,
|
|
691
|
+
wasted: win.wasted,
|
|
692
|
+
wastedMs: win.wastedMs,
|
|
693
|
+
windowMs: cfg.windowMs,
|
|
694
|
+
causes: causes.map(c => c.name)
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
el
|
|
698
|
+
)
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
function recordRerun(el, frame, timing, changed, phase, held) {
|
|
702
|
+
const causes = frame.causes;
|
|
703
|
+
const node = el;
|
|
704
|
+
const prevCauses = node._devRunCauses;
|
|
705
|
+
const interaction = frame.interaction;
|
|
706
|
+
if (excludedNode(el)) {
|
|
707
|
+
// The observer's own computation: keep the per-node bookkeeping its
|
|
708
|
+
// effect phase reads (see effectRunStart) and record nothing.
|
|
709
|
+
node._devRunInteraction = interaction;
|
|
710
|
+
node._devRunSeq = undefined;
|
|
711
|
+
node._devRunCauses = causes;
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
// The facts every run leaves on the node, record or not: the run sequence
|
|
715
|
+
// (`ChangeOrigin.run` joins an effect-phase write to it), the run count,
|
|
716
|
+
// and what the effect phase inherits — it runs later in the flush with no
|
|
717
|
+
// cause list of its own, so it takes this run's interaction and causes
|
|
718
|
+
// (see effectRunStart / pushFrame).
|
|
719
|
+
const run = ++runSeq;
|
|
720
|
+
const nodeRuns = (node._devRunCount = (node._devRunCount ?? 0) + 1);
|
|
721
|
+
node._devRunInteraction = interaction;
|
|
722
|
+
node._devRunSeq = run;
|
|
723
|
+
node._devRunCauses = causes;
|
|
724
|
+
noteInteractionRun(interaction, timing.selfMs, false);
|
|
725
|
+
if (openFlush !== null) noteFlushRun(openFlush, false, interaction);
|
|
726
|
+
// The checks read the facts, not the record, so they run with or without
|
|
727
|
+
// an audience for it.
|
|
728
|
+
const kind = nodeKind(el);
|
|
729
|
+
if (kind === "effect") checkEffectCycle(el, causes);
|
|
730
|
+
checkRelayTear(el, causes, prevCauses);
|
|
731
|
+
checkHotRuns(el, causes);
|
|
732
|
+
checkHotTime(el, timing.selfMs, causes);
|
|
733
|
+
checkWastedRecompute(el, frame.start, phase, changed, timing.selfMs, causes);
|
|
734
|
+
checkDepWidth(el);
|
|
735
|
+
// The record: built only when something wanted it at run start (see
|
|
736
|
+
// `wantsRerun`) — a listener, a fold, the log.
|
|
737
|
+
const prevDeps = frame.prevDeps;
|
|
738
|
+
if (prevDeps === null) return;
|
|
739
|
+
// Subscription diff: `prevDeps` was captured at run entry; `_deps` now
|
|
740
|
+
// holds the fresh set. A changed set is the "helper edit changed distant
|
|
741
|
+
// call sites" signal — surfaced per-event and in the console format.
|
|
742
|
+
const newDeps = captureDeps(el);
|
|
743
|
+
const prevSet = new Set(prevDeps);
|
|
744
|
+
const newSet = new Set(newDeps);
|
|
745
|
+
const depsAdded = [];
|
|
746
|
+
const depsRemoved = [];
|
|
747
|
+
for (const d of newDeps) if (!prevSet.has(d)) depsAdded.push(nodeName(d));
|
|
748
|
+
for (const d of prevDeps) if (!newSet.has(d)) depsRemoved.push(nodeName(d));
|
|
749
|
+
const event = {
|
|
750
|
+
run,
|
|
751
|
+
at: frame.start,
|
|
752
|
+
nodeRuns,
|
|
753
|
+
nodeKind: kind,
|
|
754
|
+
nodeName: nodeName(el),
|
|
755
|
+
nodeId: devId(el),
|
|
756
|
+
causes,
|
|
757
|
+
depCount: newDeps.length,
|
|
758
|
+
depsAdded,
|
|
759
|
+
depsRemoved,
|
|
760
|
+
selfMs: timing.selfMs,
|
|
761
|
+
totalMs: timing.totalMs,
|
|
762
|
+
changed,
|
|
763
|
+
phase,
|
|
764
|
+
held
|
|
765
|
+
};
|
|
766
|
+
if (interaction !== undefined) event.interaction = interaction;
|
|
767
|
+
history.push(event);
|
|
768
|
+
if (history.length > options.historyLimit) history.shift();
|
|
769
|
+
for (const f of folds) f.rerun?.(el, event);
|
|
770
|
+
records.emit("rerun", event, el);
|
|
771
|
+
if (options.log) logRerun(event);
|
|
772
|
+
}
|
|
773
|
+
function formatCause(cause, depth, out) {
|
|
774
|
+
const pad = " ".repeat(depth + 1);
|
|
775
|
+
let line = `${pad}← ${cause.kind === "derived" ? "memo" : "signal"} "${cause.name}" ${cause.kind === "derived" ? "changed" : cause.kind} (#${cause.seq})`;
|
|
776
|
+
if (cause.prev !== undefined) line += ` ${cause.prev} → ${cause.value}`;
|
|
777
|
+
if (cause.origin !== undefined && cause.origin.kind !== "external") {
|
|
778
|
+
line += ` — ${formatOrigin(cause.origin)}`;
|
|
779
|
+
const under = cause.origin.interaction;
|
|
780
|
+
if (under !== undefined) line += ` (under ${formatOrigin(under)})`;
|
|
781
|
+
}
|
|
782
|
+
out.push(line);
|
|
783
|
+
if (cause.stack) for (const frame of cause.stack) out.push(`${pad} ${frame}`);
|
|
784
|
+
if (cause.causes && depth < 10) {
|
|
785
|
+
for (const upstream of cause.causes) formatCause(upstream, depth + 1, out);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
function formatRerun(event) {
|
|
789
|
+
const out = [
|
|
790
|
+
`[why-run] ${event.nodeKind} "${event.nodeName}" ran (run ${event.nodeRuns}, ` +
|
|
791
|
+
`${event.selfMs.toFixed(2)}ms${event.changed ? "" : ", unchanged"}` +
|
|
792
|
+
`${event.phase === "plain" ? "" : `, ${event.phase}`}${event.held ? ", held" : ""})` +
|
|
793
|
+
(event.causes.length === 0 ? " — no tracked cause (pull or retry)" : "")
|
|
794
|
+
];
|
|
795
|
+
for (const cause of event.causes) formatCause(cause, 0, out);
|
|
796
|
+
if (event.depsAdded.length > 0 || event.depsRemoved.length > 0) {
|
|
797
|
+
const delta = [
|
|
798
|
+
...event.depsAdded.map(n => `+"${n}"`),
|
|
799
|
+
...event.depsRemoved.map(n => `-"${n}"`)
|
|
800
|
+
].join(" ");
|
|
801
|
+
out.push(` deps changed: ${delta} (${event.depCount} total)`);
|
|
802
|
+
}
|
|
803
|
+
return out.join("\n");
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Console face of a re-run: the headline as a collapsed group with the
|
|
807
|
+
* why-chain and dep delta inside, so a busy console stays scannable (one line
|
|
808
|
+
* per run, evidence a click away). Consoles without grouping get the text.
|
|
809
|
+
*/
|
|
810
|
+
function logRerun(event) {
|
|
811
|
+
const text = formatRerun(event);
|
|
812
|
+
const nl = text.indexOf("\n");
|
|
813
|
+
if (nl === -1 || typeof console.groupCollapsed !== "function") {
|
|
814
|
+
console.log(text);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
console.groupCollapsed(text.slice(0, nl));
|
|
818
|
+
console.log(text.slice(nl + 1));
|
|
819
|
+
console.groupEnd();
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Values eligible for the unstable-output check: plain objects and arrays
|
|
823
|
+
* only. Promises, iterators, Dates, Maps, class instances etc. all have no
|
|
824
|
+
* (or unrepresentative) own enumerable keys, so a shallow compare would
|
|
825
|
+
* false-positive on them — a fresh Promise is a genuinely new value.
|
|
826
|
+
*/
|
|
827
|
+
function isPlainShape(v) {
|
|
828
|
+
if (v === null || typeof v !== "object") return false;
|
|
829
|
+
if (Array.isArray(v)) return true;
|
|
830
|
+
const proto = Object.getPrototypeOf(v);
|
|
831
|
+
return proto === Object.prototype || proto === null;
|
|
832
|
+
}
|
|
833
|
+
/** Shallow structural equivalence, capped so hot paths stay cheap. */
|
|
834
|
+
const UNSTABLE_KEY_CAP = 64;
|
|
835
|
+
function shallowEquivalent(a, b) {
|
|
836
|
+
const aArr = Array.isArray(a);
|
|
837
|
+
if (aArr !== Array.isArray(b)) return false;
|
|
838
|
+
if (aArr) {
|
|
839
|
+
const arrA = a;
|
|
840
|
+
const arrB = b;
|
|
841
|
+
if (arrA.length !== arrB.length || arrA.length > UNSTABLE_KEY_CAP) return false;
|
|
842
|
+
for (let i = 0; i < arrA.length; i++) if (arrA[i] !== arrB[i]) return false;
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
const keys = Object.keys(a);
|
|
846
|
+
if (keys.length > UNSTABLE_KEY_CAP || keys.length !== Object.keys(b).length) return false;
|
|
847
|
+
for (const key of keys) {
|
|
848
|
+
if (!(key in b) || a[key] !== b[key]) return false;
|
|
849
|
+
}
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Unstable-output warning — the fan-out amplifier signature: a memo whose
|
|
854
|
+
* committed value is referentially new but structurally identical run after
|
|
855
|
+
* run has an equality gate that never closes, so ALL its subscribers re-run
|
|
856
|
+
* on EVERY upstream change. Checked only on plain (non-overlay) changed runs;
|
|
857
|
+
* a genuinely different value (or a non-plain shape) resets the streak.
|
|
858
|
+
*/
|
|
859
|
+
function checkUnstableOutput(el, prevValue, newValue) {
|
|
860
|
+
const limit = options.unstableMemos;
|
|
861
|
+
// typeof guard: an explicit `unstableMemos: undefined` in enable() options
|
|
862
|
+
// clobbers the default through the spread — treat any non-number as off.
|
|
863
|
+
if (typeof limit !== "number") return;
|
|
864
|
+
const node = el;
|
|
865
|
+
if (
|
|
866
|
+
prevValue === newValue || // paranoia: changed runs should never hit this
|
|
867
|
+
!isPlainShape(prevValue) ||
|
|
868
|
+
!isPlainShape(newValue) ||
|
|
869
|
+
!shallowEquivalent(prevValue, newValue)
|
|
870
|
+
) {
|
|
871
|
+
node._devUnstableRuns = 0;
|
|
872
|
+
node._devUnstableWarned = false;
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
node._devUnstableRuns = (node._devUnstableRuns ?? 0) + 1;
|
|
876
|
+
if (node._devUnstableWarned || node._devUnstableRuns < limit) return;
|
|
877
|
+
node._devUnstableWarned = true;
|
|
878
|
+
const shape = Array.isArray(newValue) ? "array" : "object";
|
|
879
|
+
const message =
|
|
880
|
+
`[UNSTABLE_MEMO_OUTPUT] memo "${nodeName(el)}" produced a new-but-equivalent ${shape} on ` +
|
|
881
|
+
`${node._devUnstableRuns} consecutive runs — its equality gate never closes, so every ` +
|
|
882
|
+
`subscriber re-runs on every upstream change. Return stable references or pass an ` +
|
|
883
|
+
`\`equals\` option.`;
|
|
884
|
+
reportDiagnostic(
|
|
885
|
+
emitDiagnostic(
|
|
886
|
+
{
|
|
887
|
+
code: "UNSTABLE_MEMO_OUTPUT",
|
|
888
|
+
kind: "perf",
|
|
889
|
+
severity: "warn",
|
|
890
|
+
message,
|
|
891
|
+
nodeName: nodeName(el),
|
|
892
|
+
data: { runs: node._devUnstableRuns, shape }
|
|
893
|
+
},
|
|
894
|
+
el
|
|
895
|
+
)
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
const EFFECT_CYCLE_MAX_HOPS = 6;
|
|
899
|
+
const reportedCycles = new Set();
|
|
900
|
+
let nextDevId = 0;
|
|
901
|
+
const devIds = new WeakMap();
|
|
902
|
+
function devId(node) {
|
|
903
|
+
let id = devIds.get(node);
|
|
904
|
+
if (id === undefined) devIds.set(node, (id = ++nextDevId));
|
|
905
|
+
return id;
|
|
906
|
+
}
|
|
907
|
+
/** @internal The id the engine's records name `node` by, if it has one yet — read without assigning. */
|
|
908
|
+
function nodeIdOf(node) {
|
|
909
|
+
return devIds.get(node);
|
|
910
|
+
}
|
|
911
|
+
function rootWrites(causes, out) {
|
|
912
|
+
for (const c of causes) {
|
|
913
|
+
if (c.kind === "derived") {
|
|
914
|
+
if (c.causes !== undefined) rootWrites(c.causes, out);
|
|
915
|
+
} else out.push(c);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* The effect writes leading from an earlier run of `target` to the run whose
|
|
920
|
+
* `causes` these are, in causal order (target's own write first), or null.
|
|
921
|
+
*/
|
|
922
|
+
function findEffectCycle(target, causes, visited, hops) {
|
|
923
|
+
const roots = [];
|
|
924
|
+
rootWrites(causes, roots);
|
|
925
|
+
for (const write of roots) {
|
|
926
|
+
const origin = write.origin;
|
|
927
|
+
if (origin === undefined || origin.kind !== "effect") continue;
|
|
928
|
+
const info = effectFrames.get(origin);
|
|
929
|
+
if (info === undefined) continue;
|
|
930
|
+
if (info.node === target) return [{ effect: info.node, write }];
|
|
931
|
+
if (hops >= EFFECT_CYCLE_MAX_HOPS || visited.has(info.node) || info.causes === undefined)
|
|
932
|
+
continue;
|
|
933
|
+
visited.add(info.node);
|
|
934
|
+
const rest = findEffectCycle(target, info.causes, visited, hops + 1);
|
|
935
|
+
if (rest !== null) {
|
|
936
|
+
rest.push({ effect: info.node, write });
|
|
937
|
+
return rest;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
/** Names of the memos between a direct cause of a run and `write`, root-first. */
|
|
943
|
+
function derivedPath(causes, write, path) {
|
|
944
|
+
for (const c of causes) {
|
|
945
|
+
if (c === write) return true;
|
|
946
|
+
if (c.kind === "derived" && c.causes !== undefined) {
|
|
947
|
+
path.unshift(c.name);
|
|
948
|
+
if (derivedPath(c.causes, write, path)) return true;
|
|
949
|
+
path.shift();
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return false;
|
|
953
|
+
}
|
|
954
|
+
function describeWrite(write) {
|
|
955
|
+
if (write.kind === "refresh") return `refreshed "${write.name}"`;
|
|
956
|
+
const values = write.prev !== undefined ? ` (${write.prev} → ${write.value})` : "";
|
|
957
|
+
return `wrote "${write.name}"${values}`;
|
|
958
|
+
}
|
|
959
|
+
function checkEffectCycle(el, causes) {
|
|
960
|
+
const links = findEffectCycle(el, causes, new Set([el]), 0);
|
|
961
|
+
if (links === null) return;
|
|
962
|
+
const key = links
|
|
963
|
+
.map(link => devId(link.effect))
|
|
964
|
+
.sort((a, b) => a - b)
|
|
965
|
+
.join(",");
|
|
966
|
+
if (reportedCycles.has(key)) return;
|
|
967
|
+
reportedCycles.add(key);
|
|
968
|
+
const flushes = links.length + 1;
|
|
969
|
+
let message;
|
|
970
|
+
if (links.length === 1) {
|
|
971
|
+
const [{ write }] = links;
|
|
972
|
+
const path = [];
|
|
973
|
+
derivedPath(causes, write, path);
|
|
974
|
+
const via = path.length > 0 ? ` through ${path.map(n => `memo "${n}"`).join(" → ")}` : "";
|
|
975
|
+
message =
|
|
976
|
+
`[EFFECT_WRITES_OWN_SOURCE] effect "${nodeName(el)}" re-ran because of its own write: it ` +
|
|
977
|
+
`${describeWrite(write)}, which fed back into its inputs${via}. Two flushes to settle, ` +
|
|
978
|
+
`and the screen rendered the pre-write value in between. The written value is a function ` +
|
|
979
|
+
`of what the effect reads — compute it in a memo (or normalize where the source is ` +
|
|
980
|
+
`written) instead of correcting it after the fact.`;
|
|
981
|
+
} else {
|
|
982
|
+
const names = links.map(link => `"${nodeName(link.effect)}"`);
|
|
983
|
+
const steps = links
|
|
984
|
+
.map(
|
|
985
|
+
(link, i) => `effect ${names[i]}${i > 0 ? " re-ran and" : ""} ${describeWrite(link.write)}`
|
|
986
|
+
)
|
|
987
|
+
.join("; ");
|
|
988
|
+
message =
|
|
989
|
+
`[EFFECT_WRITES_OWN_SOURCE] effects ${[...names, names[0]].join(" → ")} relay writes in a ` +
|
|
990
|
+
`cycle: ${steps}; which fed back into effect ${names[0]}'s inputs — ${flushes} flushes to ` +
|
|
991
|
+
`settle after each change, each rendering an intermediate state. Every relayed value is a ` +
|
|
992
|
+
`function of the original inputs: derive them in memos and drop the writes.`;
|
|
993
|
+
}
|
|
994
|
+
const severity = links.length === 1 ? "warn" : "info";
|
|
995
|
+
const entry = emitDiagnostic(
|
|
996
|
+
{
|
|
997
|
+
code: "EFFECT_WRITES_OWN_SOURCE",
|
|
998
|
+
kind: "perf",
|
|
999
|
+
severity,
|
|
1000
|
+
message,
|
|
1001
|
+
nodeName: nodeName(el),
|
|
1002
|
+
data: {
|
|
1003
|
+
effects: links.map(link => nodeName(link.effect)),
|
|
1004
|
+
writes: links.map(link => ({
|
|
1005
|
+
effect: nodeName(link.effect),
|
|
1006
|
+
kind: link.write.kind,
|
|
1007
|
+
name: link.write.name,
|
|
1008
|
+
prev: link.write.prev,
|
|
1009
|
+
value: link.write.value
|
|
1010
|
+
})),
|
|
1011
|
+
flushes
|
|
1012
|
+
}
|
|
1013
|
+
},
|
|
1014
|
+
el
|
|
1015
|
+
);
|
|
1016
|
+
if (severity === "warn") reportDiagnostic(entry);
|
|
1017
|
+
}
|
|
1018
|
+
const RELAY_WARN_AT = 3;
|
|
1019
|
+
const relays = new Map();
|
|
1020
|
+
const copyWrites = new WeakSet();
|
|
1021
|
+
const copyReported = new WeakSet();
|
|
1022
|
+
/** The node each root write record was stamped on (records are serializable and cannot hold it). */
|
|
1023
|
+
const recordNodes = new WeakMap();
|
|
1024
|
+
/**
|
|
1025
|
+
* Write-side bookkeeping for the relay heuristics: who has written this
|
|
1026
|
+
* signal, and whether an effect just copied its compute output into it.
|
|
1027
|
+
*/
|
|
1028
|
+
function trackEffectWrite(node, record, value) {
|
|
1029
|
+
const n = node;
|
|
1030
|
+
const origin = record.origin;
|
|
1031
|
+
if (origin !== undefined) noteEffectOrigin(origin);
|
|
1032
|
+
const info =
|
|
1033
|
+
origin !== undefined && origin.kind === "effect" ? effectFrames.get(origin) : undefined;
|
|
1034
|
+
const writer = info === undefined ? 0 : devId(info.node);
|
|
1035
|
+
recordNodes.set(record, node);
|
|
1036
|
+
n._devSoleWriter = n._devSoleWriter === undefined || n._devSoleWriter === writer ? writer : null;
|
|
1037
|
+
if (info !== undefined && value !== undefined && value === info.node._value) {
|
|
1038
|
+
copyWrites.add(record);
|
|
1039
|
+
n._devCopyRuns = n._devCopyFrom === writer ? (n._devCopyRuns ?? 0) + 1 : 1;
|
|
1040
|
+
n._devCopyFrom = writer;
|
|
1041
|
+
if (n._devCopyRuns >= 2 && n._devSoleWriter === writer) checkCopyEffect(info.node, node);
|
|
1042
|
+
} else n._devCopyRuns = 0;
|
|
1043
|
+
}
|
|
1044
|
+
/** The effect's source whose current value the compute output is, if any (the prop-to-state port). */
|
|
1045
|
+
function passthroughSource(effect) {
|
|
1046
|
+
for (let l = effect._deps; l !== null; l = l._nextDep)
|
|
1047
|
+
if (l._dep._value === effect._value) return nodeName(l._dep);
|
|
1048
|
+
return undefined;
|
|
1049
|
+
}
|
|
1050
|
+
/** The repair for a write that is the effect's compute output. */
|
|
1051
|
+
function copyRepair(effect, target) {
|
|
1052
|
+
const source = passthroughSource(effect);
|
|
1053
|
+
return source !== undefined
|
|
1054
|
+
? `The written value is "${source}" itself: read "${source}" where "${target}" is read ` +
|
|
1055
|
+
`(or createMemo it if a stable derivation is needed) and delete the effect.`
|
|
1056
|
+
: `The written value is the effect's compute output — by contract a pure function of ` +
|
|
1057
|
+
`what it tracks: make "${target}" a memo of that computation and delete the effect.`;
|
|
1058
|
+
}
|
|
1059
|
+
function checkCopyEffect(effect, target) {
|
|
1060
|
+
if (copyReported.has(target)) return;
|
|
1061
|
+
copyReported.add(target);
|
|
1062
|
+
const name = nodeName(target);
|
|
1063
|
+
const message =
|
|
1064
|
+
`[EFFECT_RELAY_TEAR] effect "${nodeName(effect)}" writes its compute output into ` +
|
|
1065
|
+
`"${name}" on every run, and nothing else writes "${name}" — it is derived state kept ` +
|
|
1066
|
+
`one flush late: everything reading it paints a frame behind everything reading the ` +
|
|
1067
|
+
`source. ${copyRepair(effect, name)}`;
|
|
1068
|
+
reportDiagnostic(
|
|
1069
|
+
emitDiagnostic(
|
|
1070
|
+
{
|
|
1071
|
+
code: "EFFECT_RELAY_TEAR",
|
|
1072
|
+
kind: "perf",
|
|
1073
|
+
severity: "warn",
|
|
1074
|
+
message,
|
|
1075
|
+
nodeName: nodeName(effect),
|
|
1076
|
+
data: {
|
|
1077
|
+
relay: nodeName(effect),
|
|
1078
|
+
wrote: name,
|
|
1079
|
+
copy: true,
|
|
1080
|
+
passthrough: passthroughSource(effect) ?? null,
|
|
1081
|
+
soleWriter: true
|
|
1082
|
+
}
|
|
1083
|
+
},
|
|
1084
|
+
effect
|
|
1085
|
+
)
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* `victim` re-ran with `causes`; its previous run had `prevCauses`. A tear is
|
|
1090
|
+
* a re-run whose root writes ALL came from effects (no independent outside
|
|
1091
|
+
* cause) and at least one of which was made by a run that shares a root
|
|
1092
|
+
* write with the victim's previous run.
|
|
1093
|
+
*/
|
|
1094
|
+
function checkRelayTear(victim, causes, prevCauses) {
|
|
1095
|
+
if (prevCauses === undefined || causes.length === 0) return;
|
|
1096
|
+
const roots = [];
|
|
1097
|
+
rootWrites(causes, roots);
|
|
1098
|
+
if (roots.length === 0) return;
|
|
1099
|
+
let relay;
|
|
1100
|
+
let write;
|
|
1101
|
+
let shared;
|
|
1102
|
+
for (const root of roots) {
|
|
1103
|
+
const origin = root.origin;
|
|
1104
|
+
if (origin === undefined || origin.kind !== "effect") return;
|
|
1105
|
+
const info = effectFrames.get(origin);
|
|
1106
|
+
// Own-source cycles are EFFECT_WRITES_OWN_SOURCE's; a create-run relay
|
|
1107
|
+
// is initial sync, not a tear for one change.
|
|
1108
|
+
if (info === undefined || info.node === victim || info.causes === undefined) return;
|
|
1109
|
+
if (shared === undefined) {
|
|
1110
|
+
const relayRoots = [];
|
|
1111
|
+
rootWrites(info.causes, relayRoots);
|
|
1112
|
+
const prevRoots = [];
|
|
1113
|
+
rootWrites(prevCauses, prevRoots);
|
|
1114
|
+
const hit = relayRoots.find(r => prevRoots.includes(r));
|
|
1115
|
+
if (hit !== undefined) {
|
|
1116
|
+
shared = hit;
|
|
1117
|
+
relay = info;
|
|
1118
|
+
write = root;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
if (shared === undefined || relay === undefined || write === undefined) return;
|
|
1123
|
+
const key = `${devId(relay.node)}:${write.name}`;
|
|
1124
|
+
let state = relays.get(key);
|
|
1125
|
+
if (state === undefined) relays.set(key, (state = { count: 0, warned: false }));
|
|
1126
|
+
state.count++;
|
|
1127
|
+
const copy = copyWrites.has(write);
|
|
1128
|
+
const target = recordNodes.get(write);
|
|
1129
|
+
const soleWriter = target !== undefined && target._devSoleWriter === devId(relay.node);
|
|
1130
|
+
// Derivable outright: the value is the compute output and nothing else
|
|
1131
|
+
// writes the signal. A copy INTO a signal that has other writers is the
|
|
1132
|
+
// "reset editable state from a source" shape — the tear is real, but a memo
|
|
1133
|
+
// is not the answer, so it stays advisory like any other non-derivable tear.
|
|
1134
|
+
const derivable = copy && soleWriter;
|
|
1135
|
+
const severity = derivable || state.count >= RELAY_WARN_AT ? "warn" : "info";
|
|
1136
|
+
// First sighting always reports (advisory); afterwards only the escalation.
|
|
1137
|
+
if (state.count > 1 && (severity !== "warn" || state.warned)) return;
|
|
1138
|
+
// One verdict per derivable signal: the copy report (checkCopyEffect) and
|
|
1139
|
+
// the tear report carry the same repair.
|
|
1140
|
+
if (derivable && target !== undefined) {
|
|
1141
|
+
if (copyReported.has(target)) return;
|
|
1142
|
+
copyReported.add(target);
|
|
1143
|
+
}
|
|
1144
|
+
if (severity === "warn") state.warned = true;
|
|
1145
|
+
const victimKind = victim._type ? "effect" : "memo";
|
|
1146
|
+
const relayName = nodeName(relay.node);
|
|
1147
|
+
const repair = derivable
|
|
1148
|
+
? copyRepair(relay.node, write.name)
|
|
1149
|
+
: copy
|
|
1150
|
+
? `The written value is the effect's compute output, but "${write.name}" has other ` +
|
|
1151
|
+
`writers — editable state reset from a source. If the reset is the intent, the tear ` +
|
|
1152
|
+
`is its cost; if "${write.name}" only ever mirrors the source, drop the local copy ` +
|
|
1153
|
+
`and read the source.`
|
|
1154
|
+
: soleWriter
|
|
1155
|
+
? `Nothing else writes "${write.name}" — it is derived state: make it a memo over what ` +
|
|
1156
|
+
`the effect reads and every reader gets it in the same flush.`
|
|
1157
|
+
: `If "${write.name}" is computed from what the effect reads, make it a memo so readers ` +
|
|
1158
|
+
`get it in the same flush; if the write reads something outside the graph (layout, ` +
|
|
1159
|
+
`time), the tear is the cost of measuring.`;
|
|
1160
|
+
const message =
|
|
1161
|
+
`[EFFECT_RELAY_TEAR] ${victimKind} "${nodeName(victim)}" ran twice for one write of ` +
|
|
1162
|
+
`"${shared.name}": once in the flush where "${shared.name}" changed, and again after ` +
|
|
1163
|
+
`effect "${relayName}" relayed it by writing "${write.name}" — the first frame showed the ` +
|
|
1164
|
+
`new "${shared.name}" with the stale "${write.name}"` +
|
|
1165
|
+
(state.count > 1 ? ` (${state.count} times so far)` : "") +
|
|
1166
|
+
`. ${repair}`;
|
|
1167
|
+
const entry = emitDiagnostic(
|
|
1168
|
+
{
|
|
1169
|
+
code: "EFFECT_RELAY_TEAR",
|
|
1170
|
+
kind: "perf",
|
|
1171
|
+
severity,
|
|
1172
|
+
message,
|
|
1173
|
+
nodeName: nodeName(victim),
|
|
1174
|
+
data: {
|
|
1175
|
+
victim: nodeName(victim),
|
|
1176
|
+
root: shared.name,
|
|
1177
|
+
relay: relayName,
|
|
1178
|
+
wrote: write.name,
|
|
1179
|
+
copy,
|
|
1180
|
+
passthrough: copy ? (passthroughSource(relay.node) ?? null) : null,
|
|
1181
|
+
soleWriter,
|
|
1182
|
+
occurrences: state.count
|
|
1183
|
+
}
|
|
1184
|
+
},
|
|
1185
|
+
victim
|
|
1186
|
+
);
|
|
1187
|
+
if (severity === "warn") reportDiagnostic(entry);
|
|
1188
|
+
}
|
|
1189
|
+
// --- Immutable updates in stores ---------------------------------------------
|
|
1190
|
+
//
|
|
1191
|
+
// `draft.user = { ...draft.user, name }` / `draft.items = [...draft.items,
|
|
1192
|
+
// x]` / `draft.items = draft.items.filter(...)` — the React habit of
|
|
1193
|
+
// producing a fresh container to change one leaf. The store tracks leaves, so
|
|
1194
|
+
// a fresh container is pure cost: every reader of `user` (any path below it)
|
|
1195
|
+
// re-runs for the one leaf that moved, where a draft mutation would re-run
|
|
1196
|
+
// only the readers of `name`. The store's notify sees both containers at the
|
|
1197
|
+
// write and reports a leaf census (identity on unwrapped values, capped); the
|
|
1198
|
+
// verdict is the whole detector: a replacement whose leaves are mostly the
|
|
1199
|
+
// SAME values is a spread-copy, and one whose leaves are mostly different is
|
|
1200
|
+
// new data (reconcile's job — and UNSTABLE_LIST_IDENTITY's, downstream). Once
|
|
1201
|
+
// per store path.
|
|
1202
|
+
const immutableReported = new Set();
|
|
1203
|
+
function checkImmutableUpdate(path, isArray, total, same, prevTotal, owner) {
|
|
1204
|
+
if (immutableReported.has(path) || total < 2) return;
|
|
1205
|
+
// Push/filter/splice copies change the length by a little; a wholesale
|
|
1206
|
+
// resize is a different operation even if some items survive.
|
|
1207
|
+
if (isArray && Math.abs(prevTotal - total) > Math.max(1, total >> 2)) return;
|
|
1208
|
+
// At least half the leaves carried over unchanged, and at least one did.
|
|
1209
|
+
if (same === 0 || same * 2 < total) return;
|
|
1210
|
+
const changed = total - same;
|
|
1211
|
+
const shape = isArray ? "array" : "object";
|
|
1212
|
+
const repair = isArray
|
|
1213
|
+
? `mutate the draft in place (push/splice/index assignment) so only the touched ` +
|
|
1214
|
+
`indices notify`
|
|
1215
|
+
: `assign the leaf on the draft (\`${path}.<key> = …\`) so only readers of that key re-run`;
|
|
1216
|
+
const message =
|
|
1217
|
+
`[IMMUTABLE_UPDATE_IN_STORE] "${path}" was replaced with a fresh ${shape} whose ` +
|
|
1218
|
+
`${isArray ? "items" : "leaves"} are mostly the same values (${same} of ${total} unchanged` +
|
|
1219
|
+
`${changed > 0 ? `, ${changed} changed` : ""}) — a spread-copy update. The store already ` +
|
|
1220
|
+
`tracks ${isArray ? "items" : "leaves"}; a new container makes every reader of "${path}" ` +
|
|
1221
|
+
`re-run for the ${changed === 1 ? "one that" : "few that"} moved. Instead, ${repair}. For ` +
|
|
1222
|
+
`data arriving from outside (a fetch result), merge it with reconcile(data, key)(${path}).`;
|
|
1223
|
+
// The subject is the store's own owner, not the writer's context: the
|
|
1224
|
+
// finding is about the store, and its writes legitimately arrive from
|
|
1225
|
+
// outside the graph (an event handler, an adapter). Falls back to the
|
|
1226
|
+
// ambient context (emitDiagnostic's default) when the store recorded none.
|
|
1227
|
+
const entry = emitDiagnostic(
|
|
1228
|
+
{
|
|
1229
|
+
code: "IMMUTABLE_UPDATE_IN_STORE",
|
|
1230
|
+
kind: "perf",
|
|
1231
|
+
severity: "warn",
|
|
1232
|
+
message,
|
|
1233
|
+
nodeName: path,
|
|
1234
|
+
data: { path, shape, total, unchanged: same, changed }
|
|
1235
|
+
},
|
|
1236
|
+
owner
|
|
1237
|
+
);
|
|
1238
|
+
// Paths are not unique across stores: an excluded owner's store (an
|
|
1239
|
+
// adapter's own "store.list") must not spend the app's once-per-path slot.
|
|
1240
|
+
if (isSuppressed(entry)) return;
|
|
1241
|
+
immutableReported.add(path);
|
|
1242
|
+
reportDiagnostic(entry);
|
|
1243
|
+
}
|
|
1244
|
+
// --- Unstable list identity -----------------------------------------------------
|
|
1245
|
+
//
|
|
1246
|
+
// `<For>` keyed by identity (the default) treats every new object as a new
|
|
1247
|
+
// row. When a re-fetch hands back fresh objects for the same records, or a
|
|
1248
|
+
// spread-copy rebuilds the array, most rows are disposed and recreated —
|
|
1249
|
+
// DOM, state, focus, and all — for data that did not change. mapArray knows
|
|
1250
|
+
// exactly which items exited and entered; pairing them (by `id` when the
|
|
1251
|
+
// items carry one, else by position) and sampling shallow equivalence turns
|
|
1252
|
+
// that into a verdict: churn that replaced equivalent records is unstable
|
|
1253
|
+
// identity, not a new list. A key function that still churns has the same
|
|
1254
|
+
// disease one level up (its keys are not stable). Once per list.
|
|
1255
|
+
const LIST_CHURN_SAMPLE = 8;
|
|
1256
|
+
const listIdentityWarned = new WeakSet();
|
|
1257
|
+
function recordId(item) {
|
|
1258
|
+
if (item === null || typeof item !== "object") return undefined;
|
|
1259
|
+
const o = item;
|
|
1260
|
+
return o.id ?? o.key ?? o._id ?? undefined;
|
|
1261
|
+
}
|
|
1262
|
+
function checkListIdentity(el, removed, created, newLen, keyed) {
|
|
1263
|
+
if (listIdentityWarned.has(el)) return;
|
|
1264
|
+
// Most of the list turned over, and the turnover was a swap (rows out ≈ rows in).
|
|
1265
|
+
if (created.length < 2 || created.length * 2 < newLen) return;
|
|
1266
|
+
if (Math.abs(removed.length - created.length) > Math.max(1, created.length >> 2)) return;
|
|
1267
|
+
// Pair exited with entered: by record id when present, else by position.
|
|
1268
|
+
const byId = new Map();
|
|
1269
|
+
for (const item of removed) {
|
|
1270
|
+
const id = recordId(item);
|
|
1271
|
+
if (id !== undefined) byId.set(id, item);
|
|
1272
|
+
}
|
|
1273
|
+
let sampled = 0;
|
|
1274
|
+
let equivalent = 0;
|
|
1275
|
+
const step = Math.max(1, Math.floor(created.length / LIST_CHURN_SAMPLE));
|
|
1276
|
+
for (let i = 0; i < created.length && sampled < LIST_CHURN_SAMPLE; i += step) {
|
|
1277
|
+
const item = created[i];
|
|
1278
|
+
const id = recordId(item);
|
|
1279
|
+
const prev = id !== undefined ? byId.get(id) : removed[i];
|
|
1280
|
+
if (prev === undefined || !isPlainShape(prev) || !isPlainShape(item)) continue;
|
|
1281
|
+
sampled++;
|
|
1282
|
+
if (shallowEquivalent(prev, item)) equivalent++;
|
|
1283
|
+
}
|
|
1284
|
+
if (sampled === 0 || equivalent * 2 < sampled) return;
|
|
1285
|
+
listIdentityWarned.add(el);
|
|
1286
|
+
const name = nodeName(el);
|
|
1287
|
+
const repair = keyed
|
|
1288
|
+
? `The key function returned different keys for equivalent records — return a stable ` +
|
|
1289
|
+
`field (\`keyed: item => item.id\`), not the object or a computed value that changes ` +
|
|
1290
|
+
`with the fetch.`
|
|
1291
|
+
: `Key the list by a stable field (\`keyed: item => item.id\`), or merge the data into ` +
|
|
1292
|
+
`a store with reconcile(data, "id") so the same records keep the same identity.`;
|
|
1293
|
+
const message =
|
|
1294
|
+
`[UNSTABLE_LIST_IDENTITY] list "${name}" recreated ${created.length} of ${newLen} rows on ` +
|
|
1295
|
+
`an update where the entering items are equivalent to the ones they replaced ` +
|
|
1296
|
+
`(${equivalent} of ${sampled} sampled pairs identical field-for-field) — fresh objects ` +
|
|
1297
|
+
`for the same records, so identity keying threw away every row's DOM and state and ` +
|
|
1298
|
+
`rebuilt it. ${repair}`;
|
|
1299
|
+
reportDiagnostic(
|
|
1300
|
+
emitDiagnostic(
|
|
1301
|
+
{
|
|
1302
|
+
code: "UNSTABLE_LIST_IDENTITY",
|
|
1303
|
+
kind: "perf",
|
|
1304
|
+
severity: "warn",
|
|
1305
|
+
message,
|
|
1306
|
+
nodeName: name,
|
|
1307
|
+
data: {
|
|
1308
|
+
removed: removed.length,
|
|
1309
|
+
created: created.length,
|
|
1310
|
+
length: newLen,
|
|
1311
|
+
sampled,
|
|
1312
|
+
equivalent,
|
|
1313
|
+
keyed
|
|
1314
|
+
}
|
|
1315
|
+
},
|
|
1316
|
+
el
|
|
1317
|
+
)
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
/** Provenance of an async landing: the flight, under the interaction that started it. */
|
|
1321
|
+
function asyncOrigin(el) {
|
|
1322
|
+
const origin = { kind: "async", name: nodeName(el) };
|
|
1323
|
+
const interaction = liveFlights.get(el)?.interaction;
|
|
1324
|
+
if (interaction !== undefined) origin.interaction = interaction;
|
|
1325
|
+
return origin;
|
|
1326
|
+
}
|
|
1327
|
+
// WeakMaps: an errored/abandoned flight must not leak its node or block GC.
|
|
1328
|
+
const liveFlights = new WeakMap();
|
|
1329
|
+
const landedFlights = new WeakMap();
|
|
1330
|
+
/**
|
|
1331
|
+
* Flight-object identity → earliest known start. Fed by markFlight() (the
|
|
1332
|
+
* cooperative preload/cache declaration — populated even while attribution
|
|
1333
|
+
* is disabled, so navigation-time marks survive a later enable()) and by
|
|
1334
|
+
* first sightings at registration.
|
|
1335
|
+
*/
|
|
1336
|
+
const flightOrigins = new WeakMap();
|
|
1337
|
+
let waterfallLog = [];
|
|
1338
|
+
/** Deepest landed-flight cause reachable through a cause list (derived links included). */
|
|
1339
|
+
function flightCauseIn(causes) {
|
|
1340
|
+
let best = null;
|
|
1341
|
+
for (const c of causes) {
|
|
1342
|
+
let found = null;
|
|
1343
|
+
if (c.kind === "async") found = landedFlights.get(c) ?? null;
|
|
1344
|
+
else if (c.kind === "derived" && c.causes) found = flightCauseIn(c.causes);
|
|
1345
|
+
if (found !== null && (best === null || found.chain.length > best.chain.length)) best = found;
|
|
1346
|
+
}
|
|
1347
|
+
return best;
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* The nearest enclosing frame with causes: create runs carry null (a node
|
|
1351
|
+
* born inside a parent's recompute inherits the parent's causality — the
|
|
1352
|
+
* boundary-reveal case, and the lazy sibling whose first pull is gated behind
|
|
1353
|
+
* an earlier not-ready read), so walk down to the first re-run frame.
|
|
1354
|
+
*/
|
|
1355
|
+
function enclosingCauses() {
|
|
1356
|
+
for (let i = frames.length - 1; i >= 0; i--) {
|
|
1357
|
+
const causes = frames[i].causes;
|
|
1358
|
+
if (causes !== null) return causes;
|
|
1359
|
+
}
|
|
1360
|
+
return null;
|
|
1361
|
+
}
|
|
1362
|
+
/** The flight's face as a record, at the moment it landed or was superseded. */
|
|
1363
|
+
function emitFlight(el, flight, endedAt, outcome) {
|
|
1364
|
+
if (excludedNode(el)) return;
|
|
1365
|
+
const event = {
|
|
1366
|
+
nodeId: devId(el),
|
|
1367
|
+
nodeName: nodeName(el),
|
|
1368
|
+
at: flight.origin,
|
|
1369
|
+
durationMs: endedAt - flight.origin,
|
|
1370
|
+
outcome
|
|
1371
|
+
};
|
|
1372
|
+
const path = ownerPath(el);
|
|
1373
|
+
if (path !== undefined) event.ownerPath = path;
|
|
1374
|
+
if (flight.interaction !== undefined) event.interaction = flight.interaction;
|
|
1375
|
+
records.emit("flight", event, el);
|
|
1376
|
+
}
|
|
1377
|
+
function trackFlightStart(el, flight) {
|
|
1378
|
+
const at = now();
|
|
1379
|
+
const origin = flightOrigins.get(flight) ?? at;
|
|
1380
|
+
if (origin === at) flightOrigins.set(flight, at);
|
|
1381
|
+
// Census: a flight still in the air when the node starts another was
|
|
1382
|
+
// superseded — its answer will be discarded.
|
|
1383
|
+
const superseded = liveFlights.get(el);
|
|
1384
|
+
for (const f of folds) f.flightStart?.(el, superseded !== undefined);
|
|
1385
|
+
if (superseded !== undefined) {
|
|
1386
|
+
if (records.observed("flight")) emitFlight(el, superseded, at, "abandoned");
|
|
1387
|
+
checkAbandonedFlights(el, superseded);
|
|
1388
|
+
}
|
|
1389
|
+
const causes = enclosingCauses();
|
|
1390
|
+
const live = { origin, startSeq: changeSeq, chain: [] };
|
|
1391
|
+
// Provenance: the flight belongs to whatever interaction caused the
|
|
1392
|
+
// recompute that started it (a create run under a click's handler — a
|
|
1393
|
+
// freshly mounted async node — inherits the ambient interaction instead).
|
|
1394
|
+
const interaction = causes !== null ? interactionIn(causes) : (currentInteraction ?? undefined);
|
|
1395
|
+
if (interaction !== undefined) live.interaction = interaction;
|
|
1396
|
+
if (options.waterfalls !== false && causes !== null) {
|
|
1397
|
+
let parent = flightCauseIn(causes);
|
|
1398
|
+
// The sequentiality test. A marked/previously-seen flight whose origin
|
|
1399
|
+
// predates the upstream landing was in the air alongside it: parallel.
|
|
1400
|
+
if (parent !== null && origin < parent.landedAt) parent = null;
|
|
1401
|
+
if (parent !== null) live.chain = [...parent.chain, { name: parent.name, ms: parent.ms }];
|
|
1402
|
+
}
|
|
1403
|
+
liveFlights.set(el, live);
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Flight landed (whether or not the value committed — the wall time was
|
|
1407
|
+
* spent either way). Attach the measurement to the landing's fresh "async"
|
|
1408
|
+
* stamp so downstream flights can chain through it, then judge the chain.
|
|
1409
|
+
*/
|
|
1410
|
+
function finalizeFlight(el) {
|
|
1411
|
+
const flight = liveFlights.get(el);
|
|
1412
|
+
if (flight === undefined) return;
|
|
1413
|
+
liveFlights.delete(el);
|
|
1414
|
+
const landedAt = now();
|
|
1415
|
+
const ms = landedAt - flight.origin;
|
|
1416
|
+
for (const f of folds) f.flightLanded?.(el, ms);
|
|
1417
|
+
if (records.observed("flight")) emitFlight(el, flight, landedAt, "landed");
|
|
1418
|
+
const record = el._devChange;
|
|
1419
|
+
// Only a stamp this landing produced may carry the measurement — a stale
|
|
1420
|
+
// async record from a previous landing must not be re-labeled.
|
|
1421
|
+
if (record !== undefined && record.kind === "async" && record.seq > flight.startSeq)
|
|
1422
|
+
landedFlights.set(record, { name: nodeName(el), ms, chain: flight.chain, landedAt });
|
|
1423
|
+
checkWaterfall(el, flight.chain, ms);
|
|
1424
|
+
}
|
|
1425
|
+
function checkWaterfall(el, chain, ms) {
|
|
1426
|
+
const cfg = options.waterfalls;
|
|
1427
|
+
if (cfg === false) return;
|
|
1428
|
+
if (chain.length > 0) {
|
|
1429
|
+
waterfallLog.push({
|
|
1430
|
+
chain: [...chain, { name: nodeName(el), ms }],
|
|
1431
|
+
sequentialMs: chain.reduce((sum, l) => sum + l.ms, ms)
|
|
1432
|
+
});
|
|
1433
|
+
if (waterfallLog.length > options.historyLimit) waterfallLog.shift();
|
|
1434
|
+
}
|
|
1435
|
+
// The verdict: trailing run of links that were each a real wait. A fast
|
|
1436
|
+
// tail (settled preload/cache hit) or a fast upstream breaks the sequence.
|
|
1437
|
+
if (ms < cfg.minFlightMs) return;
|
|
1438
|
+
let seq = 1;
|
|
1439
|
+
let totalMs = ms;
|
|
1440
|
+
for (let i = chain.length - 1; i >= 0 && chain[i].ms >= cfg.minFlightMs; i--) {
|
|
1441
|
+
seq++;
|
|
1442
|
+
totalMs += chain[i].ms;
|
|
1443
|
+
}
|
|
1444
|
+
if (seq < 2) return;
|
|
1445
|
+
const node = el;
|
|
1446
|
+
if ((node._devWaterfallWarnedAt ?? 0) >= seq) return;
|
|
1447
|
+
node._devWaterfallWarnedAt = seq;
|
|
1448
|
+
const links = [...chain.slice(chain.length - (seq - 1)), { name: nodeName(el), ms }];
|
|
1449
|
+
const path = links.map(l => `"${l.name}" (${l.ms.toFixed(0)}ms)`).join(" → ");
|
|
1450
|
+
const message =
|
|
1451
|
+
`[ASYNC_WATERFALL] ${seq} sequential async flights — ${path} — ` +
|
|
1452
|
+
`${totalMs.toFixed(0)}ms serialized: each began only after the previous resolved ` +
|
|
1453
|
+
`(as far as this graph can see). If a later request doesn't need the earlier ` +
|
|
1454
|
+
`response, derive both from the same inputs so they start together; if the ` +
|
|
1455
|
+
`dependency is intrinsic, preload the dependent data or join the requests ` +
|
|
1456
|
+
`server-side. If this work WAS already started elsewhere (a preloader or request ` +
|
|
1457
|
+
`cache), have that layer stamp its promises with attribution.markFlight() ` +
|
|
1458
|
+
`from "@solidjs/signals/attribution".`;
|
|
1459
|
+
// Depth 2 is advisory-only (structured consumers see it; the console does
|
|
1460
|
+
// not): a 2-chain can be an intrinsic data dependency or an unmarked
|
|
1461
|
+
// preload. A 3+ chain that survived the origin test is near-certainly
|
|
1462
|
+
// structural — that one earns the console.
|
|
1463
|
+
const severity = seq > 2 ? "warn" : "info";
|
|
1464
|
+
const entry = emitDiagnostic(
|
|
1465
|
+
{
|
|
1466
|
+
code: "ASYNC_WATERFALL",
|
|
1467
|
+
kind: "perf",
|
|
1468
|
+
severity,
|
|
1469
|
+
message,
|
|
1470
|
+
nodeName: nodeName(el),
|
|
1471
|
+
data: { chain: links.map(l => ({ name: l.name, ms: l.ms })), sequentialMs: totalMs }
|
|
1472
|
+
},
|
|
1473
|
+
el
|
|
1474
|
+
);
|
|
1475
|
+
if (severity === "warn") reportDiagnostic(entry);
|
|
1476
|
+
}
|
|
1477
|
+
const holdStates = new WeakMap();
|
|
1478
|
+
let activeHold = null;
|
|
1479
|
+
let holdLog = [];
|
|
1480
|
+
/** Companions are optimistic nodes too; `_parentSource` marks them. So is a
|
|
1481
|
+
* memo carrying a DERIVED override (lanes stage, #3479) — a lane pass's
|
|
1482
|
+
* result, not a write anyone made: neither is an acknowledgement. */
|
|
1483
|
+
function isCompanion(node) {
|
|
1484
|
+
return (
|
|
1485
|
+
(!!node._x && node._x._parentSource !== undefined) ||
|
|
1486
|
+
(node._config & CONFIG_DERIVED_OVERRIDE) !== 0
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
const HOLD_CENSUS_CAP = 10_000;
|
|
1490
|
+
function acknowledge(state, kind, source, reader) {
|
|
1491
|
+
const key = `${kind}:${source}`;
|
|
1492
|
+
const existing = state.acknowledgements.get(key);
|
|
1493
|
+
const path = reader !== null ? ownerPath(reader) : undefined;
|
|
1494
|
+
// A later census (a merged transition, the settle pass) may find the
|
|
1495
|
+
// reader an earlier one missed.
|
|
1496
|
+
if (existing === undefined)
|
|
1497
|
+
state.acknowledgements.set(
|
|
1498
|
+
key,
|
|
1499
|
+
path !== undefined ? { kind, source, reader: path } : { kind, source }
|
|
1500
|
+
);
|
|
1501
|
+
else if (path !== undefined) existing.reader ??= path;
|
|
1502
|
+
}
|
|
1503
|
+
function censusRegistrations(t, state) {
|
|
1504
|
+
const budget = { left: HOLD_CENSUS_CAP };
|
|
1505
|
+
for (const node of t._optimisticNodes)
|
|
1506
|
+
if (!isCompanion(node))
|
|
1507
|
+
acknowledge(state, "optimistic", nodeName(node), reachesEffect(node, budget));
|
|
1508
|
+
for (const store of t._optimisticStores)
|
|
1509
|
+
acknowledge(state, "optimistic", store?._name ?? "store", null);
|
|
1510
|
+
for (const node of t._affectsNodes)
|
|
1511
|
+
acknowledge(state, "affects", nodeName(node), reachesEffect(node, budget));
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Does anything that paints read `companion` — an effect, through however many
|
|
1515
|
+
* memos? A subscriber alone is not acknowledgement: memos compute eagerly, so a
|
|
1516
|
+
* router's `createMemo(() => isPending(location))` subscribes to the companion
|
|
1517
|
+
* whether or not the app ever renders the memo. Only an effect is the screen.
|
|
1518
|
+
* Returns the first effect found (the reader), or null.
|
|
1519
|
+
*/
|
|
1520
|
+
function reachesEffect(companion, budget) {
|
|
1521
|
+
const seen = new Set([companion]);
|
|
1522
|
+
const stack = [companion];
|
|
1523
|
+
while (stack.length > 0 && budget.left-- > 0) {
|
|
1524
|
+
const node = stack.pop();
|
|
1525
|
+
for (let s = node._subs; s !== null; s = s._nextSub) {
|
|
1526
|
+
const sub = s._sub;
|
|
1527
|
+
if (sub._type) return sub;
|
|
1528
|
+
if (!seen.has(sub)) {
|
|
1529
|
+
seen.add(sub);
|
|
1530
|
+
stack.push(sub);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
return null;
|
|
1535
|
+
}
|
|
1536
|
+
/** Companions an effect reads, anywhere downstream of the hold's nodes. */
|
|
1537
|
+
function censusCompanions(roots, state) {
|
|
1538
|
+
const visited = new Set();
|
|
1539
|
+
const stack = [...roots];
|
|
1540
|
+
const budget = { left: HOLD_CENSUS_CAP };
|
|
1541
|
+
while (stack.length > 0 && visited.size < HOLD_CENSUS_CAP) {
|
|
1542
|
+
const node = stack.pop();
|
|
1543
|
+
if (visited.has(node)) continue;
|
|
1544
|
+
visited.add(node);
|
|
1545
|
+
const x = node._x;
|
|
1546
|
+
if (x) {
|
|
1547
|
+
let reader;
|
|
1548
|
+
if (x._pendingSignal !== undefined && (reader = reachesEffect(x._pendingSignal, budget)))
|
|
1549
|
+
acknowledge(state, "isPending", nodeName(node), reader);
|
|
1550
|
+
if (
|
|
1551
|
+
x._latestValueComputed !== undefined &&
|
|
1552
|
+
(reader = reachesEffect(x._latestValueComputed, budget))
|
|
1553
|
+
)
|
|
1554
|
+
acknowledge(state, "latest", nodeName(node), reader);
|
|
1555
|
+
for (let child = x._child ?? null; child !== null; child = child._nextChild ?? null)
|
|
1556
|
+
stack.push(child);
|
|
1557
|
+
}
|
|
1558
|
+
for (let s = node._subs; s !== null; s = s._nextSub) stack.push(s._sub);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
function holdState(t) {
|
|
1562
|
+
let state = holdStates.get(t);
|
|
1563
|
+
if (state === undefined) {
|
|
1564
|
+
state = {
|
|
1565
|
+
start: now(),
|
|
1566
|
+
flushes: 0,
|
|
1567
|
+
blockers: new Set(),
|
|
1568
|
+
acknowledgements: new Map(),
|
|
1569
|
+
painted: 0,
|
|
1570
|
+
action: false
|
|
1571
|
+
};
|
|
1572
|
+
holdStates.set(t, state);
|
|
1573
|
+
}
|
|
1574
|
+
return state;
|
|
1575
|
+
}
|
|
1576
|
+
function trackHoldStart(t) {
|
|
1577
|
+
// Navigations and interactions learn they are held regardless of hold
|
|
1578
|
+
// tracking: their settle must wait for the transition either way (see flushEnd).
|
|
1579
|
+
markNavigationsHeld(t);
|
|
1580
|
+
markInteractionsHeld(t);
|
|
1581
|
+
if (options.holds === false) return;
|
|
1582
|
+
const state = holdState(t);
|
|
1583
|
+
state.flushes++;
|
|
1584
|
+
if (t._actions.length > 0) state.action = true;
|
|
1585
|
+
for (const [source, reporters] of t._asyncReporters)
|
|
1586
|
+
if (reporters.size > 0) state.blockers.add(source);
|
|
1587
|
+
censusRegistrations(t, state);
|
|
1588
|
+
activeHold = state;
|
|
1589
|
+
}
|
|
1590
|
+
function trackHoldMerge(target, outgoing) {
|
|
1591
|
+
mergeInteractionsHeld(target, outgoing);
|
|
1592
|
+
const from = holdStates.get(outgoing);
|
|
1593
|
+
if (from === undefined) return;
|
|
1594
|
+
holdStates.delete(outgoing);
|
|
1595
|
+
const into = holdState(target);
|
|
1596
|
+
if (from.start < into.start) into.start = from.start;
|
|
1597
|
+
into.flushes += from.flushes;
|
|
1598
|
+
into.painted += from.painted;
|
|
1599
|
+
into.action ||= from.action;
|
|
1600
|
+
for (const b of from.blockers) into.blockers.add(b);
|
|
1601
|
+
for (const [key, a] of from.acknowledgements)
|
|
1602
|
+
if (!into.acknowledgements.has(key)) into.acknowledgements.set(key, a);
|
|
1603
|
+
}
|
|
1604
|
+
function trackHoldSettled(t) {
|
|
1605
|
+
const state = holdStates.get(t);
|
|
1606
|
+
if (state === undefined) {
|
|
1607
|
+
// No hold was recorded (the transition completed in its first flush, or
|
|
1608
|
+
// hold tracking is off): navigations and interactions staged in it still
|
|
1609
|
+
// settle here.
|
|
1610
|
+
settleNavigations(t, undefined);
|
|
1611
|
+
settleInteractionsHeld(t, undefined);
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
holdStates.delete(t);
|
|
1615
|
+
// Root writes only: a memo in _pendingNodes is a derived hold, and the
|
|
1616
|
+
// question is whether the USER's input went unanswered.
|
|
1617
|
+
const heldWrites = [];
|
|
1618
|
+
let subject = null;
|
|
1619
|
+
let interaction;
|
|
1620
|
+
let origin;
|
|
1621
|
+
let lastJoinAt = -Infinity;
|
|
1622
|
+
for (const node of t._pendingNodes) {
|
|
1623
|
+
if (typeof node._fn === "function" || isCompanion(node)) continue;
|
|
1624
|
+
const change = node._devChange;
|
|
1625
|
+
if (change === undefined || change.kind !== "write") continue;
|
|
1626
|
+
if (subject === null) subject = node;
|
|
1627
|
+
const held = { name: nodeName(node), prev: change.prev, value: change.value };
|
|
1628
|
+
if (change.origin !== undefined) held.origin = change.origin;
|
|
1629
|
+
heldWrites.push(held);
|
|
1630
|
+
// Earliest interaction among the held writes: the user has been waiting
|
|
1631
|
+
// since the first thing they did that this transaction is holding.
|
|
1632
|
+
const under = interactionOf(change.origin);
|
|
1633
|
+
if (under !== undefined && (interaction === undefined || under.at < interaction.at))
|
|
1634
|
+
interaction = under;
|
|
1635
|
+
// The declared frame the writes belong to — earliest navigation, by the
|
|
1636
|
+
// same reasoning.
|
|
1637
|
+
if (
|
|
1638
|
+
change.origin?.kind === "navigation" &&
|
|
1639
|
+
(origin === undefined || change.origin.at < origin.at)
|
|
1640
|
+
)
|
|
1641
|
+
origin = change.origin;
|
|
1642
|
+
// Latest write: a signal written twice while held carries the later
|
|
1643
|
+
// stamp, so this is the user's final input, not their first.
|
|
1644
|
+
if (change.at !== undefined && change.at > lastJoinAt) lastJoinAt = change.at;
|
|
1645
|
+
}
|
|
1646
|
+
if (heldWrites.length === 0) {
|
|
1647
|
+
settleNavigations(t, undefined);
|
|
1648
|
+
settleInteractionsHeld(t, undefined);
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
censusRegistrations(t, state);
|
|
1652
|
+
censusCompanions([...t._pendingNodes, ...state.blockers], state);
|
|
1653
|
+
const end = now();
|
|
1654
|
+
// The hold began no later than its first parked flush; an interaction stamp
|
|
1655
|
+
// reaches further back (dispatch). A node rewritten mid-hold keeps only its
|
|
1656
|
+
// latest record, so the surviving interaction may be a later one — the
|
|
1657
|
+
// flush clock keeps the first wait from being forgotten.
|
|
1658
|
+
const at = Math.min(state.start, interaction !== undefined ? interaction.at : Infinity);
|
|
1659
|
+
const holdMs = end - at;
|
|
1660
|
+
const tailMs = lastJoinAt === -Infinity ? holdMs : Math.min(holdMs, end - lastJoinAt);
|
|
1661
|
+
const acknowledgements = [...state.acknowledgements.values()];
|
|
1662
|
+
// The verdicts, stamped on the record so a consumer — in-process or
|
|
1663
|
+
// offline — applies the engine's own tiering rather than a threshold of
|
|
1664
|
+
// its own: silent is duration-free (the finding adds the floor); long is
|
|
1665
|
+
// the tail against `longHolds.infoMs` as the options stand at settle.
|
|
1666
|
+
const longCfg = options.longHolds;
|
|
1667
|
+
const event = {
|
|
1668
|
+
at,
|
|
1669
|
+
holdMs,
|
|
1670
|
+
tailMs,
|
|
1671
|
+
flushes: state.flushes,
|
|
1672
|
+
heldWrites,
|
|
1673
|
+
blockers: [...state.blockers].map(nodeName),
|
|
1674
|
+
acknowledgements,
|
|
1675
|
+
paintedDuringHold: state.painted,
|
|
1676
|
+
action: state.action,
|
|
1677
|
+
silent: state.painted === 0 && acknowledgements.length === 0,
|
|
1678
|
+
long: longCfg !== false && longCfg !== undefined && tailMs >= longCfg.infoMs
|
|
1679
|
+
};
|
|
1680
|
+
if (interaction !== undefined) event.interaction = interaction;
|
|
1681
|
+
if (origin !== undefined) event.origin = origin;
|
|
1682
|
+
holdLog.push(event);
|
|
1683
|
+
if (holdLog.length > options.historyLimit) holdLog.shift();
|
|
1684
|
+
// Bottom-up delivery: the hold, then the navigations it held, then the
|
|
1685
|
+
// interactions those belong to — each record complete when its parent is.
|
|
1686
|
+
// `live` is the first held root write's signal: the subject the findings
|
|
1687
|
+
// below name.
|
|
1688
|
+
records.emit("hold", event, subject);
|
|
1689
|
+
checkStackedHolds(t, event, subject);
|
|
1690
|
+
settleNavigations(t, event);
|
|
1691
|
+
settleInteractionsHeld(t, event);
|
|
1692
|
+
for (const f of folds) f.hold?.(event);
|
|
1693
|
+
if (event.silent) checkSilentHold(event, subject);
|
|
1694
|
+
else checkLongHold(event, subject);
|
|
1695
|
+
}
|
|
1696
|
+
function describeHeldWrites(event) {
|
|
1697
|
+
return event.heldWrites
|
|
1698
|
+
.map(w => (w.prev !== undefined ? `"${w.name}" (${w.prev} → ${w.value})` : `"${w.name}"`))
|
|
1699
|
+
.join(", ");
|
|
1700
|
+
}
|
|
1701
|
+
function describeBlockers(event, lead) {
|
|
1702
|
+
return event.blockers.length > 0
|
|
1703
|
+
? ` ${lead} ${event.blockers.map(b => `"${b}"`).join(", ")}`
|
|
1704
|
+
: "";
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* The boundary repair, shared by LONG_HOLD and a long SILENT_HOLD: a wait
|
|
1708
|
+
* this long should show a fallback, not a stale screen. A `Loading` boundary
|
|
1709
|
+
* lifts the write out of the hold only when it has not revealed yet or its
|
|
1710
|
+
* `on` prop changed — a revealed boundary with no `on` IS the stale screen.
|
|
1711
|
+
*/
|
|
1712
|
+
function boundaryRepair(event) {
|
|
1713
|
+
const key = event.heldWrites[0]?.name ?? "key";
|
|
1714
|
+
return (
|
|
1715
|
+
`A wait this long is past what a stale screen should carry: show a fallback instead. Put ` +
|
|
1716
|
+
`the reader behind a Loading boundary keyed on what changed — <Loading on={${key}()} ` +
|
|
1717
|
+
`fallback={…}> — so the write commits at once and the fallback shows where the data lands; ` +
|
|
1718
|
+
`a boundary that has already revealed keeps the old content unless \`on\` changes. If the ` +
|
|
1719
|
+
`data itself is the problem, preload it or cache it so the wait never gets this long.`
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
function holdData(event) {
|
|
1723
|
+
const data = {
|
|
1724
|
+
holdMs: event.holdMs,
|
|
1725
|
+
tailMs: event.tailMs,
|
|
1726
|
+
flushes: event.flushes,
|
|
1727
|
+
heldWrites: event.heldWrites.map(w => w.name),
|
|
1728
|
+
blockers: event.blockers,
|
|
1729
|
+
action: event.action
|
|
1730
|
+
};
|
|
1731
|
+
if (event.interaction !== undefined)
|
|
1732
|
+
data.interaction = { type: event.interaction.name, target: event.interaction.target };
|
|
1733
|
+
if (event.origin?.kind === "navigation") data.navigation = navigationData(event.origin);
|
|
1734
|
+
return data;
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Who the verdict sentence starts from: the interaction, with the navigation
|
|
1738
|
+
* it performed in parentheses — `click on a.nav (navigation to /users/:id)`;
|
|
1739
|
+
* the navigation alone when nothing user-dispatched is known (a redirect);
|
|
1740
|
+
* empty when neither is.
|
|
1741
|
+
*/
|
|
1742
|
+
function holdActor(event) {
|
|
1743
|
+
const via = event.origin !== undefined ? formatOrigin(event.origin) : "";
|
|
1744
|
+
if (event.interaction !== undefined)
|
|
1745
|
+
return `${formatOrigin(event.interaction)}${via ? ` (${via})` : ""}`;
|
|
1746
|
+
return via;
|
|
1747
|
+
}
|
|
1748
|
+
function checkSilentHold(event, subject) {
|
|
1749
|
+
const cfg = options.holds;
|
|
1750
|
+
if (cfg === false) return;
|
|
1751
|
+
if (event.holdMs < cfg.infoMs) return;
|
|
1752
|
+
const ms = event.holdMs.toFixed(0);
|
|
1753
|
+
const writes = describeHeldWrites(event);
|
|
1754
|
+
const waitedOn = describeBlockers(event, "waiting on");
|
|
1755
|
+
// With the interaction (or navigation) stamped the sentence starts from
|
|
1756
|
+
// what the user did; without it, from the writes.
|
|
1757
|
+
const actor = holdActor(event);
|
|
1758
|
+
const who = actor ? `${actor} ` : "";
|
|
1759
|
+
let message = event.action
|
|
1760
|
+
? `[SILENT_HOLD] ${who}${who ? "started an action that" : "an action"} held ${writes} for ` +
|
|
1761
|
+
`${ms}ms${waitedOn} and the screen showed nothing for the whole round-trip: no optimistic ` +
|
|
1762
|
+
`value, no isPending() reader, no affects() mark, and no effect ran while it was held. ` +
|
|
1763
|
+
`Pair the action with a createOptimistic/createOptimisticStore write for the expected ` +
|
|
1764
|
+
`outcome (it reverts on failure), or co-write a createOptimistic(false) "saving" flag ` +
|
|
1765
|
+
`the UI reads.`
|
|
1766
|
+
: `[SILENT_HOLD] ${who}${who ? "wrote" : "writes to"} ${writes}${who ? "; the write was" : " were"} ` +
|
|
1767
|
+
`held ${ms}ms${waitedOn} and the screen showed nothing for the wait: no ` +
|
|
1768
|
+
`isPending()/latest() reader downstream, no optimistic value, no affects() mark, and no ` +
|
|
1769
|
+
`effect ran while it was held — the interaction was dead for ${ms}ms. Show the wait: ` +
|
|
1770
|
+
`read isPending(() => ${event.blockers[0] ?? "source"}()) to render a busy state, or ` +
|
|
1771
|
+
`latest(${event.heldWrites[0].name}) to reveal the new input immediately while the data ` +
|
|
1772
|
+
`catches up. The hold itself is correct — do not "fix" this by moving the write off the ` +
|
|
1773
|
+
`async path.`;
|
|
1774
|
+
const long = event.long;
|
|
1775
|
+
if (long) message += ` ${boundaryRepair(event)}`;
|
|
1776
|
+
const severity = event.holdMs >= cfg.warnMs ? "warn" : "info";
|
|
1777
|
+
const data = holdData(event);
|
|
1778
|
+
data.long = long;
|
|
1779
|
+
const entry = emitDiagnostic(
|
|
1780
|
+
{
|
|
1781
|
+
code: "SILENT_HOLD",
|
|
1782
|
+
kind: "responsiveness",
|
|
1783
|
+
severity,
|
|
1784
|
+
message,
|
|
1785
|
+
nodeName: nodeName(subject),
|
|
1786
|
+
data
|
|
1787
|
+
},
|
|
1788
|
+
subject
|
|
1789
|
+
);
|
|
1790
|
+
if (severity === "warn") reportDiagnostic(entry);
|
|
1791
|
+
}
|
|
1792
|
+
function checkLongHold(event, subject) {
|
|
1793
|
+
const cfg = options.longHolds;
|
|
1794
|
+
if (cfg === false || cfg === undefined) return;
|
|
1795
|
+
if (event.tailMs < cfg.infoMs) return;
|
|
1796
|
+
const tail = event.tailMs.toFixed(0);
|
|
1797
|
+
const writes = describeHeldWrites(event);
|
|
1798
|
+
const waitedOn = describeBlockers(event, "waiting on");
|
|
1799
|
+
const actor = holdActor(event);
|
|
1800
|
+
const who = actor ? `${actor} ` : "";
|
|
1801
|
+
const answered =
|
|
1802
|
+
event.acknowledgements.length > 0
|
|
1803
|
+
? `${event.acknowledgements.map(a => `"${a.kind}:${a.source}"`).join(", ")} said it was pending`
|
|
1804
|
+
: `an effect painted meanwhile`;
|
|
1805
|
+
const sinceLast =
|
|
1806
|
+
event.tailMs < event.holdMs - 1
|
|
1807
|
+
? ` after the last input (${event.holdMs.toFixed(0)}ms in all)`
|
|
1808
|
+
: "";
|
|
1809
|
+
const message =
|
|
1810
|
+
`[LONG_HOLD] ${who}${who ? "wrote" : "writes to"} ${writes}; the screen kept the old ` +
|
|
1811
|
+
`content for ${tail}ms${sinceLast}${waitedOn} — ${answered}, but the hold ran on well past ` +
|
|
1812
|
+
`the point where "loading" over stale content reads as broken. ${boundaryRepair(event)}`;
|
|
1813
|
+
const severity = event.tailMs >= cfg.warnMs ? "warn" : "info";
|
|
1814
|
+
const data = holdData(event);
|
|
1815
|
+
data.acknowledgements = event.acknowledgements;
|
|
1816
|
+
const entry = emitDiagnostic(
|
|
1817
|
+
{
|
|
1818
|
+
code: "LONG_HOLD",
|
|
1819
|
+
kind: "responsiveness",
|
|
1820
|
+
severity,
|
|
1821
|
+
message,
|
|
1822
|
+
nodeName: nodeName(subject),
|
|
1823
|
+
data
|
|
1824
|
+
},
|
|
1825
|
+
subject
|
|
1826
|
+
);
|
|
1827
|
+
if (severity === "warn") reportDiagnostic(entry);
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* The person saw the guess, then the correction. An optimistic value is a
|
|
1831
|
+
* promise the UI makes about the outcome; when the outcome differs — the
|
|
1832
|
+
* action failed and the override lifted back to the old value, or the
|
|
1833
|
+
* source answered with something else — the screen changes twice for one
|
|
1834
|
+
* intent. Expected on failure and correct by construction (the override
|
|
1835
|
+
* reverts; that is the feature), so `info`: a count that grows for one
|
|
1836
|
+
* source is what says the guess, or the failure rate, is wrong. Judged by
|
|
1837
|
+
* the node's own equality, so a structurally equal replacement is not a
|
|
1838
|
+
* revert.
|
|
1839
|
+
*/
|
|
1840
|
+
function checkOptimisticRevert(el, shown, truth, how) {
|
|
1841
|
+
// The runtime's own optimistic nodes are not guesses the person saw: an
|
|
1842
|
+
// `isPending()` companion goes true while pending and back to false at
|
|
1843
|
+
// commit by design — the acknowledgement SILENT_HOLD asks for — and a
|
|
1844
|
+
// derived override promotes rather than reverts. Same predicate the hold
|
|
1845
|
+
// census uses to skip them.
|
|
1846
|
+
if (!options.optimisticReverts || isCompanion(el)) return;
|
|
1847
|
+
const equals = el._equals;
|
|
1848
|
+
if (equals && equals(shown, truth)) return;
|
|
1849
|
+
const source = nodeName(el);
|
|
1850
|
+
// The two values are user data: quoted only under `values: "full"`; the
|
|
1851
|
+
// other levels say what happened without saying what was shown.
|
|
1852
|
+
const values = options.values === "full";
|
|
1853
|
+
const change = how === "superseded" ? "settled to" : "reverted to";
|
|
1854
|
+
const message =
|
|
1855
|
+
`[OPTIMISTIC_REVERTED] the optimistic value of ${source} ` +
|
|
1856
|
+
(values
|
|
1857
|
+
? `showed ${preview(shown)}; it ${change} ${preview(truth)}.`
|
|
1858
|
+
: `${how === "superseded" ? "was superseded by the settled value" : "reverted at settle"}.`) +
|
|
1859
|
+
` The person saw the guess, then the correction. A revert on failure is the feature; one ` +
|
|
1860
|
+
`that recurs says the guess is wrong for this input or the action fails often — show the ` +
|
|
1861
|
+
`failure where the value renders (the action's catch, an Errored boundary) rather than ` +
|
|
1862
|
+
`letting the value snap back on its own.`;
|
|
1863
|
+
const data = { source, how };
|
|
1864
|
+
if (values) {
|
|
1865
|
+
data.shown = preview(shown);
|
|
1866
|
+
data.truth = preview(truth);
|
|
1867
|
+
}
|
|
1868
|
+
emitDiagnostic(
|
|
1869
|
+
{
|
|
1870
|
+
code: "OPTIMISTIC_REVERTED",
|
|
1871
|
+
kind: "responsiveness",
|
|
1872
|
+
severity: "info",
|
|
1873
|
+
message,
|
|
1874
|
+
nodeName: source,
|
|
1875
|
+
data
|
|
1876
|
+
},
|
|
1877
|
+
el
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
// --- Graph growth -----------------------------------------------------------------
|
|
1881
|
+
/** Per route: the graph's size at its last `visits` settles, oldest first. */
|
|
1882
|
+
const routeCounts = new Map();
|
|
1883
|
+
/**
|
|
1884
|
+
* The live graph's size — a walk, not a counter: nothing is charged at node
|
|
1885
|
+
* creation, disposal, write or re-run; the engine asks at a navigation's
|
|
1886
|
+
* settle. Two passes. The owner tree from the registered top-level roots
|
|
1887
|
+
* gives `owners` and seeds the computations. Then each computation's
|
|
1888
|
+
* dependency links give `edges` and the `signals` and computations they
|
|
1889
|
+
* reach, and each newly met source's subscriber list gives the computations
|
|
1890
|
+
* that read it — including one created with no owner, which no chain holds
|
|
1891
|
+
* and only its sources keep alive: the subscription leak a heap snapshot
|
|
1892
|
+
* finds. Measured at 5–10 ns per link; a 50k-owner graph walks in under a
|
|
1893
|
+
* millisecond. Dormant nodes are spliced out of their chain and are not
|
|
1894
|
+
* counted unless a subscription still reaches them.
|
|
1895
|
+
*/
|
|
1896
|
+
function graphSize() {
|
|
1897
|
+
const roots = liveRootOwners();
|
|
1898
|
+
const size = { roots: roots.length, owners: 0, computations: 0, signals: 0, edges: 0 };
|
|
1899
|
+
const seen = new Set();
|
|
1900
|
+
// A worklist, not recursion: a subscriber chain can be thousands deep.
|
|
1901
|
+
const work = [];
|
|
1902
|
+
const stack = [];
|
|
1903
|
+
const meet = node => {
|
|
1904
|
+
if (seen.has(node)) return;
|
|
1905
|
+
seen.add(node);
|
|
1906
|
+
if (typeof node._fn === "function") size.computations++;
|
|
1907
|
+
else size.signals++;
|
|
1908
|
+
work.push(node);
|
|
1909
|
+
};
|
|
1910
|
+
for (const root of roots) {
|
|
1911
|
+
size.owners++;
|
|
1912
|
+
// Iterative: a deep tree must not grow the call stack.
|
|
1913
|
+
let child = root._firstChild;
|
|
1914
|
+
for (;;) {
|
|
1915
|
+
while (child !== null) {
|
|
1916
|
+
size.owners++;
|
|
1917
|
+
if (typeof child._fn === "function") meet(child);
|
|
1918
|
+
if (child._firstChild !== null) stack.push(child);
|
|
1919
|
+
child = child._nextSibling;
|
|
1920
|
+
}
|
|
1921
|
+
const next = stack.pop();
|
|
1922
|
+
if (next === undefined) break;
|
|
1923
|
+
child = next._firstChild;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
for (let i = 0; i < work.length; i++) {
|
|
1927
|
+
const node = work[i];
|
|
1928
|
+
// Whoever reads this node: owned computations are already known; an
|
|
1929
|
+
// ownerless one is met only here.
|
|
1930
|
+
for (let s = node._subs; s !== null; s = s._nextSub) meet(s._sub);
|
|
1931
|
+
if (typeof node._fn === "function")
|
|
1932
|
+
for (let link = node._deps; link !== null; link = link._nextDep) {
|
|
1933
|
+
size.edges++;
|
|
1934
|
+
meet(link._dep);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return size;
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* settleNavigation: the app has finished moving to a route — count the live
|
|
1941
|
+
* graph. One record per settle for a listener; the growth check compares
|
|
1942
|
+
* this settle of the route with its previous ones. A count that climbs on
|
|
1943
|
+
* consecutive visits is something the previous visit left behind: a
|
|
1944
|
+
* `createRoot` in an effect with no dispose, a subscription a component
|
|
1945
|
+
* registered outside its owner. Nothing here runs per node; the walk is
|
|
1946
|
+
* the cost, at navigation cadence.
|
|
1947
|
+
*/
|
|
1948
|
+
const GRAPH_SERIES = ["owners", "computations", "signals", "edges"];
|
|
1949
|
+
function trackGraph(navigation) {
|
|
1950
|
+
const cfg = options.graphGrowth;
|
|
1951
|
+
if (cfg === false && !records.observed("graph")) return;
|
|
1952
|
+
const size = graphSize();
|
|
1953
|
+
const route = navigation.name ?? navigation.to;
|
|
1954
|
+
const event = { at: now(), ...size, navigation };
|
|
1955
|
+
if (route !== undefined) event.route = route;
|
|
1956
|
+
records.emit("graph", event, undefined);
|
|
1957
|
+
if (cfg === false || route === undefined) return;
|
|
1958
|
+
let history = routeCounts.get(route);
|
|
1959
|
+
if (history === undefined) routeCounts.set(route, (history = []));
|
|
1960
|
+
history.push(size);
|
|
1961
|
+
if (history.length > cfg.visits) history.shift();
|
|
1962
|
+
if (history.length < cfg.visits) return;
|
|
1963
|
+
// Any series climbing on every visit to the ratio is growth; which ones
|
|
1964
|
+
// did says what leaked — computations with owners flat is an ownerless
|
|
1965
|
+
// effect per visit, edges alone is a subscription per visit to something
|
|
1966
|
+
// long-lived, owners is an undisposed root.
|
|
1967
|
+
const grew = GRAPH_SERIES.filter(key => {
|
|
1968
|
+
for (let i = 1; i < history.length; i++)
|
|
1969
|
+
if (history[i][key] <= history[i - 1][key]) return false;
|
|
1970
|
+
return history[history.length - 1][key] >= history[0][key] * cfg.ratio;
|
|
1971
|
+
});
|
|
1972
|
+
if (grew.length === 0) return;
|
|
1973
|
+
// The count is the whole graph's, so a leak shows at every route's settle;
|
|
1974
|
+
// the first route to complete its climb reports, naming the others seen.
|
|
1975
|
+
const routes = [...routeCounts.keys()];
|
|
1976
|
+
const series = grew.map(key => `${key} ${history.map(h => h[key]).join(" → ")}`).join("; ");
|
|
1977
|
+
const shape = grew.includes("owners")
|
|
1978
|
+
? "a createRoot() in an effect or handler with no dispose, or a Portal or panel mounted per visit"
|
|
1979
|
+
: grew.includes("computations")
|
|
1980
|
+
? "an effect or memo created with no owner (a module-level or callback createEffect) that only its sources keep alive"
|
|
1981
|
+
: "a subscription per visit to something long-lived — a global store or signal read by a computation that outlives the visit";
|
|
1982
|
+
const message =
|
|
1983
|
+
`[GRAPH_GROWTH] the live graph grew on ${cfg.visits} consecutive visits to ${route}: ${series} ` +
|
|
1984
|
+
`(${size.roots} roots)${routes.length > 1 ? `, across visits to ${routes.join(", ")}` : ""}. Each ` +
|
|
1985
|
+
`visit left something behind that the next did not reclaim — the shape says ${shape}. Dispose ` +
|
|
1986
|
+
`what a visit creates (onCleanup, or return the disposer from onSettled) and own it under the ` +
|
|
1987
|
+
`route's component so leaving the route tears it down.`;
|
|
1988
|
+
const data = {
|
|
1989
|
+
route,
|
|
1990
|
+
grew,
|
|
1991
|
+
history: history.map(h => ({ ...h })),
|
|
1992
|
+
roots: size.roots,
|
|
1993
|
+
routes
|
|
1994
|
+
};
|
|
1995
|
+
if (navigation.interaction !== undefined)
|
|
1996
|
+
data.interaction = { type: navigation.interaction.name, target: navigation.interaction.target };
|
|
1997
|
+
reportDiagnostic(
|
|
1998
|
+
emitDiagnostic({ code: "GRAPH_GROWTH", kind: "perf", severity: "warn", message, data }, null)
|
|
1999
|
+
);
|
|
2000
|
+
// Judged once for the graph, not once per route: the next verdict needs
|
|
2001
|
+
// another `visits` climbing settles.
|
|
2002
|
+
routeCounts.clear();
|
|
2003
|
+
}
|
|
2004
|
+
// --- Feedback-table findings -----------------------------------------------------
|
|
2005
|
+
/** Per async source: abandoned flights inside the current window. Engine-side; the node carries nothing. */
|
|
2006
|
+
const abandonWindows = new WeakMap();
|
|
2007
|
+
/**
|
|
2008
|
+
* One source abandoning flight after flight inside a window: every input
|
|
2009
|
+
* asked again and the earlier answers were thrown away — the
|
|
2010
|
+
* request-per-keystroke signature `feedback().flights[].abandoned` counts,
|
|
2011
|
+
* as a finding while it happens. Warned once per window per source.
|
|
2012
|
+
*/
|
|
2013
|
+
function checkAbandonedFlights(el, abandoned) {
|
|
2014
|
+
const cfg = options.abandonedFlights;
|
|
2015
|
+
if (cfg === false || excludedNode(el)) return;
|
|
2016
|
+
const at = Date.now();
|
|
2017
|
+
let win = abandonWindows.get(el);
|
|
2018
|
+
if (win === undefined || at - win.start > cfg.windowMs) {
|
|
2019
|
+
win = { start: at, count: 0, warned: false };
|
|
2020
|
+
abandonWindows.set(el, win);
|
|
2021
|
+
}
|
|
2022
|
+
win.count++;
|
|
2023
|
+
if (win.warned || win.count < cfg.count) return;
|
|
2024
|
+
win.warned = true;
|
|
2025
|
+
const source = nodeName(el);
|
|
2026
|
+
const who =
|
|
2027
|
+
abandoned.interaction !== undefined
|
|
2028
|
+
? ` (${formatOrigin(abandoned.interaction)} started the first)`
|
|
2029
|
+
: "";
|
|
2030
|
+
const message =
|
|
2031
|
+
`[ABANDONED_FLIGHTS] "${source}" abandoned ${win.count} flights in ${cfg.windowMs}ms` +
|
|
2032
|
+
`${who}: each was superseded by the next before it landed, so every input asked again and the ` +
|
|
2033
|
+
`answers were discarded. Put a debounced or equality-gated derivation between the input and ` +
|
|
2034
|
+
`the fetch, or key the fetch on what changes rather than on every keystroke; a preload the ` +
|
|
2035
|
+
`flights share (markFlight) reads as one flight, not many.`;
|
|
2036
|
+
const data = {
|
|
2037
|
+
source,
|
|
2038
|
+
abandoned: win.count,
|
|
2039
|
+
windowMs: cfg.windowMs
|
|
2040
|
+
};
|
|
2041
|
+
if (abandoned.interaction !== undefined)
|
|
2042
|
+
data.interaction = { type: abandoned.interaction.name, target: abandoned.interaction.target };
|
|
2043
|
+
const entry = emitDiagnostic(
|
|
2044
|
+
{
|
|
2045
|
+
code: "ABANDONED_FLIGHTS",
|
|
2046
|
+
kind: "responsiveness",
|
|
2047
|
+
severity: "warn",
|
|
2048
|
+
message,
|
|
2049
|
+
nodeName: source,
|
|
2050
|
+
data
|
|
2051
|
+
},
|
|
2052
|
+
el
|
|
2053
|
+
);
|
|
2054
|
+
reportDiagnostic(entry);
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* A fallback that appeared and vanished: the other end of the SILENT_HOLD
|
|
2058
|
+
* spectrum — feedback for a wait too short to need it, which reads as a
|
|
2059
|
+
* flicker. `info`, once per flash; `feedback().fallbacks[].flashes` is the
|
|
2060
|
+
* count. The repair is a preload, a cache, or lifting the fetch above the
|
|
2061
|
+
* boundary so the data is there before the boundary asks.
|
|
2062
|
+
*/
|
|
2063
|
+
function checkFallbackFlash(subtree, shownMs, interaction) {
|
|
2064
|
+
if (subtree !== undefined && excludedNode(subtree)) return;
|
|
2065
|
+
const where = subtree !== undefined ? ownerPath(subtree)?.join(" › ") : undefined;
|
|
2066
|
+
const who = interaction !== undefined ? `${formatOrigin(interaction)}: ` : "";
|
|
2067
|
+
const message =
|
|
2068
|
+
`[FALLBACK_FLASH] ${who}the Loading fallback${where ? ` at ${where}` : ""} showed for ` +
|
|
2069
|
+
`${shownMs.toFixed(0)}ms — a spinner that appeared and vanished, feedback for a wait too short ` +
|
|
2070
|
+
`to need it. Preload or cache the data so it is there before the boundary asks, or lift the ` +
|
|
2071
|
+
`read above the boundary; a fallback under ${FALLBACK_FLASH_MS}ms reads as a flicker.`;
|
|
2072
|
+
const data = { shownMs };
|
|
2073
|
+
if (interaction !== undefined)
|
|
2074
|
+
data.interaction = { type: interaction.name, target: interaction.target };
|
|
2075
|
+
emitDiagnostic(
|
|
2076
|
+
{ code: "FALLBACK_FLASH", kind: "responsiveness", severity: "info", message, data },
|
|
2077
|
+
subtree ?? null
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Several interactions waiting in one hold when it commits: the person kept
|
|
2082
|
+
* clicking or typing while the first answer was in the air, and all of them
|
|
2083
|
+
* waited on the same source. The pile is the symptom; the hold's own verdict
|
|
2084
|
+
* (SILENT_HOLD / LONG_HOLD) is the cause, so the repair is the same
|
|
2085
|
+
* acknowledgement, plus a control that does not accept the repeat.
|
|
2086
|
+
*/
|
|
2087
|
+
function checkStackedHolds(t, hold, subject) {
|
|
2088
|
+
const cfg = options.stackedHolds;
|
|
2089
|
+
if (cfg === false) return;
|
|
2090
|
+
let stacked = 0;
|
|
2091
|
+
for (const state of openInteractions) if (state.heldIn.has(t)) stacked++;
|
|
2092
|
+
if (stacked < cfg.count) return;
|
|
2093
|
+
const waitedOn = describeBlockers(hold, "waiting on");
|
|
2094
|
+
const message =
|
|
2095
|
+
`[STACKED_HOLDS] ${stacked} interactions queued behind one hold${waitedOn} for ` +
|
|
2096
|
+
`${hold.holdMs.toFixed(0)}ms: the person kept ${hold.interaction?.name === "input" ? "typing" : "clicking"} ` +
|
|
2097
|
+
`while the first answer was in the air, and every repeat waited on the same source. ` +
|
|
2098
|
+
`Acknowledge the wait where the control is (isPending() to disable or dim it) so the repeats ` +
|
|
2099
|
+
`stop, or debounce the input; the hold itself is judged by SILENT_HOLD/LONG_HOLD.`;
|
|
2100
|
+
const data = holdData(hold);
|
|
2101
|
+
data.interactions = stacked;
|
|
2102
|
+
const entry = emitDiagnostic(
|
|
2103
|
+
{
|
|
2104
|
+
code: "STACKED_HOLDS",
|
|
2105
|
+
kind: "responsiveness",
|
|
2106
|
+
severity: "warn",
|
|
2107
|
+
message,
|
|
2108
|
+
nodeName: nodeName(subject),
|
|
2109
|
+
data
|
|
2110
|
+
},
|
|
2111
|
+
subject
|
|
2112
|
+
);
|
|
2113
|
+
reportDiagnostic(entry);
|
|
2114
|
+
}
|
|
2115
|
+
const navStates = new WeakMap();
|
|
2116
|
+
/** Opened, not yet settled. */
|
|
2117
|
+
const openNavs = new Set();
|
|
2118
|
+
let navigationLog = [];
|
|
2119
|
+
/** Drains completed since enable() — the clock `writeDrain` reads. */
|
|
2120
|
+
let drainSeq = 0;
|
|
2121
|
+
/**
|
|
2122
|
+
* Copy what the router currently says onto the frame (what writes stamped —
|
|
2123
|
+
* `formatOrigin` reads it) and the event. Called when the frame opens, when a
|
|
2124
|
+
* redirect re-describes it, and when the record settles, so a description
|
|
2125
|
+
* refined during the hold is what every consumer ends up reading.
|
|
2126
|
+
*/
|
|
2127
|
+
function syncNavigation(state) {
|
|
2128
|
+
const { event, ref } = state;
|
|
2129
|
+
const frame = event.origin;
|
|
2130
|
+
if (ref.name === undefined) {
|
|
2131
|
+
delete frame.name;
|
|
2132
|
+
delete event.name;
|
|
2133
|
+
} else frame.name = event.name = ref.name;
|
|
2134
|
+
if (ref.to === undefined) {
|
|
2135
|
+
delete frame.to;
|
|
2136
|
+
delete event.to;
|
|
2137
|
+
} else frame.to = event.to = ref.to;
|
|
2138
|
+
if (ref.params === undefined) {
|
|
2139
|
+
delete frame.params;
|
|
2140
|
+
delete event.params;
|
|
2141
|
+
} else frame.params = event.params = ref.params;
|
|
2142
|
+
}
|
|
2143
|
+
function openNavigation(frame, ref) {
|
|
2144
|
+
const event = { at: frame.at, writes: 0, origin: frame };
|
|
2145
|
+
if (frame.from !== undefined) event.from = frame.from;
|
|
2146
|
+
if (frame.interaction !== undefined) event.interaction = frame.interaction;
|
|
2147
|
+
const state = {
|
|
2148
|
+
event,
|
|
2149
|
+
ref,
|
|
2150
|
+
open: 1,
|
|
2151
|
+
held: false,
|
|
2152
|
+
writeDrain: drainSeq
|
|
2153
|
+
};
|
|
2154
|
+
syncNavigation(state);
|
|
2155
|
+
navStates.set(frame, state);
|
|
2156
|
+
openNavs.add(state);
|
|
2157
|
+
navigationLog.push(event);
|
|
2158
|
+
if (navigationLog.length > options.historyLimit) navigationLog.shift();
|
|
2159
|
+
noteInteractionNavigation(event);
|
|
2160
|
+
}
|
|
2161
|
+
/** The navigation a redirect hop folds onto: the most recently opened one still pending. */
|
|
2162
|
+
function lastOpenNavigation() {
|
|
2163
|
+
let last;
|
|
2164
|
+
for (const state of openNavs) last = state;
|
|
2165
|
+
return last;
|
|
2166
|
+
}
|
|
2167
|
+
/** A redirect re-describes `state`: the current destination becomes a hop it abandoned. */
|
|
2168
|
+
function redirectNavigation(state, ref) {
|
|
2169
|
+
const event = state.event;
|
|
2170
|
+
// As the router last described the destination being left behind.
|
|
2171
|
+
syncNavigation(state);
|
|
2172
|
+
const hop = { at: ref.at ?? now() };
|
|
2173
|
+
if (event.name !== undefined) hop.name = event.name;
|
|
2174
|
+
if (event.to !== undefined) hop.to = event.to;
|
|
2175
|
+
if (event.params !== undefined) hop.params = event.params;
|
|
2176
|
+
(event.redirects ??= []).push(hop);
|
|
2177
|
+
state.ref = ref;
|
|
2178
|
+
state.open++;
|
|
2179
|
+
syncNavigation(state);
|
|
2180
|
+
}
|
|
2181
|
+
function closeNavigation(frame) {
|
|
2182
|
+
const state = navStates.get(frame);
|
|
2183
|
+
if (state === undefined || --state.open > 0) return;
|
|
2184
|
+
syncNavigation(state);
|
|
2185
|
+
// Nothing to wait for: no write survived the equality gate (navigating to
|
|
2186
|
+
// where we already are), or a drain inside the frame already committed
|
|
2187
|
+
// them (`flush(() => setLocation(…))`) with no hold.
|
|
2188
|
+
if (state.event.writes === 0 || (!state.held && drainSeq > state.writeDrain))
|
|
2189
|
+
settleNavigation(state, "committed");
|
|
2190
|
+
}
|
|
2191
|
+
/** stampWrite: the record replacing `prior` on a node was just stamped. */
|
|
2192
|
+
function noteNavigationWrite(prior, record) {
|
|
2193
|
+
const origin = record.origin;
|
|
2194
|
+
if (origin.kind === "navigation") {
|
|
2195
|
+
const state = navStates.get(origin);
|
|
2196
|
+
if (state !== undefined) {
|
|
2197
|
+
state.event.writes++;
|
|
2198
|
+
state.writeDrain = drainSeq;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
// The node now carries a different frame's record: whatever `prior`'s
|
|
2202
|
+
// navigation was waiting to show on it will never land as that navigation.
|
|
2203
|
+
const before = prior?.origin;
|
|
2204
|
+
if (before !== undefined && before !== origin && before.kind === "navigation") {
|
|
2205
|
+
const state = navStates.get(before);
|
|
2206
|
+
if (state !== undefined && openNavs.has(state)) settleNavigation(state, "superseded");
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
/** holdStart: `t`'s staged writes are parked — their navigations settle with `t`. */
|
|
2210
|
+
function markNavigationsHeld(t) {
|
|
2211
|
+
if (openNavs.size === 0) return;
|
|
2212
|
+
for (const node of t._pendingNodes) {
|
|
2213
|
+
const origin = node._devChange?.origin;
|
|
2214
|
+
if (origin?.kind !== "navigation") continue;
|
|
2215
|
+
const state = navStates.get(origin);
|
|
2216
|
+
if (state !== undefined) state.held = true;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
/** transitionSettled: `t` commits — the navigations whose writes it staged are done. */
|
|
2220
|
+
function settleNavigations(t, hold) {
|
|
2221
|
+
if (openNavs.size === 0) return;
|
|
2222
|
+
for (const node of t._pendingNodes) {
|
|
2223
|
+
const origin = node._devChange?.origin;
|
|
2224
|
+
if (origin?.kind !== "navigation") continue;
|
|
2225
|
+
const state = navStates.get(origin);
|
|
2226
|
+
if (state === undefined || !openNavs.has(state)) continue;
|
|
2227
|
+
// A navigation whose frame is still open when its transition commits
|
|
2228
|
+
// (`until()` inside the frame) settles here too: its writes are through.
|
|
2229
|
+
settleNavigation(state, state.held ? "held" : "committed", hold);
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
let openFlush = null;
|
|
2233
|
+
function noteFlushRun(flush, create, interaction) {
|
|
2234
|
+
if (create) flush.created++;
|
|
2235
|
+
else flush.runs++;
|
|
2236
|
+
if (interaction === undefined || flush.interaction === null) return;
|
|
2237
|
+
if (flush.interaction === undefined) flush.interaction = interaction;
|
|
2238
|
+
else if (flush.interaction !== interaction) flush.interaction = null;
|
|
2239
|
+
}
|
|
2240
|
+
function trackFlushStart() {
|
|
2241
|
+
if (!records.observed("flush")) return;
|
|
2242
|
+
openFlush = { at: now(), runs: 0, created: 0, held: false, interaction: undefined };
|
|
2243
|
+
}
|
|
2244
|
+
/** The key for swaps no transaction carries — an object, so the map below can be weak. */
|
|
2245
|
+
const DRAIN = { drain: true };
|
|
2246
|
+
const openFallbacks = new WeakMap();
|
|
2247
|
+
// Weak on the transaction: one that is dropped without settling or merging
|
|
2248
|
+
// (its boundary disposed, its queues discarded) takes its staged opens with it.
|
|
2249
|
+
const stagedFallbacks = new WeakMap();
|
|
2250
|
+
/** Bumped by `resetTracking` — the engine's install generation. */
|
|
2251
|
+
let trackingGen = 0;
|
|
2252
|
+
function trackFallback$1(boundary, tree, shown, transition) {
|
|
2253
|
+
if (shown) {
|
|
2254
|
+
// Folds count showings too, and the flash finding needs the show's
|
|
2255
|
+
// clock: an open is kept for any of the three audiences.
|
|
2256
|
+
const record = records.observed("fallback");
|
|
2257
|
+
if (!record && folds.length === 0 && options.fallbackFlashes === false) return;
|
|
2258
|
+
// The wait is the enclosing recompute's cause's interaction — the read
|
|
2259
|
+
// that registered the pending source runs inside one — else the ambient.
|
|
2260
|
+
const causes = enclosingCauses();
|
|
2261
|
+
const staged = transition ?? DRAIN;
|
|
2262
|
+
const open = {
|
|
2263
|
+
at: now(),
|
|
2264
|
+
gen: trackingGen,
|
|
2265
|
+
record,
|
|
2266
|
+
boundary,
|
|
2267
|
+
tree,
|
|
2268
|
+
interaction: causes !== null ? interactionIn(causes) : (currentInteraction ?? undefined),
|
|
2269
|
+
staged
|
|
2270
|
+
};
|
|
2271
|
+
openFallbacks.set(boundary, open);
|
|
2272
|
+
const list = stagedFallbacks.get(staged);
|
|
2273
|
+
if (list === undefined) stagedFallbacks.set(staged, [open]);
|
|
2274
|
+
else list.push(open);
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
const open = openFallbacks.get(boundary);
|
|
2278
|
+
if (open === undefined) return;
|
|
2279
|
+
openFallbacks.delete(boundary);
|
|
2280
|
+
if (open.staged !== null) {
|
|
2281
|
+
// Cleared before its commit: never on screen.
|
|
2282
|
+
const list = stagedFallbacks.get(open.staged);
|
|
2283
|
+
list.splice(list.indexOf(open), 1);
|
|
2284
|
+
if (list.length === 0) stagedFallbacks.delete(open.staged);
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
if (open.gen !== trackingGen) return;
|
|
2288
|
+
// The hide has the subtree the first show may have lacked.
|
|
2289
|
+
const subtree = tree ?? open.tree;
|
|
2290
|
+
for (const f of folds) f.fallback?.(boundary, subtree, false);
|
|
2291
|
+
const shownMs = now() - open.at;
|
|
2292
|
+
if (options.fallbackFlashes !== false && shownMs < FALLBACK_FLASH_MS)
|
|
2293
|
+
checkFallbackFlash(subtree, shownMs, open.interaction);
|
|
2294
|
+
if (!open.record) return;
|
|
2295
|
+
const event = { at: open.at, shownMs };
|
|
2296
|
+
const path = subtree !== undefined ? ownerPath(subtree) : undefined;
|
|
2297
|
+
if (path !== undefined) event.ownerPath = path;
|
|
2298
|
+
if (open.interaction !== undefined) event.interaction = open.interaction;
|
|
2299
|
+
records.emit("fallback", event, subtree);
|
|
2300
|
+
}
|
|
2301
|
+
/** flushEnd: the drain's committed swaps have rendered — those fallbacks are on screen from here. */
|
|
2302
|
+
function displayFallbacks() {
|
|
2303
|
+
const list = stagedFallbacks.get(DRAIN);
|
|
2304
|
+
if (list === undefined) return;
|
|
2305
|
+
stagedFallbacks.delete(DRAIN);
|
|
2306
|
+
const at = now();
|
|
2307
|
+
for (const open of list) {
|
|
2308
|
+
open.staged = null;
|
|
2309
|
+
open.at = at;
|
|
2310
|
+
if (open.gen === trackingGen)
|
|
2311
|
+
for (const f of folds) f.fallback?.(open.boundary, open.tree, true);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
/** transitionSettled (`from` a transaction, into this drain) and
|
|
2315
|
+
* transitionMerged (`from` the outgoing, into the surviving transaction):
|
|
2316
|
+
* the swaps staged under `from` now land with `into`. */
|
|
2317
|
+
function rebaseFallbacks(from, into) {
|
|
2318
|
+
const list = stagedFallbacks.get(from);
|
|
2319
|
+
if (list === undefined) return;
|
|
2320
|
+
stagedFallbacks.delete(from);
|
|
2321
|
+
for (const open of list) open.staged = into;
|
|
2322
|
+
const target = stagedFallbacks.get(into);
|
|
2323
|
+
if (target === undefined) stagedFallbacks.set(into, list);
|
|
2324
|
+
else target.push(...list);
|
|
2325
|
+
}
|
|
2326
|
+
/** flushEnd: every open, closed, unheld navigation's (and interaction's) writes just committed. */
|
|
2327
|
+
function trackFlushEnd() {
|
|
2328
|
+
// The drain's own record first: the interactions it settles below waited on it.
|
|
2329
|
+
const flush = openFlush;
|
|
2330
|
+
if (flush !== null) {
|
|
2331
|
+
openFlush = null;
|
|
2332
|
+
const event = {
|
|
2333
|
+
at: flush.at,
|
|
2334
|
+
durationMs: now() - flush.at,
|
|
2335
|
+
runs: flush.runs,
|
|
2336
|
+
created: flush.created,
|
|
2337
|
+
held: flush.held
|
|
2338
|
+
};
|
|
2339
|
+
if (flush.interaction != null) event.interaction = flush.interaction;
|
|
2340
|
+
records.emit("flush", event, undefined);
|
|
2341
|
+
}
|
|
2342
|
+
drainSeq++;
|
|
2343
|
+
// The swaps this drain committed have rendered.
|
|
2344
|
+
displayFallbacks();
|
|
2345
|
+
for (const state of openNavs)
|
|
2346
|
+
if (state.open === 0 && !state.held) settleNavigation(state, "committed");
|
|
2347
|
+
for (const state of openInteractions) maybeSettleInteraction(state);
|
|
2348
|
+
}
|
|
2349
|
+
function settleNavigation(state, outcome, hold) {
|
|
2350
|
+
if (!openNavs.has(state)) return;
|
|
2351
|
+
openNavs.delete(state);
|
|
2352
|
+
syncNavigation(state);
|
|
2353
|
+
const event = state.event;
|
|
2354
|
+
event.settledMs = now() - event.at;
|
|
2355
|
+
event.outcome = outcome;
|
|
2356
|
+
if (hold !== undefined) event.hold = hold;
|
|
2357
|
+
for (const f of folds) f.navigation?.(event);
|
|
2358
|
+
records.emit("navigation", event, undefined);
|
|
2359
|
+
trackGraph(event);
|
|
2360
|
+
// The interaction that performed it may have been waiting only on this.
|
|
2361
|
+
const under = openInteractionOf(event.interaction);
|
|
2362
|
+
if (under !== undefined) maybeSettleInteraction(under);
|
|
2363
|
+
}
|
|
2364
|
+
const interactionStates = new WeakMap();
|
|
2365
|
+
/** Opened, not yet settled. */
|
|
2366
|
+
const openInteractions = new Set();
|
|
2367
|
+
let interactionLog = [];
|
|
2368
|
+
function openInteraction(frame, opened) {
|
|
2369
|
+
const event = {
|
|
2370
|
+
name: frame.name,
|
|
2371
|
+
at: frame.at,
|
|
2372
|
+
handlerMs: 0,
|
|
2373
|
+
writes: 0,
|
|
2374
|
+
runs: 0,
|
|
2375
|
+
created: 0,
|
|
2376
|
+
runMs: 0,
|
|
2377
|
+
holds: [],
|
|
2378
|
+
navigations: [],
|
|
2379
|
+
origin: frame
|
|
2380
|
+
};
|
|
2381
|
+
if (frame.target !== undefined) event.target = frame.target;
|
|
2382
|
+
// The runtime may date the frame from the event's own timestamp (before
|
|
2383
|
+
// any queued task ran); the gap to here is the input delay the browser's
|
|
2384
|
+
// INP counts first.
|
|
2385
|
+
if (opened > event.at) event.inputDelayMs = opened - event.at;
|
|
2386
|
+
const state = {
|
|
2387
|
+
event,
|
|
2388
|
+
open: true,
|
|
2389
|
+
opened,
|
|
2390
|
+
writeDrain: drainSeq,
|
|
2391
|
+
heldIn: new Set(),
|
|
2392
|
+
held: false,
|
|
2393
|
+
excludedWrites: 0,
|
|
2394
|
+
awaiting: false,
|
|
2395
|
+
actioned: false
|
|
2396
|
+
};
|
|
2397
|
+
interactionStates.set(frame, state);
|
|
2398
|
+
openInteractions.add(state);
|
|
2399
|
+
interactionLog.push(event);
|
|
2400
|
+
if (interactionLog.length > options.historyLimit) interactionLog.shift();
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* How long the record waits on a handler's returned promise before settling
|
|
2404
|
+
* without it: a promise that never settles (a hung request, a listener
|
|
2405
|
+
* awaiting an event that never comes) must not keep the record open forever.
|
|
2406
|
+
*/
|
|
2407
|
+
const ASYNC_HANDLER_CAP_MS = 10_000;
|
|
2408
|
+
/** interactionEnd: the handler returned. */
|
|
2409
|
+
function closeInteraction(frame, returned) {
|
|
2410
|
+
const state = interactionStates.get(frame);
|
|
2411
|
+
if (state === undefined) return;
|
|
2412
|
+
state.open = false;
|
|
2413
|
+
const end = now();
|
|
2414
|
+
state.event.handlerMs = end - state.opened;
|
|
2415
|
+
// `async () => { await save(); set(…) }` returns a promise and continues
|
|
2416
|
+
// past the frame; the person's wait is that continuation. The record stays
|
|
2417
|
+
// open until it settles (or the cap), then judges whether anything on
|
|
2418
|
+
// screen could have shown the wait.
|
|
2419
|
+
const thenable =
|
|
2420
|
+
returned !== null &&
|
|
2421
|
+
(typeof returned === "object" || typeof returned === "function") &&
|
|
2422
|
+
typeof returned.then === "function"
|
|
2423
|
+
? returned
|
|
2424
|
+
: null;
|
|
2425
|
+
if (thenable !== null) {
|
|
2426
|
+
state.awaiting = true;
|
|
2427
|
+
let done = false;
|
|
2428
|
+
let timer;
|
|
2429
|
+
const settle = () => {
|
|
2430
|
+
if (done) return;
|
|
2431
|
+
done = true;
|
|
2432
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
2433
|
+
if (!openInteractions.has(state)) return;
|
|
2434
|
+
state.awaiting = false;
|
|
2435
|
+
const at = now();
|
|
2436
|
+
state.event.continuationMs = at - end;
|
|
2437
|
+
checkUntrackedAsyncHandler(state);
|
|
2438
|
+
maybeSettleInteraction(state, at);
|
|
2439
|
+
};
|
|
2440
|
+
timer = setTimeout(settle, ASYNC_HANDLER_CAP_MS);
|
|
2441
|
+
// A pending cap must not hold a Node process (tests, SSR harnesses) open.
|
|
2442
|
+
timer.unref?.();
|
|
2443
|
+
// Both branches: a rejected handler promise still ended the wait.
|
|
2444
|
+
thenable.then(settle, settle);
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
maybeSettleInteraction(state, end);
|
|
2448
|
+
}
|
|
2449
|
+
/**
|
|
2450
|
+
* The handler awaited past its frame with nothing in the graph carrying the
|
|
2451
|
+
* wait: no root write before the `await` (a pending flag, an optimistic
|
|
2452
|
+
* value) and no action step (an action's steps stay attributed across
|
|
2453
|
+
* yields, and its holds are judged by SILENT_HOLD). From the person's side
|
|
2454
|
+
* the click did nothing for `continuationMs`; from the engine's side the
|
|
2455
|
+
* wait is invisible — no hold opened, so no hold could be acknowledged.
|
|
2456
|
+
* Thresholds are the hold thresholds: it is the same wait.
|
|
2457
|
+
*/
|
|
2458
|
+
function checkUntrackedAsyncHandler(state) {
|
|
2459
|
+
const cfg = options.holds;
|
|
2460
|
+
if (cfg === false) return;
|
|
2461
|
+
const event = state.event;
|
|
2462
|
+
const ms = event.continuationMs;
|
|
2463
|
+
if (state.actioned || event.writes > 0 || ms < cfg.infoMs) return;
|
|
2464
|
+
const actor = formatOrigin(event.origin);
|
|
2465
|
+
const message =
|
|
2466
|
+
`[UNTRACKED_ASYNC_HANDLER] ${actor}'s handler awaited ${ms.toFixed(0)}ms past its frame with no ` +
|
|
2467
|
+
`write before the await: no hold opened, so nothing on screen could show the wait — the ` +
|
|
2468
|
+
`interaction was dead for ${ms.toFixed(0)}ms. Make the async work an action() (its steps stay ` +
|
|
2469
|
+
`attributed across yields and its hold is judged), or write the pending state first: a ` +
|
|
2470
|
+
`createOptimistic(false) "saving" flag the UI reads.`;
|
|
2471
|
+
const severity = ms >= cfg.warnMs ? "warn" : "info";
|
|
2472
|
+
const data = {
|
|
2473
|
+
interaction: { type: event.name, target: event.target },
|
|
2474
|
+
continuationMs: ms,
|
|
2475
|
+
capped: ms >= ASYNC_HANDLER_CAP_MS
|
|
2476
|
+
};
|
|
2477
|
+
const entry = emitDiagnostic(
|
|
2478
|
+
{ code: "UNTRACKED_ASYNC_HANDLER", kind: "responsiveness", severity, message, data },
|
|
2479
|
+
null
|
|
2480
|
+
);
|
|
2481
|
+
if (severity === "warn") reportDiagnostic(entry);
|
|
2482
|
+
}
|
|
2483
|
+
/** The open record a frame runs under, if any. */
|
|
2484
|
+
function openInteractionOf(origin) {
|
|
2485
|
+
const interaction = interactionOf(origin);
|
|
2486
|
+
if (interaction === undefined) return undefined;
|
|
2487
|
+
const state = interactionStates.get(interaction);
|
|
2488
|
+
return state !== undefined && openInteractions.has(state) ? state : undefined;
|
|
2489
|
+
}
|
|
2490
|
+
/** stampWrite: a root write stamped `origin`. */
|
|
2491
|
+
function noteInteractionWrite(origin, excluded) {
|
|
2492
|
+
const state = openInteractionOf(origin);
|
|
2493
|
+
if (state === undefined) return;
|
|
2494
|
+
if (excluded) {
|
|
2495
|
+
state.excludedWrites++;
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
state.event.writes++;
|
|
2499
|
+
state.writeDrain = drainSeq;
|
|
2500
|
+
}
|
|
2501
|
+
/** recordRerun / a create run: work attributed to the interaction. */
|
|
2502
|
+
function noteInteractionRun(interaction, selfMs, created) {
|
|
2503
|
+
const state = openInteractionOf(interaction);
|
|
2504
|
+
if (state === undefined) return;
|
|
2505
|
+
state.event[created ? "created" : "runs"]++;
|
|
2506
|
+
state.event.runMs += selfMs;
|
|
2507
|
+
}
|
|
2508
|
+
/** openNavigation: a navigation frame opened under the interaction. */
|
|
2509
|
+
function noteInteractionNavigation(event) {
|
|
2510
|
+
const state = openInteractionOf(event.interaction);
|
|
2511
|
+
if (state !== undefined) state.event.navigations.push(event);
|
|
2512
|
+
}
|
|
2513
|
+
/** holdStart: `t` parked writes — the interactions that performed them wait for `t`. */
|
|
2514
|
+
function markInteractionsHeld(t) {
|
|
2515
|
+
if (openInteractions.size === 0) return;
|
|
2516
|
+
for (const node of t._pendingNodes) {
|
|
2517
|
+
const state = openInteractionOf(node._devChange?.origin);
|
|
2518
|
+
if (state !== undefined) {
|
|
2519
|
+
state.heldIn.add(t);
|
|
2520
|
+
state.held = true;
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
/** transitionMerged: whoever waited for `outgoing` now waits for `target`. */
|
|
2525
|
+
function mergeInteractionsHeld(target, outgoing) {
|
|
2526
|
+
for (const state of openInteractions) if (state.heldIn.delete(outgoing)) state.heldIn.add(target);
|
|
2527
|
+
}
|
|
2528
|
+
/** transitionSettled: `t` committed — its holders' writes are through. */
|
|
2529
|
+
function settleInteractionsHeld(t, hold) {
|
|
2530
|
+
if (openInteractions.size === 0) return;
|
|
2531
|
+
for (const state of openInteractions) {
|
|
2532
|
+
if (!state.heldIn.delete(t)) continue;
|
|
2533
|
+
if (hold !== undefined) state.event.holds.push(hold);
|
|
2534
|
+
maybeSettleInteraction(state);
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
function maybeSettleInteraction(state, end = now()) {
|
|
2538
|
+
const event = state.event;
|
|
2539
|
+
if (state.open || state.awaiting || state.heldIn.size > 0) return;
|
|
2540
|
+
// A drain must have committed the last write (the handler's, or a redirect
|
|
2541
|
+
// hop's after the click's own drain) — the handler returning is not the
|
|
2542
|
+
// screen having it.
|
|
2543
|
+
if (event.writes > 0 && drainSeq <= state.writeDrain) return;
|
|
2544
|
+
for (const nav of event.navigations) if (nav.outcome === undefined) return;
|
|
2545
|
+
openInteractions.delete(state);
|
|
2546
|
+
// Every write went to an excluded subject and nothing of the app's ran: the
|
|
2547
|
+
// click was on the observer's own UI (a devtools panel's button). Not a
|
|
2548
|
+
// fact about the app — forget it rather than report a dead interaction.
|
|
2549
|
+
if (event.writes === 0 && state.excludedWrites > 0 && event.runs === 0 && event.created === 0) {
|
|
2550
|
+
const i = interactionLog.indexOf(event);
|
|
2551
|
+
if (i !== -1) interactionLog.splice(i, 1);
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
event.settledMs = end - event.at;
|
|
2555
|
+
event.outcome = event.writes === 0 ? "idle" : state.held ? "held" : "committed";
|
|
2556
|
+
records.emit("interaction", event, undefined);
|
|
2557
|
+
}
|
|
2558
|
+
/** The serializable face of a navigation origin for diagnostic `data`. */
|
|
2559
|
+
function navigationData(origin) {
|
|
2560
|
+
const data = {};
|
|
2561
|
+
if (origin.name !== undefined) data.name = origin.name;
|
|
2562
|
+
if (origin.to !== undefined) data.to = origin.to;
|
|
2563
|
+
if (origin.from !== undefined) data.from = origin.from;
|
|
2564
|
+
if (origin.params !== undefined) data.params = origin.params;
|
|
2565
|
+
return data;
|
|
2566
|
+
}
|
|
2567
|
+
// The engine's implementation of the core's dev hook points. Installed by
|
|
2568
|
+
// enable(), uninstalled by disable() — while uninstalled the core pays one
|
|
2569
|
+
// null check per site and nothing else.
|
|
2570
|
+
let asyncStartSeq = 0;
|
|
2571
|
+
let asyncStartTime = 0;
|
|
2572
|
+
let asyncStartValue;
|
|
2573
|
+
const engineHooks = {
|
|
2574
|
+
interactionStart,
|
|
2575
|
+
interactionEnd,
|
|
2576
|
+
originStart,
|
|
2577
|
+
originEnd,
|
|
2578
|
+
flushStart() {
|
|
2579
|
+
trackFlushStart();
|
|
2580
|
+
},
|
|
2581
|
+
flushEnd() {
|
|
2582
|
+
trackFlushEnd();
|
|
2583
|
+
},
|
|
2584
|
+
recomputeStart(el, create) {
|
|
2585
|
+
const causes = create ? null : collectCauses(el);
|
|
2586
|
+
frames.push({
|
|
2587
|
+
start: now(),
|
|
2588
|
+
childMs: 0,
|
|
2589
|
+
causes,
|
|
2590
|
+
// The record's dep diff needs the deps as they were: captured here,
|
|
2591
|
+
// and only when the record will have an audience (see wantsRerun).
|
|
2592
|
+
prevDeps: create || !wantsRerun() ? null : captureDeps(el),
|
|
2593
|
+
// Mirror recompute's own prev-value resolution: an earlier run in the
|
|
2594
|
+
// same flush may still be holding in _pendingValue.
|
|
2595
|
+
prevValue: el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value,
|
|
2596
|
+
// A create run inherits the interaction of whatever is building it: the
|
|
2597
|
+
// enclosing recompute (a parent's fn creating children) or, at the top
|
|
2598
|
+
// of the recompute stack, the effect callback / handler frame.
|
|
2599
|
+
interaction:
|
|
2600
|
+
causes !== null
|
|
2601
|
+
? interactionIn(causes)
|
|
2602
|
+
: frames.length > 0
|
|
2603
|
+
? frames[frames.length - 1].interaction
|
|
2604
|
+
: enclosingInteraction()
|
|
2605
|
+
});
|
|
2606
|
+
},
|
|
2607
|
+
derivedChanged(el) {
|
|
2608
|
+
const frame = frames[frames.length - 1];
|
|
2609
|
+
stampDerived(el, frame !== undefined && frame.causes !== null ? frame.causes : []);
|
|
2610
|
+
},
|
|
2611
|
+
recomputeEnd(el, _create, changed, optimistic, transition, held) {
|
|
2612
|
+
const frame = frames.pop();
|
|
2613
|
+
// enable() can land mid-recompute: no opening frame, nothing to report.
|
|
2614
|
+
if (frame === undefined) return;
|
|
2615
|
+
const totalMs = now() - frame.start;
|
|
2616
|
+
if (frames.length > 0) frames[frames.length - 1].childMs += totalMs;
|
|
2617
|
+
const selfMs = Math.max(0, totalMs - frame.childMs);
|
|
2618
|
+
// Effect-output honesty: effects run with `_equals: false`, so core
|
|
2619
|
+
// reports EVERY effect recompute as changed — which made effect waste
|
|
2620
|
+
// invisible to costs() (and compiled JSX bindings are effects: the
|
|
2621
|
+
// fan-out waste a naive selected-row produces is all effects). The
|
|
2622
|
+
// engine re-derives the fact from its own snapshot: an identical
|
|
2623
|
+
// committed compute output is an unchanged run. `undefined` outputs are
|
|
2624
|
+
// exempt — a side-effect-only compute's work IS its effect phase, and
|
|
2625
|
+
// identity of `undefined` proves nothing.
|
|
2626
|
+
if (changed && frame.causes !== null && el._type) {
|
|
2627
|
+
const committed = el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value;
|
|
2628
|
+
if (committed !== undefined && committed === frame.prevValue) changed = false;
|
|
2629
|
+
}
|
|
2630
|
+
// Unstable-output check: memos only, non-create, plain runs with a
|
|
2631
|
+
// committed change. The fresh value sits in `_pendingValue` for held
|
|
2632
|
+
// plain-flush memo commits and in `_value` for direct ones. Overlay runs
|
|
2633
|
+
// are excluded — an optimistic re-derive legitimately produces fresh
|
|
2634
|
+
// equivalents while the lane settles.
|
|
2635
|
+
if (frame.causes !== null && changed && !optimistic && !transition && !el._type)
|
|
2636
|
+
checkUnstableOutput(
|
|
2637
|
+
el,
|
|
2638
|
+
frame.prevValue,
|
|
2639
|
+
el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value
|
|
2640
|
+
);
|
|
2641
|
+
if (frame.causes !== null)
|
|
2642
|
+
recordRerun(
|
|
2643
|
+
el,
|
|
2644
|
+
frame,
|
|
2645
|
+
{ selfMs, totalMs },
|
|
2646
|
+
changed,
|
|
2647
|
+
optimistic ? "optimistic" : transition ? "held" : "plain",
|
|
2648
|
+
held
|
|
2649
|
+
);
|
|
2650
|
+
else if (!excludedNode(el)) {
|
|
2651
|
+
// Creation runs still get the wide-scope check: a memo can be born with
|
|
2652
|
+
// its coarse-read problem already in place — and their time is charged
|
|
2653
|
+
// to the interaction building them (the interaction record's `created`).
|
|
2654
|
+
checkDepWidth(el);
|
|
2655
|
+
noteInteractionRun(frame.interaction, selfMs, true);
|
|
2656
|
+
// The first effect callback runs with no re-run record to inherit from;
|
|
2657
|
+
// hand it the interaction that built the node (see effectRunStart).
|
|
2658
|
+
el._devRunInteraction = frame.interaction;
|
|
2659
|
+
if (openFlush !== null) noteFlushRun(openFlush, true, frame.interaction);
|
|
2660
|
+
if (records.observed("create")) {
|
|
2661
|
+
const event = {
|
|
2662
|
+
at: frame.start,
|
|
2663
|
+
nodeKind: nodeKind(el),
|
|
2664
|
+
nodeName: nodeName(el),
|
|
2665
|
+
nodeId: devId(el),
|
|
2666
|
+
depCount: captureDeps(el).length,
|
|
2667
|
+
selfMs,
|
|
2668
|
+
totalMs,
|
|
2669
|
+
phase: optimistic ? "optimistic" : transition ? "held" : "plain",
|
|
2670
|
+
held
|
|
2671
|
+
};
|
|
2672
|
+
if (frame.interaction !== undefined) event.interaction = frame.interaction;
|
|
2673
|
+
records.emit("create", event, el);
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
markSeen(el);
|
|
2677
|
+
},
|
|
2678
|
+
write(el, prev, value) {
|
|
2679
|
+
stampWrite(el, "write", prev, value);
|
|
2680
|
+
},
|
|
2681
|
+
refreshed(el) {
|
|
2682
|
+
stampWrite(el, "refresh");
|
|
2683
|
+
},
|
|
2684
|
+
flightStart(el, flight) {
|
|
2685
|
+
trackFlightStart(el, flight);
|
|
2686
|
+
},
|
|
2687
|
+
asyncStart(el) {
|
|
2688
|
+
asyncStartSeq = el._devChange?.seq ?? 0;
|
|
2689
|
+
asyncStartTime = el._time;
|
|
2690
|
+
asyncStartValue = el._value;
|
|
2691
|
+
},
|
|
2692
|
+
asyncEnd(el, prev, value, direct) {
|
|
2693
|
+
if (direct) {
|
|
2694
|
+
// Core calls this unconditionally (hook calls cannot live inside its
|
|
2695
|
+
// try blocks — see attribution-hooks.ts), so committed-ness is detected
|
|
2696
|
+
// here against the asyncStart snapshot: a direct commit moves `_value`
|
|
2697
|
+
// (or `_time`, for a same-reference commit under `equals: false`), and
|
|
2698
|
+
// a transition hold parks the value in `_pendingValue`. A landing the
|
|
2699
|
+
// equality gate swallowed moves none of them and must leave no stamp.
|
|
2700
|
+
const committed =
|
|
2701
|
+
el._value !== asyncStartValue || el._time !== asyncStartTime || el._pendingValue === value;
|
|
2702
|
+
if (committed) stampWrite(el, "async", prev === undefined ? NO_VALUES : prev, value);
|
|
2703
|
+
// Flight over either way — an equality-swallowed landing still spent
|
|
2704
|
+
// the wall time (finalizeFlight only chains through a fresh stamp).
|
|
2705
|
+
finalizeFlight(el);
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
// Landed through setSignal: reclassify its "write" stamp as an async
|
|
2709
|
+
// landing — but only if it actually stamped (the value changed) since
|
|
2710
|
+
// asyncStart; a no-change landing must leave no fresh stamp behind.
|
|
2711
|
+
const change = el._devChange;
|
|
2712
|
+
if (change !== undefined && change.seq > asyncStartSeq && change.kind === "write")
|
|
2713
|
+
stampWrite(el, "async", NO_VALUES, value);
|
|
2714
|
+
finalizeFlight(el);
|
|
2715
|
+
},
|
|
2716
|
+
effectRunStart(el) {
|
|
2717
|
+
pushFrame("effect", nodeName(el), el._devRunInteraction, el);
|
|
2718
|
+
// The frame's own `at` doubles as the record's start: set only while
|
|
2719
|
+
// listened, so a listener arriving mid-callback finds no start and the
|
|
2720
|
+
// end emits nothing for it — and an unlistened callback pays no clock read.
|
|
2721
|
+
if (records.observed("effect")) originFrames[originFrames.length - 1].at = now();
|
|
2722
|
+
},
|
|
2723
|
+
effectRunEnd(el) {
|
|
2724
|
+
const frame = originFrames[originFrames.length - 1];
|
|
2725
|
+
if (frame !== undefined && frame.kind === "effect" && frame.at !== undefined) {
|
|
2726
|
+
if (!excludedNode(el)) {
|
|
2727
|
+
const event = {
|
|
2728
|
+
at: frame.at,
|
|
2729
|
+
durationMs: now() - frame.at,
|
|
2730
|
+
nodeId: devId(el),
|
|
2731
|
+
nodeName: nodeName(el)
|
|
2732
|
+
};
|
|
2733
|
+
if (frame.run !== undefined) event.run = frame.run;
|
|
2734
|
+
if (frame.interaction !== undefined) event.interaction = frame.interaction;
|
|
2735
|
+
records.emit("effect", event, el);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
popFrame("effect");
|
|
2739
|
+
if (activeHold !== null) activeHold.painted++;
|
|
2740
|
+
},
|
|
2741
|
+
actionStepStart(it, name) {
|
|
2742
|
+
// Steps after a yield resume from a promise callback with no ambient
|
|
2743
|
+
// interaction; the one that started the action (its first step) is the
|
|
2744
|
+
// action's interaction for every step.
|
|
2745
|
+
let interaction = actionInteractions.get(it);
|
|
2746
|
+
if (interaction === undefined && !actionInteractions.has(it)) {
|
|
2747
|
+
interaction = currentInteraction ?? undefined;
|
|
2748
|
+
actionInteractions.set(it, interaction);
|
|
2749
|
+
// The handler's async work is an action's: tracked across yields.
|
|
2750
|
+
const state = interaction !== undefined ? interactionStates.get(interaction) : undefined;
|
|
2751
|
+
if (state !== undefined) state.actioned = true;
|
|
2752
|
+
}
|
|
2753
|
+
pushFrame("action", name, interaction);
|
|
2754
|
+
},
|
|
2755
|
+
actionStepEnd() {
|
|
2756
|
+
popFrame("action");
|
|
2757
|
+
},
|
|
2758
|
+
holdStart(t) {
|
|
2759
|
+
if (openFlush !== null) openFlush.held = true;
|
|
2760
|
+
trackHoldStart(t);
|
|
2761
|
+
},
|
|
2762
|
+
holdEnd() {
|
|
2763
|
+
activeHold = null;
|
|
2764
|
+
},
|
|
2765
|
+
transitionSettled(t) {
|
|
2766
|
+
trackHoldSettled(t);
|
|
2767
|
+
rebaseFallbacks(t, DRAIN);
|
|
2768
|
+
},
|
|
2769
|
+
transitionMerged(target, outgoing) {
|
|
2770
|
+
trackHoldMerge(target, outgoing);
|
|
2771
|
+
rebaseFallbacks(outgoing, target);
|
|
2772
|
+
},
|
|
2773
|
+
storeReplaced(path, isArray, total, unchanged, prevTotal, owner) {
|
|
2774
|
+
checkImmutableUpdate(path, isArray, total, unchanged, prevTotal, owner);
|
|
2775
|
+
},
|
|
2776
|
+
listChurn(el, removed, created, newLen, keyed) {
|
|
2777
|
+
checkListIdentity(el, removed, created, newLen, keyed);
|
|
2778
|
+
},
|
|
2779
|
+
boundaryFallback(boundary, tree, shown, transition) {
|
|
2780
|
+
// The folds hear the show at its display (see trackFallback), not here.
|
|
2781
|
+
trackFallback$1(boundary, tree, shown, transition ?? null);
|
|
2782
|
+
},
|
|
2783
|
+
optimisticReverted(el, shown, truth, how) {
|
|
2784
|
+
checkOptimisticRevert(el, shown, truth, how);
|
|
2785
|
+
},
|
|
2786
|
+
currentOrigin() {
|
|
2787
|
+
return ambientOrigin();
|
|
2788
|
+
}
|
|
2789
|
+
};
|
|
2790
|
+
const holds = [];
|
|
2791
|
+
/**
|
|
2792
|
+
* The windows: what a consumer reads back — the ring buffers, the once-only
|
|
2793
|
+
* warning memories (so a new window can warn again), the hot-cause windows,
|
|
2794
|
+
* the folds' tables. Reset by every `enable()` (each opens a fresh window)
|
|
2795
|
+
* and by the last `disable()`. Never touches the live tracking state —
|
|
2796
|
+
* open frames, open interactions and navigations, the active hold,
|
|
2797
|
+
* `drainSeq` — which belongs to whoever is mid-flight when a second
|
|
2798
|
+
* consumer arrives.
|
|
2799
|
+
*/
|
|
2800
|
+
function resetWindows() {
|
|
2801
|
+
history = [];
|
|
2802
|
+
waterfallLog = [];
|
|
2803
|
+
holdLog = [];
|
|
2804
|
+
navigationLog = [];
|
|
2805
|
+
interactionLog = [];
|
|
2806
|
+
reportedCycles.clear();
|
|
2807
|
+
relays.clear();
|
|
2808
|
+
immutableReported.clear();
|
|
2809
|
+
hotCauses.clear();
|
|
2810
|
+
for (const f of folds) f.reset?.();
|
|
2811
|
+
}
|
|
2812
|
+
/** The live tracking state — reset only when the engine is (un)installed. */
|
|
2813
|
+
function resetTracking() {
|
|
2814
|
+
frames.length = 0;
|
|
2815
|
+
activeHold = null;
|
|
2816
|
+
openFlush = null;
|
|
2817
|
+
stagedFallbacks.delete(DRAIN);
|
|
2818
|
+
trackingGen++;
|
|
2819
|
+
openNavs.clear();
|
|
2820
|
+
openInteractions.clear();
|
|
2821
|
+
routeCounts.clear();
|
|
2822
|
+
drainSeq = 0;
|
|
2823
|
+
originFrames.length = 0;
|
|
2824
|
+
effectStack.length = 0;
|
|
2825
|
+
interactionStack.length = 0;
|
|
2826
|
+
currentInteraction = null;
|
|
2827
|
+
}
|
|
2828
|
+
/**
|
|
2829
|
+
* One hold's request in full: the defaults for what it left unsaid (an
|
|
2830
|
+
* explicit `undefined` is unsaid), and `checks: false` folding its five cost
|
|
2831
|
+
* checks off before anyone else's request is considered.
|
|
2832
|
+
*/
|
|
2833
|
+
function resolveHold(opts) {
|
|
2834
|
+
const out = { ...defaultOptions };
|
|
2835
|
+
if (opts !== undefined) {
|
|
2836
|
+
for (const key of Object.keys(opts)) {
|
|
2837
|
+
const value = opts[key];
|
|
2838
|
+
if (value !== undefined) out[key] = value;
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
if (!out.checks) {
|
|
2842
|
+
out.hotRuns = false;
|
|
2843
|
+
out.hotTime = false;
|
|
2844
|
+
out.wideDeps = false;
|
|
2845
|
+
out.unstableMemos = false;
|
|
2846
|
+
out.fanOut = false;
|
|
2847
|
+
out.wastedRecompute = false;
|
|
2848
|
+
}
|
|
2849
|
+
return out;
|
|
2850
|
+
}
|
|
2851
|
+
/** `values` levels by how much they carry: the merge takes the lowest. */
|
|
2852
|
+
const VALUES_RANK = { none: 0, labels: 1, full: 2 };
|
|
2853
|
+
/**
|
|
2854
|
+
* The more demanding of two settings for one key: `true` over `false`, the
|
|
2855
|
+
* larger `historyLimit`, a threshold config over `false`, and between two
|
|
2856
|
+
* configs the field values that fire sooner — the lower count, budget or
|
|
2857
|
+
* millisecond bound, the longer `windowMs`. For `values` "more demanding"
|
|
2858
|
+
* is LESS data: the level that carries the least wins, so a holder that
|
|
2859
|
+
* must not see user data is never overruled by one that wants it.
|
|
2860
|
+
*/
|
|
2861
|
+
function demanding(key, a, b) {
|
|
2862
|
+
if (key === "values") return VALUES_RANK[a] <= VALUES_RANK[b] ? a : b;
|
|
2863
|
+
if (typeof a === "boolean") return a || b;
|
|
2864
|
+
if (key === "historyLimit") return Math.max(a, b);
|
|
2865
|
+
if (a === false) return b;
|
|
2866
|
+
if (b === false) return a;
|
|
2867
|
+
if (typeof a === "number") return Math.min(a, b);
|
|
2868
|
+
const out = {};
|
|
2869
|
+
for (const field in a) {
|
|
2870
|
+
const x = a[field];
|
|
2871
|
+
const y = b[field];
|
|
2872
|
+
out[field] = field === "windowMs" ? Math.max(x, y) : Math.min(x, y);
|
|
2873
|
+
}
|
|
2874
|
+
return out;
|
|
2875
|
+
}
|
|
2876
|
+
/**
|
|
2877
|
+
* Recompute the options in effect: each live hold's request resolved, then
|
|
2878
|
+
* combined per key by the most demanding value — the one that has the
|
|
2879
|
+
* engine observe more, report more or keep more. A hold says what it wants
|
|
2880
|
+
* and can only add to what another hold asked for, never take it away, so
|
|
2881
|
+
* the result does not depend on the order the holds were taken: the console
|
|
2882
|
+
* log prints while any holder wants it, a check runs while any holder wants
|
|
2883
|
+
* it and at the most sensitive threshold anyone asked for, the ring buffer
|
|
2884
|
+
* is the largest requested, and records carry the least user data any
|
|
2885
|
+
* holder allows (`values`). With no hold outstanding the defaults stand.
|
|
2886
|
+
*/
|
|
2887
|
+
function applyOptions() {
|
|
2888
|
+
if (holds.length === 0) {
|
|
2889
|
+
options = { ...defaultOptions };
|
|
2890
|
+
return;
|
|
2891
|
+
}
|
|
2892
|
+
const out = resolveHold(holds[0].opts);
|
|
2893
|
+
for (let i = 1; i < holds.length; i++) {
|
|
2894
|
+
const next = resolveHold(holds[i].opts);
|
|
2895
|
+
for (const key in out) out[key] = demanding(key, out[key], next[key]);
|
|
2896
|
+
}
|
|
2897
|
+
options = out;
|
|
2898
|
+
}
|
|
2899
|
+
/** The last hold is gone (or `disable()` was called): uninstall and clear everything. */
|
|
2900
|
+
function uninstall() {
|
|
2901
|
+
holds.length = 0;
|
|
2902
|
+
applyOptions();
|
|
2903
|
+
attributionActive = false;
|
|
2904
|
+
resetWindows();
|
|
2905
|
+
resetTracking();
|
|
2906
|
+
setAttributionHooks(null);
|
|
2907
|
+
}
|
|
2908
|
+
const attribution = {
|
|
2909
|
+
enable(opts) {
|
|
2910
|
+
const hold = { opts };
|
|
2911
|
+
holds.push(hold);
|
|
2912
|
+
applyOptions();
|
|
2913
|
+
resetWindows();
|
|
2914
|
+
if (!attributionActive) {
|
|
2915
|
+
attributionActive = true;
|
|
2916
|
+
resetTracking();
|
|
2917
|
+
setAttributionHooks(engineHooks);
|
|
2918
|
+
}
|
|
2919
|
+
return () => {
|
|
2920
|
+
const i = holds.indexOf(hold);
|
|
2921
|
+
if (i === -1) return;
|
|
2922
|
+
holds.splice(i, 1);
|
|
2923
|
+
if (holds.length === 0) uninstall();
|
|
2924
|
+
else applyOptions();
|
|
2925
|
+
};
|
|
2926
|
+
},
|
|
2927
|
+
disable() {
|
|
2928
|
+
uninstall();
|
|
2929
|
+
},
|
|
2930
|
+
history(type) {
|
|
2931
|
+
let buffer;
|
|
2932
|
+
switch (type) {
|
|
2933
|
+
case "rerun":
|
|
2934
|
+
buffer = history;
|
|
2935
|
+
break;
|
|
2936
|
+
case "waterfall":
|
|
2937
|
+
buffer = waterfallLog;
|
|
2938
|
+
break;
|
|
2939
|
+
case "hold":
|
|
2940
|
+
buffer = holdLog;
|
|
2941
|
+
break;
|
|
2942
|
+
case "navigation":
|
|
2943
|
+
buffer = navigationLog;
|
|
2944
|
+
break;
|
|
2945
|
+
case "interaction":
|
|
2946
|
+
buffer = interactionLog;
|
|
2947
|
+
break;
|
|
2948
|
+
default:
|
|
2949
|
+
throw new Error(`attribution.history: unknown record type "${String(type)}"`);
|
|
2950
|
+
}
|
|
2951
|
+
// The buffers are typed by their variable; the switch is the proof.
|
|
2952
|
+
return buffer;
|
|
2953
|
+
},
|
|
2954
|
+
markFlight(flight, startedAt = now()) {
|
|
2955
|
+
// Earliest wins: re-marking (a cache re-serving the same promise) must
|
|
2956
|
+
// not move the origin later.
|
|
2957
|
+
const existing = flightOrigins.get(flight);
|
|
2958
|
+
if (existing === undefined || startedAt < existing) flightOrigins.set(flight, startedAt);
|
|
2959
|
+
}
|
|
2960
|
+
};
|
|
2961
|
+
|
|
2962
|
+
/**
|
|
2963
|
+
* `costs()` — the cost tables: scopes ranked by the self-time they burn,
|
|
2964
|
+
* root writes ranked by the downstream re-run time they cause. Folded from
|
|
2965
|
+
* every `RerunEvent` as the engine records it.
|
|
2966
|
+
*
|
|
2967
|
+
* Its own module on purpose: the tables register with the engine's fold seam
|
|
2968
|
+
* when this module is evaluated, so an observe-tier consumer that only
|
|
2969
|
+
* subscribes to records (an APM adapter) never ships the accumulation or the
|
|
2970
|
+
* tables; a consumer that imports `costs` gets both. Reset with the engine on
|
|
2971
|
+
* `enable()`/`disable()`.
|
|
2972
|
+
*/
|
|
2973
|
+
const scopeCosts = new Map();
|
|
2974
|
+
const writeCosts = new Map();
|
|
2975
|
+
function recordCosts(el, event) {
|
|
2976
|
+
let scope = scopeCosts.get(el);
|
|
2977
|
+
if (scope === undefined) {
|
|
2978
|
+
scope = {
|
|
2979
|
+
name: event.nodeName,
|
|
2980
|
+
kind: event.nodeKind,
|
|
2981
|
+
runs: 0,
|
|
2982
|
+
selfMs: 0,
|
|
2983
|
+
wastedMs: 0,
|
|
2984
|
+
overlayMs: 0
|
|
2985
|
+
};
|
|
2986
|
+
scopeCosts.set(el, scope);
|
|
2987
|
+
}
|
|
2988
|
+
scope.runs++;
|
|
2989
|
+
scope.selfMs += event.selfMs;
|
|
2990
|
+
if (event.phase !== "plain") scope.overlayMs += event.selfMs;
|
|
2991
|
+
else if (!event.changed && !event.held) scope.wastedMs += event.selfMs;
|
|
2992
|
+
const roots = new Set();
|
|
2993
|
+
rootsOf(event.causes, roots);
|
|
2994
|
+
for (const name of roots) {
|
|
2995
|
+
let write = writeCosts.get(name);
|
|
2996
|
+
if (write === undefined) writeCosts.set(name, (write = { name, runs: 0, downstreamMs: 0 }));
|
|
2997
|
+
write.runs++;
|
|
2998
|
+
write.downstreamMs += event.selfMs;
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
registerFold({
|
|
3002
|
+
rerun: recordCosts,
|
|
3003
|
+
reset() {
|
|
3004
|
+
scopeCosts.clear();
|
|
3005
|
+
writeCosts.clear();
|
|
3006
|
+
}
|
|
3007
|
+
});
|
|
3008
|
+
/**
|
|
3009
|
+
* Aggregated cost tables since `enable()`: `scopes` ranked by self-time
|
|
3010
|
+
* (with `wastedMs` = time spent on unchanged-value runs), `writes` ranked by
|
|
3011
|
+
* total downstream re-run time each root write caused.
|
|
3012
|
+
*/
|
|
3013
|
+
function costs() {
|
|
3014
|
+
return {
|
|
3015
|
+
scopes: [...scopeCosts.values()].sort((a, b) => b.selfMs - a.selfMs),
|
|
3016
|
+
writes: [...writeCosts.values()].sort((a, b) => b.downstreamMs - a.downstreamMs)
|
|
3017
|
+
};
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
/**
|
|
3021
|
+
* `feedback()` — what the user waited on, as ranked tables. Folded from the
|
|
3022
|
+
* records the engine already keeps (holds, the interaction on each re-run,
|
|
3023
|
+
* navigations) and two censuses the engine reports as they happen (flights
|
|
3024
|
+
* started/landed/abandoned per async source; fallback shows per boundary).
|
|
3025
|
+
*
|
|
3026
|
+
* Its own module on purpose: the tables register with the engine's fold seam
|
|
3027
|
+
* when this module is evaluated, so an observe-tier consumer that only
|
|
3028
|
+
* subscribes to records never ships them. Reset with the engine on
|
|
3029
|
+
* `enable()`/`disable()`.
|
|
3030
|
+
*/
|
|
3031
|
+
const feedbackSources = new Map();
|
|
3032
|
+
const feedbackInteractions = new Map();
|
|
3033
|
+
const feedbackNavigations = new Map();
|
|
3034
|
+
const flightStats = new Map();
|
|
3035
|
+
const fallbackStats = new Map();
|
|
3036
|
+
function flightBucket(el) {
|
|
3037
|
+
let row = flightStats.get(el);
|
|
3038
|
+
if (row === undefined) {
|
|
3039
|
+
row = { source: nodeName(el), flights: 0, landed: 0, abandoned: 0, landedMs: 0, worstMs: 0 };
|
|
3040
|
+
flightStats.set(el, row);
|
|
3041
|
+
}
|
|
3042
|
+
return row;
|
|
3043
|
+
}
|
|
3044
|
+
function trackFallback(boundary, tree, shown) {
|
|
3045
|
+
let bucket = fallbackStats.get(boundary);
|
|
3046
|
+
if (bucket === undefined) {
|
|
3047
|
+
bucket = {
|
|
3048
|
+
row: { boundary: "boundary", shows: 0, shownMs: 0, worstMs: 0, flashes: 0 },
|
|
3049
|
+
shownAt: null
|
|
3050
|
+
};
|
|
3051
|
+
fallbackStats.set(boundary, bucket);
|
|
3052
|
+
}
|
|
3053
|
+
// The first show can fire before the subtree exists; name on first sight.
|
|
3054
|
+
if (bucket.row.boundary === "boundary" && tree !== undefined) {
|
|
3055
|
+
const path = ownerPath(tree);
|
|
3056
|
+
if (path !== undefined) bucket.row.boundary = path.join(" › ");
|
|
3057
|
+
}
|
|
3058
|
+
if (shown) {
|
|
3059
|
+
if (bucket.shownAt === null) {
|
|
3060
|
+
bucket.shownAt = now();
|
|
3061
|
+
bucket.row.shows++;
|
|
3062
|
+
}
|
|
3063
|
+
return;
|
|
3064
|
+
}
|
|
3065
|
+
if (bucket.shownAt === null) return;
|
|
3066
|
+
const ms = now() - bucket.shownAt;
|
|
3067
|
+
bucket.shownAt = null;
|
|
3068
|
+
bucket.row.shownMs += ms;
|
|
3069
|
+
if (ms > bucket.row.worstMs) bucket.row.worstMs = ms;
|
|
3070
|
+
if (ms < FALLBACK_FLASH_MS) bucket.row.flashes++;
|
|
3071
|
+
}
|
|
3072
|
+
function interactionBucket(interaction) {
|
|
3073
|
+
const key = formatOrigin(interaction);
|
|
3074
|
+
let bucket = feedbackInteractions.get(key);
|
|
3075
|
+
if (bucket === undefined) {
|
|
3076
|
+
bucket = {
|
|
3077
|
+
row: {
|
|
3078
|
+
interaction: key,
|
|
3079
|
+
dispatches: 0,
|
|
3080
|
+
runs: 0,
|
|
3081
|
+
selfMs: 0,
|
|
3082
|
+
worstDispatchMs: 0,
|
|
3083
|
+
holds: 0,
|
|
3084
|
+
heldMs: 0,
|
|
3085
|
+
silentMs: 0,
|
|
3086
|
+
worstHoldMs: 0
|
|
3087
|
+
},
|
|
3088
|
+
dispatches: new Map()
|
|
3089
|
+
};
|
|
3090
|
+
feedbackInteractions.set(key, bucket);
|
|
3091
|
+
}
|
|
3092
|
+
const at = interaction.at ?? 0;
|
|
3093
|
+
if (!bucket.dispatches.has(at)) {
|
|
3094
|
+
bucket.dispatches.set(at, 0);
|
|
3095
|
+
bucket.row.dispatches++;
|
|
3096
|
+
}
|
|
3097
|
+
return bucket;
|
|
3098
|
+
}
|
|
3099
|
+
function recordFeedbackRun(event) {
|
|
3100
|
+
if (event.interaction === undefined) return;
|
|
3101
|
+
const bucket = interactionBucket(event.interaction);
|
|
3102
|
+
bucket.row.runs++;
|
|
3103
|
+
bucket.row.selfMs += event.selfMs;
|
|
3104
|
+
const at = event.interaction.at ?? 0;
|
|
3105
|
+
const dispatchMs = bucket.dispatches.get(at) + event.selfMs;
|
|
3106
|
+
bucket.dispatches.set(at, dispatchMs);
|
|
3107
|
+
if (dispatchMs > bucket.row.worstDispatchMs) bucket.row.worstDispatchMs = dispatchMs;
|
|
3108
|
+
}
|
|
3109
|
+
function recordFeedbackHold(event) {
|
|
3110
|
+
const sources = [...event.blockers].sort();
|
|
3111
|
+
const key = sources.join("\u0000");
|
|
3112
|
+
let bucket = feedbackSources.get(key);
|
|
3113
|
+
if (bucket === undefined) {
|
|
3114
|
+
bucket = {
|
|
3115
|
+
row: {
|
|
3116
|
+
sources,
|
|
3117
|
+
holds: 0,
|
|
3118
|
+
heldMs: 0,
|
|
3119
|
+
worstMs: 0,
|
|
3120
|
+
silent: 0,
|
|
3121
|
+
silentMs: 0,
|
|
3122
|
+
latestOnly: 0,
|
|
3123
|
+
long: 0,
|
|
3124
|
+
longMs: 0,
|
|
3125
|
+
acknowledgedBy: [],
|
|
3126
|
+
interactions: [],
|
|
3127
|
+
writes: [],
|
|
3128
|
+
actions: 0
|
|
3129
|
+
},
|
|
3130
|
+
acks: new Map(),
|
|
3131
|
+
interactions: new Map(),
|
|
3132
|
+
writes: new Set()
|
|
3133
|
+
};
|
|
3134
|
+
feedbackSources.set(key, bucket);
|
|
3135
|
+
}
|
|
3136
|
+
const row = bucket.row;
|
|
3137
|
+
const silent = event.silent;
|
|
3138
|
+
row.holds++;
|
|
3139
|
+
row.heldMs += event.holdMs;
|
|
3140
|
+
if (event.holdMs > row.worstMs) row.worstMs = event.holdMs;
|
|
3141
|
+
if (silent) {
|
|
3142
|
+
row.silent++;
|
|
3143
|
+
row.silentMs += event.holdMs;
|
|
3144
|
+
} else if (
|
|
3145
|
+
event.acknowledgements.length > 0 &&
|
|
3146
|
+
event.acknowledgements.every(a => a.kind === "latest")
|
|
3147
|
+
)
|
|
3148
|
+
row.latestOnly++;
|
|
3149
|
+
if (event.long) {
|
|
3150
|
+
row.long++;
|
|
3151
|
+
row.longMs += event.tailMs;
|
|
3152
|
+
}
|
|
3153
|
+
if (event.action) row.actions++;
|
|
3154
|
+
for (const a of event.acknowledgements) {
|
|
3155
|
+
const by = `${a.kind}:${a.source}`;
|
|
3156
|
+
bucket.acks.set(by, (bucket.acks.get(by) ?? 0) + 1);
|
|
3157
|
+
}
|
|
3158
|
+
for (const w of event.heldWrites) bucket.writes.add(w.name);
|
|
3159
|
+
if (event.interaction !== undefined) {
|
|
3160
|
+
const key = formatOrigin(event.interaction);
|
|
3161
|
+
bucket.interactions.set(key, (bucket.interactions.get(key) ?? 0) + 1);
|
|
3162
|
+
const ib = interactionBucket(event.interaction);
|
|
3163
|
+
ib.row.holds++;
|
|
3164
|
+
ib.row.heldMs += event.holdMs;
|
|
3165
|
+
if (silent) ib.row.silentMs += event.holdMs;
|
|
3166
|
+
if (event.holdMs > ib.row.worstHoldMs) ib.row.worstHoldMs = event.holdMs;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
function recordFeedbackNavigation(event) {
|
|
3170
|
+
const name = event.name ?? event.to;
|
|
3171
|
+
if (name === undefined) return;
|
|
3172
|
+
let row = feedbackNavigations.get(name);
|
|
3173
|
+
if (row === undefined) {
|
|
3174
|
+
row = {
|
|
3175
|
+
name,
|
|
3176
|
+
navigations: 0,
|
|
3177
|
+
settledMs: 0,
|
|
3178
|
+
worstMs: 0,
|
|
3179
|
+
held: 0,
|
|
3180
|
+
heldMs: 0,
|
|
3181
|
+
silent: 0,
|
|
3182
|
+
superseded: 0,
|
|
3183
|
+
redirected: 0
|
|
3184
|
+
};
|
|
3185
|
+
feedbackNavigations.set(name, row);
|
|
3186
|
+
}
|
|
3187
|
+
row.navigations++;
|
|
3188
|
+
if (event.redirects !== undefined) row.redirected++;
|
|
3189
|
+
if (event.outcome === "superseded") {
|
|
3190
|
+
row.superseded++;
|
|
3191
|
+
return;
|
|
3192
|
+
}
|
|
3193
|
+
const ms = event.settledMs;
|
|
3194
|
+
row.settledMs += ms;
|
|
3195
|
+
if (ms > row.worstMs) row.worstMs = ms;
|
|
3196
|
+
if (event.outcome === "held") {
|
|
3197
|
+
row.held++;
|
|
3198
|
+
row.heldMs += event.hold?.holdMs ?? ms;
|
|
3199
|
+
if (event.hold !== undefined && event.hold.silent) row.silent++;
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
function rankedCounts(counts, key) {
|
|
3203
|
+
return [...counts].sort((a, b) => b[1] - a[1]).map(([name, holds]) => ({ [key]: name, holds }));
|
|
3204
|
+
}
|
|
3205
|
+
registerFold({
|
|
3206
|
+
rerun: (_el, event) => recordFeedbackRun(event),
|
|
3207
|
+
hold: recordFeedbackHold,
|
|
3208
|
+
navigation: recordFeedbackNavigation,
|
|
3209
|
+
flightStart(el, abandoned) {
|
|
3210
|
+
const stats = flightBucket(el);
|
|
3211
|
+
stats.flights++;
|
|
3212
|
+
if (abandoned) stats.abandoned++;
|
|
3213
|
+
},
|
|
3214
|
+
flightLanded(el, ms) {
|
|
3215
|
+
const stats = flightBucket(el);
|
|
3216
|
+
stats.landed++;
|
|
3217
|
+
stats.landedMs += ms;
|
|
3218
|
+
if (ms > stats.worstMs) stats.worstMs = ms;
|
|
3219
|
+
},
|
|
3220
|
+
fallback: trackFallback,
|
|
3221
|
+
reset() {
|
|
3222
|
+
feedbackSources.clear();
|
|
3223
|
+
feedbackInteractions.clear();
|
|
3224
|
+
feedbackNavigations.clear();
|
|
3225
|
+
flightStats.clear();
|
|
3226
|
+
fallbackStats.clear();
|
|
3227
|
+
}
|
|
3228
|
+
});
|
|
3229
|
+
/**
|
|
3230
|
+
* What the user waited on, folded from holds and the interaction on each
|
|
3231
|
+
* re-run: `sources` ranks async sources by the silent time writes spent held
|
|
3232
|
+
* behind them (with which affordances answered, how often, and which
|
|
3233
|
+
* interactions were held); `interactions` ranks user events by the total time
|
|
3234
|
+
* they cost — re-run work caused (long-flush hazard) beside time held
|
|
3235
|
+
* (silent-hold hazard). Facts at every duration; SILENT_HOLD is the
|
|
3236
|
+
* thresholded verdict. Three more tables round out the picture: `navigations`
|
|
3237
|
+
* ranks routes by the time spent held navigating to them (folded from settled
|
|
3238
|
+
* navigations), `flights` counts each async source's flights and how many
|
|
3239
|
+
* were abandoned before landing (the re-ask storm), and `fallbacks` measures
|
|
3240
|
+
* how long each loading boundary showed its fallback and how often that was a
|
|
3241
|
+
* flash.
|
|
3242
|
+
*/
|
|
3243
|
+
function feedback() {
|
|
3244
|
+
const navigations = [...feedbackNavigations.values()]
|
|
3245
|
+
.map(row => ({ ...row }))
|
|
3246
|
+
.sort((a, b) => b.heldMs - a.heldMs || b.settledMs - a.settledMs);
|
|
3247
|
+
const flights = [...flightStats.values()]
|
|
3248
|
+
.map(row => ({ ...row }))
|
|
3249
|
+
.sort((a, b) => b.abandoned - a.abandoned || b.flights - a.flights);
|
|
3250
|
+
const fallbacks = [...fallbackStats.values()]
|
|
3251
|
+
.map(bucket => ({ ...bucket.row }))
|
|
3252
|
+
.sort((a, b) => b.flashes - a.flashes || b.shownMs - a.shownMs);
|
|
3253
|
+
const sources = [...feedbackSources.values()]
|
|
3254
|
+
.map(bucket => ({
|
|
3255
|
+
...bucket.row,
|
|
3256
|
+
acknowledgedBy: rankedCounts(bucket.acks, "by"),
|
|
3257
|
+
interactions: rankedCounts(bucket.interactions, "interaction"),
|
|
3258
|
+
writes: [...bucket.writes]
|
|
3259
|
+
}))
|
|
3260
|
+
.sort((a, b) => b.silentMs - a.silentMs || b.heldMs - a.heldMs);
|
|
3261
|
+
// Ranked by the total time the user spent on it: held plus synchronous work.
|
|
3262
|
+
const interactions = [...feedbackInteractions.values()]
|
|
3263
|
+
.map(bucket => ({ ...bucket.row }))
|
|
3264
|
+
.sort((a, b) => b.heldMs + b.selfMs - (a.heldMs + a.selfMs));
|
|
3265
|
+
return { sources, interactions, navigations, flights, fallbacks };
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
/**
|
|
3269
|
+
* Point queries over the engine's live state — the devtools/console view of
|
|
3270
|
+
* one scope. Their own module so a records-only consumer never ships them;
|
|
3271
|
+
* they read the engine's ring buffer and the graph, and register nothing.
|
|
3272
|
+
*/
|
|
3273
|
+
/** The node behind a memo/effect accessor, or the raw node passed through. */
|
|
3274
|
+
function nodeOf(target) {
|
|
3275
|
+
return target?.[$REFRESH] ?? target;
|
|
3276
|
+
}
|
|
3277
|
+
/**
|
|
3278
|
+
* Re-run history for one scope — pass a memo/effect accessor or raw node,
|
|
3279
|
+
* or a scope's name as a string (an out-of-process consumer such as the
|
|
3280
|
+
* diagnostics bridge holds no node, only the `nodeName` the records
|
|
3281
|
+
* carry). Records name their scope by `nodeId`; a node that has never run
|
|
3282
|
+
* under the engine has none, and no history. By name, every scope of that
|
|
3283
|
+
* name answers. A view of `history("rerun")`, so it shares that buffer's
|
|
3284
|
+
* gate: runs nothing wanted a record of (no `rerun` listener, fold or log
|
|
3285
|
+
* at the time) left no record and are not here — a console session that
|
|
3286
|
+
* wants them subscribes or imports a fold first.
|
|
3287
|
+
*/
|
|
3288
|
+
function why(target) {
|
|
3289
|
+
const history = attribution.history("rerun");
|
|
3290
|
+
if (typeof target === "string") return history.filter(event => event.nodeName === target);
|
|
3291
|
+
const id = nodeIdOf(nodeOf(target));
|
|
3292
|
+
if (id === undefined) return [];
|
|
3293
|
+
return history.filter(event => event.nodeId === id);
|
|
3294
|
+
}
|
|
3295
|
+
/**
|
|
3296
|
+
* Current dependency names of one scope — the devtools subscription view.
|
|
3297
|
+
* Read from the graph, not a record, so it answers with or without an
|
|
3298
|
+
* audience for re-run records (and with the engine disabled).
|
|
3299
|
+
*/
|
|
3300
|
+
function subscriptions(target) {
|
|
3301
|
+
const node = nodeOf(target);
|
|
3302
|
+
const names = [];
|
|
3303
|
+
for (let l = node?._deps ?? null; l !== null; l = l._nextDep) names.push(nodeName(l._dep));
|
|
3304
|
+
return names;
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
export { attribution, costs, feedback, formatOrigin, formatRerun, graphSize, subscriptions, why };
|