@vercel/next-browser 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.js CHANGED
@@ -30,9 +30,9 @@ let page = null;
30
30
  let profileDirPath = null;
31
31
  let initialOrigin = null;
32
32
  let ssrLocked = false;
33
- let previewBrowser = null;
34
- let previewPage = null;
35
- let previewImages = [];
33
+ let screenshotBrowser = null;
34
+ let screenshotPage = null;
35
+ let screenshotEntries = [];
36
36
  /** Install or remove the script-blocking route handler based on ssrLocked. */
37
37
  async function syncSsrRoutes() {
38
38
  if (!page)
@@ -77,10 +77,10 @@ export async function cookies(cookies, domain) {
77
77
  }
78
78
  /** Close the browser and reset all state. */
79
79
  export async function close() {
80
- await previewBrowser?.close().catch(() => { });
81
- previewBrowser = null;
82
- previewPage = null;
83
- previewImages = [];
80
+ await screenshotBrowser?.close().catch(() => { });
81
+ screenshotBrowser = null;
82
+ screenshotPage = null;
83
+ screenshotEntries = [];
84
84
  await context?.close();
85
85
  context = null;
86
86
  page = null;
@@ -517,65 +517,59 @@ async function formatSource([file, line, col]) {
517
517
  return `source: ${file}:${line}:${col}`;
518
518
  }
519
519
  // ── Utilities ────────────────────────────────────────────────────────────────
520
- /** Take a screenshot and display it in a separate headed Chromium window.
521
- * Images accumulate across calls — use `clear` to reset. */
522
- export async function preview(caption, clear) {
523
- if (!page)
524
- throw new Error("browser not open");
525
- if (clear)
526
- previewImages = [];
527
- const path = await screenshot();
528
- const imgData = readFileSync(path).toString("base64");
529
- const timestamp = new Date().toLocaleTimeString();
530
- previewImages.unshift({ caption, imgData, timestamp });
531
- const imagesHtml = previewImages
532
- .map((img) => `<div style="padding:4px 12px;display:flex;align-items:baseline;gap:8px">` +
533
- (img.caption
534
- ? `<span style="font-size:14px">${escapeHtml(img.caption)}</span>`
535
- : "") +
536
- `<span style="font-size:11px;opacity:0.5">${escapeHtml(img.timestamp)}</span>` +
537
- `</div>` +
538
- `<img src="data:image/png;base64,${img.imgData}" style="display:block;max-width:100%">`)
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
+ })
539
535
  .join(`<hr style="border:none;border-top:1px solid #333;margin:12px 0">`);
540
- const html = `<html><head><title>next-browser preview</title></head>` +
536
+ const html = `<html><head><title>Screenshot Log</title></head>` +
541
537
  `<body style="margin:0;background:#111;color:#fff;font-family:system-ui">` +
542
- `<div style="padding:8px 12px;font-size:11px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">next-browser preview</div>` +
543
- `${imagesHtml}` +
538
+ `<div style="padding:8px 12px;font-size:11px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">Screenshot Log</div>` +
539
+ `${entriesHtml}` +
544
540
  `</body></html>`;
545
- const htmlPath = path.replace(/\.png$/, ".html");
541
+ const htmlPath = join(tmpdir(), `next-browser-screenshots-${process.pid}.html`);
546
542
  writeFileSync(htmlPath, html);
547
543
  const target = `file://${htmlPath}`;
548
- // Reuse existing preview window, or launch a new one.
549
- if (previewPage && !previewPage.isClosed()) {
544
+ // Reuse existing log window, or launch a new one.
545
+ if (screenshotPage && !screenshotPage.isClosed()) {
550
546
  try {
551
- await previewPage.goto(target);
552
- await previewPage.bringToFront();
553
- return path;
547
+ await screenshotPage.goto(target);
548
+ await screenshotPage.bringToFront();
549
+ return;
554
550
  }
555
551
  catch {
556
552
  // Window was closed by user — fall through to launch a new one.
557
- await previewBrowser?.close().catch(() => { });
553
+ await screenshotBrowser?.close().catch(() => { });
558
554
  }
559
555
  }
560
556
  const { mkdtempSync } = await import("node:fs");
561
- const { join } = await import("node:path");
562
- const { tmpdir } = await import("node:os");
563
- const userDataDir = mkdtempSync(join(tmpdir(), "nb-preview-"));
557
+ const userDataDir = mkdtempSync(join(tmpdir(), "nb-screenshots-"));
564
558
  const ctx = await chromium.launchPersistentContext(userDataDir, {
565
559
  headless: false,
566
560
  args: [`--app=${target}`, "--window-size=820,640"],
567
561
  viewport: null,
568
562
  });
569
- previewBrowser = ctx;
570
- previewPage = ctx.pages()[0] ?? (await ctx.waitForEvent("page"));
571
- await previewPage.waitForLoadState();
572
- await previewPage.bringToFront();
573
- return path;
563
+ screenshotBrowser = ctx;
564
+ screenshotPage = ctx.pages()[0] ?? (await ctx.waitForEvent("page"));
565
+ await screenshotPage.waitForLoadState();
566
+ await screenshotPage.bringToFront();
574
567
  }
575
568
  function escapeHtml(s) {
576
569
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
577
570
  }
578
- /** Screenshot saved to a temp file. Returns the file path. */
571
+ /** Screenshot saved to a temp file. Opens the Screenshot Log window in headed mode.
572
+ * Returns the file path. */
579
573
  export async function screenshot(opts) {
580
574
  if (!page)
581
575
  throw new Error("browser not open");
@@ -584,6 +578,10 @@ export async function screenshot(opts) {
584
578
  const { tmpdir } = await import("node:os");
585
579
  const path = join(tmpdir(), `next-browser-${Date.now()}.png`);
586
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();
587
585
  return path;
588
586
  }
589
587
  /** Remove Next.js devtools overlay from the page before screenshots. */
@@ -773,6 +771,312 @@ export async function mcp(tool, args) {
773
771
  const origin = initialOrigin ?? new URL(page.url()).origin;
774
772
  return nextMcp.call(origin, tool, args);
775
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
+ }
776
1080
  /** Get network request log, or detail for a specific request index. */
777
1081
  export function network(idx) {
778
1082
  return idx == null ? net.format() : net.detail(idx);
@@ -874,6 +1178,44 @@ async function launch() {
874
1178
  return orig.apply(console, [label, ...args]);
875
1179
  };
876
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
+ });
877
1219
  // Next.js devtools overlay is removed before each screenshot via hideDevOverlay().
878
1220
  return ctx;
879
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];
@@ -132,15 +134,10 @@ if (cmd === "back") {
132
134
  const res = await send("back");
133
135
  exit(res, "back");
134
136
  }
