@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 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
- let locked = await suspenseTree.snapshot(page).catch(() => []);
130
- // For initial-load (goto) under lock, DevTools may not be connected
131
- // the shell uses a production-like renderer. Fall back to counting
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
- return suspenseTree.formatAnalysis(unlocked, locked, origin);
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
- /** Full-page screenshot saved to a temp file. Returns the file path. */
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, fullPage: true });
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(arg).hostname;
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: arg });
34
- exit(res, `opened → ${arg} (${cookies.length} cookies for ${domain})`);
34
+ await send("goto", { url });
35
+ exit(res, `opened → ${url} (${cookies.length} cookies for ${domain})`);
35
36
  }
36
- const res = await send("open", { url: arg });
37
- exit(res, `opened → ${arg}`);
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
- exit(res, res.ok && res.data ? `unlocked\n\n${res.data}` : "unlocked");
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
- return text ? JSON.parse(text) : parsed.result;
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
- if (fromDom) {
35
- const dynamicUnlocked = unlocked.filter((b) => b.parentID !== 0 && b.suspendedBy.length > 0);
36
- for (const lb of locked) {
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
- const totalBoundaries = fromDom ? holes.length + statics.length : locked.length;
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: ${holes.length} dynamic holes, ${statics.length} static`,
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 { shell, full } of holes) {
67
- const b = full ?? shell;
68
- const name = b.name?.startsWith("shell-hole") ? "(hole)" : (b.name ?? "(unnamed)");
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 (b.owners.length > 0)
72
- lines.push(` rendered by: ${b.owners.join(" > ")}`);
73
- if (shell.environments.length > 0)
74
- lines.push(` environments: ${shell.environments.join(", ")}`);
75
- if (full && full.suspendedBy.length > 0) {
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 s of full.suspendedBy) {
78
- const dur = s.duration > 0 ? ` (${s.duration}ms)` : "";
79
- const env = s.env ? ` [${s.env}]` : "";
80
- const owner = s.ownerName ? ` initiated by <${s.ownerName}>` : "";
81
- const awaiter = s.awaiterName ? ` awaited in <${s.awaiterName}>` : "";
82
- lines.push(` - ${s.name}: ${s.description || "(no description)"}${dur}${env}${owner}${awaiter}`);
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 (full?.unknownSuspenders) {
86
- lines.push(` suspenders unknown: ${full.unknownSuspenders}`);
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.jsxSource ? ` at ${b.jsxSource[0]}:${b.jsxSource[1]}:${b.jsxSource[2]}` : "";
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
- const [file, line, col] = b.jsxSource;
110
- const resolved = (await sourcemap.resolve(origin, file, line, col)) ??
111
- (await sourcemap.resolveViaMap(origin, file, line, col));
112
- if (resolved)
113
- b.jsxSource = [resolved.file, resolved.line, resolved.column];
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
- const listener = (e) => {
159
- const p = e.data?.payload;
160
- if (e.data?.source === "react-devtools-bridge" && p?.event === "operations") {
161
- out.push(p.payload);
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
- window.removeEventListener("message", listener);
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
- boundary.owners.push(o.displayName);
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 "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/next-browser",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Headed Playwright browser with React DevTools pre-loaded",
5
5
  "license": "MIT",
6
6
  "repository": {