@vercel/next-browser 0.2.0 → 0.4.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 +6 -4
- package/dist/browser.js +456 -31
- package/dist/cli.js +126 -7
- package/dist/daemon.js +20 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,7 +46,8 @@ Requires Node >= 20.
|
|
|
46
46
|
| Command | Description |
|
|
47
47
|
| ------------------ | --------------------------------------------------------- |
|
|
48
48
|
| `goto <url>` | Full-page navigation (new document load) |
|
|
49
|
-
| `ssr
|
|
49
|
+
| `ssr lock` | Block external scripts on all navigations (SSR-only mode) |
|
|
50
|
+
| `ssr unlock` | Re-enable external scripts |
|
|
50
51
|
| `push [path]` | Client-side navigation (interactive picker if no path) |
|
|
51
52
|
| `back` | Go back in history |
|
|
52
53
|
| `reload` | Reload current page |
|
|
@@ -62,7 +63,8 @@ Requires Node >= 20.
|
|
|
62
63
|
| `errors` | Build and runtime errors for the current page |
|
|
63
64
|
| `logs` | Recent dev server log output |
|
|
64
65
|
| `network [idx]` | List network requests, or inspect one (headers, body) |
|
|
65
|
-
| `
|
|
66
|
+
| `preview [caption]` | Screenshot + open in viewer window (accumulates across calls) |
|
|
67
|
+
| `screenshot` | Viewport PNG to a temp file (`--full-page` for entire page) |
|
|
66
68
|
|
|
67
69
|
### Interaction
|
|
68
70
|
|
|
@@ -138,8 +140,8 @@ $ next-browser perf http://localhost:3000/dashboard
|
|
|
138
140
|
$ next-browser ppr lock
|
|
139
141
|
locked
|
|
140
142
|
$ next-browser goto http://localhost:3000/dashboard
|
|
141
|
-
$ next-browser
|
|
142
|
-
/var/folders/.../next-browser-screenshot.png
|
|
143
|
+
$ next-browser preview "PPR shell — locked"
|
|
144
|
+
preview → /var/folders/.../next-browser-screenshot.png
|
|
143
145
|
$ next-browser ppr unlock
|
|
144
146
|
# PPR Shell Analysis — 131 boundaries: 3 dynamic holes, 128 static
|
|
145
147
|
|
package/dist/browser.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Module-level state: one browser context, one page, one PPR lock.
|
|
12
12
|
*/
|
|
13
|
-
import { readFileSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { join, resolve } from "node:path";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
16
|
import { chromium } from "playwright";
|
|
@@ -29,6 +29,23 @@ let context = null;
|
|
|
29
29
|
let page = null;
|
|
30
30
|
let profileDirPath = null;
|
|
31
31
|
let initialOrigin = null;
|
|
32
|
+
let ssrLocked = false;
|
|
33
|
+
let screenshotBrowser = null;
|
|
34
|
+
let screenshotPage = null;
|
|
35
|
+
let screenshotEntries = [];
|
|
36
|
+
/** Install or remove the script-blocking route handler based on ssrLocked. */
|
|
37
|
+
async function syncSsrRoutes() {
|
|
38
|
+
if (!page)
|
|
39
|
+
return;
|
|
40
|
+
await page.unrouteAll({ behavior: "wait" });
|
|
41
|
+
if (ssrLocked) {
|
|
42
|
+
await page.route("**/*", (route) => {
|
|
43
|
+
if (route.request().resourceType() === "script")
|
|
44
|
+
return route.abort();
|
|
45
|
+
return route.continue();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
32
49
|
// ── Browser lifecycle ────────────────────────────────────────────────────────
|
|
33
50
|
/**
|
|
34
51
|
* Launch the browser (if not already open) and optionally navigate to a URL.
|
|
@@ -36,11 +53,12 @@ let initialOrigin = null;
|
|
|
36
53
|
* reuse the existing context.
|
|
37
54
|
*/
|
|
38
55
|
export async function open(url) {
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
page = context.pages()[0] ?? (await context.newPage());
|
|
42
|
-
net.attach(page);
|
|
56
|
+
if (context) {
|
|
57
|
+
await close();
|
|
43
58
|
}
|
|
59
|
+
context = await launch();
|
|
60
|
+
page = context.pages()[0] ?? (await context.newPage());
|
|
61
|
+
net.attach(page);
|
|
44
62
|
if (url) {
|
|
45
63
|
initialOrigin = new URL(url).origin;
|
|
46
64
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
@@ -59,11 +77,16 @@ export async function cookies(cookies, domain) {
|
|
|
59
77
|
}
|
|
60
78
|
/** Close the browser and reset all state. */
|
|
61
79
|
export async function close() {
|
|
80
|
+
await screenshotBrowser?.close().catch(() => { });
|
|
81
|
+
screenshotBrowser = null;
|
|
82
|
+
screenshotPage = null;
|
|
83
|
+
screenshotEntries = [];
|
|
62
84
|
await context?.close();
|
|
63
85
|
context = null;
|
|
64
86
|
page = null;
|
|
65
87
|
release = null;
|
|
66
88
|
settled = null;
|
|
89
|
+
ssrLocked = false;
|
|
67
90
|
// Clean up temp profile directory.
|
|
68
91
|
if (profileDirPath) {
|
|
69
92
|
const { rmSync } = await import("node:fs");
|
|
@@ -88,7 +111,7 @@ export function lock() {
|
|
|
88
111
|
if (!page)
|
|
89
112
|
throw new Error("browser not open");
|
|
90
113
|
if (release)
|
|
91
|
-
|
|
114
|
+
return Promise.resolve();
|
|
92
115
|
return new Promise((locked) => {
|
|
93
116
|
settled = instant(page, () => {
|
|
94
117
|
locked();
|
|
@@ -225,6 +248,29 @@ async function waitForDevToolsReconnect(p) {
|
|
|
225
248
|
await new Promise((r) => setTimeout(r, 200));
|
|
226
249
|
}
|
|
227
250
|
}
|
|
251
|
+
// ── SSR lock/unlock ──────────────────────────────────────────────────────────
|
|
252
|
+
//
|
|
253
|
+
// While SSR-locked, every navigation blocks external script resources so the
|
|
254
|
+
// page renders only the server-side HTML shell (no React hydration, no client
|
|
255
|
+
// bundles). Useful for inspecting raw SSR output across multiple navigations.
|
|
256
|
+
/** Enter SSR-locked mode. All subsequent navigations block external scripts. */
|
|
257
|
+
export async function ssrLock() {
|
|
258
|
+
if (!page)
|
|
259
|
+
throw new Error("browser not open");
|
|
260
|
+
if (ssrLocked)
|
|
261
|
+
return;
|
|
262
|
+
ssrLocked = true;
|
|
263
|
+
await syncSsrRoutes();
|
|
264
|
+
}
|
|
265
|
+
/** Exit SSR-locked mode. Re-enables external scripts. */
|
|
266
|
+
export async function ssrUnlock() {
|
|
267
|
+
if (!page)
|
|
268
|
+
throw new Error("browser not open");
|
|
269
|
+
if (!ssrLocked)
|
|
270
|
+
return;
|
|
271
|
+
ssrLocked = false;
|
|
272
|
+
await syncSsrRoutes();
|
|
273
|
+
}
|
|
228
274
|
// ── Navigation ───────────────────────────────────────────────────────────────
|
|
229
275
|
/** Hard reload the current page. Returns the URL after reload. */
|
|
230
276
|
export async function reload() {
|
|
@@ -412,30 +458,9 @@ export async function push(path) {
|
|
|
412
458
|
/** Full-page navigation (new document load). Resolves relative URLs against the current page. */
|
|
413
459
|
export async function goto(url) {
|
|
414
460
|
if (!page)
|
|
415
|
-
|
|
416
|
-
await page.unrouteAll({ behavior: "wait" });
|
|
417
|
-
const target = new URL(url, page.url()).href;
|
|
418
|
-
initialOrigin = new URL(target).origin;
|
|
419
|
-
await page.goto(target, { waitUntil: "domcontentloaded" });
|
|
420
|
-
return target;
|
|
421
|
-
}
|
|
422
|
-
/**
|
|
423
|
-
* Navigate like goto but block external script resources.
|
|
424
|
-
* The HTML loads and inline <script> blocks still execute, but external JS
|
|
425
|
-
* bundles (React, hydration, etc.) are aborted. Shows the SSR shell.
|
|
426
|
-
*/
|
|
427
|
-
export async function ssrGoto(url) {
|
|
428
|
-
if (!page)
|
|
429
|
-
throw new Error("browser not open");
|
|
461
|
+
await open(undefined);
|
|
430
462
|
const target = new URL(url, page.url()).href;
|
|
431
463
|
initialOrigin = new URL(target).origin;
|
|
432
|
-
// Clear any stale route handlers from previous ssr-goto calls.
|
|
433
|
-
await page.unrouteAll({ behavior: "wait" });
|
|
434
|
-
await page.route("**/*", (route) => {
|
|
435
|
-
if (route.request().resourceType() === "script")
|
|
436
|
-
return route.abort();
|
|
437
|
-
return route.continue();
|
|
438
|
-
});
|
|
439
464
|
await page.goto(target, { waitUntil: "domcontentloaded" });
|
|
440
465
|
return target;
|
|
441
466
|
}
|
|
@@ -492,15 +517,71 @@ async function formatSource([file, line, col]) {
|
|
|
492
517
|
return `source: ${file}:${line}:${col}`;
|
|
493
518
|
}
|
|
494
519
|
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
495
|
-
/**
|
|
496
|
-
|
|
520
|
+
/** Render screenshot entries as HTML and refresh (or launch) the log window.
|
|
521
|
+
* No-op in headless mode. */
|
|
522
|
+
async function refreshScreenshotLog() {
|
|
523
|
+
if (process.env.NEXT_BROWSER_HEADLESS)
|
|
524
|
+
return;
|
|
525
|
+
const entriesHtml = screenshotEntries
|
|
526
|
+
.map((e) => {
|
|
527
|
+
const header = `<div style="padding:4px 12px;display:flex;align-items:baseline;gap:8px">` +
|
|
528
|
+
(e.caption
|
|
529
|
+
? `<span style="font-size:14px">${escapeHtml(e.caption)}</span>`
|
|
530
|
+
: "") +
|
|
531
|
+
`<span style="font-size:11px;opacity:0.5">${escapeHtml(e.timestamp)}</span>` +
|
|
532
|
+
`</div>`;
|
|
533
|
+
return header + `<img src="data:image/png;base64,${e.imgData}" style="display:block;max-width:100%">`;
|
|
534
|
+
})
|
|
535
|
+
.join(`<hr style="border:none;border-top:1px solid #333;margin:12px 0">`);
|
|
536
|
+
const html = `<html><head><title>Screenshot Log</title></head>` +
|
|
537
|
+
`<body style="margin:0;background:#111;color:#fff;font-family:system-ui">` +
|
|
538
|
+
`<div style="padding:8px 12px;font-size:11px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">Screenshot Log</div>` +
|
|
539
|
+
`${entriesHtml}` +
|
|
540
|
+
`</body></html>`;
|
|
541
|
+
const htmlPath = join(tmpdir(), `next-browser-screenshots-${process.pid}.html`);
|
|
542
|
+
writeFileSync(htmlPath, html);
|
|
543
|
+
const target = `file://${htmlPath}`;
|
|
544
|
+
// Reuse existing log window, or launch a new one.
|
|
545
|
+
if (screenshotPage && !screenshotPage.isClosed()) {
|
|
546
|
+
try {
|
|
547
|
+
await screenshotPage.goto(target);
|
|
548
|
+
await screenshotPage.bringToFront();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// Window was closed by user — fall through to launch a new one.
|
|
553
|
+
await screenshotBrowser?.close().catch(() => { });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const { mkdtempSync } = await import("node:fs");
|
|
557
|
+
const userDataDir = mkdtempSync(join(tmpdir(), "nb-screenshots-"));
|
|
558
|
+
const ctx = await chromium.launchPersistentContext(userDataDir, {
|
|
559
|
+
headless: false,
|
|
560
|
+
args: [`--app=${target}`, "--window-size=820,640"],
|
|
561
|
+
viewport: null,
|
|
562
|
+
});
|
|
563
|
+
screenshotBrowser = ctx;
|
|
564
|
+
screenshotPage = ctx.pages()[0] ?? (await ctx.waitForEvent("page"));
|
|
565
|
+
await screenshotPage.waitForLoadState();
|
|
566
|
+
await screenshotPage.bringToFront();
|
|
567
|
+
}
|
|
568
|
+
function escapeHtml(s) {
|
|
569
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
570
|
+
}
|
|
571
|
+
/** Screenshot saved to a temp file. Opens the Screenshot Log window in headed mode.
|
|
572
|
+
* Returns the file path. */
|
|
573
|
+
export async function screenshot(opts) {
|
|
497
574
|
if (!page)
|
|
498
575
|
throw new Error("browser not open");
|
|
499
576
|
await hideDevOverlay();
|
|
500
577
|
const { join } = await import("node:path");
|
|
501
578
|
const { tmpdir } = await import("node:os");
|
|
502
579
|
const path = join(tmpdir(), `next-browser-${Date.now()}.png`);
|
|
503
|
-
await page.screenshot({ path });
|
|
580
|
+
await page.screenshot({ path, fullPage: opts?.fullPage });
|
|
581
|
+
const imgData = readFileSync(path).toString("base64");
|
|
582
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
583
|
+
screenshotEntries.unshift({ caption: opts?.caption, imgData, timestamp });
|
|
584
|
+
await refreshScreenshotLog();
|
|
504
585
|
return path;
|
|
505
586
|
}
|
|
506
587
|
/** Remove Next.js devtools overlay from the page before screenshots. */
|
|
@@ -690,6 +771,312 @@ export async function mcp(tool, args) {
|
|
|
690
771
|
const origin = initialOrigin ?? new URL(page.url()).origin;
|
|
691
772
|
return nextMcp.call(origin, tool, args);
|
|
692
773
|
}
|
|
774
|
+
/** Return browser console output captured by the init-script interceptor. */
|
|
775
|
+
export async function browserLogs() {
|
|
776
|
+
if (!page)
|
|
777
|
+
throw new Error("browser not open");
|
|
778
|
+
return page.evaluate(() => window.__NEXT_BROWSER_CONSOLE_LOGS__ ?? []);
|
|
779
|
+
}
|
|
780
|
+
// ── Render Profiling ────────────────────────────────────────────────────────
|
|
781
|
+
/**
|
|
782
|
+
* The browser-side script that installs the onCommitFiberRoot hook.
|
|
783
|
+
* Extracted so it can be used by both rendersStart (page.evaluate)
|
|
784
|
+
* and rendersAuto (addInitScript).
|
|
785
|
+
*/
|
|
786
|
+
const rendersHookScript = `(() => {
|
|
787
|
+
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
788
|
+
if (!hook || window.__NEXT_BROWSER_RENDERS_ACTIVE__) return;
|
|
789
|
+
|
|
790
|
+
const MAX_COMPONENTS = 200;
|
|
791
|
+
const data = {};
|
|
792
|
+
const fps = { frames: [], last: 0, rafId: 0 };
|
|
793
|
+
|
|
794
|
+
window.__NEXT_BROWSER_RENDERS__ = data;
|
|
795
|
+
window.__NEXT_BROWSER_RENDERS_FPS__ = fps;
|
|
796
|
+
window.__NEXT_BROWSER_RENDERS_START__ = performance.now();
|
|
797
|
+
window.__NEXT_BROWSER_RENDERS_ACTIVE__ = true;
|
|
798
|
+
|
|
799
|
+
// FPS tracking via requestAnimationFrame
|
|
800
|
+
function fpsLoop(now) {
|
|
801
|
+
if (fps.last > 0) fps.frames.push(now - fps.last);
|
|
802
|
+
fps.last = now;
|
|
803
|
+
fps.rafId = requestAnimationFrame(fpsLoop);
|
|
804
|
+
}
|
|
805
|
+
fps.rafId = requestAnimationFrame(fpsLoop);
|
|
806
|
+
|
|
807
|
+
const origOnCommit = hook.onCommitFiberRoot;
|
|
808
|
+
window.__NEXT_BROWSER_RENDERS_ORIG_COMMIT__ = origOnCommit;
|
|
809
|
+
|
|
810
|
+
hook.onCommitFiberRoot = function(rendererID, root) {
|
|
811
|
+
try { walkFiber(root.current); } catch {}
|
|
812
|
+
if (typeof origOnCommit === "function") {
|
|
813
|
+
return origOnCommit.apply(hook, arguments);
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
function getName(fiber) {
|
|
818
|
+
if (!fiber.type || typeof fiber.type === "string") return null;
|
|
819
|
+
return fiber.type.displayName || fiber.type.name || null;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function brief(val) {
|
|
823
|
+
if (val === undefined) return "undefined";
|
|
824
|
+
if (val === null) return "null";
|
|
825
|
+
if (typeof val === "function") return "fn()";
|
|
826
|
+
if (typeof val === "string") return val.length > 60 ? '"' + val.slice(0, 57) + '..."' : '"' + val + '"';
|
|
827
|
+
if (typeof val === "number" || typeof val === "boolean") return String(val);
|
|
828
|
+
if (Array.isArray(val)) return "Array(" + val.length + ")";
|
|
829
|
+
if (typeof val === "object") {
|
|
830
|
+
try {
|
|
831
|
+
const keys = Object.keys(val);
|
|
832
|
+
return keys.length <= 3 ? "{" + keys.join(", ") + "}" : "{" + keys.slice(0, 3).join(", ") + ", ...}";
|
|
833
|
+
} catch { return "{...}"; }
|
|
834
|
+
}
|
|
835
|
+
return String(val).slice(0, 40);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function getChanges(fiber) {
|
|
839
|
+
const changes = [];
|
|
840
|
+
const alt = fiber.alternate;
|
|
841
|
+
if (!alt) { changes.push({ type: "mount" }); return changes; }
|
|
842
|
+
|
|
843
|
+
// Props
|
|
844
|
+
if (fiber.memoizedProps !== alt.memoizedProps) {
|
|
845
|
+
const curr = fiber.memoizedProps || {};
|
|
846
|
+
const prev = alt.memoizedProps || {};
|
|
847
|
+
const allKeys = new Set([...Object.keys(curr), ...Object.keys(prev)]);
|
|
848
|
+
for (const k of allKeys) {
|
|
849
|
+
if (k !== "children" && curr[k] !== prev[k]) {
|
|
850
|
+
changes.push({ type: "props", name: k, prev: brief(prev[k]), next: brief(curr[k]) });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// State — walk memoizedState linked list
|
|
856
|
+
if (fiber.memoizedState !== alt.memoizedState) {
|
|
857
|
+
let curr = fiber.memoizedState;
|
|
858
|
+
let prev = alt.memoizedState;
|
|
859
|
+
let hookIdx = 0;
|
|
860
|
+
while (curr || prev) {
|
|
861
|
+
if (curr?.memoizedState !== prev?.memoizedState) {
|
|
862
|
+
changes.push({
|
|
863
|
+
type: "state", name: "hook #" + hookIdx,
|
|
864
|
+
prev: brief(prev?.memoizedState), next: brief(curr?.memoizedState)
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
curr = curr?.next;
|
|
868
|
+
prev = prev?.next;
|
|
869
|
+
hookIdx++;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Context — walk dependencies linked list
|
|
874
|
+
if (fiber.dependencies?.firstContext) {
|
|
875
|
+
let ctx = fiber.dependencies.firstContext;
|
|
876
|
+
let altCtx = alt.dependencies?.firstContext;
|
|
877
|
+
while (ctx) {
|
|
878
|
+
if (!altCtx || ctx.memoizedValue !== altCtx?.memoizedValue) {
|
|
879
|
+
const ctxName = ctx.context?.displayName || ctx.context?.Provider?.displayName || "unknown";
|
|
880
|
+
changes.push({
|
|
881
|
+
type: "context", name: ctxName,
|
|
882
|
+
prev: brief(altCtx?.memoizedValue), next: brief(ctx.memoizedValue)
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
ctx = ctx.next;
|
|
886
|
+
altCtx = altCtx?.next;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (changes.length === 0) {
|
|
891
|
+
// Find the nearest parent component name
|
|
892
|
+
let parent = fiber.return;
|
|
893
|
+
while (parent) {
|
|
894
|
+
const pName = getName(parent);
|
|
895
|
+
if (pName) {
|
|
896
|
+
const suffix = !parent.alternate ? " (mount)" : "";
|
|
897
|
+
changes.push({ type: "parent", name: pName + suffix });
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
parent = parent.return;
|
|
901
|
+
}
|
|
902
|
+
if (changes.length === 0) changes.push({ type: "parent", name: "unknown" });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return changes;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function childrenTime(fiber) {
|
|
909
|
+
let t = 0;
|
|
910
|
+
let child = fiber.child;
|
|
911
|
+
while (child) {
|
|
912
|
+
if (typeof child.actualDuration === "number") t += child.actualDuration;
|
|
913
|
+
child = child.sibling;
|
|
914
|
+
}
|
|
915
|
+
return t;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function hasDomMutation(fiber) {
|
|
919
|
+
// Check if this fiber or any host (DOM) child has the Mutation flag (4)
|
|
920
|
+
// or Placement flag (2) or Update flag (4) in subtreeFlags/flags
|
|
921
|
+
if (!fiber.alternate) return true; // mount always mutates
|
|
922
|
+
let child = fiber.child;
|
|
923
|
+
while (child) {
|
|
924
|
+
if (typeof child.type === "string" && (child.flags & 6) > 0) return true;
|
|
925
|
+
child = child.sibling;
|
|
926
|
+
}
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function walkFiber(fiber) {
|
|
931
|
+
if (!fiber) return;
|
|
932
|
+
|
|
933
|
+
const tag = fiber.tag;
|
|
934
|
+
if (tag === 0 || tag === 1 || tag === 2 || tag === 11 || tag === 15) {
|
|
935
|
+
const didRender =
|
|
936
|
+
fiber.alternate === null ||
|
|
937
|
+
fiber.flags > 0 ||
|
|
938
|
+
fiber.memoizedProps !== fiber.alternate?.memoizedProps ||
|
|
939
|
+
fiber.memoizedState !== fiber.alternate?.memoizedState;
|
|
940
|
+
|
|
941
|
+
if (didRender) {
|
|
942
|
+
const name = getName(fiber);
|
|
943
|
+
if (name) {
|
|
944
|
+
if (!(name in data) && Object.keys(data).length >= MAX_COMPONENTS) {
|
|
945
|
+
// skip — at cap
|
|
946
|
+
} else {
|
|
947
|
+
if (!data[name]) {
|
|
948
|
+
data[name] = {
|
|
949
|
+
count: 0, mounts: 0, totalTime: 0, selfTime: 0,
|
|
950
|
+
domMutations: 0, changes: [],
|
|
951
|
+
_instances: new Set()
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
data[name].count++;
|
|
955
|
+
if (!fiber.alternate) data[name].mounts++;
|
|
956
|
+
if (!data[name]._instances.has(fiber)) {
|
|
957
|
+
data[name]._instances.add(fiber);
|
|
958
|
+
if (fiber.alternate) data[name]._instances.add(fiber.alternate);
|
|
959
|
+
}
|
|
960
|
+
if (typeof fiber.actualDuration === "number") {
|
|
961
|
+
data[name].totalTime += fiber.actualDuration;
|
|
962
|
+
data[name].selfTime += Math.max(0, fiber.actualDuration - childrenTime(fiber));
|
|
963
|
+
}
|
|
964
|
+
if (hasDomMutation(fiber)) data[name].domMutations++;
|
|
965
|
+
const ch = getChanges(fiber);
|
|
966
|
+
// Keep last 50 change entries per component to cap memory
|
|
967
|
+
for (const c of ch) {
|
|
968
|
+
if (data[name].changes.length < 50) data[name].changes.push(c);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
walkFiber(fiber.child);
|
|
976
|
+
walkFiber(fiber.sibling);
|
|
977
|
+
}
|
|
978
|
+
})()`;
|
|
979
|
+
/**
|
|
980
|
+
* Start recording React re-renders by hooking into onCommitFiberRoot.
|
|
981
|
+
* Installs via both addInitScript (survives navigations, captures mount)
|
|
982
|
+
* and page.evaluate (activates immediately on current page).
|
|
983
|
+
*/
|
|
984
|
+
export async function rendersStart() {
|
|
985
|
+
if (!page)
|
|
986
|
+
throw new Error("browser not open");
|
|
987
|
+
const ctx = page.context();
|
|
988
|
+
await ctx.addInitScript(rendersHookScript);
|
|
989
|
+
await page.evaluate(rendersHookScript);
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Stop recording and return a per-component render profile with raw data.
|
|
993
|
+
*/
|
|
994
|
+
export async function rendersStop() {
|
|
995
|
+
if (!page)
|
|
996
|
+
throw new Error("browser not open");
|
|
997
|
+
return page.evaluate(() => {
|
|
998
|
+
const active = window.__NEXT_BROWSER_RENDERS_ACTIVE__;
|
|
999
|
+
if (!active)
|
|
1000
|
+
throw new Error("renders recording not active — run `renders start` first");
|
|
1001
|
+
const data = window.__NEXT_BROWSER_RENDERS__;
|
|
1002
|
+
const startTime = window.__NEXT_BROWSER_RENDERS_START__;
|
|
1003
|
+
const elapsed = performance.now() - startTime;
|
|
1004
|
+
// Collect FPS data
|
|
1005
|
+
const fpsData = window.__NEXT_BROWSER_RENDERS_FPS__;
|
|
1006
|
+
let fpsStats = { avg: 0, min: 0, max: 0, drops: 0 };
|
|
1007
|
+
if (fpsData) {
|
|
1008
|
+
cancelAnimationFrame(fpsData.rafId);
|
|
1009
|
+
if (fpsData.frames.length > 0) {
|
|
1010
|
+
const fpsSamples = fpsData.frames.map((dt) => dt > 0 ? 1000 / dt : 0);
|
|
1011
|
+
const sum = fpsSamples.reduce((a, b) => a + b, 0);
|
|
1012
|
+
fpsStats = {
|
|
1013
|
+
avg: Math.round(sum / fpsSamples.length),
|
|
1014
|
+
min: Math.round(Math.min(...fpsSamples)),
|
|
1015
|
+
max: Math.round(Math.max(...fpsSamples)),
|
|
1016
|
+
drops: fpsSamples.filter((f) => f < 30).length,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
// Restore original hook
|
|
1021
|
+
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
1022
|
+
const orig = window.__NEXT_BROWSER_RENDERS_ORIG_COMMIT__;
|
|
1023
|
+
if (hook) {
|
|
1024
|
+
hook.onCommitFiberRoot = orig || undefined;
|
|
1025
|
+
}
|
|
1026
|
+
delete window.__NEXT_BROWSER_RENDERS__;
|
|
1027
|
+
delete window.__NEXT_BROWSER_RENDERS_START__;
|
|
1028
|
+
delete window.__NEXT_BROWSER_RENDERS_ACTIVE__;
|
|
1029
|
+
delete window.__NEXT_BROWSER_RENDERS_ORIG_COMMIT__;
|
|
1030
|
+
delete window.__NEXT_BROWSER_RENDERS_FPS__;
|
|
1031
|
+
if (!data)
|
|
1032
|
+
return {
|
|
1033
|
+
elapsed: 0,
|
|
1034
|
+
fps: fpsStats,
|
|
1035
|
+
totalRenders: 0,
|
|
1036
|
+
totalMounts: 0,
|
|
1037
|
+
totalReRenders: 0,
|
|
1038
|
+
totalComponents: 0,
|
|
1039
|
+
components: [],
|
|
1040
|
+
};
|
|
1041
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
1042
|
+
const components = Object.entries(data)
|
|
1043
|
+
.map(([name, entry]) => {
|
|
1044
|
+
// Summarize changes: count by type+name for the table view
|
|
1045
|
+
const summary = {};
|
|
1046
|
+
for (const c of entry.changes) {
|
|
1047
|
+
const key = c.type === "props" ? "props." + c.name
|
|
1048
|
+
: c.type === "state" ? "state (" + c.name + ")"
|
|
1049
|
+
: c.type === "context" ? "context (" + c.name + ")"
|
|
1050
|
+
: c.type === "parent" ? "parent (" + c.name + ")"
|
|
1051
|
+
: c.type;
|
|
1052
|
+
summary[key] = (summary[key] || 0) + 1;
|
|
1053
|
+
}
|
|
1054
|
+
return {
|
|
1055
|
+
name,
|
|
1056
|
+
count: entry.count,
|
|
1057
|
+
mounts: entry.mounts,
|
|
1058
|
+
reRenders: entry.count - entry.mounts,
|
|
1059
|
+
instanceCount: entry._instances.size,
|
|
1060
|
+
totalTime: round(entry.totalTime),
|
|
1061
|
+
selfTime: round(entry.selfTime),
|
|
1062
|
+
domMutations: entry.domMutations,
|
|
1063
|
+
changes: entry.changes,
|
|
1064
|
+
changeSummary: summary,
|
|
1065
|
+
};
|
|
1066
|
+
})
|
|
1067
|
+
.sort((a, b) => b.totalTime - a.totalTime || b.count - a.count);
|
|
1068
|
+
const totalMounts = components.reduce((s, c) => s + c.mounts, 0);
|
|
1069
|
+
return {
|
|
1070
|
+
elapsed: round(elapsed / 1000),
|
|
1071
|
+
fps: fpsStats,
|
|
1072
|
+
totalRenders: components.reduce((s, c) => s + c.count, 0),
|
|
1073
|
+
totalMounts,
|
|
1074
|
+
totalReRenders: components.reduce((s, c) => s + c.reRenders, 0),
|
|
1075
|
+
totalComponents: components.length,
|
|
1076
|
+
components,
|
|
1077
|
+
};
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
693
1080
|
/** Get network request log, or detail for a specific request index. */
|
|
694
1081
|
export function network(idx) {
|
|
695
1082
|
return idx == null ? net.format() : net.detail(idx);
|
|
@@ -791,6 +1178,44 @@ async function launch() {
|
|
|
791
1178
|
return orig.apply(console, [label, ...args]);
|
|
792
1179
|
};
|
|
793
1180
|
});
|
|
1181
|
+
// Intercept console.log/warn/error/info to capture browser console output.
|
|
1182
|
+
// This works for both dev and prod builds — unlike `logs`/`errors` which
|
|
1183
|
+
// rely on the Next.js dev server MCP endpoint.
|
|
1184
|
+
await ctx.addInitScript(() => {
|
|
1185
|
+
const MAX = 500;
|
|
1186
|
+
const entries = [];
|
|
1187
|
+
window.__NEXT_BROWSER_CONSOLE_LOGS__ = entries;
|
|
1188
|
+
function safe(val) {
|
|
1189
|
+
if (val === undefined)
|
|
1190
|
+
return "undefined";
|
|
1191
|
+
if (val === null)
|
|
1192
|
+
return "null";
|
|
1193
|
+
if (val instanceof Error)
|
|
1194
|
+
return `${val.name}: ${val.message}`;
|
|
1195
|
+
if (typeof val === "object") {
|
|
1196
|
+
try {
|
|
1197
|
+
return JSON.stringify(val);
|
|
1198
|
+
}
|
|
1199
|
+
catch {
|
|
1200
|
+
return String(val);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return String(val);
|
|
1204
|
+
}
|
|
1205
|
+
for (const level of ["log", "warn", "error", "info"]) {
|
|
1206
|
+
const orig = console[level];
|
|
1207
|
+
console[level] = function (...args) {
|
|
1208
|
+
entries.push({
|
|
1209
|
+
level,
|
|
1210
|
+
args: args.map(safe).join(" "),
|
|
1211
|
+
timestamp: performance.now(),
|
|
1212
|
+
});
|
|
1213
|
+
if (entries.length > MAX)
|
|
1214
|
+
entries.splice(0, entries.length - MAX);
|
|
1215
|
+
return orig.apply(console, args);
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
794
1219
|
// Next.js devtools overlay is removed before each screenshot via hideDevOverlay().
|
|
795
1220
|
return ctx;
|
|
796
1221
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
3
5
|
import { send } from "./client.js";
|
|
4
6
|
const args = process.argv.slice(2);
|
|
5
7
|
const cmd = args[0];
|
|
@@ -120,16 +122,22 @@ if (cmd === "goto") {
|
|
|
120
122
|
const res = await send("goto", { url: arg });
|
|
121
123
|
exit(res, res.ok ? `→ ${res.data}` : "");
|
|
122
124
|
}
|
|
123
|
-
if (cmd === "ssr
|
|
124
|
-
const res = await send("ssr-
|
|
125
|
-
exit(res,
|
|
125
|
+
if (cmd === "ssr" && arg === "lock") {
|
|
126
|
+
const res = await send("ssr-lock");
|
|
127
|
+
exit(res, "ssr locked — external scripts blocked on all navigations");
|
|
128
|
+
}
|
|
129
|
+
if (cmd === "ssr" && arg === "unlock") {
|
|
130
|
+
const res = await send("ssr-unlock");
|
|
131
|
+
exit(res, "ssr unlocked — external scripts re-enabled");
|
|
126
132
|
}
|
|
127
133
|
if (cmd === "back") {
|
|
128
134
|
const res = await send("back");
|
|
129
135
|
exit(res, "back");
|
|
130
136
|
}
|
|
131
137
|
if (cmd === "screenshot") {
|
|
132
|
-
const
|
|
138
|
+
const fullPage = args.includes("--full-page");
|
|
139
|
+
const caption = args.slice(1).filter((a) => a !== "--full-page").join(" ") || undefined;
|
|
140
|
+
const res = await send("screenshot", { fullPage, caption });
|
|
133
141
|
exit(res, res.ok ? String(res.data) : "");
|
|
134
142
|
}
|
|
135
143
|
if (cmd === "snapshot") {
|
|
@@ -216,6 +224,113 @@ if (cmd === "logs") {
|
|
|
216
224
|
console.log(content || "(log file is empty)");
|
|
217
225
|
process.exit(0);
|
|
218
226
|
}
|
|
227
|
+
if (cmd === "browser-logs") {
|
|
228
|
+
const res = await send("browser-logs");
|
|
229
|
+
if (!res.ok)
|
|
230
|
+
exit(res, "");
|
|
231
|
+
const entries = res.data;
|
|
232
|
+
if (entries.length === 0) {
|
|
233
|
+
console.log("(no console output captured)");
|
|
234
|
+
process.exit(0);
|
|
235
|
+
}
|
|
236
|
+
const lines = entries.map((e) => `[${e.level.toUpperCase().padEnd(5)}] ${e.args}`);
|
|
237
|
+
const output = lines.join("\n");
|
|
238
|
+
if (output.length > 4000) {
|
|
239
|
+
const path = join(tmpdir(), `next-browser-console-${process.pid}.log`);
|
|
240
|
+
writeFileSync(path, output);
|
|
241
|
+
console.log(`(${entries.length} entries written to ${path})`);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
console.log(output);
|
|
245
|
+
}
|
|
246
|
+
process.exit(0);
|
|
247
|
+
}
|
|
248
|
+
if (cmd === "renders" && arg === "start") {
|
|
249
|
+
const res = await send("renders-start");
|
|
250
|
+
exit(res, "recording renders — interact with the page, then run `renders stop`");
|
|
251
|
+
}
|
|
252
|
+
if (cmd === "renders" && arg === "stop") {
|
|
253
|
+
const useJson = args.includes("--json");
|
|
254
|
+
const res = await send("renders-stop");
|
|
255
|
+
if (!res.ok)
|
|
256
|
+
exit(res, "");
|
|
257
|
+
const d = res.data;
|
|
258
|
+
if (d.components.length === 0) {
|
|
259
|
+
if (useJson)
|
|
260
|
+
console.log(JSON.stringify(d, null, 2));
|
|
261
|
+
else
|
|
262
|
+
console.log("(no renders captured)");
|
|
263
|
+
process.exit(0);
|
|
264
|
+
}
|
|
265
|
+
if (useJson) {
|
|
266
|
+
const output = JSON.stringify(d, null, 2);
|
|
267
|
+
if (output.length > 4000) {
|
|
268
|
+
const path = join(tmpdir(), `next-browser-renders-${process.pid}.json`);
|
|
269
|
+
writeFileSync(path, output);
|
|
270
|
+
console.log(path);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
console.log(output);
|
|
274
|
+
}
|
|
275
|
+
process.exit(0);
|
|
276
|
+
}
|
|
277
|
+
const lines = [
|
|
278
|
+
`# Render Profile — ${d.elapsed}s recording`,
|
|
279
|
+
`# ${d.totalRenders} renders (${d.totalMounts} mounts + ${d.totalReRenders} re-renders) across ${d.totalComponents} components`,
|
|
280
|
+
`# FPS: avg ${d.fps.avg}, min ${d.fps.min}, max ${d.fps.max}, drops (<30fps): ${d.fps.drops}`,
|
|
281
|
+
"",
|
|
282
|
+
"## Components by total render time",
|
|
283
|
+
];
|
|
284
|
+
const nameW = Math.max(9, ...d.components.slice(0, 50).map((c) => c.name.length));
|
|
285
|
+
const header = `| ${"Component".padEnd(nameW)} | Insts | Mounts | Re-renders | Total | Self | DOM | Top change reason |`;
|
|
286
|
+
const sep = `| ${"-".repeat(nameW)} | ----- | ------ | ---------- | -------- | -------- | ----- | -------------------------- |`;
|
|
287
|
+
lines.push(header, sep);
|
|
288
|
+
for (const c of d.components.slice(0, 50)) {
|
|
289
|
+
const total = c.totalTime > 0 ? `${c.totalTime}ms` : "—";
|
|
290
|
+
const self = c.selfTime > 0 ? `${c.selfTime}ms` : "—";
|
|
291
|
+
const dom = `${c.domMutations}/${c.count}`;
|
|
292
|
+
const topChange = Object.entries(c.changeSummary).sort((a, b) => b[1] - a[1])[0];
|
|
293
|
+
const changeStr = topChange ? topChange[0] : "—";
|
|
294
|
+
lines.push(`| ${c.name.padEnd(nameW)} | ${String(c.instanceCount).padStart(5)} | ${String(c.mounts).padStart(6)} | ${String(c.reRenders).padStart(10)} | ${total.padStart(8)} | ${self.padStart(8)} | ${dom.padStart(5)} | ${changeStr.padEnd(26)} |`);
|
|
295
|
+
}
|
|
296
|
+
if (d.components.length > 50) {
|
|
297
|
+
lines.push(`... and ${d.components.length - 50} more`);
|
|
298
|
+
}
|
|
299
|
+
// Detail: show prev→next values for top components with non-mount changes
|
|
300
|
+
const detailed = d.components
|
|
301
|
+
.filter((c) => c.changes.some((ch) => ch.type !== "mount" && ch.type !== "parent"))
|
|
302
|
+
.slice(0, 15);
|
|
303
|
+
if (detailed.length > 0) {
|
|
304
|
+
lines.push("", "## Change details (prev → next)");
|
|
305
|
+
for (const c of detailed) {
|
|
306
|
+
lines.push(` ${c.name}`);
|
|
307
|
+
// Deduplicate: show unique type+name combinations with their prev→next
|
|
308
|
+
const seen = new Set();
|
|
309
|
+
for (const ch of c.changes) {
|
|
310
|
+
if (ch.type === "mount" || ch.type === "parent")
|
|
311
|
+
continue;
|
|
312
|
+
const key = `${ch.type}:${ch.name}`;
|
|
313
|
+
if (seen.has(key))
|
|
314
|
+
continue;
|
|
315
|
+
seen.add(key);
|
|
316
|
+
const label = ch.type === "props" ? `props.${ch.name}`
|
|
317
|
+
: ch.type === "state" ? `state (${ch.name})`
|
|
318
|
+
: `context (${ch.name})`;
|
|
319
|
+
lines.push(` ${label}: ${ch.prev ?? "?"} → ${ch.next ?? "?"}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const output = lines.join("\n");
|
|
324
|
+
if (output.length > 4000) {
|
|
325
|
+
const path = join(tmpdir(), `next-browser-renders-${process.pid}.txt`);
|
|
326
|
+
writeFileSync(path, output);
|
|
327
|
+
console.log(`(${d.totalComponents} components written to ${path})`);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
console.log(output);
|
|
331
|
+
}
|
|
332
|
+
process.exit(0);
|
|
333
|
+
}
|
|
219
334
|
if (cmd === "action") {
|
|
220
335
|
const res = await send("mcp", { tool: "get_server_action_by_id", args: { actionId: arg } });
|
|
221
336
|
exit(res, res.ok ? json(res.data) : "");
|
|
@@ -317,11 +432,14 @@ function printUsage() {
|
|
|
317
432
|
" close close browser and daemon\n" +
|
|
318
433
|
"\n" +
|
|
319
434
|
" goto <url> full-page navigation (new document load)\n" +
|
|
320
|
-
" ssr
|
|
435
|
+
" ssr lock block external scripts on all navigations\n" +
|
|
436
|
+
" ssr unlock re-enable external scripts\n" +
|
|
321
437
|
" push [path] client-side navigation (interactive picker if no path)\n" +
|
|
322
438
|
" back go back in history\n" +
|
|
323
439
|
" reload reload current page\n" +
|
|
324
440
|
" perf [url] profile page load (CWVs + React hydration timing)\n" +
|
|
441
|
+
" renders start start recording React re-renders\n" +
|
|
442
|
+
" renders stop [--json] stop and print render profile\n" +
|
|
325
443
|
" restart-server restart the Next.js dev server (clears fs cache)\n" +
|
|
326
444
|
"\n" +
|
|
327
445
|
" ppr lock enter PPR instant-navigation mode\n" +
|
|
@@ -331,7 +449,7 @@ function printUsage() {
|
|
|
331
449
|
" tree <id> inspect component (props, hooks, state, source)\n" +
|
|
332
450
|
"\n" +
|
|
333
451
|
" viewport [WxH] show or set viewport size (e.g. 1280x720)\n" +
|
|
334
|
-
" screenshot
|
|
452
|
+
" screenshot [caption] [--full-page] screenshot + Screenshot Log\n" +
|
|
335
453
|
" snapshot accessibility tree with interactive refs\n" +
|
|
336
454
|
" click <ref|sel> click an element (real pointer events)\n" +
|
|
337
455
|
" fill <ref|sel> <v> fill a text input\n" +
|
|
@@ -341,6 +459,7 @@ function printUsage() {
|
|
|
341
459
|
"\n" +
|
|
342
460
|
" errors show build/runtime errors\n" +
|
|
343
461
|
" logs show recent dev server log output\n" +
|
|
462
|
+
" browser-logs show browser console output (log/warn/error/info)\n" +
|
|
344
463
|
" network [idx] list network requests, or inspect one\n" +
|
|
345
464
|
"\n" +
|
|
346
465
|
" page show current page segments and router info\n" +
|
package/dist/daemon.js
CHANGED
|
@@ -61,12 +61,24 @@ async function run(cmd) {
|
|
|
61
61
|
const data = await browser.perf(cmd.url);
|
|
62
62
|
return { ok: true, data };
|
|
63
63
|
}
|
|
64
|
+
if (cmd.action === "browser-logs") {
|
|
65
|
+
const data = await browser.browserLogs();
|
|
66
|
+
return { ok: true, data };
|
|
67
|
+
}
|
|
68
|
+
if (cmd.action === "renders-start") {
|
|
69
|
+
await browser.rendersStart();
|
|
70
|
+
return { ok: true };
|
|
71
|
+
}
|
|
72
|
+
if (cmd.action === "renders-stop") {
|
|
73
|
+
const data = await browser.rendersStop();
|
|
74
|
+
return { ok: true, data };
|
|
75
|
+
}
|
|
64
76
|
if (cmd.action === "restart") {
|
|
65
77
|
const data = await browser.restart();
|
|
66
78
|
return { ok: true, data };
|
|
67
79
|
}
|
|
68
80
|
if (cmd.action === "screenshot") {
|
|
69
|
-
const data = await browser.screenshot();
|
|
81
|
+
const data = await browser.screenshot({ fullPage: cmd.fullPage, caption: cmd.caption });
|
|
70
82
|
return { ok: true, data };
|
|
71
83
|
}
|
|
72
84
|
if (cmd.action === "links") {
|
|
@@ -81,9 +93,13 @@ async function run(cmd) {
|
|
|
81
93
|
const data = await browser.goto(cmd.url);
|
|
82
94
|
return { ok: true, data };
|
|
83
95
|
}
|
|
84
|
-
if (cmd.action === "ssr-
|
|
85
|
-
|
|
86
|
-
return { ok: true
|
|
96
|
+
if (cmd.action === "ssr-lock") {
|
|
97
|
+
await browser.ssrLock();
|
|
98
|
+
return { ok: true };
|
|
99
|
+
}
|
|
100
|
+
if (cmd.action === "ssr-unlock") {
|
|
101
|
+
await browser.ssrUnlock();
|
|
102
|
+
return { ok: true };
|
|
87
103
|
}
|
|
88
104
|
if (cmd.action === "back") {
|
|
89
105
|
await browser.back();
|