There is a gesture I make a few hundred times a day. Something on the page is wrong — a price set in the wrong weight, a button four pixels off its row — and I point at it and ask the machine a single question. Which line of mine drew this?

For 13 years, in mobile work, that gesture was answered. You clicked the widget and the tooling put you in your own file, on the right line, because the framework and the tooling were built by the same people and the compiler kept the mapping.

Then I moved to the web, where it is not answered, and it took me longer than I would like to admit to understand that nobody was withholding the answer. It had been thrown away.

The question the browser will not answer

A browser will tell you almost everything about an element. Its box model to a subpixel. Its computed styles, and which rule won. Its listeners, its stacking context, its paint timings, the bytes it cost.

It will not tell you who wrote it.

That is not an oversight. The DOM is the output. By the time a node exists there is no back-pointer to the JSX expression, template directive or string concatenation that produced it, because nothing in the platform ever needed one. The browser's job finished at "here is a <span>". Yours started three abstractions earlier.

Every other question a devtool answers is a question about the output. This one is a question about the input, asked from the output side, and that is why it is the only one that is structurally hard.

What the build takes away

For a while React answered it for you. The development JSX transform attached a _debugSource to each element — a file, a line, a column — and the adapter that reads it is nine lines long:

function getSource(fiber: any): Source | undefined {
  if (!fiber?._debugSource) return undefined
  return {
    fileName: fiber._debugSource.fileName,
    lineNumber: fiber._debugSource.lineNumber || 0,
    columnNumber: fiber._debugSource.columnNumber || 0,
  }
}

Nine lines, and on a current Next.js dev server they return undefined.

The comment beside the workaround in this codebase is careful about what it claims, and so am I: React fibers carry no _debugSource under Next 15 with SWC (src/core/commandHandler.ts:970). That is what the repository observed, on real projects, and it is all it says. I have seen the same behaviour reported in wider forms — that a particular React release deleted the field outright — and found no changelog entry I would be willing to cite, so I am not making that claim in an article about not being confidently wrong.

What is clear is that the pressure runs one way. Three separate forces, none of them mistaken, all removing the same thing.

Compilers drop what the runtime does not need. A development-only field that exists purely for tooling is exactly the kind of thing a faster transform stops emitting, and no specification obliges it to.

Minification rewrites your names. A production-shaped build hands you t, e and n where you wrote LineItem, CartTotal and PriceLabel. Even the component name — the weakest signal that is still useful — is gone.

Server components make the file graph lie. In a Next.js build the (rsc) bundle contains a client-reference stub for every 'use client' file. The file is in the graph. It has a source map. It has no executable mapping at any line, because nothing in it runs there. That one cost me days on the debugger side, where a stub's honest "cannot bind here" is indistinguishable from "this line is browser-only":

// The Next.js (rsc) bundle is a client-reference STUB for every
// 'use client' file — it never has an executable mapping, even for lines
// that ARE server-reachable via the (ssr) bundle. Treating an (rsc) miss
// as "browser-only" produced FALSE "Switch to Browser" prompts.
const isRscStub = /\(rsc\)/.test(scriptUrl)

Every one of those decisions is correct for shipping fast code. They are simply all correct in the same direction, and what they collectively delete is the map back to the author.

You do not get to change the app

Here is the constraint that turns an annoyance into a design problem.

Every workaround available to a library requires the developer to help. Add a Babel plugin. Stamp a data-source attribute onto every element. Wrap the tree in a debug provider. Run a patched compiler in development. Each of those works, and each of them asks a person to modify their build for the privilege of being inspectable.

That is a tool which works on the demo repository and on nobody's actual product.

So the requirement, stated once and then obeyed everywhere: attach to an app that is already running, that was built by a toolchain you did not choose, and answer which line drew this without having asked for anything in advance.

Everything after this sentence is a consequence of that one.

Why not just add a Babel plugin?

This is the first thing anyone suggests, and it deserves a straight answer rather than a dismissal, because it is a good suggestion. A transform that stamped data-src="file:line" onto every element would make everything below unnecessary. The information would simply be there, exact, on every node.

Three reasons it is not the design.

It only works where it is installed. A tool whose first instruction is "add this to your build config" is a tool you evaluate on a scratch project and never reach for during the incident you actually needed it in. The moment of demand always arrives on an unprepared codebase.

