@vercel/next-browser 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/browser.js +53 -14
- package/dist/cli.js +14 -6
- package/dist/daemon.js +4 -0
- package/dist/mcp.js +8 -1
- package/dist/suspense.js +512 -68
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ open <url> [--cookies-json <file>] launch browser and navigate
|
|
|
56
56
|
close close browser and daemon
|
|
57
57
|
|
|
58
58
|
goto <url> full-page navigation (new document load)
|
|
59
|
+
ssr-goto <url> goto but block external scripts (SSR shell)
|
|
59
60
|
push [path] client-side navigation (interactive picker if no path)
|
|
60
61
|
back go back in history
|
|
61
62
|
reload reload current page
|
package/dist/browser.js
CHANGED
|
@@ -126,14 +126,9 @@ export async function unlock() {
|
|
|
126
126
|
// only truly stuck boundaries remain as "holes."
|
|
127
127
|
await stabilizeSuspenseState(page);
|
|
128
128
|
// Capture what's suspended right now under the lock.
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
// <template id="B:..."> elements in the DOM (PPR's Suspense placeholders).
|
|
133
|
-
const hasDevToolsData = locked.some((b) => b.parentID !== 0);
|
|
134
|
-
if (!hasDevToolsData) {
|
|
135
|
-
locked = await suspenseTree.snapshotFromDom(page);
|
|
136
|
-
}
|
|
129
|
+
// Under goto + lock, DevTools may not be connected (shell is static HTML).
|
|
130
|
+
// That's fine — we get all the rich data from the unlocked snapshot below.
|
|
131
|
+
const locked = await suspenseTree.snapshot(page).catch(() => []);
|
|
137
132
|
// Release the lock. instant() clears the cookie.
|
|
138
133
|
// - push case: dynamic content streams in immediately (no reload)
|
|
139
134
|
// - goto case: cookieStore change → auto-reload → full page load
|
|
@@ -141,14 +136,26 @@ export async function unlock() {
|
|
|
141
136
|
release = null;
|
|
142
137
|
await settled;
|
|
143
138
|
settled = null;
|
|
139
|
+
// For goto case: the page auto-reloads. Wait for the new page to load
|
|
140
|
+
// and React/DevTools to reconnect before trying to snapshot boundaries.
|
|
141
|
+
await page.waitForLoadState("load").catch(() => { });
|
|
142
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
144
143
|
// Wait for all boundaries to resolve after unlock.
|
|
145
|
-
// Polls the DevTools suspense tree (works for both push and goto cases).
|
|
146
144
|
await waitForSuspenseToSettle(page);
|
|
147
145
|
// Capture the fully-resolved state with rich suspendedBy data.
|
|
148
146
|
const unlocked = await suspenseTree.snapshot(page).catch(() => []);
|
|
149
|
-
if (locked.length === 0 && unlocked.length === 0)
|
|
150
|
-
return null;
|
|
151
|
-
|
|
147
|
+
if (locked.length === 0 && unlocked.length === 0) {
|
|
148
|
+
return { text: "No suspense boundaries detected.", boundaries: unlocked, locked, report: null };
|
|
149
|
+
}
|
|
150
|
+
const report = await suspenseTree.analyzeBoundaries(unlocked, locked, origin);
|
|
151
|
+
const pageMetadata = await nextMcp
|
|
152
|
+
.call(initialOrigin ?? origin, "get_page_metadata")
|
|
153
|
+
.catch(() => null);
|
|
154
|
+
if (pageMetadata) {
|
|
155
|
+
suspenseTree.annotateReportWithPageMetadata(report, pageMetadata);
|
|
156
|
+
}
|
|
157
|
+
const text = suspenseTree.formatReport(report);
|
|
158
|
+
return { text, boundaries: unlocked, locked, report };
|
|
152
159
|
}
|
|
153
160
|
/**
|
|
154
161
|
* Wait for the suspended boundary count to stop changing.
|
|
@@ -228,6 +235,7 @@ export async function captureGoto(url) {
|
|
|
228
235
|
mkdirSync(dir, { recursive: true });
|
|
229
236
|
let frameIdx = 0;
|
|
230
237
|
async function snap() {
|
|
238
|
+
await hideDevOverlay();
|
|
231
239
|
const path = join(dir, `frame-${String(frameIdx).padStart(4, "0")}.png`);
|
|
232
240
|
const buf = await page.screenshot({ path }).catch(() => null);
|
|
233
241
|
frameIdx++;
|
|
@@ -336,10 +344,31 @@ export async function push(path) {
|
|
|
336
344
|
}
|
|
337
345
|
/** Full-page navigation (new document load). Resolves relative URLs against the current page. */
|
|
338
346
|
export async function goto(url) {
|
|
347
|
+
if (!page)
|
|
348
|
+
throw new Error("browser not open");
|
|
349
|
+
await page.unrouteAll({ behavior: "wait" });
|
|
350
|
+
const target = new URL(url, page.url()).href;
|
|
351
|
+
initialOrigin = new URL(target).origin;
|
|
352
|
+
await page.goto(target, { waitUntil: "domcontentloaded" });
|
|
353
|
+
return target;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Navigate like goto but block external script resources.
|
|
357
|
+
* The HTML loads and inline <script> blocks still execute, but external JS
|
|
358
|
+
* bundles (React, hydration, etc.) are aborted. Shows the SSR shell.
|
|
359
|
+
*/
|
|
360
|
+
export async function ssrGoto(url) {
|
|
339
361
|
if (!page)
|
|
340
362
|
throw new Error("browser not open");
|
|
341
363
|
const target = new URL(url, page.url()).href;
|
|
342
364
|
initialOrigin = new URL(target).origin;
|
|
365
|
+
// Clear any stale route handlers from previous ssr-goto calls.
|
|
366
|
+
await page.unrouteAll({ behavior: "wait" });
|
|
367
|
+
await page.route("**/*", (route) => {
|
|
368
|
+
if (route.request().resourceType() === "script")
|
|
369
|
+
return route.abort();
|
|
370
|
+
return route.continue();
|
|
371
|
+
});
|
|
343
372
|
await page.goto(target, { waitUntil: "domcontentloaded" });
|
|
344
373
|
return target;
|
|
345
374
|
}
|
|
@@ -396,16 +425,25 @@ async function formatSource([file, line, col]) {
|
|
|
396
425
|
return `source: ${file}:${line}:${col}`;
|
|
397
426
|
}
|
|
398
427
|
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
399
|
-
/**
|
|
428
|
+
/** Viewport screenshot saved to a temp file. Returns the file path. */
|
|
400
429
|
export async function screenshot() {
|
|
401
430
|
if (!page)
|
|
402
431
|
throw new Error("browser not open");
|
|
432
|
+
await hideDevOverlay();
|
|
403
433
|
const { join } = await import("node:path");
|
|
404
434
|
const { tmpdir } = await import("node:os");
|
|
405
435
|
const path = join(tmpdir(), `next-browser-${Date.now()}.png`);
|
|
406
|
-
await page.screenshot({ path
|
|
436
|
+
await page.screenshot({ path });
|
|
407
437
|
return path;
|
|
408
438
|
}
|
|
439
|
+
/** Remove Next.js devtools overlay from the page before screenshots. */
|
|
440
|
+
async function hideDevOverlay() {
|
|
441
|
+
if (!page)
|
|
442
|
+
return;
|
|
443
|
+
await page.evaluate(() => {
|
|
444
|
+
document.querySelectorAll("[data-nextjs-dev-overlay]").forEach((el) => el.remove());
|
|
445
|
+
}).catch(() => { });
|
|
446
|
+
}
|
|
409
447
|
/** Evaluate arbitrary JavaScript in the page context. */
|
|
410
448
|
export async function evaluate(script) {
|
|
411
449
|
if (!page)
|
|
@@ -501,5 +539,6 @@ async function launch() {
|
|
|
501
539
|
args: headless ? ["--no-sandbox"] : [],
|
|
502
540
|
});
|
|
503
541
|
await ctx.addInitScript(installHook);
|
|
542
|
+
// Next.js devtools overlay is removed before each screenshot via hideDevOverlay().
|
|
504
543
|
return ctx;
|
|
505
544
|
}
|
package/dist/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ if (cmd === "open") {
|
|
|
18
18
|
console.error("usage: next-browser open <url> [--cookies-json <file>]");
|
|
19
19
|
process.exit(1);
|
|
20
20
|
}
|
|
21
|
+
const url = /^https?:\/\//.test(arg) ? arg : `http://${arg}`;
|
|
21
22
|
const cookieIdx = args.indexOf("--cookies-json");
|
|
22
23
|
const cookieFile = cookieIdx >= 0 ? args[cookieIdx + 1] : undefined;
|
|
23
24
|
if (cookieFile) {
|
|
@@ -26,15 +27,15 @@ if (cmd === "open") {
|
|
|
26
27
|
exit(res, "");
|
|
27
28
|
const raw = readFileSync(cookieFile, "utf-8");
|
|
28
29
|
const cookies = JSON.parse(raw);
|
|
29
|
-
const domain = new URL(
|
|
30
|
+
const domain = new URL(url).hostname;
|
|
30
31
|
const cRes = await send("cookies", { cookies, domain });
|
|
31
32
|
if (!cRes.ok)
|
|
32
33
|
exit(cRes, "");
|
|
33
|
-
await send("goto", { url
|
|
34
|
-
exit(res, `opened → ${
|
|
34
|
+
await send("goto", { url });
|
|
35
|
+
exit(res, `opened → ${url} (${cookies.length} cookies for ${domain})`);
|
|
35
36
|
}
|
|
36
|
-
const res = await send("open", { url
|
|
37
|
-
exit(res, `opened → ${
|
|
37
|
+
const res = await send("open", { url });
|
|
38
|
+
exit(res, `opened → ${url}`);
|
|
38
39
|
}
|
|
39
40
|
if (cmd === "ppr" && arg === "lock") {
|
|
40
41
|
const res = await send("lock");
|
|
@@ -42,7 +43,9 @@ if (cmd === "ppr" && arg === "lock") {
|
|
|
42
43
|
}
|
|
43
44
|
if (cmd === "ppr" && arg === "unlock") {
|
|
44
45
|
const res = await send("unlock");
|
|
45
|
-
|
|
46
|
+
const d = res.ok ? res.data : null;
|
|
47
|
+
const text = typeof d === "string" ? d : d?.text ?? "";
|
|
48
|
+
exit(res, res.ok ? `unlocked${text ? `\n\n${text}` : ""}` : "unlocked");
|
|
46
49
|
}
|
|
47
50
|
if (cmd === "reload") {
|
|
48
51
|
const res = await send("reload");
|
|
@@ -82,6 +85,10 @@ if (cmd === "goto") {
|
|
|
82
85
|
const res = await send("goto", { url: arg });
|
|
83
86
|
exit(res, res.ok ? `→ ${res.data}` : "");
|
|
84
87
|
}
|
|
88
|
+
if (cmd === "ssr-goto") {
|
|
89
|
+
const res = await send("ssr-goto", { url: arg });
|
|
90
|
+
exit(res, res.ok ? `→ ${res.data} (external scripts blocked)` : "");
|
|
91
|
+
}
|
|
85
92
|
if (cmd === "back") {
|
|
86
93
|
const res = await send("back");
|
|
87
94
|
exit(res, "back");
|
|
@@ -226,6 +233,7 @@ function printUsage() {
|
|
|
226
233
|
" close close browser and daemon\n" +
|
|
227
234
|
"\n" +
|
|
228
235
|
" goto <url> full-page navigation (new document load)\n" +
|
|
236
|
+
" ssr-goto <url> goto but block external scripts (SSR shell)\n" +
|
|
229
237
|
" push [path] client-side navigation (interactive picker if no path)\n" +
|
|
230
238
|
" back go back in history\n" +
|
|
231
239
|
" reload reload current page\n" +
|
package/dist/daemon.js
CHANGED
|
@@ -81,6 +81,10 @@ async function run(cmd) {
|
|
|
81
81
|
const data = await browser.goto(cmd.url);
|
|
82
82
|
return { ok: true, data };
|
|
83
83
|
}
|
|
84
|
+
if (cmd.action === "ssr-goto") {
|
|
85
|
+
const data = await browser.ssrGoto(cmd.url);
|
|
86
|
+
return { ok: true, data };
|
|
87
|
+
}
|
|
84
88
|
if (cmd.action === "back") {
|
|
85
89
|
await browser.back();
|
|
86
90
|
return { ok: true };
|
package/dist/mcp.js
CHANGED
|
@@ -27,5 +27,12 @@ export async function call(origin, tool, args = {}) {
|
|
|
27
27
|
if (parsed.error)
|
|
28
28
|
throw new Error(parsed.error.message);
|
|
29
29
|
const text = parsed.result?.content?.[0]?.text;
|
|
30
|
-
|
|
30
|
+
if (!text)
|
|
31
|
+
return parsed.result;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(text);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
31
38
|
}
|
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,470 @@ 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) {
|
|
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}`);
|
|
111
|
+
}
|
|
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(", ")}`);
|
|
124
|
+
}
|
|
125
|
+
lines.push("");
|
|
126
|
+
}
|
|
65
127
|
lines.push("## Dynamic holes (suspended in shell)");
|
|
66
|
-
for (const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const src = b.jsxSource ? `${b.jsxSource[0]}:${b.jsxSource[1]}:${b.jsxSource[2]}` : null;
|
|
128
|
+
for (const hole of report.holes) {
|
|
129
|
+
const name = hole.name ?? "(unnamed)";
|
|
130
|
+
const src = hole.source ? `${hole.source[0]}:${hole.source[1]}:${hole.source[2]}` : null;
|
|
70
131
|
lines.push(` ${name}${src ? ` at ${src}` : ""}`);
|
|
71
|
-
if (
|
|
72
|
-
lines.push(` rendered by: ${
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
132
|
+
if (hole.renderedBy.length > 0) {
|
|
133
|
+
lines.push(` rendered by: ${hole.renderedBy.map((o) => {
|
|
134
|
+
const env = o.env ? ` [${o.env}]` : "";
|
|
135
|
+
const src = o.source ? ` at ${o.source[0]}:${o.source[1]}` : "";
|
|
136
|
+
return `${o.name}${env}${src}`;
|
|
137
|
+
}).join(" > ")}`);
|
|
138
|
+
}
|
|
139
|
+
if (hole.environments.length > 0)
|
|
140
|
+
lines.push(` environments: ${hole.environments.join(", ")}`);
|
|
141
|
+
if (hole.primaryBlocker) {
|
|
142
|
+
lines.push(` primary blocker: ${hole.primaryBlocker.name} ` +
|
|
143
|
+
`(${hole.primaryBlocker.kind}, actionability ${labelActionability(hole.primaryBlocker.actionability)})`);
|
|
144
|
+
if (hole.fallbackSource.path) {
|
|
145
|
+
lines.push(` fallback source: ${hole.fallbackSource.path} ` +
|
|
146
|
+
`(${hole.fallbackSource.confidence} confidence)`);
|
|
147
|
+
}
|
|
148
|
+
if (hole.primaryBlocker.sourceFrame) {
|
|
149
|
+
lines.push(` source: ${hole.primaryBlocker.sourceFrame[0] || "(anonymous)"} ` +
|
|
150
|
+
`${hole.primaryBlocker.sourceFrame[1]}:${hole.primaryBlocker.sourceFrame[2]}`);
|
|
151
|
+
}
|
|
152
|
+
lines.push(` next step: ${hole.recommendation}`);
|
|
153
|
+
}
|
|
154
|
+
if (hole.blockers.length > 0) {
|
|
76
155
|
lines.push(" blocked by:");
|
|
77
|
-
for (const
|
|
78
|
-
const dur =
|
|
79
|
-
const env =
|
|
80
|
-
const owner =
|
|
81
|
-
const awaiter =
|
|
82
|
-
lines.push(` - ${
|
|
156
|
+
for (const blocker of hole.blockers) {
|
|
157
|
+
const dur = hole.primaryBlocker?.name === blocker.name ? " [primary]" : "";
|
|
158
|
+
const env = blocker.env ? ` [${blocker.env}]` : "";
|
|
159
|
+
const owner = blocker.ownerName ? ` initiated by <${blocker.ownerName}>` : "";
|
|
160
|
+
const awaiter = blocker.awaiterName ? ` awaited in <${blocker.awaiterName}>` : "";
|
|
161
|
+
lines.push(` - ${blocker.name}: ${blocker.description || "(no description)"}${env}${dur}${owner}${awaiter}`);
|
|
162
|
+
if (blocker.ownerFrame) {
|
|
163
|
+
const [fn, file, line] = blocker.ownerFrame;
|
|
164
|
+
lines.push(` owner: ${fn || "(anonymous)"} ${file}:${line}`);
|
|
165
|
+
}
|
|
166
|
+
if (blocker.awaiterFrame && !blocker.ownerFrame) {
|
|
167
|
+
const [fn, file, line] = blocker.awaiterFrame;
|
|
168
|
+
lines.push(` awaiter: ${fn || "(anonymous)"} ${file}:${line}`);
|
|
169
|
+
}
|
|
170
|
+
if (blocker.ownerFrame && hole.primaryBlocker?.name === blocker.name) {
|
|
171
|
+
for (const [fn, file, line] of [blocker.ownerFrame].slice(0, 3)) {
|
|
172
|
+
lines.push(` at ${fn || "(anonymous)"} ${file}:${line}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
83
175
|
}
|
|
84
176
|
}
|
|
85
|
-
else if (
|
|
86
|
-
lines.push(` suspenders unknown: ${
|
|
177
|
+
else if (hole.unknownSuspenders) {
|
|
178
|
+
lines.push(` suspenders unknown: ${hole.unknownSuspenders}`);
|
|
87
179
|
}
|
|
88
180
|
}
|
|
89
181
|
lines.push("");
|
|
90
182
|
}
|
|
91
|
-
if (statics.length > 0) {
|
|
183
|
+
if (report.statics.length > 0) {
|
|
92
184
|
lines.push("## Static (pre-rendered in shell)");
|
|
93
|
-
for (const b of statics) {
|
|
185
|
+
for (const b of report.statics) {
|
|
94
186
|
const name = b.name ?? "(unnamed)";
|
|
95
|
-
const src = b.
|
|
187
|
+
const src = b.source ? ` at ${b.source[0]}:${b.source[1]}:${b.source[2]}` : "";
|
|
96
188
|
lines.push(` ${name}${src}`);
|
|
97
189
|
}
|
|
98
190
|
}
|
|
99
191
|
return lines.join("\n");
|
|
100
192
|
}
|
|
193
|
+
function buildBoundaryInsight(shell, resolved) {
|
|
194
|
+
const boundaryKind = inferBoundaryKind(resolved);
|
|
195
|
+
const blockers = resolved.suspendedBy
|
|
196
|
+
.map((blocker) => buildActionableBlocker(blocker))
|
|
197
|
+
.sort(compareActionableBlockers);
|
|
198
|
+
const primaryBlocker = blockers[0] ?? null;
|
|
199
|
+
const recommendation = recommendBoundaryFix(boundaryKind, primaryBlocker, resolved.unknownSuspenders);
|
|
200
|
+
return {
|
|
201
|
+
id: resolved.id,
|
|
202
|
+
name: resolved.name,
|
|
203
|
+
boundaryKind,
|
|
204
|
+
environments: shell.environments,
|
|
205
|
+
source: resolved.jsxSource,
|
|
206
|
+
renderedBy: resolved.owners,
|
|
207
|
+
fallbackSource: {
|
|
208
|
+
kind: "unknown",
|
|
209
|
+
path: null,
|
|
210
|
+
confidence: boundaryKind === "route-segment" ? "medium" : "low",
|
|
211
|
+
},
|
|
212
|
+
primaryBlocker,
|
|
213
|
+
blockers,
|
|
214
|
+
unknownSuspenders: resolved.unknownSuspenders,
|
|
215
|
+
actionability: Math.max(primaryBlocker?.actionability ?? 0, boundaryKind === "route-segment" ? 55 : 0),
|
|
216
|
+
recommendation: recommendation.text,
|
|
217
|
+
recommendationKind: recommendation.kind,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function buildActionableBlocker(suspender) {
|
|
221
|
+
const ownerFrame = pickPreferredFrame(suspender.ownerStack);
|
|
222
|
+
const awaiterFrame = pickPreferredFrame(suspender.awaiterStack);
|
|
223
|
+
const sourceFrame = ownerFrame ?? awaiterFrame;
|
|
224
|
+
const kind = classifyBlocker(suspender, sourceFrame);
|
|
225
|
+
const suggestion = suggestBlockerFix(kind);
|
|
226
|
+
let actionability = blockerActionability(kind);
|
|
227
|
+
if (sourceFrame && !isFrameworkishPath(sourceFrame[1]))
|
|
228
|
+
actionability += 8;
|
|
229
|
+
if (suspender.ownerName || suspender.awaiterName)
|
|
230
|
+
actionability += 4;
|
|
231
|
+
actionability = Math.min(actionability, 100);
|
|
232
|
+
return {
|
|
233
|
+
key: buildBlockerKey(suspender.name, kind, sourceFrame),
|
|
234
|
+
name: suspender.name,
|
|
235
|
+
kind,
|
|
236
|
+
env: suspender.env,
|
|
237
|
+
description: suspender.description,
|
|
238
|
+
ownerName: suspender.ownerName,
|
|
239
|
+
awaiterName: suspender.awaiterName,
|
|
240
|
+
sourceFrame,
|
|
241
|
+
ownerFrame,
|
|
242
|
+
awaiterFrame,
|
|
243
|
+
actionability,
|
|
244
|
+
suggestion,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function buildRootCauseGroups(holes) {
|
|
248
|
+
const groups = new Map();
|
|
249
|
+
for (const hole of holes) {
|
|
250
|
+
const blocker = hole.primaryBlocker;
|
|
251
|
+
if (!blocker)
|
|
252
|
+
continue;
|
|
253
|
+
const existing = groups.get(blocker.key);
|
|
254
|
+
if (existing) {
|
|
255
|
+
existing.boundaryIds.push(hole.id);
|
|
256
|
+
existing.boundaryNames.push(hole.name ?? `boundary-${hole.id}`);
|
|
257
|
+
existing.count++;
|
|
258
|
+
existing.actionability = Math.max(existing.actionability, blocker.actionability);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
groups.set(blocker.key, {
|
|
262
|
+
key: blocker.key,
|
|
263
|
+
kind: blocker.kind,
|
|
264
|
+
name: blocker.name,
|
|
265
|
+
sourceFrame: blocker.sourceFrame,
|
|
266
|
+
boundaryIds: [hole.id],
|
|
267
|
+
boundaryNames: [hole.name ?? `boundary-${hole.id}`],
|
|
268
|
+
count: 1,
|
|
269
|
+
actionability: blocker.actionability,
|
|
270
|
+
suggestion: blocker.suggestion,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return [...groups.values()].sort((a, b) => {
|
|
274
|
+
const scoreA = a.count * a.actionability;
|
|
275
|
+
const scoreB = b.count * b.actionability;
|
|
276
|
+
return scoreB - scoreA || a.name.localeCompare(b.name);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function collectFilesToRead(holes, rootCauses) {
|
|
280
|
+
const counts = new Map();
|
|
281
|
+
const add = (file) => {
|
|
282
|
+
if (!file)
|
|
283
|
+
return;
|
|
284
|
+
counts.set(file, (counts.get(file) ?? 0) + 1);
|
|
285
|
+
};
|
|
286
|
+
for (const hole of holes) {
|
|
287
|
+
add(hole.source?.[0]);
|
|
288
|
+
add(hole.primaryBlocker?.sourceFrame?.[1]);
|
|
289
|
+
for (const owner of hole.renderedBy)
|
|
290
|
+
add(owner.source?.[0]);
|
|
291
|
+
}
|
|
292
|
+
for (const cause of rootCauses)
|
|
293
|
+
add(cause.sourceFrame?.[1]);
|
|
294
|
+
return [...counts.entries()]
|
|
295
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
296
|
+
.map(([file]) => file)
|
|
297
|
+
.slice(0, 12);
|
|
298
|
+
}
|
|
299
|
+
function compareBoundaryInsights(a, b) {
|
|
300
|
+
return (b.actionability - a.actionability ||
|
|
301
|
+
boundaryKindWeight(b.boundaryKind) - boundaryKindWeight(a.boundaryKind) ||
|
|
302
|
+
(b.blockers.length - a.blockers.length) ||
|
|
303
|
+
(a.name ?? "").localeCompare(b.name ?? ""));
|
|
304
|
+
}
|
|
305
|
+
function compareActionableBlockers(a, b) {
|
|
306
|
+
return (b.actionability - a.actionability ||
|
|
307
|
+
blockerKindWeight(b.kind) - blockerKindWeight(a.kind) ||
|
|
308
|
+
a.name.localeCompare(b.name));
|
|
309
|
+
}
|
|
310
|
+
function boundaryKindWeight(kind) {
|
|
311
|
+
if (kind === "route-segment")
|
|
312
|
+
return 3;
|
|
313
|
+
if (kind === "explicit-suspense")
|
|
314
|
+
return 2;
|
|
315
|
+
return 1;
|
|
316
|
+
}
|
|
317
|
+
function blockerKindWeight(kind) {
|
|
318
|
+
switch (kind) {
|
|
319
|
+
case "client-hook": return 7;
|
|
320
|
+
case "request-api": return 6;
|
|
321
|
+
case "server-fetch": return 5;
|
|
322
|
+
case "cache": return 4;
|
|
323
|
+
case "stream": return 3;
|
|
324
|
+
case "unknown": return 2;
|
|
325
|
+
case "framework": return 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function blockerActionability(kind) {
|
|
329
|
+
switch (kind) {
|
|
330
|
+
case "client-hook": return 90;
|
|
331
|
+
case "request-api": return 88;
|
|
332
|
+
case "server-fetch": return 82;
|
|
333
|
+
case "cache": return 74;
|
|
334
|
+
case "stream": return 60;
|
|
335
|
+
case "unknown": return 35;
|
|
336
|
+
case "framework": return 18;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function inferBoundaryKind(boundary) {
|
|
340
|
+
const ownerNames = boundary.owners.map((owner) => owner.name);
|
|
341
|
+
if ((boundary.name && boundary.name.endsWith("/")) ||
|
|
342
|
+
ownerNames.includes("LoadingBoundary") ||
|
|
343
|
+
ownerNames.includes("OuterLayoutRouter")) {
|
|
344
|
+
return "route-segment";
|
|
345
|
+
}
|
|
346
|
+
if (boundary.name?.includes("Suspense") ||
|
|
347
|
+
ownerNames.some((name) => name.includes("Suspense"))) {
|
|
348
|
+
return "explicit-suspense";
|
|
349
|
+
}
|
|
350
|
+
return "component";
|
|
351
|
+
}
|
|
352
|
+
function classifyBlocker(suspender, sourceFrame) {
|
|
353
|
+
const name = suspender.name.toLowerCase();
|
|
354
|
+
if (name === "usepathname" ||
|
|
355
|
+
name === "useparams" ||
|
|
356
|
+
name === "usesearchparams" ||
|
|
357
|
+
name === "useselectedlayoutsegments" ||
|
|
358
|
+
name === "useselectedlayoutsegment" ||
|
|
359
|
+
name === "userouter") {
|
|
360
|
+
return "client-hook";
|
|
361
|
+
}
|
|
362
|
+
if (name === "cookies" ||
|
|
363
|
+
name === "headers" ||
|
|
364
|
+
name === "connection" ||
|
|
365
|
+
name === "params" ||
|
|
366
|
+
name === "searchparams" ||
|
|
367
|
+
name === "draftmode") {
|
|
368
|
+
return "request-api";
|
|
369
|
+
}
|
|
370
|
+
if (name === "rsc stream")
|
|
371
|
+
return "stream";
|
|
372
|
+
if (name.includes("fetch"))
|
|
373
|
+
return "server-fetch";
|
|
374
|
+
if (name.includes("cache") || suspender.description.toLowerCase().includes("cache")) {
|
|
375
|
+
return "cache";
|
|
376
|
+
}
|
|
377
|
+
if (name.startsWith("use"))
|
|
378
|
+
return "client-hook";
|
|
379
|
+
if (sourceFrame && isFrameworkishPath(sourceFrame[1]))
|
|
380
|
+
return "framework";
|
|
381
|
+
return "unknown";
|
|
382
|
+
}
|
|
383
|
+
function suggestBlockerFix(kind) {
|
|
384
|
+
switch (kind) {
|
|
385
|
+
case "client-hook":
|
|
386
|
+
return "Move route hooks behind a smaller client Suspense or provide a real non-null loading fallback for this segment.";
|
|
387
|
+
case "request-api":
|
|
388
|
+
return "Push request-bound reads to a smaller server leaf, or cache around them so the parent shell can stay static.";
|
|
389
|
+
case "server-fetch":
|
|
390
|
+
return "Split static shell content from data widgets, then push the fetch into smaller Suspense leaves or cache it.";
|
|
391
|
+
case "cache":
|
|
392
|
+
return "This looks cache-related; check whether \"use cache\" or runtime prefetch can eliminate the suspension.";
|
|
393
|
+
case "stream":
|
|
394
|
+
return "A stream is still pending here; extract static siblings outside the boundary and push the stream consumer deeper.";
|
|
395
|
+
case "framework":
|
|
396
|
+
return "This currently looks framework-driven; find the nearest user-owned caller above it before changing code.";
|
|
397
|
+
case "unknown":
|
|
398
|
+
return "Inspect the nearest user-owned owner/awaiter frame and verify whether this suspender really belongs at this boundary.";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
function recommendBoundaryFix(boundaryKind, primaryBlocker, unknownSuspenders) {
|
|
402
|
+
if (boundaryKind === "route-segment" && primaryBlocker?.kind === "client-hook") {
|
|
403
|
+
return {
|
|
404
|
+
kind: "check-loading-fallback",
|
|
405
|
+
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.",
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (primaryBlocker?.kind === "client-hook") {
|
|
409
|
+
return {
|
|
410
|
+
kind: "push-client-hooks-down",
|
|
411
|
+
text: "Push the hook-using client UI behind a smaller local Suspense boundary so the parent shell can prerender.",
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
if (primaryBlocker?.kind === "request-api" || primaryBlocker?.kind === "server-fetch") {
|
|
415
|
+
return {
|
|
416
|
+
kind: "push-request-io-down",
|
|
417
|
+
text: "Push the request-bound async work into a smaller leaf or split static siblings out of this boundary.",
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (primaryBlocker?.kind === "cache") {
|
|
421
|
+
return {
|
|
422
|
+
kind: "cache-or-runtime-prefetch",
|
|
423
|
+
text: "Check whether caching or runtime prefetch can move this personalized content into the shell.",
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
if (primaryBlocker?.kind === "stream") {
|
|
427
|
+
return {
|
|
428
|
+
kind: "extract-static-shell",
|
|
429
|
+
text: "Keep the stream behind Suspense, but extract any static shell content outside the boundary.",
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
if (primaryBlocker?.kind === "framework") {
|
|
433
|
+
return {
|
|
434
|
+
kind: "investigate-framework",
|
|
435
|
+
text: "The top blocker still looks framework-heavy. Find the nearest user-owned caller before changing boundary placement.",
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
if (unknownSuspenders) {
|
|
439
|
+
return {
|
|
440
|
+
kind: "investigate-unknown",
|
|
441
|
+
text: `React could not identify the suspender (${unknownSuspenders}). Investigate the nearest user-owned owner or awaiter frame.`,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
kind: "investigate-unknown",
|
|
446
|
+
text: "No primary blocker was identified. Inspect the boundary source and owner chain directly.",
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function pickPreferredFrame(stack) {
|
|
450
|
+
if (!stack || stack.length === 0)
|
|
451
|
+
return null;
|
|
452
|
+
return stack.find((frame) => !isFrameworkishPath(frame[1])) ?? stack[0];
|
|
453
|
+
}
|
|
454
|
+
function isFrameworkishPath(file) {
|
|
455
|
+
return file.includes("/node_modules/");
|
|
456
|
+
}
|
|
457
|
+
function buildBlockerKey(name, kind, sourceFrame) {
|
|
458
|
+
if (!sourceFrame)
|
|
459
|
+
return `${kind}:${name}:unknown`;
|
|
460
|
+
return `${kind}:${name}:${sourceFrame[1]}:${sourceFrame[2]}`;
|
|
461
|
+
}
|
|
462
|
+
function labelActionability(value) {
|
|
463
|
+
if (value >= 80)
|
|
464
|
+
return "high";
|
|
465
|
+
if (value >= 50)
|
|
466
|
+
return "medium";
|
|
467
|
+
return "low";
|
|
468
|
+
}
|
|
469
|
+
function escapeCell(value) {
|
|
470
|
+
return value.replaceAll("|", "\\|");
|
|
471
|
+
}
|
|
472
|
+
export function annotateReportWithPageMetadata(report, pageMetadata) {
|
|
473
|
+
const sessions = Array.isArray(pageMetadata?.sessions)
|
|
474
|
+
? (pageMetadata.sessions)
|
|
475
|
+
: [];
|
|
476
|
+
const loadingPaths = sessions.flatMap((session) => (session.segments ?? [])
|
|
477
|
+
.filter((segment) => segment.type === "boundary:loading" && typeof segment.path === "string")
|
|
478
|
+
.map((segment) => segment.path));
|
|
479
|
+
for (const hole of report.holes) {
|
|
480
|
+
if (hole.boundaryKind !== "route-segment")
|
|
481
|
+
continue;
|
|
482
|
+
const segmentName = normalizeBoundarySegmentName(hole.name);
|
|
483
|
+
if (!segmentName)
|
|
484
|
+
continue;
|
|
485
|
+
const exact = loadingPaths.find((path) => path.split("/").at(-2) === segmentName);
|
|
486
|
+
if (exact) {
|
|
487
|
+
hole.fallbackSource = {
|
|
488
|
+
kind: "loading-tsx",
|
|
489
|
+
path: exact,
|
|
490
|
+
confidence: "high",
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function normalizeBoundarySegmentName(name) {
|
|
496
|
+
if (!name)
|
|
497
|
+
return null;
|
|
498
|
+
return name.endsWith("/") ? name.slice(0, -1) : name;
|
|
499
|
+
}
|
|
101
500
|
function boundaryKey(b) {
|
|
102
501
|
if (b.jsxSource)
|
|
103
502
|
return `${b.jsxSource[0]}:${b.jsxSource[1]}:${b.jsxSource[2]}`;
|
|
@@ -106,13 +505,40 @@ function boundaryKey(b) {
|
|
|
106
505
|
async function resolveSources(boundaries, origin) {
|
|
107
506
|
for (const b of boundaries) {
|
|
108
507
|
if (b.jsxSource) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
508
|
+
b.jsxSource = await resolveOne(b.jsxSource, origin);
|
|
509
|
+
}
|
|
510
|
+
for (const o of b.owners) {
|
|
511
|
+
if (o.source) {
|
|
512
|
+
o.source = await resolveOne(o.source, origin);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
for (const s of b.suspendedBy) {
|
|
516
|
+
if (s.ownerStack)
|
|
517
|
+
s.ownerStack = await resolveStack(s.ownerStack, origin);
|
|
518
|
+
if (s.awaiterStack)
|
|
519
|
+
s.awaiterStack = await resolveStack(s.awaiterStack, origin);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
async function resolveOne(src, origin) {
|
|
524
|
+
const [file, line, col] = src;
|
|
525
|
+
const resolved = (await sourcemap.resolve(origin, file, line, col)) ??
|
|
526
|
+
(await sourcemap.resolveViaMap(origin, file, line, col));
|
|
527
|
+
return resolved ? [resolved.file, resolved.line, resolved.column] : src;
|
|
528
|
+
}
|
|
529
|
+
async function resolveStack(stack, origin) {
|
|
530
|
+
const out = [];
|
|
531
|
+
for (const [name, file, line, col] of stack) {
|
|
532
|
+
const resolved = (await sourcemap.resolve(origin, file, line, col)) ??
|
|
533
|
+
(await sourcemap.resolveViaMap(origin, file, line, col));
|
|
534
|
+
if (resolved) {
|
|
535
|
+
out.push([name, resolved.file, resolved.line, resolved.column]);
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
out.push([name, file, line, col]);
|
|
114
539
|
}
|
|
115
540
|
}
|
|
541
|
+
return out;
|
|
116
542
|
}
|
|
117
543
|
async function inPageSuspense(inspect) {
|
|
118
544
|
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
@@ -155,16 +581,17 @@ async function inPageSuspense(inspect) {
|
|
|
155
581
|
function collect(renderer) {
|
|
156
582
|
return new Promise((resolve) => {
|
|
157
583
|
const out = [];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
584
|
+
// Operations are emitted via hook.emit("operations", payload),
|
|
585
|
+
// NOT via window.postMessage.
|
|
586
|
+
const origEmit = hook.emit;
|
|
587
|
+
hook.emit = function (event, payload) {
|
|
588
|
+
if (event === "operations")
|
|
589
|
+
out.push(payload);
|
|
590
|
+
return origEmit.apply(this, arguments);
|
|
163
591
|
};
|
|
164
|
-
window.addEventListener("message", listener);
|
|
165
592
|
renderer.flushInitialOperations();
|
|
166
593
|
setTimeout(() => {
|
|
167
|
-
|
|
594
|
+
hook.emit = origEmit;
|
|
168
595
|
resolve(out);
|
|
169
596
|
}, 50);
|
|
170
597
|
});
|
|
@@ -283,7 +710,9 @@ async function inPageSuspense(inspect) {
|
|
|
283
710
|
duration: awaited.end && awaited.start ? Math.round(awaited.end - awaited.start) : 0,
|
|
284
711
|
env: awaited.env ?? entry?.env ?? null,
|
|
285
712
|
ownerName: awaited.owner?.displayName ?? null,
|
|
713
|
+
ownerStack: parseStack(awaited.owner?.stack ?? awaited.stack),
|
|
286
714
|
awaiterName: entry?.owner?.displayName ?? null,
|
|
715
|
+
awaiterStack: parseStack(entry?.owner?.stack ?? entry?.stack),
|
|
287
716
|
});
|
|
288
717
|
}
|
|
289
718
|
}
|
|
@@ -297,8 +726,16 @@ async function inPageSuspense(inspect) {
|
|
|
297
726
|
}
|
|
298
727
|
if (Array.isArray(data.owners)) {
|
|
299
728
|
for (const o of data.owners) {
|
|
300
|
-
if (o?.displayName)
|
|
301
|
-
|
|
729
|
+
if (o?.displayName) {
|
|
730
|
+
const src = Array.isArray(o.stack) && o.stack.length > 0 && Array.isArray(o.stack[0])
|
|
731
|
+
? [o.stack[0][1] || "(unknown)", o.stack[0][2], o.stack[0][3]]
|
|
732
|
+
: null;
|
|
733
|
+
boundary.owners.push({
|
|
734
|
+
name: o.displayName,
|
|
735
|
+
env: o.env ?? null,
|
|
736
|
+
source: src,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
302
739
|
}
|
|
303
740
|
}
|
|
304
741
|
if (Array.isArray(data.stack) && data.stack.length > 0) {
|
|
@@ -308,6 +745,13 @@ async function inPageSuspense(inspect) {
|
|
|
308
745
|
}
|
|
309
746
|
}
|
|
310
747
|
}
|
|
748
|
+
function parseStack(raw) {
|
|
749
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
750
|
+
return null;
|
|
751
|
+
return raw
|
|
752
|
+
.filter((f) => Array.isArray(f) && f.length >= 4)
|
|
753
|
+
.map((f) => [f[0] ?? "", f[1] ?? "", f[2] ?? 0, f[3] ?? 0]);
|
|
754
|
+
}
|
|
311
755
|
function preview(v) {
|
|
312
756
|
if (v == null)
|
|
313
757
|
return "";
|