React DevTools
react-x11 speaks to the standalone React DevTools app over its WebSocket bridge.
Setup
npm i -D react-devtools-core ws # already dev deps of this repo
# terminal 1 — the standalone UI (listens on :8097)
npx react-devtools
# terminal 2 — any app/example with the bridge enabled
REACT_X11_DEVTOOLS=1 npm run examples:dashboard
Order matters only in that the standalone should be listening when the app starts (the backend retries, so starting the app first also works after a few seconds).
What you get:
- the component tree, live as the app updates;
- props, hooks and state when selecting a component, and editing any of them — the edit goes through the reconciler's own override path, so the app really re-renders;
- highlight-on-hover — hovering an element in the tree tints its rect in the X11 window;
- "highlight updates when components render" — the rects that just re-rendered are outlined, in DevTools' own colour ramp (teal for a first render through amber for one that keeps re-rendering), fading out on the backend's clock;
- the element picker — the crosshair in the DevTools toolbar. While it
is on, the pointer belongs to DevTools: motion tints whatever is under
it, a click selects that element in the tree,
Escapegives up. The app sees none of those events; - the style editor — the box model and the flattened
styleof a selected element, editable live. Host elements are hidden by the default component filter, so turn that filter off (⚙ → Components → uncheck "host components") to have something with a style to select; - the Profiler's commit list and flamegraph, including "reload and start
profiling", which re-execs the app with profiling already on so the
mount itself is recorded. Not its Timeline view, which needs
injectProfilingHooks— this reconciler build does not expose it, so there is nothing to enable; - DevTools' own settings — component filters, console patching — kept
between runs, in
$XDG_CACHE_HOME/react-x11/devtools.json(~/Library/Caches/react-x11/devtools.jsonon macOS).
With REACT_X11_CLICK_TO_COMPONENT=1 as well, an Alt+Click also selects
the clicked element in the DevTools tree on its way to opening the source
(click-to-component.md).
REACT_X11_DEVTOOLS_HOST / REACT_X11_DEVTOOLS_PORT point the backend at
a non-default standalone (e.g. devtools on another machine — handy when the
X app runs on a headless box). REACT_X11_DEVTOOLS_STATE moves the
settings file.
DevTools has no network panel — nothing in its protocol carries network
data, and no injection would change that. For that, node --inspect and
chrome://inspect still work alongside all of this.
Opening the source from the DevTools panel
The inspected-element panel already shows where a component was written —
file:///…/tasks.jsx:42:7, from the same React 19 call-site stacks
click-to-component reads, absolute and source-mapped. Making that line
clickable is a standalone-side setting, and it has two independent
routes:
-
Spawn an editor. The standalone launches one itself, choosing it from
REACT_EDITOR(a full command line, shell-quoted), else — on macOS — by looking throughps xfor an editor that is already running, elseVISUAL, elseEDITOR. It knows each editor's argument style — vim takes+42, sublime and the JetBrains editors takefile:42— so this is the route to reach for:REACT_EDITOR=cursor npx react-devtools -
Open a URL. ⚙ → General → "Open local files directly in your code editor", with the VS Code preset or a custom template using
{path},{line}and{column}. The frontend turns ourfile://source into a plain path before filling it in. The setting lives in the standalone's own storage, not the app's.
Both are configured where the standalone runs, which may be a different machine from the app. Neither is something the app can inject: the source location is all the backend is asked for, and it is already sent. Click-to-component (click-to-component.md) is the app-side answer to the same question, and the one that works when DevTools runs somewhere without your editor on it.
The same backend, minus the socket, powers react-x11/test's component
inspection: inspect()/setHook in
testing.md drive the renderer
interface the backend registers at injection, entirely in-process — no
standalone app, no ws, no env variable. If a test run does set
REACT_X11_DEVTOOLS=1, the two share one renderer registration (see the
guard in connect()).
Watching the wire
The bridge is a plain WebSocket carrying text frames of {"event": …, "payload": …} — no CDP, no binary framing. scripts/devtools-proxy.mjs
sits in the middle of it and prints what goes past, decoding the packed
operations arrays (the tree deltas) into readable lines:
npx react-devtools # terminal 1 — the real UI on :8097
npm run devtools:proxy # terminal 2 — :8098 -> :8097
REACT_X11_DEVTOOLS=1 REACT_X11_DEVTOOLS_PORT=8098 npm run examples:dashboard
0.008 ▲ app operations 193B
· renderer 1, root 1
· add root 1
· add 3 <Counter> (Function) under 2
0.291 ▼ devtools inspectElement {"id":3,"rendererID":1,…}
--only/--skip filter by event name, --full prints whole payloads, and
--jsonl FILE appends every frame for later analysis.
How it works (and its sharp edges)
src/DevToolsIntegration.js. When REACT_X11_DEVTOOLS is set, the hook is
installed from render()/createRoot() rather than at import time — a
top-level await there would make every bundle of react-x11 ESM-only, which
rules out a single executable (packaging.md). The ordering
guarantee is the same: a commit can only follow a root, and there is no way
to get one without awaiting the install. It connects the backend and
registers the renderer via injectIntoDevTools.
Notes for maintainers:
injectIntoDevTools()takes no arguments in react-reconciler 0.33. What it reports comes from the host config —rendererPackageName,rendererVersion,extraDevToolsConfig— and an argument passed here is ignored without a word, which is how react-x11 shipped for a while with its renderer name and version quietly missing (#121). A test intest/smoke.test.jsasserts what reaches the hook, so the next reconciler bump cannot repeat it.- react-devtools-core v7 requires
initialize()beforeconnectToDevTools, exposes its API on the default export under node ESM interop, and expects browser-ish globals (self,window) — all handled inprepare(). - The Highlighter only emits
showNativeHighlightfor host instances that implementgetClientRects()returning a non-empty rect, and anything withgetClientRectsis measured at mount throughinstance.ownerDocument.documentElement— nodes therefore expose both (seeNode.getClientRects/ theownerDocumentstub insrc/nodes.js). - Highlight events arrive on the backend agent
(
hook.reactDevtoolsAgent, or the'react-devtools'hook event);attachHighlightAgentmaps the public instance to its owning window node and paints a translucent overlay. - What makes the native paths run at all is
isReactNativeEnvironment(), which the backend defines aswindow.document == null. That is true for a node process withwindow = global, so the backend takes React Native's route everywhere: it asks the host to draw the highlight and the update outlines, and to drive the picker, instead of drawing into a DOM overlay of its own. Every injection below is one of those asks. - Update outlines: the backend counts updates, picks each rect's colour
and expires it on its own clock, then emits
drawTraceUpdates([{node, color}]— the whole live set, every time) anddisableTraceUpdates.attachTraceUpdatesAgentgroups them by window and hands each root the rects;WindowNode.setTraceUpdatesis a dumb overlay that replaces rather than accumulates. It schedules those redraws onrequestAnimationFrame, which node does not have —prepare()installs an unref'dsetTimeoutshim, and without it the first traced commit throws inside the backend. - The picker: with no
window.addEventListenerto install, the backend emitsstartInspectingNativeand waits.attachPickerAgenttakes the pointer throughsetInspectHandler(events.js) — motion, press and release are answered and go no further — and callsagent.selectNode()thenagent.stopInspectingNative(true). Cancelling from the DevTools side arrives as a bridge message with no host-facing event, so the picker also listens onagent._bridgedirectly; without that a cancelled picker would keep swallowing the app's input. - The style editor needs three things and is offered only if it gets
them:
resolveRNStyle(ourflattenStyle, minus state blocks, which are not editable values),nativeStyleEditorValidAttributes(STYLE_PROP_NAMES), andinstance.measure(cb)on host instances for the box model — RN's contract, implemented byNode.measure. Edits arrive asoverrideValueAtPathon['style']with the value wrapped in an array, which works here only because a react-x11styleprop already takes an array. - Reload and profile is gated on a backend check that asks whether
synchronous XHR works, so
isReloadAndProfileSupported: trueis passed explicitly. Registering a renderer does not start profiling, either — the run that comes back from the restart starts it itself, before the first commit, which is the only reason to restart at all. localStorage/sessionStorageare defined on the global rather than assigned: readingglobal.localStoragefirst is what makes node print its--localstorage-filewarning on every DevTools run.test/devtools.test.jsdrives the real backend over a socket for all of the above; the fake-agent tests intest/smoke.test.jsprove the drawing but not that the backend still asks for it.
Other debugging aids
REACT_X11_DEBUG_LAYOUT=1— outline every laid-out node, color-coded by tree depth.- Refs: drawn elements expose the retained node (
absrect,scrollTo, …);<window>/<popup>expose the live ntk window (getContext('2d'),queryTree,setCursor, …). - The in-process X server (
x11/lib/xserver) renders headlessly and reads pixels back — seetest/integration.test.jsfor the harness pattern, which is also how the README screenshots are generated.