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