It changes what it measures. This repository learned that expensively once already: it injects a stub of __REACT_DEVTOOLS_GLOBAL_HOOK__ into every page for the Performance tab, and that made the React adapter answer "yes, this is React" on Vue and Angular apps too. A build transform is the same failure mode with a much larger blast radius — now the bug you are chasing might be in the instrumentation.

It is one implementation per toolchain. Babel, SWC, Vite, esbuild, the Angular compiler. The thing that generalises across all of them is not a transform. It is your repository, sitting on disk — the one artefact the build cannot delete.

Three runtimes that share no memory

Which is why this is an editor extension, and why it is three programs rather than one.

Your app, in the browserdist/agent.js · injected IIFEcomponent tree + overlaysReact · Next · Angular · Vue adaptersnetwork + console interceptionVS Code extension hostdist/extension.js · NodewsServer · typed protocolmessage routerworkspace search · open fileDevTools UI, in the browserdist/devtools.js · ReactInspector · Network · DebugConsole · Coverage · Previewsends viewer commandsWebSocket?token=…agent:* ⇄ host:*WebSocket?viewer=1extension:* ⇄ viewer:*Your dev servernext dev · vite · ng serveagentInjector · HTTP proxyrewrites HTML before </body>plain HTMLsame HTML, plus one <script> tagThe agent and the UI never speak directly. The host is the only process that can see your repository.
Three programs, three runtimes, no shared memory. Everything the browser knows reaches the editor as a message, and everything the editor knows about your files stays in the editor.

The agent is a single IIFE injected into your running app. It knows the DOM and the framework's instances, and nothing whatsoever about your filesystem. The extension host is a Node process inside VS Code. It knows your workspace and has never seen your page. The DevTools UI is a third browser context, a React app, which knows neither and renders both.

Each of the two browser-side programs holds one WebSocket to the host. They never hold one to each other. The agent gets into the page through an HTTP proxy that buffers your dev server's HTML on the way past and rewrites it:

if (html.includes('</body>')) {
  html = html.replace('</body>', scriptTag + '</body>')
}

That is the whole injection mechanism — one string replacement on a response that was going to the browser anyway. Nothing in your project changes, and when the proxy is not running your app is exactly your app.

The socket is the part that needed care, because it carries a channel that can read and write workspace files and evaluate expressions in the debuggee. It fails closed on a missing token, not merely a wrong one:

// Fail CLOSED. The old guard was `if (this._sessionToken && ...)`, so
// while the token was still unset — `start()` runs at activation, but
// `setSessionToken()` only lands once the injector has generated one —
// the check was skipped entirely and ANY local connector was accepted
// onto a channel that can read and write workspace files.
if (!this._sessionToken || url.searchParams.get('token') !== this._sessionToken) {
  ws.close(1008, 'stale or missing session token')
  return
}

A short window, and unauthenticated for the whole of it.

What one click actually does

Youthe running appBrowser agentdist/agent.jsExtension hostdist/extension.jsVS Codeyour workspacebrowser ↔ editor1 · click an elementselect mode intercepts the eventwalk ancestors → owning componentextractVisibleText(el) → clickedText2 · agent:component-selected{ source?, clickedText, componentName }alt[ no exact line, or precision: 'component' ]2a · rankedTextSearch(name)→ the owning component file2b · pickTextLine(file, text)→ the element's own line3 · openFileAtLine(path, line)path validated against the workspace4 · the file is open, caret on the lineEverything left of the band happens in the page. Everything right of it happens in your repository.
One round trip. Steps 2a and 2b run only when the build left no exact line behind — which, on a current Next.js dev server, is most of the time.

Follow the gesture across those three processes.

You click. Select mode intercepts the event before the page can act on it, and the agent does two things that look like one. It walks up the ancestor chain for the component that owns the element, and it reads the text you clicked.

Those are two different questions, and the codebase says so at the exact point where it would be easy to conflate them:

// Two different questions, previously answered with one element.
//
//   WHICH ELEMENT did the user click?   → its box, its computed values, its
//                                         matched rules. That is `el`.
//   WHICH COMPONENT owns it?            → its props, state, handlers, source
//                                         file. That is `target`, found by the
//                                         walk above.

