@presto1314w/vite-devtools-browser 0.3.5 → 0.3.6

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.
@@ -10,8 +10,11 @@ export function initBrowserEventCollector() {
10
10
  q.shift();
11
11
  };
12
12
  }
13
+ const getVueApp = () => document.querySelector("#app")?.__vue_app__ ||
14
+ document.querySelector("[data-v-app]")?.__vue_app__ ||
15
+ null;
13
16
  const inferFramework = () => {
14
- if (window.__VUE__ || window.__VUE_DEVTOOLS_GLOBAL_HOOK__)
17
+ if (window.__VUE__ || window.__VUE_DEVTOOLS_GLOBAL_HOOK__ || getVueApp())
15
18
  return "vue";
16
19
  if (window.__REACT_DEVTOOLS_GLOBAL_HOOK__ || window.React)
17
20
  return "react";
@@ -30,10 +33,8 @@ export function initBrowserEventCollector() {
30
33
  const inferVueRenderDetails = () => {
31
34
  const hook = window.__VUE_DEVTOOLS_GLOBAL_HOOK__;
32
35
  const apps = hook?.apps;
33
- if (!Array.isArray(apps) || apps.length === 0)
34
- return null;
35
- const app = apps[0];
36
- const rootInstance = app?._instance || app?._container?._vnode?.component;
36
+ const app = getVueApp() || (Array.isArray(apps) ? apps[0] : null);
37
+ const rootInstance = app?._instance || app?._container?._vnode?.component || app?._component?.subTree?.component;
37
38
  if (!rootInstance)
38
39
  return null;
39
40
  const names = [];
@@ -120,7 +121,7 @@ export function initBrowserEventCollector() {
120
121
  };
121
122
  const attachPiniaSubscriptions = () => {
122
123
  const hook = window.__VUE_DEVTOOLS_GLOBAL_HOOK__;
123
- const app = Array.isArray(hook?.apps) ? hook.apps[0] : null;
124
+ const app = getVueApp() || (Array.isArray(hook?.apps) ? hook.apps[0] : null);
124
125
  const pinia = window.__PINIA__ || window.pinia || app?.config?.globalProperties?.$pinia;
125
126
  const registry = pinia?._s;
126
127
  if (!(registry instanceof Map) || registry.size === 0)
@@ -1,5 +1,15 @@
1
1
  import type { Page } from "playwright";
2
2
  import type { BrowserFramework, BrowserSessionState } from "./browser-session.js";
3
+ export declare function detectFrameworkFromGlobals(globals: {
4
+ vueVersion?: string | null;
5
+ hasVueHook?: boolean;
6
+ hasVueAppMarker?: boolean;
7
+ hasReactGlobal?: boolean;
8
+ hasReactRootMarker?: boolean;
9
+ reactRendererVersion?: string | null;
10
+ svelteVersion?: string | null;
11
+ hasSvelteHook?: boolean;
12
+ }): string;
3
13
  export declare function detectBrowserFramework(page: Page): Promise<{
4
14
  detected: string;
5
15
  framework: BrowserFramework;
@@ -1,26 +1,51 @@
1
1
  import * as vueDevtools from "./vue/devtools.js";
2
2
  import * as reactDevtools from "./react/devtools.js";
3
+ import { checkHookHealth, injectHook } from "./react/hook-manager.js";
3
4
  import * as svelteDevtools from "./svelte/devtools.js";
5
+ export function detectFrameworkFromGlobals(globals) {
6
+ if (globals.vueVersion || globals.hasVueHook || globals.hasVueAppMarker) {
7
+ return `vue@${globals.vueVersion || "unknown"}`;
8
+ }
9
+ if (globals.reactRendererVersion || globals.hasReactGlobal || globals.hasReactRootMarker) {
10
+ return `react@${globals.reactRendererVersion || "unknown"}`;
11
+ }
12
+ if (globals.svelteVersion || globals.hasSvelteHook) {
13
+ return `svelte@${globals.svelteVersion || "unknown"}`;
14
+ }
15
+ return "unknown";
16
+ }
4
17
  export async function detectBrowserFramework(page) {
18
+ await page.waitForFunction?.(() => Boolean(window.__VUE__ ||
19
+ window.__VUE_DEVTOOLS_GLOBAL_HOOK__ ||
20
+ window.React ||
21
+ window.__SVELTE__ ||
22
+ window.__svelte ||
23
+ document.querySelector("[data-v-app]") ||
24
+ document.querySelector("#app")?.__vue_app__ ||
25
+ document.querySelector("[data-reactroot]")), undefined, { timeout: 1_000 }).catch(() => { });
5
26
  const detected = await page.evaluate(() => {
6
- if (window.__VUE__ || window.__VUE_DEVTOOLS_GLOBAL_HOOK__) {
7
- const version = window.__VUE__?.version || "unknown";
8
- return `vue@${version}`;
9
- }
10
27
  const reactHook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
11
- if (reactHook || window.React || document.querySelector("[data-reactroot]")) {
12
- const renderers = reactHook?.renderers;
13
- const firstRenderer = renderers ? renderers.values().next().value : null;
14
- const version = firstRenderer?.version || window.React?.version || "unknown";
15
- return `react@${version}`;
28
+ const firstRenderer = reactHook?.renderers?.values?.().next?.().value ?? null;
29
+ const globals = {
30
+ vueVersion: window.__VUE__?.version ?? null,
31
+ hasVueHook: Boolean(window.__VUE_DEVTOOLS_GLOBAL_HOOK__),
32
+ hasVueAppMarker: Boolean(document.querySelector("#app")?.__vue_app__ ||
33
+ document.querySelector("[data-v-app]")?.__vue_app__ ||
34
+ document.querySelector("[data-v-app]")),
35
+ hasReactGlobal: Boolean(window.React),
36
+ hasReactRootMarker: Boolean(document.querySelector("[data-reactroot]")),
37
+ reactRendererVersion: firstRenderer?.version ?? window.React?.version ?? null,
38
+ svelteVersion: window.__SVELTE__?.VERSION || window.__svelte?.version || null,
39
+ hasSvelteHook: Boolean(window.__SVELTE_DEVTOOLS_GLOBAL_HOOK__),
40
+ };
41
+ if (globals.vueVersion || globals.hasVueHook || globals.hasVueAppMarker) {
42
+ return `vue@${globals.vueVersion || "unknown"}`;
16
43
  }
17
- if (window.__SVELTE__ ||
18
- window.__svelte ||
19
- window.__SVELTE_DEVTOOLS_GLOBAL_HOOK__) {
20
- const version = window.__SVELTE__?.VERSION ||
21
- window.__svelte?.version ||
22
- "unknown";
23
- return `svelte@${version}`;
44
+ if (globals.reactRendererVersion || globals.hasReactGlobal || globals.hasReactRootMarker) {
45
+ return `react@${globals.reactRendererVersion || "unknown"}`;
46
+ }
47
+ if (globals.svelteVersion || globals.hasSvelteHook) {
48
+ return `svelte@${globals.svelteVersion || "unknown"}`;
24
49
  }
25
50
  return "unknown";
26
51
  });
@@ -40,13 +65,13 @@ export async function inspectVueRouter(page) {
40
65
  }
41
66
  export async function inspectReactTree(state, page, id) {
42
67
  if (!id) {
43
- state.lastReactSnapshot = await reactDevtools.snapshot(page);
68
+ state.lastReactSnapshot = await withReactInspectorRecovery(page, () => reactDevtools.snapshot(page));
44
69
  return reactDevtools.format(state.lastReactSnapshot);
45
70
  }
46
71
  const parsed = Number.parseInt(id, 10);
47
72
  if (!Number.isFinite(parsed))
48
73
  throw new Error("react component id must be a number");
49
- const inspected = await reactDevtools.inspect(page, parsed);
74
+ const inspected = await withReactInspectorRecovery(page, () => reactDevtools.inspect(page, parsed));
50
75
  const lines = [];
51
76
  const componentPath = reactDevtools.path(state.lastReactSnapshot, parsed);
52
77
  if (componentPath)
@@ -61,6 +86,26 @@ export async function inspectReactTree(state, page, id) {
61
86
  export async function inspectSvelteTree(page, id) {
62
87
  return id ? svelteDevtools.getComponentDetails(page, id) : svelteDevtools.getComponentTree(page);
63
88
  }
89
+ async function withReactInspectorRecovery(page, read) {
90
+ try {
91
+ return await read();
92
+ }
93
+ catch (error) {
94
+ if (!isRecoverableReactInspectorError(error))
95
+ throw error;
96
+ const health = await checkHookHealth(page);
97
+ if (!health.installed) {
98
+ await injectHook(page);
99
+ }
100
+ await page.waitForTimeout(60).catch(() => { });
101
+ return read();
102
+ }
103
+ }
104
+ function isRecoverableReactInspectorError(error) {
105
+ if (!(error instanceof Error))
106
+ return false;
107
+ return /hook not installed|no React renderer attached/i.test(error.message);
108
+ }
64
109
  function toBrowserFramework(detected) {
65
110
  if (detected.startsWith("vue"))
66
111
  return "vue";
@@ -45,3 +45,5 @@ export declare function isClosedTargetError(error: unknown): boolean;
45
45
  * process crashes on older drivers.
46
46
  */
47
47
  export declare function platformChromiumArgs(extra?: string[]): string[];
48
+ export declare function resolveChromiumExecutablePath(env?: NodeJS.ProcessEnv): string | undefined;
49
+ export declare function resolveBrowserHeadless(env?: NodeJS.ProcessEnv): boolean;
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { chromium } from "playwright";
2
3
  import { getHookSource } from "./react/hook-manager.js";
3
4
  import { isWindows, isLinux } from "./paths.js";
@@ -94,9 +95,36 @@ export function platformChromiumArgs(extra = []) {
94
95
  }
95
96
  return args;
96
97
  }
98
+ export function resolveChromiumExecutablePath(env = process.env) {
99
+ const explicit = env.VITE_BROWSER_EXECUTABLE_PATH || env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
100
+ if (explicit && existsSync(explicit))
101
+ return explicit;
102
+ const candidates = isWindows
103
+ ? [
104
+ `${env.PROGRAMFILES ?? "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`,
105
+ `${env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`,
106
+ `${env.LOCALAPPDATA ?? ""}\\Google\\Chrome\\Application\\chrome.exe`,
107
+ ]
108
+ : isLinux
109
+ ? [
110
+ "/usr/bin/google-chrome",
111
+ "/usr/bin/chromium",
112
+ "/usr/bin/chromium-browser",
113
+ "/snap/bin/chromium",
114
+ ]
115
+ : [
116
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
117
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
118
+ ];
119
+ return candidates.find((candidate) => Boolean(candidate) && existsSync(candidate));
120
+ }
121
+ export function resolveBrowserHeadless(env = process.env) {
122
+ return /^(1|true|yes)$/i.test(env.VITE_BROWSER_HEADLESS || "");
123
+ }
97
124
  async function launchBrowserContext() {
98
125
  const browser = await chromium.launch({
99
- headless: false,
126
+ headless: resolveBrowserHeadless(),
127
+ executablePath: resolveChromiumExecutablePath(),
100
128
  args: platformChromiumArgs(),
101
129
  });
102
130
  const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
package/dist/browser.d.ts CHANGED
@@ -40,6 +40,12 @@ export declare function vueTree(id?: string): Promise<string>;
40
40
  export declare function vuePinia(store?: string): Promise<string>;
41
41
  export declare function vueRouter(): Promise<string>;
42
42
  export declare function reactTree(id?: string): Promise<string>;
43
+ export declare function reactStoreList(): Promise<string>;
44
+ export declare function reactStoreInspect(store: string): Promise<string>;
45
+ export declare function reactHookHealth(): Promise<string>;
46
+ export declare function reactHookInject(): Promise<string>;
47
+ export declare function reactCommits(limit?: number): Promise<string>;
48
+ export declare function reactCommitsClear(): Promise<string>;
43
49
  export declare function svelteTree(id?: string): Promise<string>;
44
50
  export declare function viteRestart(): Promise<string>;
45
51
  export declare function viteHMR(): Promise<string>;
package/dist/browser.js CHANGED
@@ -5,6 +5,9 @@ import { detectBrowserFramework, inspectReactTree, inspectSvelteTree, inspectVue
5
5
  import { closeBrowserSession, createBrowserSessionState, ensureBrowserPage, getCurrentPage as getSessionPage, } from "./browser-session.js";
6
6
  import { collectModuleRows, formatHmrTrace, formatModuleGraphSnapshot, formatModuleGraphTrace, formatRuntimeStatus, readOverlayError, readRuntimeSnapshot, } from "./browser-vite.js";
7
7
  import * as networkLog from "./network.js";
8
+ import { checkHookHealth, formatHookHealth, injectHook } from "./react/hook-manager.js";
9
+ import { clearRenderHistory, formatRenderInfo, getRecentRenders, installRenderTracking } from "./react/profiler.js";
10
+ import { formatStoreInspection, formatStoreList, inspectStore, listStores } from "./react/zustand.js";
8
11
  import { resolveViaSourceMap } from "./sourcemap.js";
9
12
  export { contextUsable, isClosedTargetError } from "./browser-session.js";
10
13
  export { formatHmrTrace, formatModuleGraphSnapshot, formatModuleGraphTrace, formatRuntimeStatus, normalizeLimit, } from "./browser-vite.js";
@@ -189,6 +192,36 @@ export async function vueRouter() {
189
192
  export async function reactTree(id) {
190
193
  return inspectReactTree(session, requireCurrentPage(), id);
191
194
  }
195
+ export async function reactStoreList() {
196
+ return formatStoreList(await listStores(requireCurrentPage()));
197
+ }
198
+ export async function reactStoreInspect(store) {
199
+ const result = await inspectStore(requireCurrentPage(), store);
200
+ if (!result) {
201
+ throw new Error(`zustand store not found: ${store}`);
202
+ }
203
+ return formatStoreInspection(result);
204
+ }
205
+ export async function reactHookHealth() {
206
+ return formatHookHealth(await checkHookHealth(requireCurrentPage()));
207
+ }
208
+ export async function reactHookInject() {
209
+ const currentPage = requireCurrentPage();
210
+ const injected = await injectHook(currentPage);
211
+ const status = await checkHookHealth(currentPage);
212
+ const prefix = injected ? "React hook injected.\n\n" : "React hook already installed.\n\n";
213
+ return `${prefix}${formatHookHealth(status)}`;
214
+ }
215
+ export async function reactCommits(limit = 20) {
216
+ const currentPage = requireCurrentPage();
217
+ await installRenderTracking(currentPage);
218
+ return formatRenderInfo(await getRecentRenders(currentPage, limit));
219
+ }
220
+ export async function reactCommitsClear() {
221
+ const currentPage = requireCurrentPage();
222
+ await clearRenderHistory(currentPage);
223
+ return "cleared React commit history";
224
+ }
192
225
  export async function svelteTree(id) {
193
226
  return inspectSvelteTree(requireCurrentPage(), id);
194
227
  }
@@ -210,6 +243,10 @@ export async function viteHMR() {
210
243
  }
211
244
  export async function viteRuntimeStatus() {
212
245
  const currentPage = requireCurrentPage();
246
+ if (session.framework === "unknown") {
247
+ const result = await detectBrowserFramework(currentPage);
248
+ session.framework = result.framework;
249
+ }
213
250
  const runtime = await readRuntimeSnapshot(currentPage);
214
251
  return formatRuntimeStatus(runtime, session.framework, session.hmrEvents);
215
252
  }
@@ -334,6 +371,8 @@ async function navigateAndRefreshContext(currentPage, navigate, refreshFramework
334
371
  clearRuntimeErrors();
335
372
  await injectEventCollector(currentPage);
336
373
  if (refreshFramework) {
374
+ await currentPage.waitForLoadState?.("networkidle", { timeout: 1_000 }).catch(() => { });
375
+ await currentPage.waitForTimeout?.(100).catch(() => { });
337
376
  await detectFramework();
338
377
  }
339
378
  }
package/dist/cli.js CHANGED
@@ -97,6 +97,47 @@ export async function runCli(argv, io) {
97
97
  const res = await io.send("react-tree", { id });
98
98
  exit(io, res, res.ok && res.data ? String(res.data) : "");
99
99
  }
100
+ if (cmd === "react" && arg === "store") {
101
+ const sub = args[2];
102
+ if (sub === "list") {
103
+ const res = await io.send("react-store-list");
104
+ exit(io, res, res.ok && res.data ? String(res.data) : "");
105
+ }
106
+ if (sub === "inspect") {
107
+ const store = args[3];
108
+ if (!store) {
109
+ io.stderr("usage: vite-browser react store inspect <name>");
110
+ io.exit(1);
111
+ }
112
+ const res = await io.send("react-store-inspect", { store });
113
+ exit(io, res, res.ok && res.data ? String(res.data) : "");
114
+ }
115
+ io.stderr("usage: vite-browser react store <list|inspect> [name]");
116
+ io.exit(1);
117
+ }
118
+ if (cmd === "react" && arg === "hook") {
119
+ const sub = args[2];
120
+ if (sub === "health") {
121
+ const res = await io.send("react-hook-health");
122
+ exit(io, res, res.ok && res.data ? String(res.data) : "");
123
+ }
124
+ if (sub === "inject") {
125
+ const res = await io.send("react-hook-inject");
126
+ exit(io, res, res.ok && res.data ? String(res.data) : "");
127
+ }
128
+ io.stderr("usage: vite-browser react hook <health|inject>");
129
+ io.exit(1);
130
+ }
131
+ if (cmd === "react" && arg === "commits") {
132
+ const sub = args[2];
133
+ if (sub === "clear") {
134
+ const res = await io.send("react-commits-clear");
135
+ exit(io, res, res.ok && res.data ? String(res.data) : "cleared React commit history");
136
+ }
137
+ const limit = parseNumberFlag(args, "--limit", 20);
138
+ const res = await io.send("react-commits", { limit });
139
+ exit(io, res, res.ok && res.data ? String(res.data) : "");
140
+ }
100
141
  if (cmd === "svelte" && arg === "tree") {
101
142
  const id = args[2];
102
143
  const res = await io.send("svelte-tree", { id });
@@ -228,6 +269,12 @@ VUE COMMANDS
228
269
 
229
270
  REACT COMMANDS
230
271
  react tree [id] Show React component tree or inspect component
272
+ react store list List detected Zustand stores
273
+ react store inspect <name> Show Zustand store state and actions
274
+ react hook health Show bundled React hook status
275
+ react hook inject Inject bundled React hook into current page
276
+ react commits [--limit <n>] Show recent React commit records
277
+ react commits clear Clear recorded React commit history
231
278
 
232
279
  SVELTE COMMANDS
233
280
  svelte tree [id] Show Svelte component tree or inspect component
package/dist/daemon.js CHANGED
@@ -71,6 +71,30 @@ export function createRunner(api = browser) {
71
71
  const data = await api.reactTree(cmd.id);
72
72
  return { ok: true, data };
73
73
  }
74
+ if (cmd.action === "react-store-list") {
75
+ const data = await api.reactStoreList();
76
+ return { ok: true, data };
77
+ }
78
+ if (cmd.action === "react-store-inspect") {
79
+ const data = await api.reactStoreInspect(cmd.store);
80
+ return { ok: true, data };
81
+ }
82
+ if (cmd.action === "react-hook-health") {
83
+ const data = await api.reactHookHealth();
84
+ return { ok: true, data };
85
+ }
86
+ if (cmd.action === "react-hook-inject") {
87
+ const data = await api.reactHookInject();
88
+ return { ok: true, data };
89
+ }
90
+ if (cmd.action === "react-commits") {
91
+ const data = await api.reactCommits(cmd.limit ?? 20);
92
+ return { ok: true, data };
93
+ }
94
+ if (cmd.action === "react-commits-clear") {
95
+ const data = await api.reactCommitsClear();
96
+ return { ok: true, data };
97
+ }
74
98
  // Svelte commands
75
99
  if (cmd.action === "svelte-tree") {
76
100
  const data = await api.svelteTree(cmd.id);
package/dist/paths.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { accessSync, constants } from "node:fs";
1
2
  import { homedir, tmpdir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  export const isWindows = process.platform === "win32";
@@ -19,11 +20,14 @@ const session = sanitizeSession(process.env.VITE_BROWSER_SESSION || "default");
19
20
  export function resolveSocketDir() {
20
21
  try {
21
22
  const home = homedir();
22
- if (home)
23
+ if (home) {
24
+ accessSync(home, constants.W_OK);
23
25
  return join(home, ".vite-browser");
26
+ }
24
27
  }
25
28
  catch {
26
- // homedir() can throw on misconfigured systems
29
+ // homedir() can throw, and sandboxed / CI environments may expose a
30
+ // home directory that is present but not writable.
27
31
  }
28
32
  // Fallback: use tmpdir scoped by uid to avoid collisions
29
33
  const uid = process.getuid?.() ?? process.pid;
@@ -12,6 +12,16 @@ export type ReactInspection = {
12
12
  };
13
13
  export declare function snapshot(page: Page): Promise<ReactNode[]>;
14
14
  export declare function inspect(page: Page, id: number): Promise<ReactInspection>;
15
+ export declare function getPrimaryRendererInterface(hook: {
16
+ rendererInterfaces?: Map<unknown, unknown> | {
17
+ values?: () => IterableIterator<unknown>;
18
+ };
19
+ } | null | undefined): unknown;
20
+ export declare function findRendererInterfaceForElement(hook: {
21
+ rendererInterfaces?: Map<unknown, unknown> | {
22
+ values?: () => IterableIterator<unknown>;
23
+ };
24
+ } | null | undefined, id: number): unknown;
15
25
  export declare function format(nodes: ReactNode[]): string;
16
26
  export declare function path(nodes: ReactNode[], id: number): string;
17
27
  export declare function typeName(type: number): string;
@@ -4,6 +4,21 @@ export async function snapshot(page) {
4
4
  export async function inspect(page, id) {
5
5
  return page.evaluate(inPageInspect, id);
6
6
  }
7
+ export function getPrimaryRendererInterface(hook) {
8
+ const values = hook?.rendererInterfaces?.values?.();
9
+ return values?.next().value ?? null;
10
+ }
11
+ export function findRendererInterfaceForElement(hook, id) {
12
+ const values = hook?.rendererInterfaces?.values?.();
13
+ if (!values)
14
+ return null;
15
+ for (const rendererInterface of values) {
16
+ if (rendererInterface?.hasElementWithId?.(id)) {
17
+ return rendererInterface;
18
+ }
19
+ }
20
+ return null;
21
+ }
7
22
  export function format(nodes) {
8
23
  const children = new Map();
9
24
  for (const n of nodes) {
@@ -182,7 +197,7 @@ async function inPageSnapshot() {
182
197
  const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
183
198
  if (!hook)
184
199
  throw new Error("React DevTools hook not installed");
185
- const ri = hook.rendererInterfaces?.get?.(1);
200
+ const ri = getPrimaryRendererInterface(hook);
186
201
  if (!ri)
187
202
  throw new Error("no React renderer attached");
188
203
  const batches = await collect(ri);
@@ -207,7 +222,8 @@ async function inPageSnapshot() {
207
222
  }
208
223
  function inPageInspect(id) {
209
224
  const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
210
- const ri = hook?.rendererInterfaces?.get?.(1);
225
+ const ri = findRendererInterfaceForElement(hook, id) ??
226
+ getPrimaryRendererInterface(hook);
211
227
  if (!ri)
212
228
  throw new Error("no React renderer attached");
213
229
  if (!ri.hasElementWithId(id))
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * React DevTools Hook Management
3
3
  *
4
- * Provides health checks and auto-injection for the bundled React DevTools hook.
4
+ * Provides health checks and injection helpers for the bundled React DevTools hook.
5
5
  * This removes the dependency on external browser extensions for React inspection.
6
6
  */
7
7
  import type { Page } from "playwright";
@@ -20,7 +20,7 @@ export interface HookHealthStatus {
20
20
  */
21
21
  export declare function checkHookHealth(page: Page): Promise<HookHealthStatus>;
22
22
  /**
23
- * Inject the React DevTools hook into a page if not already present
23
+ * Inject the React DevTools hook into a page if not already present.
24
24
  */
25
25
  export declare function injectHook(page: Page): Promise<boolean>;
26
26
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * React DevTools Hook Management
3
3
  *
4
- * Provides health checks and auto-injection for the bundled React DevTools hook.
4
+ * Provides health checks and injection helpers for the bundled React DevTools hook.
5
5
  * This removes the dependency on external browser extensions for React inspection.
6
6
  */
7
7
  import { readFileSync } from "node:fs";
@@ -26,7 +26,7 @@ export async function checkHookHealth(page) {
26
26
  return page.evaluate(inPageCheckHookHealth);
27
27
  }
28
28
  /**
29
- * Inject the React DevTools hook into a page if not already present
29
+ * Inject the React DevTools hook into a page if not already present.
30
30
  */
31
31
  export async function injectHook(page) {
32
32
  const alreadyInstalled = await page.evaluate(() => !!window.__REACT_DEVTOOLS_GLOBAL_HOOK__);
@@ -18,13 +18,14 @@
18
18
  return;
19
19
  if (window.__REACT_DEVTOOLS_GLOBAL_HOOK__)
20
20
  return;
21
+ let nextRendererId = 1;
21
22
  const hook = {
22
23
  renderers: new Map(),
23
24
  rendererInterfaces: new Map(),
24
25
  supportsFiber: true,
25
26
  // Called by React when a renderer is injected
26
27
  inject(renderer) {
27
- const id = Math.random();
28
+ const id = nextRendererId++;
28
29
  hook.renderers.set(id, renderer);
29
30
  // Create renderer interface for component inspection
30
31
  const rendererInterface = createRendererInterface(renderer);
@@ -33,11 +34,13 @@
33
34
  },
34
35
  // Called by React on fiber root commits
35
36
  onCommitFiberRoot(id, root, priorityLevel) {
36
- // Hook for tracking renders - can be overridden by profiler
37
+ const rendererInterface = hook.rendererInterfaces.get(id);
38
+ rendererInterface?.trackFiberRoot(root);
37
39
  },
38
40
  // Called by React on fiber unmount
39
41
  onCommitFiberUnmount(id, fiber) {
40
- // Hook for tracking unmounts
42
+ const rendererInterface = hook.rendererInterfaces.get(id);
43
+ rendererInterface?.handleCommitFiberUnmount(fiber);
41
44
  },
42
45
  // Sub-hooks for advanced features
43
46
  sub(event, handler) {
@@ -58,12 +61,25 @@
58
61
  const fiberRoots = new Set();
59
62
  const fiberToId = new Map();
60
63
  const idToFiber = new Map();
61
- let nextId = 1;
64
+ const fiberParents = new Map();
65
+ let nextId = 2;
62
66
  return {
67
+ trackFiberRoot(root) {
68
+ if (root && root.current) {
69
+ fiberRoots.add(root);
70
+ }
71
+ },
72
+ handleCommitFiberUnmount(fiber) {
73
+ pruneFiber(fiber);
74
+ },
63
75
  // Flush initial operations to populate component tree
64
76
  flushInitialOperations() {
77
+ fiberToId.clear();
78
+ idToFiber.clear();
79
+ fiberParents.clear();
80
+ nextId = 2;
65
81
  for (const root of fiberRoots) {
66
- walkTree(root.current, null);
82
+ walkTree(root.current, 1);
67
83
  }
68
84
  // Send operations via message event
69
85
  const operations = buildOperations();
@@ -113,6 +129,7 @@
113
129
  const id = nextId++;
114
130
  fiberToId.set(fiber, id);
115
131
  idToFiber.set(id, fiber);
132
+ fiberParents.set(fiber, parentId);
116
133
  // Walk children
117
134
  let child = fiber.child;
118
135
  while (child) {
@@ -121,29 +138,25 @@
121
138
  }
122
139
  }
123
140
  function buildOperations() {
124
- const ops = [2, 0]; // version, string table size
125
141
  const strings = [];
126
- // Add root operation
127
- ops.push(1, 1, 11, 0, 0, 0, 0); // ADD_ROOT
142
+ const nodeOps = [];
128
143
  // Add fiber nodes
129
144
  for (const [id, fiber] of idToFiber) {
130
- if (id === 1)
131
- continue; // Skip root
132
145
  const name = getDisplayName(fiber);
133
146
  const nameIdx = addString(name);
134
147
  const keyIdx = fiber.key ? addString(String(fiber.key)) : 0;
135
- const parentId = fiberToId.get(fiber.return) || 0;
136
- ops.push(1, id, 5, parentId, 0, nameIdx, keyIdx, 0); // ADD_NODE
148
+ const parentId = fiberParents.get(fiber) || 0;
149
+ nodeOps.push(1, id, 0, parentId, 0, nameIdx, keyIdx, 0); // ADD_NODE
137
150
  }
138
- // Update string table size
139
- ops[1] = strings.length;
140
- // Insert string table after version
141
151
  const stringOps = [];
142
152
  for (const str of strings) {
143
153
  const codePoints = Array.from(str).map(c => c.codePointAt(0));
144
154
  stringOps.push(codePoints.length, ...codePoints);
145
155
  }
146
- ops.splice(2, 0, ...stringOps);
156
+ const ops = [0, 0, stringOps.length, ...stringOps];
157
+ // Add root operation
158
+ ops.push(1, 1, 11, 0, 0, 0, 0); // ADD_ROOT
159
+ ops.push(...nodeOps);
147
160
  return ops;
148
161
  function addString(str) {
149
162
  let idx = strings.indexOf(str);
@@ -215,6 +228,21 @@
215
228
  const { fileName, lineNumber, columnNumber } = fiber._debugSource;
216
229
  return [null, fileName, lineNumber, columnNumber];
217
230
  }
231
+ function pruneFiber(fiber) {
232
+ if (!fiber)
233
+ return;
234
+ const mappedId = fiberToId.get(fiber);
235
+ if (mappedId != null) {
236
+ fiberToId.delete(fiber);
237
+ idToFiber.delete(mappedId);
238
+ fiberParents.delete(fiber);
239
+ }
240
+ let child = fiber.child;
241
+ while (child) {
242
+ pruneFiber(child);
243
+ child = child.sibling;
244
+ }
245
+ }
218
246
  }
219
247
  // Install the hook
220
248
  Object.defineProperty(window, "__REACT_DEVTOOLS_GLOBAL_HOOK__", {
@@ -1,55 +1,38 @@
1
1
  /**
2
- * React render tracking and profiling
2
+ * React commit tracking groundwork.
3
3
  *
4
- * Tracks component renders, durations, and trigger reasons using React DevTools Profiler API.
4
+ * This module records real commit metadata that can be observed from the
5
+ * React DevTools hook without pretending to expose a full Profiler surface.
5
6
  */
6
7
  import type { Page } from "playwright";
8
+ export interface RenderInteraction {
9
+ id: number;
10
+ name: string;
11
+ timestamp: number;
12
+ }
7
13
  export interface RenderInfo {
8
- componentId: number;
9
- componentName: string;
14
+ rendererId: number;
15
+ rootName: string;
10
16
  phase: "mount" | "update" | "nested-update";
11
- actualDuration: number;
12
- baseDuration: number;
13
- startTime: number;
17
+ actualDuration: number | null;
18
+ baseDuration: number | null;
19
+ startTime: number | null;
14
20
  commitTime: number;
15
- interactions: Set<{
16
- id: number;
17
- name: string;
18
- timestamp: number;
19
- }>;
21
+ fiberCount: number;
22
+ interactions: RenderInteraction[];
20
23
  }
21
24
  export interface RenderTrigger {
22
- componentId: number;
23
- componentName: string;
25
+ rendererId: number;
26
+ rootName: string;
24
27
  reason: "props" | "state" | "hooks" | "parent" | "context" | "unknown";
25
28
  timestamp: number;
26
29
  details?: string;
27
30
  }
28
- /**
29
- * Install render tracking in the page
30
- */
31
31
  export declare function installRenderTracking(page: Page): Promise<void>;
32
- /**
33
- * Get recent render events
34
- */
35
32
  export declare function getRecentRenders(page: Page, limit?: number): Promise<RenderInfo[]>;
36
- /**
37
- * Get render triggers (why components re-rendered)
38
- */
39
33
  export declare function getRenderTriggers(page: Page, limit?: number): Promise<RenderTrigger[]>;
40
- /**
41
- * Clear render history
42
- */
43
34
  export declare function clearRenderHistory(page: Page): Promise<void>;
44
- /**
45
- * Format render info for CLI output
46
- */
35
+ export declare function formatDuration(duration: number | null): string;
47
36
  export declare function formatRenderInfo(renders: RenderInfo[]): string;
48
- /**
49
- * Format render triggers for CLI output
50
- */
51
37
  export declare function formatRenderTriggers(triggers: RenderTrigger[]): string;
52
- /**
53
- * Analyze slow renders (> 16ms)
54
- */
55
38
  export declare function analyzeSlowRenders(renders: RenderInfo[]): string;
@@ -1,56 +1,44 @@
1
1
  /**
2
- * React render tracking and profiling
2
+ * React commit tracking groundwork.
3
3
  *
4
- * Tracks component renders, durations, and trigger reasons using React DevTools Profiler API.
5
- */
6
- /**
7
- * Install render tracking in the page
4
+ * This module records real commit metadata that can be observed from the
5
+ * React DevTools hook without pretending to expose a full Profiler surface.
8
6
  */
9
7
  export async function installRenderTracking(page) {
10
8
  await page.evaluate(inPageInstallRenderTracking);
11
9
  }
12
- /**
13
- * Get recent render events
14
- */
15
10
  export async function getRecentRenders(page, limit = 50) {
16
11
  return page.evaluate(inPageGetRecentRenders, limit);
17
12
  }
18
- /**
19
- * Get render triggers (why components re-rendered)
20
- */
21
13
  export async function getRenderTriggers(page, limit = 50) {
22
14
  return page.evaluate(inPageGetRenderTriggers, limit);
23
15
  }
24
- /**
25
- * Clear render history
26
- */
27
16
  export async function clearRenderHistory(page) {
28
17
  await page.evaluate(inPageClearRenderHistory);
29
18
  }
30
- /**
31
- * Format render info for CLI output
32
- */
19
+ export function formatDuration(duration) {
20
+ return duration == null ? "n/a" : `${duration.toFixed(2)}ms`;
21
+ }
33
22
  export function formatRenderInfo(renders) {
34
23
  if (renders.length === 0)
35
24
  return "No renders recorded";
36
- const lines = ["# React Renders\n"];
25
+ const lines = ["# React Commits\n"];
37
26
  for (const render of renders) {
38
27
  const phase = render.phase === "mount" ? "MOUNT" : render.phase === "update" ? "UPDATE" : "NESTED";
39
- const duration = render.actualDuration.toFixed(2);
40
- const slow = render.actualDuration > 16 ? " ⚠️ SLOW" : "";
41
- lines.push(`[${phase}] ${render.componentName} (${duration}ms)${slow}`);
42
- if (render.interactions.size > 0) {
43
- const interactions = Array.from(render.interactions)
44
- .map((i) => i.name)
45
- .join(", ");
28
+ const duration = formatDuration(render.actualDuration);
29
+ const slow = render.actualDuration != null && render.actualDuration > 16 ? " ⚠️ SLOW" : "";
30
+ lines.push(`[${phase}] ${render.rootName} (${duration})${slow}`);
31
+ lines.push(` Fibers: ${render.fiberCount}`);
32
+ if (render.baseDuration != null) {
33
+ lines.push(` Base duration: ${render.baseDuration.toFixed(2)}ms`);
34
+ }
35
+ if (render.interactions.length > 0) {
36
+ const interactions = render.interactions.map((i) => i.name).join(", ");
46
37
  lines.push(` Interactions: ${interactions}`);
47
38
  }
48
39
  }
49
40
  return lines.join("\n");
50
41
  }
51
- /**
52
- * Format render triggers for CLI output
53
- */
54
42
  export function formatRenderTriggers(triggers) {
55
43
  if (triggers.length === 0)
56
44
  return "No render triggers recorded";
@@ -58,40 +46,36 @@ export function formatRenderTriggers(triggers) {
58
46
  for (const trigger of triggers) {
59
47
  const reason = trigger.reason.toUpperCase();
60
48
  const details = trigger.details ? ` - ${trigger.details}` : "";
61
- lines.push(`[${reason}] ${trigger.componentName}${details}`);
49
+ lines.push(`[${reason}] ${trigger.rootName}${details}`);
62
50
  }
63
51
  return lines.join("\n");
64
52
  }
65
- /**
66
- * Analyze slow renders (> 16ms)
67
- */
68
53
  export function analyzeSlowRenders(renders) {
69
- const slowRenders = renders.filter((r) => r.actualDuration > 16);
54
+ const slowRenders = renders.filter((r) => r.actualDuration != null && r.actualDuration > 16);
70
55
  if (slowRenders.length === 0) {
71
- return "No slow renders detected (all renders < 16ms)";
56
+ return "No slow renders detected with measurable duration (> 16ms)";
72
57
  }
73
58
  const lines = [
74
59
  "# Slow Renders Analysis\n",
75
60
  `Found ${slowRenders.length} slow render(s) (> 16ms)\n`,
76
61
  ];
77
- // Group by component
78
- const byComponent = new Map();
62
+ const byRoot = new Map();
79
63
  for (const render of slowRenders) {
80
- const list = byComponent.get(render.componentName) || [];
64
+ const list = byRoot.get(render.rootName) || [];
81
65
  list.push(render);
82
- byComponent.set(render.componentName, list);
66
+ byRoot.set(render.rootName, list);
83
67
  }
84
- // Sort by total time
85
- const sorted = Array.from(byComponent.entries()).sort(([, a], [, b]) => {
86
- const totalA = a.reduce((sum, r) => sum + r.actualDuration, 0);
87
- const totalB = b.reduce((sum, r) => sum + r.actualDuration, 0);
68
+ const sorted = Array.from(byRoot.entries()).sort(([, a], [, b]) => {
69
+ const totalA = a.reduce((sum, r) => sum + (r.actualDuration ?? 0), 0);
70
+ const totalB = b.reduce((sum, r) => sum + (r.actualDuration ?? 0), 0);
88
71
  return totalB - totalA;
89
72
  });
90
- for (const [name, renders] of sorted) {
91
- const count = renders.length;
92
- const total = renders.reduce((sum, r) => sum + r.actualDuration, 0);
73
+ for (const [name, commits] of sorted) {
74
+ const durations = commits.map((r) => r.actualDuration ?? 0);
75
+ const total = durations.reduce((sum, value) => sum + value, 0);
76
+ const count = commits.length;
93
77
  const avg = total / count;
94
- const max = Math.max(...renders.map((r) => r.actualDuration));
78
+ const max = Math.max(...durations);
95
79
  lines.push(`${name}:`);
96
80
  lines.push(` Count: ${count}`);
97
81
  lines.push(` Total: ${total.toFixed(2)}ms`);
@@ -101,58 +85,39 @@ export function analyzeSlowRenders(renders) {
101
85
  }
102
86
  return lines.join("\n");
103
87
  }
104
- // In-page functions
105
88
  function inPageInstallRenderTracking() {
106
89
  const win = window;
107
- // Initialize storage
108
90
  win.__REACT_RENDER_HISTORY__ = win.__REACT_RENDER_HISTORY__ || [];
109
91
  win.__REACT_RENDER_TRIGGERS__ = win.__REACT_RENDER_TRIGGERS__ || [];
110
- // Hook into React DevTools if available
111
92
  const hook = win.__REACT_DEVTOOLS_GLOBAL_HOOK__;
112
93
  if (!hook) {
113
94
  console.warn("React DevTools hook not found, render tracking limited");
114
95
  return;
115
96
  }
116
- // Listen for commit events
117
- if (!win.__REACT_RENDER_TRACKING_INSTALLED__) {
118
- win.__REACT_RENDER_TRACKING_INSTALLED__ = true;
119
- // Track renders via Profiler API
120
- const originalOnCommitFiberRoot = hook.onCommitFiberRoot;
121
- hook.onCommitFiberRoot = function (id, root, priorityLevel) {
122
- try {
123
- // Record render info
124
- const renderInfo = {
125
- componentId: id,
126
- componentName: root?.current?.type?.name || "Root",
127
- phase: root?.current?.mode === 0 ? "mount" : "update",
128
- actualDuration: 0,
129
- baseDuration: 0,
130
- startTime: Date.now(),
131
- commitTime: Date.now(),
132
- interactions: new Set(),
133
- };
134
- win.__REACT_RENDER_HISTORY__.push(renderInfo);
135
- // Keep only last 100 renders
136
- if (win.__REACT_RENDER_HISTORY__.length > 100) {
137
- win.__REACT_RENDER_HISTORY__.shift();
138
- }
139
- }
140
- catch (e) {
141
- // Ignore errors in tracking
142
- }
143
- if (originalOnCommitFiberRoot) {
144
- return originalOnCommitFiberRoot.call(this, id, root, priorityLevel);
97
+ if (win.__REACT_RENDER_TRACKING_INSTALLED__)
98
+ return;
99
+ win.__REACT_RENDER_TRACKING_INSTALLED__ = true;
100
+ const originalOnCommitFiberRoot = hook.onCommitFiberRoot;
101
+ hook.onCommitFiberRoot = function (rendererId, root, priorityLevel) {
102
+ try {
103
+ const renderInfo = toRenderInfo(rendererId, root);
104
+ win.__REACT_RENDER_HISTORY__.push(renderInfo);
105
+ if (win.__REACT_RENDER_HISTORY__.length > 100) {
106
+ win.__REACT_RENDER_HISTORY__.shift();
145
107
  }
146
- };
147
- }
108
+ }
109
+ catch {
110
+ // Ignore tracking errors to avoid breaking the inspected app.
111
+ }
112
+ if (originalOnCommitFiberRoot) {
113
+ return originalOnCommitFiberRoot.call(this, rendererId, root, priorityLevel);
114
+ }
115
+ };
148
116
  }
149
117
  function inPageGetRecentRenders(limit) {
150
118
  const win = window;
151
119
  const history = win.__REACT_RENDER_HISTORY__ || [];
152
- return history.slice(-limit).map((r) => ({
153
- ...r,
154
- interactions: Array.from(r.interactions || []),
155
- }));
120
+ return history.slice(-limit);
156
121
  }
157
122
  function inPageGetRenderTriggers(limit) {
158
123
  const win = window;
@@ -164,3 +129,67 @@ function inPageClearRenderHistory() {
164
129
  win.__REACT_RENDER_HISTORY__ = [];
165
130
  win.__REACT_RENDER_TRIGGERS__ = [];
166
131
  }
132
+ function toRenderInfo(rendererId, root) {
133
+ const current = root?.current ?? null;
134
+ const rootChild = current?.child ?? null;
135
+ return {
136
+ rendererId,
137
+ rootName: getFiberName(rootChild || current),
138
+ phase: inferPhase(current),
139
+ actualDuration: numberOrNull(current?.actualDuration),
140
+ baseDuration: numberOrNull(current?.treeBaseDuration ?? current?.baseDuration),
141
+ startTime: numberOrNull(current?.actualStartTime),
142
+ commitTime: Date.now(),
143
+ fiberCount: countFibers(current),
144
+ interactions: normalizeInteractions(root?.memoizedInteractions),
145
+ };
146
+ }
147
+ function inferPhase(current) {
148
+ if (!current?.alternate)
149
+ return "mount";
150
+ return current.flags && (current.flags & 1024) !== 0 ? "nested-update" : "update";
151
+ }
152
+ function getFiberName(fiber) {
153
+ if (!fiber)
154
+ return "Root";
155
+ if (typeof fiber.type === "string")
156
+ return fiber.type;
157
+ if (fiber.type?.displayName)
158
+ return fiber.type.displayName;
159
+ if (fiber.type?.name)
160
+ return fiber.type.name;
161
+ if (fiber.elementType?.displayName)
162
+ return fiber.elementType.displayName;
163
+ if (fiber.elementType?.name)
164
+ return fiber.elementType.name;
165
+ return "Anonymous";
166
+ }
167
+ function countFibers(fiber) {
168
+ let count = 0;
169
+ const stack = fiber ? [fiber] : [];
170
+ while (stack.length > 0) {
171
+ const current = stack.pop();
172
+ if (!current)
173
+ continue;
174
+ count++;
175
+ if (current.sibling)
176
+ stack.push(current.sibling);
177
+ if (current.child)
178
+ stack.push(current.child);
179
+ }
180
+ return count;
181
+ }
182
+ function numberOrNull(value) {
183
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
184
+ }
185
+ function normalizeInteractions(interactions) {
186
+ if (!(interactions instanceof Set))
187
+ return [];
188
+ return Array.from(interactions)
189
+ .map((interaction) => ({
190
+ id: typeof interaction?.id === "number" ? interaction.id : 0,
191
+ name: typeof interaction?.name === "string" ? interaction.name : "interaction",
192
+ timestamp: typeof interaction?.timestamp === "number" ? interaction.timestamp : 0,
193
+ }))
194
+ .filter((interaction) => interaction.name.length > 0);
195
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@presto1314w/vite-devtools-browser",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Runtime diagnostics CLI for Vite apps with event-stream correlation, HMR diagnosis, framework inspection, and mapped errors",
5
5
  "license": "MIT",
6
6
  "keywords": [