Skip to main content

React DevTools

react-x11 speaks to the standalone React DevTools app over its WebSocket bridge.

Setup​

npm i -D react-devtools-core # already a dev dep 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

Either order works: the backend retries every two seconds, so an app started first attaches a moment after the UI comes up. Under npx react-x11 dev the bridge is on by default wherever react-devtools-core is installed — run npx react-devtools and the app shows up, REACT_X11_DEVTOOLS=0 turns it off — and where it is not installed while a UI is listening, dev says what to install.

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, Escape gives up. The app sees none of those events;
  • the style editor — the box model and the flattened style of 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.json on 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 through ps x for an editor that is already running, else VISUAL, else EDITOR. It knows each editor's argument style — vim takes +42, sublime and the JetBrains editors take file: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 our file:// 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.

The backend never stops trying to connect — a retry timer while no UI listens, an open socket while one does — and either kept the process up after the app's last window closed. Its code runs in a scope (inBackend) where the timers it starts and the sockets it opens are unref'd, entered at our connect call, at its socket's close and error handlers, and at the timers it armed there; the globals are swapped only for that synchronous stretch, so the app's own setTimeout and WebSocket are untouched. The socket is ws from react-devtools-core's own dependencies, which is why ws is not a separate install.

Under react-x11 dev the hook is installed earlier, before React Refresh loads: Refresh installs a stub hook where it finds none, the backend installs none where it finds one, and the stub cannot connect.

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 in test/smoke.test.js asserts what reaches the hook, so the next reconciler bump cannot repeat it.
  • react-devtools-core v7 requires initialize() before connectToDevTools, exposes its API on the default export under node ESM interop, and expects browser-ish globals (self, window) — all handled in prepare().
  • The Highlighter only emits showNativeHighlight for host instances that implement getClientRects() returning a non-empty rect, and anything with getClientRects is measured at mount through instance.ownerDocument.documentElement — nodes therefore expose both (see Node.getClientRects / the ownerDocument stub in src/nodes/).
  • Highlight events arrive on the backend agent (hook.reactDevtoolsAgent, or the 'react-devtools' hook event); attachHighlightAgent maps 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 as window.document == null. That is true for a node process with window = 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) and disableTraceUpdates. attachTraceUpdatesAgent groups them by window and hands each root the rects; WindowNode.setTraceUpdates is a dumb overlay that replaces rather than accumulates. It schedules those redraws on requestAnimationFrame, which node does not have — prepare() installs an unref'd setTimeout shim, and without it the first traced commit throws inside the backend.
  • The picker: with no window.addEventListener to install, the backend emits startInspectingNative and waits. attachPickerAgent takes the pointer through setInspectHandler (events.js) — motion, press and release are answered and go no further — and calls agent.selectNode() then agent.stopInspectingNative(true). Cancelling from the DevTools side arrives as a bridge message with no host-facing event, so the picker also listens on agent._bridge directly; 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 (our flattenStyle, minus state blocks, which are not editable values), nativeStyleEditorValidAttributes (STYLE_PROP_NAMES), and instance.measure(cb) on host instances for the box model — RN's contract, implemented by Node.measure. Edits arrive as overrideValueAtPath on ['style'] with the value wrapped in an array, which works here only because a react-x11 style prop already takes an array.
  • Reload and profile is gated on a backend check that asks whether synchronous XHR works, so isReloadAndProfileSupported: true is 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/sessionStorage are defined on the global rather than assigned: reading global.localStorage first is what makes node print its --localstorage-file warning on every DevTools run.
  • Hook names and sources need a deeper stack than V8 keeps. React reads each hook's location — and the custom-hook nesting the names come from — off a stack captured at the hook dispatch, and Error.stackTraceLimit is 10 by default. A loader leaves frames of its own between the hook and its call site (tsx, which every npm run examples:* goes through, costs about ten), so the identifying frames fall off the end: hookSource comes back {fileName: null, lineNumber: null, …}, the frontend logs "Hook source code location not found", and the element shows its hooks with no names and no source lines (#514). prepare() raises the limit to 50 when the bridge is enabled, and only upwards, so an app that asked for more keeps it. test/devtools-hooks.test.js squeezes the budget and asserts the names and sources still come back over the bridge.
  • Selecting a component with hooks can blank the DevTools window. That is the standalone app's own renderer process aborting, upstream of anything the host sends: it happens with every hook source complete, and under runtimes with no loader at all. Turning ⚙ → Components → "Always parse hook names for the selected element" off makes it intermittent rather than reliable; restarting npx react-devtools reconnects to the app, which is unaffected.
  • test/devtools.test.js drives the real backend over a socket for all of the above; the fake-agent tests in test/smoke.test.js prove 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 (abs rect, 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 — see test/integration.test.js for the harness pattern, which is also how the README screenshots are generated.