Then one message crosses the boundary: agent:component-selected, carrying a source location if the runtime had one, plus clickedText and componentName if it did not. A few hundred bytes of JSON. That is the entirety of what the browser hands the editor.

Everything after that happens where your files are. The host resolves a location, sends the component detail on to the DevTools UI, and opens the file. Opening it is four lines of real work and a paragraph of paranoia, because the function is exported and one careless caller away from being something much worse:

// Same two defects as openSourceFile had, same fix: constrain the path to the
// workspace, and never interpolate it into a shell string. Callers pass paths
// that originate in workspace search results today, but this function is
// exported and one untrusted caller away from being a file-disclosure and
// code-execution primitive.
const safePath = validateWorkspaceFilePath(filePath)

Two hops and a search. The reason it cannot be one hop is the band down the middle of that diagram: the page has the runtime, the editor has the repository, and no amount of cleverness inside the page will get it one.

Five answers, in order of how much you should believe them

The resolution step is not a mechanism. It is a cascade, because every framework leaves behind something different to start from.

FrameworkWhat the runtime still exposesWhat it is worth
React (older toolchains)_debugSource on the fiberThe element's exact line
React (Next 15 / SWC dev)Fiber, display name, owner chainA name, and no line
Vue__file on the component optionsThe right file, line zero
Vue + vite-plugin-vue-tracerA traced call-site positionThe element's exact line
Angularɵcmp.debugInfoThe export class Foo { line

Two of those five rows are an answer. The rest are the beginning of one — a file, or a name that can find a file — so the host treats the location as something to be resolved rather than read:

/**
 * Resolve a definitive source location for a selected component when the agent
 * couldn't (the React fiber's `_debugSource` is absent in some toolchains, e.g.
 * Next.js SWC dev). Strategy: resolve the owning component file by name, then
 * locate the clicked text's exact line *within that file*.
 */
THE TEST, IN ORDERWHAT YOU GETDoes the fiber carry _debugSource?the JSX transform left the position behindthe element's own authoring lineexactyesnoDid the adapter give us a file?Vue __file · Angular ɵcmp.debugInfosearch that one file for the clicked textexactyesnoDo we have a component name?rankedTextSearch(name) → the owning filethen the clicked text inside that fileexactyesnoIs the clicked text long enough?≥ 5 characters, ranked across the workspacethe best-scored line in the repositoryrankedyesnoIs there a definition for the name?export function Foo · const Foo = · class Foothe component, not the element you clickedcomponentyesnoNothing left to go on.no text, no name, no file“No definition found for …”say so
Five ways to answer one question, tried hardest-evidence first. Only the top three land on the element you actually clicked — and all five used to arrive at the caller looking identical.

The load-bearing idea is that last step. Once you know which file, the text you clicked is the strongest signal available, because it is the one thing that survived the build — the string in your JSX is the string on your screen. Minifiers rewrite identifiers. They do not rewrite your copy.

And it is scoped. A workspace-wide search for "Environment" returns a mess; the same search inside one file you already trust returns the line. Even there the occurrences are ranked — rendered markup content beats a mention in a doc comment, which beats a substring of an identifier — but the ambiguity is now small enough that ranking is worth doing.

The line number that lied

The bug I got the most out of was not a crash. Nothing threw. The wrong file never opened. It just kept opening the right file at the wrong line.

Angular's ɵcmp.debugInfo carries a line number, and that line number points at the class declaration — export class Hero { — not at the element you clicked, which is somewhere above it in the template. Vue without the tracer plugin has no line at all, so it reports zero.

The refinement that finds the clicked text inside the file was already written. It never ran.

Both cases arrived at the host as an ordinary { fileName, lineNumber }, so every do we have a line? check passed. Line 1 looked like a line. Line 118 looked like a line. A wrong line and a right line are the same shape.

The fix was to make the type carry what the number means:

export interface Source {
  fileName: string
  lineNumber: number
  columnNumber: number
  /** What `lineNumber` actually points at.
   *
   *  'element'   — the element's own authoring position. React's `_debugSource`
   *                is per-JSX-element, so this is exact.
   *  'component' — the COMPONENT'S DECLARATION, not the element. Angular's
   *                `ɵcmp.debugInfo` stamps the `export class Foo {` line; the
   *                template sits above it and the clicked element is somewhere
   *                inside that template.
   *
   *  Without this, all three arrive as an indistinguishable
   *  {fileName, lineNumber} and every "do we have a line?" check passes.
   */
  precision?: 'element' | 'component'
}

One optional field, and the refinement woke up. It also woke a second thing I had not connected. In Angular a component has exactly one source location, so the element's location and its owning instance's location are literally the same object — which meant the right-click menu's "would this row open the same place?" check was comparing a value against itself, always winning, and suppressing its single most useful row. Adding precision turned that comparison into a real question.

The lesson generalises, and I have now paid for it twice in this codebase: a value and your confidence in it are two different values. Collapsing them is how a system ends up confidently wrong instead of usefully unsure, and a debugger has no worse failure mode than being believed.

The search that wrote to the wrong file

The same resolution machinery feeds the Inspector's write-back — change a className in the panel and it edits the file. Which means a ranking mistake stops being a wrong jump and becomes a wrong edit.

It found one. A save resolved into Angular's Vite dependency cache and wrote there. Successfully. Into a generated file that the next build overwrote:

// Build output must be excluded, not merely deprioritised. A text search that
// reaches it can resolve an element to a bundled dependency — and the save then
// SUCCEEDS, stamping a style attribute into a generated file the next build
// overwrites. `.angular/` (Angular's Vite dep cache) was the gap.

There is a second sentence in that comment which is the more important one: keep this list in step with the stylesheet exclusions in commandHandler.ts — they must never disagree about what is generated. Two lists encoding the same belief is a bug with a delay fuse, and writing the constraint down beside both of them is the cheapest fix available short of merging them.

I am not going to pretend the ranking is solved. It is scored, the weights are derived from how specific your query is rather than from magic numbers, and everything generated is excluded outright rather than merely pushed down. If the same label appears in four of your files, it is picking, not knowing.

Shipping it costs something too

Inspectro is on the VS Code Marketplace, at 1.0.1, for React, Next.js, Angular and Vue. That is the announcement, and the last stretch to it was not the engineering I had planned for.

The Marketplace rejects any extension that declares enabledApiProposals in its package.json. The integrated browser tab — the one that puts the running app inside the editor rather than beside it — needs exactly that field, for the browser proposal. There is no configuration that satisfies both.

So the repository builds two flavors of the same extension, and a script mutates the manifest between them:

INSPECTRO_PROPOSED_API=true   node scripts/package-with-flavor.mjs   # sideload
INSPECTRO_PROPOSED_API=false  node scripts/package-with-flavor.mjs   # marketplace

It restores package.json afterwards "regardless of whether the build succeeded or failed", which reads as paranoia right up until the first time a failed package leaves a mutated manifest in your working tree and you commit it.

The published build is the smaller of the two, and picking it was the right call. A feature that only exists in a build you have to install by hand is not shipped. It is demonstrated.

What this still does not do

An essay that lists only strengths is marketing. Here is the state of it, including the parts I would rather not write.

The text search needs text. Click an icon, a spacer, an empty container, and there is nothing distinctive to look for. The cascade falls through to the component file and opens it at the definition — useful, and not what you asked for.

Duplicate strings are genuinely ambiguous. Ranked, and scoped to a single file wherever possible, and still a guess when your design system uses the same label in four places.

React resolves by evidence; Angular and Vue resolve by inference. When the fiber carries a real _debugSource the answer is read off the runtime. Everywhere else it is reconstructed from a name and a string, and reconstruction has a failure rate you will occasionally meet.

Prop editing is React-only, and write-back is narrower still. The component tree works across all four supported frameworks. Editing a value live and watching the UI update is React today, and writing that edit back into the file only happens for values that exist as a static literal in source — the panel marks the rest read-only rather than offering an edit that always fails.

It needs your dev server. This attaches to a dev server running on your machine, not to production, and that boundary is deliberate. It does mean it cannot help with a bug you can only reproduce in prod.

If one of those is the thing you actually need, I would rather you learned it from an article than from an install.

Where this goes next

The last article promised a piece on how these three runtimes hold one coherent picture between them, and why keeping the preview alive while execution is paused at a breakpoint turned out to be the hardest problem in the product. That is still the next one, and this was its missing half — because every piece of state that machinery works to keep alive is ultimately in service of the same question.

If you have ever pointed at something on your own screen and had no way to ask your own editor about it, that is the gap we are building against. Which line drew this?