Inspecting Page State from a DevTools Extension
Read a page's framework state, selected element and network activity from a DevTools extension — inspectedWindow.eval with $0, polling versus hooks, resource and HAR access, and staying fast.
Table of Contents
The most useful DevTools extensions show something the browser’s own panels do not: a framework’s component tree, a store’s current state, the analytics events a page fired, the feature flags it evaluated. All of that lives in the page’s main world — the page’s own JavaScript objects — which neither a content script nor the service worker can see directly. The DevTools APIs give you three ways in, each with a different cost and a different failure mode. This guide is part of side panel and DevTools interfaces.
Three ways to read page state
Step-by-step
1. Snapshot state with eval
1// panel.js
2export function evalInPage(expr) {
3 return new Promise((resolve, reject) => {
4 chrome.devtools.inspectedWindow.eval(expr, (result, ex) => {
5 if (ex?.isException) reject(new Error(ex.value));
6 else if (ex?.isError) reject(new Error(ex.description ?? "eval failed"));
7 else resolve(result);
8 });
9 });
10}
11
12const state = await evalInPage(`(() => {
13 const s = window.__STORE__?.getState?.();
14 return s ? JSON.parse(JSON.stringify(s)) : null;
15})()`);
Execution context: the panel calls eval; the expression runs in the inspected page’s main world, with its globals. The round trip through JSON.stringify strips functions, cycles and class prototypes, which would otherwise fail to serialise. Wrapping in an IIFE lets you use statements and keeps temporary names out of the page’s global scope.
2. Read the element the developer selected
DevTools exposes the currently selected element in the Elements panel as $0, and eval can use it.
1chrome.devtools.panels.elements.onSelectionChanged.addListener(async () => {
2 const info = await evalInPage(`(() => {
3 const el = $0;
4 if (!el) return null;
5 const key = Object.keys(el).find((k) => k.startsWith("__reactFiber$"));
6 return { tag: el.tagName, id: el.id, component: key ? el[key]?.type?.name ?? null : null };
7 })()`);
8 render(info);
9});
Execution context: the panel document. $0 is only defined inside inspectedWindow.eval — it is a DevTools console helper, not a page global. Framework internals like React’s fiber keys are undocumented and change between versions; treat them as best-effort and fail gracefully when absent.
3. Stream events with a main-world hook
Polling with eval is fine for occasional reads and wasteful for anything continuous. For a live feed — every analytics call, every store dispatch — install a hook in the main world that posts events out.
1// content/hook.js — injected into the MAIN world at document_start
2(() => {
3 const orig = window.dataLayer?.push?.bind(window.dataLayer);
4 if (!orig) return;
5 window.dataLayer.push = (...args) => {
6 window.postMessage({ source: "reader-devtools", type: "dataLayer", payload: JSON.parse(JSON.stringify(args)) }, location.origin);
7 return orig(...args);
8 };
9})();
1// content/relay.js — isolated world, forwards to the worker
2window.addEventListener("message", (e) => {
3 if (e.source === window && e.data?.source === "reader-devtools") chrome.runtime.sendMessage(e.data);
4});
Execution context: the hook runs in the page’s main world; the relay in the extension’s isolated world. The worker forwards to the panel’s port as described in building a custom DevTools panel. The main-world injection itself is covered in bridging data between main world and isolated world.
4. Install the hook only when DevTools is open
A hook on every page for every user is a real cost for a feature only developers use. Register it when the panel connects and unregister when it disconnects.
1// sw.js
2chrome.runtime.onConnect.addListener(async (port) => {
3 if (!port.name.startsWith("devtools:")) return;
4 const tabId = Number(port.name.split(":")[1]);
5 await chrome.scripting.executeScript({
6 target: { tabId }, world: "MAIN", files: ["content/hook.js"], injectImmediately: true,
7 });
8 await chrome.scripting.executeScript({ target: { tabId }, files: ["content/relay.js"] });
9});
Execution context: the service worker. Injecting on connect means the hook exists only in tabs being inspected. The trade is that events fired before the panel opened are missed — acceptable for most tools, and if not, a page reload with the panel open catches everything from document_start.
5. Use the network API for anything that went over the wire
1chrome.devtools.network.onRequestFinished.addListener((req) => {
2 if (!req.request.url.includes("/graphql")) return;
3 req.getContent((body) => {
4 try { appendOperation(JSON.parse(body)); } catch { /* not JSON */ }
5 });
6});
Execution context: the panel document. onRequestFinished delivers a HAR entry for every request made while DevTools is open; getContent fetches the response body. This sees requests the page made through any mechanism — fetch, XHR, service workers — without injecting anything, which makes it the lightest option for network-shaped questions.
Keeping the panel fast on a busy page
Inspection tools are used on exactly the pages that are already under stress — the slow page, the one with the memory leak. A panel that adds its own load makes the problem worse and the measurements wrong.
Three habits help. Serialise on the page side, sparingly. JSON.stringify of a large store on every event can cost more than the event itself; send a diff, or send only the path that changed. Batch messages. Hundreds of postMessage calls per second will each cross two process boundaries; buffer in the hook and flush every animation frame. Cap what you keep. A panel that stores every event since it opened grows without bound during a long session; keep the last few thousand and let the developer export if they need more.
1let buffer = [];
2function emit(evt) {
3 buffer.push(evt);
4 if (buffer.length === 1) requestAnimationFrame(() => {
5 window.postMessage({ source: "reader-devtools", type: "batch", payload: buffer }, location.origin);
6 buffer = [];
7 });
8}
Execution context: the main-world hook. One message per frame, however many events occurred, keeps the overhead roughly constant under load — the difference is measurable in the page’s own Performance trace, which is covered in measuring content script impact on page load.
Cross-browser variation
- Chrome / Edge:
inspectedWindow.evalwith$0,panels.elements.onSelectionChanged, andnetwork.onRequestFinishedwithgetContent.scripting.executeScript({ world: "MAIN" })installs hooks. - Firefox: supports
inspectedWindow.evalincluding$0,onSelectionChanged, andnetwork.onRequestFinished. Main-world injection usesuserScriptsor a script element rather thanworld: "MAIN"in older versions. - Safari: Web Inspector extensions support
inspectedWindow.evaland panel creation; network hooks and element selection events are more limited. - All three:
evalresults must be serialisable. DOM nodes, functions and cyclic objects either fail or arrive as empty objects — convert to plain data on the page side.
Verification
- Select an element in the Elements panel and confirm the panel shows its tag and component.
- Confirm a snapshot read works from the panel’s own console:
1await evalInPage("document.title");
2// "Dashboard — Example"
Execution context: the panel document’s console (DevTools on DevTools). An exception here with isError set usually means the inspected page is a restricted URL where eval is not allowed.
- Open the panel, trigger analytics events on the page, and confirm they stream in; close DevTools and confirm the hook is no longer injected on reload.
- Record a Performance trace of the page with and without the panel open and compare main-thread time.
FAQ
Why is $0 undefined in my content script?
$0 is a DevTools console helper that exists only inside inspectedWindow.eval. Content scripts never see it.
Can I modify page state from the panel?
Yes, through eval — setting a flag, dispatching an action. Treat it as a developer tool feature and make it explicit in the UI; never do it implicitly on open.
Does the network API see requests from before DevTools opened?
No. onRequestFinished fires only while DevTools is open. getHAR returns what DevTools captured since it opened.
Related
- Building a custom DevTools panel — the panel and broker this plugs into.
- Bridging data between main world and isolated world — the hook’s transport.
- Debugging content scripts in the isolated world — the other side of the relay.
- Side panel and DevTools interfaces — the parent guide.