@vercel/next-browser 0.1.7 → 0.2.0
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/README.md +148 -48
- package/dist/browser.js +362 -71
- package/dist/cli.js +108 -12
- package/dist/daemon.js +19 -3
- package/dist/paths.js +3 -1
- package/dist/suspense.js +502 -73
- package/package.json +12 -9
- package/dist/cloud-client.js +0 -72
- package/dist/cloud-daemon.js +0 -87
- package/dist/cloud-paths.js +0 -7
- package/dist/cloud.js +0 -230
package/dist/suspense.js
CHANGED
|
@@ -7,46 +7,24 @@ export async function countBoundaries(page) {
|
|
|
7
7
|
const nonRoot = boundaries.filter((b) => b.parentID !== 0);
|
|
8
8
|
return { total: nonRoot.length, suspended: nonRoot.filter((b) => b.isSuspended).length };
|
|
9
9
|
}
|
|
10
|
-
export async function snapshotFromDom(page) {
|
|
11
|
-
const count = await page.evaluate(() => document.querySelectorAll('template[id^="B:"]').length).catch(() => 0);
|
|
12
|
-
const boundaries = [];
|
|
13
|
-
for (let i = 0; i < count; i++) {
|
|
14
|
-
boundaries.push({
|
|
15
|
-
id: -(i + 1),
|
|
16
|
-
parentID: 1,
|
|
17
|
-
name: `shell-hole-${i}`,
|
|
18
|
-
isSuspended: true,
|
|
19
|
-
environments: [],
|
|
20
|
-
suspendedBy: [],
|
|
21
|
-
unknownSuspenders: null,
|
|
22
|
-
owners: [],
|
|
23
|
-
jsxSource: null,
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
return boundaries;
|
|
27
|
-
}
|
|
28
10
|
export async function formatAnalysis(unlocked, locked, origin) {
|
|
11
|
+
const report = await analyzeBoundaries(unlocked, locked, origin);
|
|
12
|
+
return formatReport(report);
|
|
13
|
+
}
|
|
14
|
+
export async function analyzeBoundaries(unlocked, locked, origin) {
|
|
29
15
|
await resolveSources(unlocked, origin);
|
|
30
16
|
await resolveSources(locked, origin);
|
|
31
|
-
const fromDom = locked.length > 0 && locked[0].id < 0;
|
|
32
17
|
const holes = [];
|
|
33
18
|
const statics = [];
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const match = dynamicUnlocked.shift();
|
|
38
|
-
holes.push({ shell: lb, full: match });
|
|
39
|
-
}
|
|
40
|
-
for (const ub of unlocked) {
|
|
41
|
-
if (ub.parentID !== 0 && ub.suspendedBy.length === 0)
|
|
42
|
-
statics.push(ub);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
19
|
+
const hasLockedData = locked.some((b) => b.parentID !== 0);
|
|
20
|
+
if (hasLockedData) {
|
|
21
|
+
// DevTools was connected during lock — match locked vs unlocked by key
|
|
46
22
|
const unlockedByKey = new Map();
|
|
47
23
|
for (const b of unlocked)
|
|
48
24
|
unlockedByKey.set(boundaryKey(b), b);
|
|
49
25
|
for (const lb of locked) {
|
|
26
|
+
if (lb.parentID === 0)
|
|
27
|
+
continue;
|
|
50
28
|
if (lb.isSuspended) {
|
|
51
29
|
holes.push({ shell: lb, full: unlockedByKey.get(boundaryKey(lb)) });
|
|
52
30
|
}
|
|
@@ -55,49 +33,455 @@ export async function formatAnalysis(unlocked, locked, origin) {
|
|
|
55
33
|
}
|
|
56
34
|
}
|
|
57
35
|
}
|
|
58
|
-
|
|
36
|
+
else {
|
|
37
|
+
// DevTools wasn't connected during lock (goto case) — derive from unlocked.
|
|
38
|
+
// Boundaries with suspendedBy data were dynamic holes in the shell.
|
|
39
|
+
for (const b of unlocked) {
|
|
40
|
+
if (b.parentID === 0)
|
|
41
|
+
continue;
|
|
42
|
+
if (b.suspendedBy.length > 0 || b.unknownSuspenders) {
|
|
43
|
+
holes.push({ shell: b, full: b });
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
statics.push(b);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const holeInsights = holes
|
|
51
|
+
.map(({ shell, full }) => buildBoundaryInsight(shell, full ?? shell))
|
|
52
|
+
.sort(compareBoundaryInsights);
|
|
53
|
+
const staticSummaries = statics.map((b) => ({
|
|
54
|
+
id: b.id,
|
|
55
|
+
name: b.name,
|
|
56
|
+
source: b.jsxSource,
|
|
57
|
+
renderedBy: b.owners,
|
|
58
|
+
}));
|
|
59
|
+
const rootCauses = buildRootCauseGroups(holeInsights);
|
|
60
|
+
const filesToRead = collectFilesToRead(holeInsights, rootCauses);
|
|
61
|
+
return {
|
|
62
|
+
totalBoundaries: holeInsights.length + staticSummaries.length,
|
|
63
|
+
dynamicHoleCount: holeInsights.length,
|
|
64
|
+
staticCount: staticSummaries.length,
|
|
65
|
+
holes: holeInsights,
|
|
66
|
+
statics: staticSummaries,
|
|
67
|
+
rootCauses,
|
|
68
|
+
filesToRead,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function formatReport(report) {
|
|
59
72
|
const lines = [
|
|
60
73
|
"# PPR Shell Analysis",
|
|
61
|
-
`# ${totalBoundaries} boundaries: ${
|
|
74
|
+
`# ${report.totalBoundaries} boundaries: ${report.dynamicHoleCount} dynamic holes, ${report.staticCount} static`,
|
|
62
75
|
"",
|
|
63
76
|
];
|
|
64
|
-
if (holes.length > 0) {
|
|
65
|
-
lines.push("##
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
lines.push(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
if (report.holes.length > 0) {
|
|
78
|
+
lines.push("## Summary");
|
|
79
|
+
const topBoundary = report.holes[0];
|
|
80
|
+
if (topBoundary?.primaryBlocker) {
|
|
81
|
+
lines.push(`- Top actionable hole: ${topBoundary.name ?? "(unnamed)"} — ${topBoundary.primaryBlocker.name} ` +
|
|
82
|
+
`(${topBoundary.primaryBlocker.kind})`);
|
|
83
|
+
lines.push(`- Suggested next step: ${topBoundary.recommendation}`);
|
|
84
|
+
}
|
|
85
|
+
const topRootCause = report.rootCauses[0];
|
|
86
|
+
if (topRootCause) {
|
|
87
|
+
lines.push(`- Most common root cause: ${topRootCause.name} (${topRootCause.kind}) ` +
|
|
88
|
+
`affecting ${topRootCause.count} boundar${topRootCause.count === 1 ? "y" : "ies"}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push("## Quick Reference");
|
|
92
|
+
lines.push("| Boundary | Type | Fallback source | Primary blocker | Source | Suggested next step |");
|
|
93
|
+
lines.push("| --- | --- | --- | --- | --- | --- |");
|
|
94
|
+
for (const hole of report.holes) {
|
|
95
|
+
const blocker = hole.primaryBlocker;
|
|
96
|
+
const source = blocker?.sourceFrame
|
|
97
|
+
? `${blocker.sourceFrame[1]}:${blocker.sourceFrame[2]}`
|
|
98
|
+
: hole.source
|
|
99
|
+
? `${hole.source[0]}:${hole.source[1]}`
|
|
100
|
+
: "unknown";
|
|
101
|
+
const fallback = hole.fallbackSource.path ?? hole.fallbackSource.kind;
|
|
102
|
+
lines.push(`| ${escapeCell(hole.name ?? "(unnamed)")} | ${hole.boundaryKind} | ${escapeCell(fallback)} | ` +
|
|
103
|
+
`${escapeCell(blocker ? `${blocker.name} (${blocker.kind})` : "unknown")} | ` +
|
|
104
|
+
`${escapeCell(source)} | ${escapeCell(hole.recommendation)} |`);
|
|
105
|
+
}
|
|
106
|
+
lines.push("");
|
|
107
|
+
if (report.filesToRead.length > 0) {
|
|
108
|
+
lines.push("## Files to Read");
|
|
109
|
+
for (const file of report.filesToRead) {
|
|
110
|
+
lines.push(`- ${file}`);
|
|
84
111
|
}
|
|
85
|
-
|
|
86
|
-
|
|
112
|
+
lines.push("");
|
|
113
|
+
}
|
|
114
|
+
if (report.rootCauses.length > 0) {
|
|
115
|
+
lines.push("## Root Causes");
|
|
116
|
+
for (const cause of report.rootCauses) {
|
|
117
|
+
const source = cause.sourceFrame
|
|
118
|
+
? `${cause.sourceFrame[1]}:${cause.sourceFrame[2]}`
|
|
119
|
+
: "unknown";
|
|
120
|
+
lines.push(`- ${cause.name} (${cause.kind}) at ${source} — affects ${cause.count} ` +
|
|
121
|
+
`boundar${cause.count === 1 ? "y" : "ies"}`);
|
|
122
|
+
lines.push(` next step: ${cause.suggestion}`);
|
|
123
|
+
lines.push(` boundaries: ${cause.boundaryNames.join(", ")}`);
|
|
87
124
|
}
|
|
125
|
+
lines.push("");
|
|
126
|
+
}
|
|
127
|
+
// Detail section — only shows info NOT already in the Quick Reference table:
|
|
128
|
+
// owner chains, environment tags, secondary blockers, and stack frames.
|
|
129
|
+
const holesWithDetail = report.holes.filter((h) => h.renderedBy.length > 0 || h.environments.length > 0 || h.blockers.length > 1 || h.unknownSuspenders);
|
|
130
|
+
if (holesWithDetail.length > 0) {
|
|
131
|
+
lines.push("## Detail");
|
|
132
|
+
for (const hole of holesWithDetail) {
|
|
133
|
+
const name = hole.name ?? "(unnamed)";
|
|
134
|
+
lines.push(` ${name}`);
|
|
135
|
+
if (hole.renderedBy.length > 0) {
|
|
136
|
+
lines.push(` rendered by: ${hole.renderedBy.map((o) => {
|
|
137
|
+
const env = o.env ? ` [${o.env}]` : "";
|
|
138
|
+
const src = o.source ? ` at ${o.source[0]}:${o.source[1]}` : "";
|
|
139
|
+
return `${o.name}${env}${src}`;
|
|
140
|
+
}).join(" > ")}`);
|
|
141
|
+
}
|
|
142
|
+
if (hole.environments.length > 0)
|
|
143
|
+
lines.push(` environments: ${hole.environments.join(", ")}`);
|
|
144
|
+
if (hole.blockers.length > 1) {
|
|
145
|
+
lines.push(" secondary blockers:");
|
|
146
|
+
for (const blocker of hole.blockers.slice(1)) {
|
|
147
|
+
const env = blocker.env ? ` [${blocker.env}]` : "";
|
|
148
|
+
const owner = blocker.ownerName ? ` initiated by <${blocker.ownerName}>` : "";
|
|
149
|
+
const awaiter = blocker.awaiterName ? ` awaited in <${blocker.awaiterName}>` : "";
|
|
150
|
+
lines.push(` - ${blocker.name}: ${blocker.description || "(no description)"}${env}${owner}${awaiter}`);
|
|
151
|
+
if (blocker.ownerFrame) {
|
|
152
|
+
const [fn, file, line] = blocker.ownerFrame;
|
|
153
|
+
lines.push(` owner: ${fn || "(anonymous)"} ${file}:${line}`);
|
|
154
|
+
}
|
|
155
|
+
else if (blocker.awaiterFrame) {
|
|
156
|
+
const [fn, file, line] = blocker.awaiterFrame;
|
|
157
|
+
lines.push(` awaiter: ${fn || "(anonymous)"} ${file}:${line}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (hole.unknownSuspenders) {
|
|
162
|
+
lines.push(` suspenders unknown: ${hole.unknownSuspenders}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
lines.push("");
|
|
88
166
|
}
|
|
89
|
-
lines.push("");
|
|
90
167
|
}
|
|
91
|
-
if (statics.length > 0) {
|
|
168
|
+
if (report.statics.length > 0) {
|
|
92
169
|
lines.push("## Static (pre-rendered in shell)");
|
|
93
|
-
for (const b of statics) {
|
|
170
|
+
for (const b of report.statics) {
|
|
94
171
|
const name = b.name ?? "(unnamed)";
|
|
95
|
-
const src = b.
|
|
172
|
+
const src = b.source ? ` at ${b.source[0]}:${b.source[1]}:${b.source[2]}` : "";
|
|
96
173
|
lines.push(` ${name}${src}`);
|
|
97
174
|
}
|
|
98
175
|
}
|
|
99
176
|
return lines.join("\n");
|
|
100
177
|
}
|
|
178
|
+
function buildBoundaryInsight(shell, resolved) {
|
|
179
|
+
const boundaryKind = inferBoundaryKind(resolved);
|
|
180
|
+
const blockers = resolved.suspendedBy
|
|
181
|
+
.map((blocker) => buildActionableBlocker(blocker))
|
|
182
|
+
.sort(compareActionableBlockers);
|
|
183
|
+
const primaryBlocker = blockers[0] ?? null;
|
|
184
|
+
const recommendation = recommendBoundaryFix(boundaryKind, primaryBlocker, resolved.unknownSuspenders);
|
|
185
|
+
return {
|
|
186
|
+
id: resolved.id,
|
|
187
|
+
name: resolved.name,
|
|
188
|
+
boundaryKind,
|
|
189
|
+
environments: shell.environments,
|
|
190
|
+
source: resolved.jsxSource,
|
|
191
|
+
renderedBy: resolved.owners,
|
|
192
|
+
fallbackSource: {
|
|
193
|
+
kind: "unknown",
|
|
194
|
+
path: null,
|
|
195
|
+
confidence: boundaryKind === "route-segment" ? "medium" : "low",
|
|
196
|
+
},
|
|
197
|
+
primaryBlocker,
|
|
198
|
+
blockers,
|
|
199
|
+
unknownSuspenders: resolved.unknownSuspenders,
|
|
200
|
+
actionability: Math.max(primaryBlocker?.actionability ?? 0, boundaryKind === "route-segment" ? 55 : 0),
|
|
201
|
+
recommendation: recommendation.text,
|
|
202
|
+
recommendationKind: recommendation.kind,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function buildActionableBlocker(suspender) {
|
|
206
|
+
const ownerFrame = pickPreferredFrame(suspender.ownerStack);
|
|
207
|
+
const awaiterFrame = pickPreferredFrame(suspender.awaiterStack);
|
|
208
|
+
const sourceFrame = ownerFrame ?? awaiterFrame;
|
|
209
|
+
const kind = classifyBlocker(suspender, sourceFrame);
|
|
210
|
+
const suggestion = suggestBlockerFix(kind);
|
|
211
|
+
let actionability = blockerActionability(kind);
|
|
212
|
+
if (sourceFrame && !isFrameworkishPath(sourceFrame[1]))
|
|
213
|
+
actionability += 8;
|
|
214
|
+
if (suspender.ownerName || suspender.awaiterName)
|
|
215
|
+
actionability += 4;
|
|
216
|
+
actionability = Math.min(actionability, 100);
|
|
217
|
+
return {
|
|
218
|
+
key: buildBlockerKey(suspender.name, kind, sourceFrame),
|
|
219
|
+
name: suspender.name,
|
|
220
|
+
kind,
|
|
221
|
+
env: suspender.env,
|
|
222
|
+
description: suspender.description,
|
|
223
|
+
ownerName: suspender.ownerName,
|
|
224
|
+
awaiterName: suspender.awaiterName,
|
|
225
|
+
sourceFrame,
|
|
226
|
+
ownerFrame,
|
|
227
|
+
awaiterFrame,
|
|
228
|
+
actionability,
|
|
229
|
+
suggestion,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function buildRootCauseGroups(holes) {
|
|
233
|
+
const groups = new Map();
|
|
234
|
+
for (const hole of holes) {
|
|
235
|
+
const blocker = hole.primaryBlocker;
|
|
236
|
+
if (!blocker)
|
|
237
|
+
continue;
|
|
238
|
+
const existing = groups.get(blocker.key);
|
|
239
|
+
if (existing) {
|
|
240
|
+
existing.boundaryIds.push(hole.id);
|
|
241
|
+
existing.boundaryNames.push(hole.name ?? `boundary-${hole.id}`);
|
|
242
|
+
existing.count++;
|
|
243
|
+
existing.actionability = Math.max(existing.actionability, blocker.actionability);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
groups.set(blocker.key, {
|
|
247
|
+
key: blocker.key,
|
|
248
|
+
kind: blocker.kind,
|
|
249
|
+
name: blocker.name,
|
|
250
|
+
sourceFrame: blocker.sourceFrame,
|
|
251
|
+
boundaryIds: [hole.id],
|
|
252
|
+
boundaryNames: [hole.name ?? `boundary-${hole.id}`],
|
|
253
|
+
count: 1,
|
|
254
|
+
actionability: blocker.actionability,
|
|
255
|
+
suggestion: blocker.suggestion,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return [...groups.values()].sort((a, b) => {
|
|
259
|
+
const scoreA = a.count * a.actionability;
|
|
260
|
+
const scoreB = b.count * b.actionability;
|
|
261
|
+
return scoreB - scoreA || a.name.localeCompare(b.name);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
function collectFilesToRead(holes, rootCauses) {
|
|
265
|
+
const counts = new Map();
|
|
266
|
+
const add = (file) => {
|
|
267
|
+
if (!file)
|
|
268
|
+
return;
|
|
269
|
+
counts.set(file, (counts.get(file) ?? 0) + 1);
|
|
270
|
+
};
|
|
271
|
+
for (const hole of holes) {
|
|
272
|
+
add(hole.source?.[0]);
|
|
273
|
+
add(hole.primaryBlocker?.sourceFrame?.[1]);
|
|
274
|
+
for (const owner of hole.renderedBy)
|
|
275
|
+
add(owner.source?.[0]);
|
|
276
|
+
}
|
|
277
|
+
for (const cause of rootCauses)
|
|
278
|
+
add(cause.sourceFrame?.[1]);
|
|
279
|
+
return [...counts.entries()]
|
|
280
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
281
|
+
.map(([file]) => file)
|
|
282
|
+
.slice(0, 12);
|
|
283
|
+
}
|
|
284
|
+
function compareBoundaryInsights(a, b) {
|
|
285
|
+
return (b.actionability - a.actionability ||
|
|
286
|
+
boundaryKindWeight(b.boundaryKind) - boundaryKindWeight(a.boundaryKind) ||
|
|
287
|
+
(b.blockers.length - a.blockers.length) ||
|
|
288
|
+
(a.name ?? "").localeCompare(b.name ?? ""));
|
|
289
|
+
}
|
|
290
|
+
function compareActionableBlockers(a, b) {
|
|
291
|
+
return (b.actionability - a.actionability ||
|
|
292
|
+
blockerKindWeight(b.kind) - blockerKindWeight(a.kind) ||
|
|
293
|
+
a.name.localeCompare(b.name));
|
|
294
|
+
}
|
|
295
|
+
function boundaryKindWeight(kind) {
|
|
296
|
+
if (kind === "route-segment")
|
|
297
|
+
return 3;
|
|
298
|
+
if (kind === "explicit-suspense")
|
|
299
|
+
return 2;
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
function blockerKindWeight(kind) {
|
|
303
|
+
switch (kind) {
|
|
304
|
+
case "client-hook": return 7;
|
|
305
|
+
case "request-api": return 6;
|
|
306
|
+
case "server-fetch": return 5;
|
|
307
|
+
case "cache": return 4;
|
|
308
|
+
case "stream": return 3;
|
|
309
|
+
case "unknown": return 2;
|
|
310
|
+
case "framework": return 1;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function blockerActionability(kind) {
|
|
314
|
+
switch (kind) {
|
|
315
|
+
case "client-hook": return 90;
|
|
316
|
+
case "request-api": return 88;
|
|
317
|
+
case "server-fetch": return 82;
|
|
318
|
+
case "cache": return 74;
|
|
319
|
+
case "stream": return 60;
|
|
320
|
+
case "unknown": return 35;
|
|
321
|
+
case "framework": return 18;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function inferBoundaryKind(boundary) {
|
|
325
|
+
const ownerNames = boundary.owners.map((owner) => owner.name);
|
|
326
|
+
if ((boundary.name && boundary.name.endsWith("/")) ||
|
|
327
|
+
ownerNames.includes("LoadingBoundary") ||
|
|
328
|
+
ownerNames.includes("OuterLayoutRouter")) {
|
|
329
|
+
return "route-segment";
|
|
330
|
+
}
|
|
331
|
+
if (boundary.name?.includes("Suspense") ||
|
|
332
|
+
ownerNames.some((name) => name.includes("Suspense"))) {
|
|
333
|
+
return "explicit-suspense";
|
|
334
|
+
}
|
|
335
|
+
return "component";
|
|
336
|
+
}
|
|
337
|
+
function classifyBlocker(suspender, sourceFrame) {
|
|
338
|
+
const name = suspender.name.toLowerCase();
|
|
339
|
+
if (name === "usepathname" ||
|
|
340
|
+
name === "useparams" ||
|
|
341
|
+
name === "usesearchparams" ||
|
|
342
|
+
name === "useselectedlayoutsegments" ||
|
|
343
|
+
name === "useselectedlayoutsegment" ||
|
|
344
|
+
name === "userouter") {
|
|
345
|
+
return "client-hook";
|
|
346
|
+
}
|
|
347
|
+
if (name === "cookies" ||
|
|
348
|
+
name === "headers" ||
|
|
349
|
+
name === "connection" ||
|
|
350
|
+
name === "params" ||
|
|
351
|
+
name === "searchparams" ||
|
|
352
|
+
name === "draftmode") {
|
|
353
|
+
return "request-api";
|
|
354
|
+
}
|
|
355
|
+
if (name === "rsc stream")
|
|
356
|
+
return "stream";
|
|
357
|
+
if (name.includes("fetch"))
|
|
358
|
+
return "server-fetch";
|
|
359
|
+
if (name.includes("cache") || suspender.description.toLowerCase().includes("cache")) {
|
|
360
|
+
return "cache";
|
|
361
|
+
}
|
|
362
|
+
if (name.startsWith("use"))
|
|
363
|
+
return "client-hook";
|
|
364
|
+
if (sourceFrame && isFrameworkishPath(sourceFrame[1]))
|
|
365
|
+
return "framework";
|
|
366
|
+
return "unknown";
|
|
367
|
+
}
|
|
368
|
+
function suggestBlockerFix(kind) {
|
|
369
|
+
switch (kind) {
|
|
370
|
+
case "client-hook":
|
|
371
|
+
return "Move route hooks behind a smaller client Suspense or provide a real non-null loading fallback for this segment.";
|
|
372
|
+
case "request-api":
|
|
373
|
+
return "Push request-bound reads to a smaller server leaf, or cache around them so the parent shell can stay static.";
|
|
374
|
+
case "server-fetch":
|
|
375
|
+
return "Split static shell content from data widgets, then push the fetch into smaller Suspense leaves or cache it.";
|
|
376
|
+
case "cache":
|
|
377
|
+
return "This looks cache-related; check whether \"use cache\" or runtime prefetch can eliminate the suspension.";
|
|
378
|
+
case "stream":
|
|
379
|
+
return "A stream is still pending here; extract static siblings outside the boundary and push the stream consumer deeper.";
|
|
380
|
+
case "framework":
|
|
381
|
+
return "This currently looks framework-driven; find the nearest user-owned caller above it before changing code.";
|
|
382
|
+
case "unknown":
|
|
383
|
+
return "Inspect the nearest user-owned owner/awaiter frame and verify whether this suspender really belongs at this boundary.";
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function recommendBoundaryFix(boundaryKind, primaryBlocker, unknownSuspenders) {
|
|
387
|
+
if (boundaryKind === "route-segment" && primaryBlocker?.kind === "client-hook") {
|
|
388
|
+
return {
|
|
389
|
+
kind: "check-loading-fallback",
|
|
390
|
+
text: "This route segment is suspending on client hooks. Check loading.tsx first; if it is null or visually empty, fix the fallback before chasing deeper push-down work.",
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (primaryBlocker?.kind === "client-hook") {
|
|
394
|
+
return {
|
|
395
|
+
kind: "push-client-hooks-down",
|
|
396
|
+
text: "Push the hook-using client UI behind a smaller local Suspense boundary so the parent shell can prerender.",
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (primaryBlocker?.kind === "request-api" || primaryBlocker?.kind === "server-fetch") {
|
|
400
|
+
return {
|
|
401
|
+
kind: "push-request-io-down",
|
|
402
|
+
text: "Push the request-bound async work into a smaller leaf or split static siblings out of this boundary.",
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
if (primaryBlocker?.kind === "cache") {
|
|
406
|
+
return {
|
|
407
|
+
kind: "cache-or-runtime-prefetch",
|
|
408
|
+
text: "Check whether caching or runtime prefetch can move this personalized content into the shell.",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (primaryBlocker?.kind === "stream") {
|
|
412
|
+
return {
|
|
413
|
+
kind: "extract-static-shell",
|
|
414
|
+
text: "Keep the stream behind Suspense, but extract any static shell content outside the boundary.",
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (primaryBlocker?.kind === "framework") {
|
|
418
|
+
return {
|
|
419
|
+
kind: "investigate-framework",
|
|
420
|
+
text: "The top blocker still looks framework-heavy. Find the nearest user-owned caller before changing boundary placement.",
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (unknownSuspenders) {
|
|
424
|
+
return {
|
|
425
|
+
kind: "investigate-unknown",
|
|
426
|
+
text: `React could not identify the suspender (${unknownSuspenders}). Investigate the nearest user-owned owner or awaiter frame.`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
kind: "investigate-unknown",
|
|
431
|
+
text: "No primary blocker was identified. Inspect the boundary source and owner chain directly.",
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function pickPreferredFrame(stack) {
|
|
435
|
+
if (!stack || stack.length === 0)
|
|
436
|
+
return null;
|
|
437
|
+
return stack.find((frame) => !isFrameworkishPath(frame[1])) ?? stack[0];
|
|
438
|
+
}
|
|
439
|
+
function isFrameworkishPath(file) {
|
|
440
|
+
return file.includes("/node_modules/");
|
|
441
|
+
}
|
|
442
|
+
function buildBlockerKey(name, kind, sourceFrame) {
|
|
443
|
+
if (!sourceFrame)
|
|
444
|
+
return `${kind}:${name}:unknown`;
|
|
445
|
+
return `${kind}:${name}:${sourceFrame[1]}:${sourceFrame[2]}`;
|
|
446
|
+
}
|
|
447
|
+
function labelActionability(value) {
|
|
448
|
+
if (value >= 80)
|
|
449
|
+
return "high";
|
|
450
|
+
if (value >= 50)
|
|
451
|
+
return "medium";
|
|
452
|
+
return "low";
|
|
453
|
+
}
|
|
454
|
+
function escapeCell(value) {
|
|
455
|
+
return value.replaceAll("|", "\\|");
|
|
456
|
+
}
|
|
457
|
+
export function annotateReportWithPageMetadata(report, pageMetadata) {
|
|
458
|
+
const sessions = Array.isArray(pageMetadata?.sessions)
|
|
459
|
+
? (pageMetadata.sessions)
|
|
460
|
+
: [];
|
|
461
|
+
const loadingPaths = sessions.flatMap((session) => (session.segments ?? [])
|
|
462
|
+
.filter((segment) => segment.type === "boundary:loading" && typeof segment.path === "string")
|
|
463
|
+
.map((segment) => segment.path));
|
|
464
|
+
for (const hole of report.holes) {
|
|
465
|
+
if (hole.boundaryKind !== "route-segment")
|
|
466
|
+
continue;
|
|
467
|
+
const segmentName = normalizeBoundarySegmentName(hole.name);
|
|
468
|
+
if (!segmentName)
|
|
469
|
+
continue;
|
|
470
|
+
const exact = loadingPaths.find((path) => path.split("/").at(-2) === segmentName);
|
|
471
|
+
if (exact) {
|
|
472
|
+
hole.fallbackSource = {
|
|
473
|
+
kind: "loading-tsx",
|
|
474
|
+
path: exact,
|
|
475
|
+
confidence: "high",
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function normalizeBoundarySegmentName(name) {
|
|
481
|
+
if (!name)
|
|
482
|
+
return null;
|
|
483
|
+
return name.endsWith("/") ? name.slice(0, -1) : name;
|
|
484
|
+
}
|
|
101
485
|
function boundaryKey(b) {
|
|
102
486
|
if (b.jsxSource)
|
|
103
487
|
return `${b.jsxSource[0]}:${b.jsxSource[1]}:${b.jsxSource[2]}`;
|
|
@@ -106,13 +490,40 @@ function boundaryKey(b) {
|
|
|
106
490
|
async function resolveSources(boundaries, origin) {
|
|
107
491
|
for (const b of boundaries) {
|
|
108
492
|
if (b.jsxSource) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
493
|
+
b.jsxSource = await resolveOne(b.jsxSource, origin);
|
|
494
|
+
}
|
|
495
|
+
for (const o of b.owners) {
|
|
496
|
+
if (o.source) {
|
|
497
|
+
o.source = await resolveOne(o.source, origin);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (const s of b.suspendedBy) {
|
|
501
|
+
if (s.ownerStack)
|
|
502
|
+
s.ownerStack = await resolveStack(s.ownerStack, origin);
|
|
503
|
+
if (s.awaiterStack)
|
|
504
|
+
s.awaiterStack = await resolveStack(s.awaiterStack, origin);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async function resolveOne(src, origin) {
|
|
509
|
+
const [file, line, col] = src;
|
|
510
|
+
const resolved = (await sourcemap.resolve(origin, file, line, col)) ??
|
|
511
|
+
(await sourcemap.resolveViaMap(origin, file, line, col));
|
|
512
|
+
return resolved ? [resolved.file, resolved.line, resolved.column] : src;
|
|
513
|
+
}
|
|
514
|
+
async function resolveStack(stack, origin) {
|
|
515
|
+
const out = [];
|
|
516
|
+
for (const [name, file, line, col] of stack) {
|
|
517
|
+
const resolved = (await sourcemap.resolve(origin, file, line, col)) ??
|
|
518
|
+
(await sourcemap.resolveViaMap(origin, file, line, col));
|
|
519
|
+
if (resolved) {
|
|
520
|
+
out.push([name, resolved.file, resolved.line, resolved.column]);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
out.push([name, file, line, col]);
|
|
114
524
|
}
|
|
115
525
|
}
|
|
526
|
+
return out;
|
|
116
527
|
}
|
|
117
528
|
async function inPageSuspense(inspect) {
|
|
118
529
|
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
@@ -155,16 +566,17 @@ async function inPageSuspense(inspect) {
|
|
|
155
566
|
function collect(renderer) {
|
|
156
567
|
return new Promise((resolve) => {
|
|
157
568
|
const out = [];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
569
|
+
// Operations are emitted via hook.emit("operations", payload),
|
|
570
|
+
// NOT via window.postMessage.
|
|
571
|
+
const origEmit = hook.emit;
|
|
572
|
+
hook.emit = function (event, payload) {
|
|
573
|
+
if (event === "operations")
|
|
574
|
+
out.push(payload);
|
|
575
|
+
return origEmit.apply(this, arguments);
|
|
163
576
|
};
|
|
164
|
-
window.addEventListener("message", listener);
|
|
165
577
|
renderer.flushInitialOperations();
|
|
166
578
|
setTimeout(() => {
|
|
167
|
-
|
|
579
|
+
hook.emit = origEmit;
|
|
168
580
|
resolve(out);
|
|
169
581
|
}, 50);
|
|
170
582
|
});
|
|
@@ -283,7 +695,9 @@ async function inPageSuspense(inspect) {
|
|
|
283
695
|
duration: awaited.end && awaited.start ? Math.round(awaited.end - awaited.start) : 0,
|
|
284
696
|
env: awaited.env ?? entry?.env ?? null,
|
|
285
697
|
ownerName: awaited.owner?.displayName ?? null,
|
|
698
|
+
ownerStack: parseStack(awaited.owner?.stack ?? awaited.stack),
|
|
286
699
|
awaiterName: entry?.owner?.displayName ?? null,
|
|
700
|
+
awaiterStack: parseStack(entry?.owner?.stack ?? entry?.stack),
|
|
287
701
|
});
|
|
288
702
|
}
|
|
289
703
|
}
|
|
@@ -297,8 +711,16 @@ async function inPageSuspense(inspect) {
|
|
|
297
711
|
}
|
|
298
712
|
if (Array.isArray(data.owners)) {
|
|
299
713
|
for (const o of data.owners) {
|
|
300
|
-
if (o?.displayName)
|
|
301
|
-
|
|
714
|
+
if (o?.displayName) {
|
|
715
|
+
const src = Array.isArray(o.stack) && o.stack.length > 0 && Array.isArray(o.stack[0])
|
|
716
|
+
? [o.stack[0][1] || "(unknown)", o.stack[0][2], o.stack[0][3]]
|
|
717
|
+
: null;
|
|
718
|
+
boundary.owners.push({
|
|
719
|
+
name: o.displayName,
|
|
720
|
+
env: o.env ?? null,
|
|
721
|
+
source: src,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
302
724
|
}
|
|
303
725
|
}
|
|
304
726
|
if (Array.isArray(data.stack) && data.stack.length > 0) {
|
|
@@ -308,6 +730,13 @@ async function inPageSuspense(inspect) {
|
|
|
308
730
|
}
|
|
309
731
|
}
|
|
310
732
|
}
|
|
733
|
+
function parseStack(raw) {
|
|
734
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
735
|
+
return null;
|
|
736
|
+
return raw
|
|
737
|
+
.filter((f) => Array.isArray(f) && f.length >= 4)
|
|
738
|
+
.map((f) => [f[0] ?? "", f[1] ?? "", f[2] ?? 0, f[3] ?? 0]);
|
|
739
|
+
}
|
|
311
740
|
function preview(v) {
|
|
312
741
|
if (v == null)
|
|
313
742
|
return "";
|