135
- if (cmd === "preview") {
136
- const clear = args.includes("--clear");
137
- const caption = args.filter((a) => a !== "--clear").slice(1).join(" ") || undefined;
138
- const res = await send("preview", { caption, clear });
139
- exit(res, res.ok ? `preview → ${res.data}` : "");
140
- }
141
137
  if (cmd === "screenshot") {
142
138
  const fullPage = args.includes("--full-page");
143
- const res = await send("screenshot", { fullPage });
139
+ const caption = args.slice(1).filter((a) => a !== "--full-page").join(" ") || undefined;
140
+ const res = await send("screenshot", { fullPage, caption });
144
141
  exit(res, res.ok ? String(res.data) : "");
145
142
  }
146
143
  if (cmd === "snapshot") {
@@ -227,6 +224,113 @@ if (cmd === "logs") {
227
224
  console.log(content || "(log file is empty)");
228
225
  process.exit(0);
229
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
+ }
230
334
  if (cmd === "action") {
231
335
  const res = await send("mcp", { tool: "get_server_action_by_id", args: { actionId: arg } });
232
336
  exit(res, res.ok ? json(res.data) : "");
@@ -334,6 +438,8 @@ function printUsage() {
334
438
  " back go back in history\n" +
335
439
  " reload reload current page\n" +
336
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" +
337
443
  " restart-server restart the Next.js dev server (clears fs cache)\n" +
338
444
  "\n" +
339
445
  " ppr lock enter PPR instant-navigation mode\n" +
@@ -343,8 +449,7 @@ function printUsage() {
343
449
  " tree <id> inspect component (props, hooks, state, source)\n" +
344
450
  "\n" +
345
451
  " viewport [WxH] show or set viewport size (e.g. 1280x720)\n" +
346
- " preview [caption] [--clear] screenshot + open in viewer (accumulates)\n" +
347
- " screenshot [--full-page] save screenshot to tmp file\n" +
452
+ " screenshot [caption] [--full-page] screenshot + Screenshot Log\n" +
348
453
  " snapshot accessibility tree with interactive refs\n" +
349
454
  " click <ref|sel> click an element (real pointer events)\n" +
350
455
  " fill <ref|sel> <v> fill a text input\n" +
@@ -354,6 +459,7 @@ function printUsage() {
354
459
  "\n" +
355
460
  " errors show build/runtime errors\n" +
356
461
  " logs show recent dev server log output\n" +
462
+ " browser-logs show browser console output (log/warn/error/info)\n" +
357
463
  " network [idx] list network requests, or inspect one\n" +
358
464
  "\n" +
359
465
  " page show current page segments and router info\n" +
package/dist/daemon.js CHANGED
@@ -61,16 +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 === "restart") {
65
- const data = await browser.restart();
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();
66
74
  return { ok: true, data };
67
75
  }
68
- if (cmd.action === "preview") {
69
- const data = await browser.preview(cmd.caption, cmd.clear);
76
+ if (cmd.action === "restart") {
77
+ const data = await browser.restart();
70
78
  return { ok: true, data };
71
79
  }
72
80
  if (cmd.action === "screenshot") {
73
- const data = await browser.screenshot({ fullPage: cmd.fullPage });
81
+ const data = await browser.screenshot({ fullPage: cmd.fullPage, caption: cmd.caption });
74
82
  return { ok: true, data };
75
83
  }
76
84
  if (cmd.action === "links") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/next-browser",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Headed Playwright browser with React DevTools pre-loaded",
5
5
  "license": "MIT",
6
6
  "repository": {