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.
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
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.
| Framework | What the runtime still exposes | What it is worth |
|---|---|---|
| React (older toolchains) | _debugSource on the fiber | The element's exact line |
| React (Next 15 / SWC dev) | Fiber, display name, owner chain | A name, and no line |
| Vue | __file on the component options | The right file, line zero |
Vue + vite-plugin-vue-tracer | A traced call-site position | The element's exact line |
| Angular | ɵcmp.debugInfo | The 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 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?

