Issues that appeared specifically during the transition to fiber-based tracing — not reasons for the migration, but problems that had to be solved to make it work end-to-end.
_debugSource.fileName
Turbopack emits /Users/alex/project/app/page.tsx as Users/alex/project/app/page.tsx — no leading /. goToCode() received a path VS Code couldn't resolve. Fixed in EditorBridge.ts: prepend / when path looks absolute but lacks the slash.
Turbopack bundles multiple source files into a single chunk and uses a top-level "sections" array instead of a flat "mappings" string. The resolver threw on this format. Fixed: detect sections format, iterate each section's offset to find the right one for the clicked line/column.
SyncPositionService: _pendingSource consumed after early-return guard
The fiber click path sends a SourceLocation with the click event. But the service checked _getCurrentComponent() first — when no .tsx file was open, it returned early and dropped the source location silently. Click-to-source never worked in Next.js because of this. Fixed: consume _pendingSource before the active-component check. Regression test added.
node_modules paths for React internals
When clicking elements close to React internal boundaries (e.g. Suspense boundaries), source map resolution sometimes returned a node_modules/react/ path. Navigation opened a library file instead of user code. Fixed: skip results pointing to node_modules or .yarn/cache, fall back to last valid user-land result.
Next.js Turbopack loads source maps lazily. On first click after page load, the source map request was in-flight when the click handler ran, returning null. Click was silently dropped. Fixed: retry loop in iframe-interaction.ts with 5-second timeout. Click is buffered until the map arrives.
Preview generator picked up layout.tsx, loading.tsx, error.tsx as candidate entry points — these are App Router convention files, not renderable standalone components. Fixed: exclude known App Router reserved filenames from entry point heuristic.
Per-module breakdown — what each piece does, why it exists, and non-obvious connections between them.
Low-level React fiber accessor. Reads __reactFiber$xxx from DOM nodes (the property React writes at mount time; same approach as React DevTools). Extracts _debugSource: { fileName, lineNumber, columnNumber } from the fiber.
Non-obvious: the property suffix $xxx is a random string generated per React instance — can't do a direct property lookup. Must scan Object.keys(domNode) for the key that starts with __reactFiber$. Also: _debugSource is only present in dev mode builds and is populated by SWC/Babel's JSX source transform — absent in production.
Runtime fiber tree walker. Builds a two-way index: nodeRef → HTMLElement[] (for overlay rendering) and sourceKey → nodeRef (for reverse lookup). Walks the full React fiber tree on demand via walkFibers().
Non-obvious: both directions are needed. Forward (nodeRef→DOM) is used by overlay rendering to position highlight rectangles over elements. Reverse (source→nodeRef) is used when the cursor moves in VS Code to highlight the corresponding element in the preview. The index must be rebuilt after every React re-render since fibers are mutable.
Adapts fiber-internals for React 19 and SWC. React 19 renamed some internal fiber fields. SWC emits _debugSource.columnNumber with a ±1-2 line offset vs Babel due to different AST transformation order.
Non-obvious: the ±2 line tolerance in getDebugSource() is not a hack — SWC genuinely shifts line numbers by 1-2 for certain JSX patterns (self-closing vs children). Tighter tolerance causes misses; looser causes false positives when multiple elements are on adjacent lines.
Resolves compiled fiber positions back to original TypeScript source. Needed for Turbopack: _debugSource points to the compiled bundle chunk position, not the original .tsx file. Fetches the source map URL from the bundle's last line comment, decodes VLQ mappings, resolves the original file and position.
Non-obvious: Turbopack uses "indexed" source maps — a top-level "sections" array where each entry covers a line range and has its own sub-map. Standard source-map libraries don't handle this format. Each section has an offset: { line, column } that must be subtracted from the query position before looking up in that section's mappings.
Script injected into the preview iframe at load time. Intercepts all click events, reads the React fiber from the clicked DOM node, optionally resolves Turbopack source maps, then sends a hypercanvas:elementClick postMessage to the parent frame with the resolved SourceLocation.
Non-obvious: the whole pipeline is async because source map fetches are lazy. The script buffers the pending click and retries resolution for up to 5 seconds. Without this, the first click on a freshly loaded Next.js page always fails — Turbopack hasn't served the source map yet.
Bridge between iframe's window.postMessage and the extension's state management. Listens for hypercanvas:elementClick messages and calls stateHub.applyUpdate({ selectedIds, source }) — the same update path used by all other selection mechanisms.
Non-obvious: source is piggybacked on the existing applyUpdate call as an extra field. SyncPositionService reads it out of the patch via _pendingSource before the generic selection handler runs. This avoids adding a separate IPC channel for source locations.
Subscribes to stateHub selection changes and navigates VS Code's editor cursor. Two paths: (1) fast path — _pendingSource set by PostMessageTransport, navigate directly; (2) legacy fallback — no source available, look up element by UUID via AstService. Handles cursor-sync suppression to avoid feedback loops.
Column convention: SourceLocation.column is 0-based throughout the system. But goToCode() expects 1-based. The +1 conversion happens here: goToCode(fileName, line, column + 1). This is the only place the conversion occurs — both sides of the system must not try to compensate.
Server-side AST index: parses TSX/JSX files into a NodeMap keyed by nodeRef (relPath:line:col). Used by all 17 mutation routes to locate elements for style changes, renames, and deletions. File change detection triggers incremental rebuilds.
Non-obvious: nodeRef format is "relative/path.tsx:line:col" with a relative path (relative to workspace root). The client fiber sends an absolute path from _debugSource.fileName. The transport layer strips the workspace prefix before storing in nodeRef. This is the only place the conversion happens — callers must not strip/add paths themselves.
| Location | Convention | Notes |
|---|---|---|
_debugSource.columnNumber |
1-based | React internal; empirically verified for Vite+Babel |
SourceLocation.column |
0-based | System-wide standard; matches Babel AST node.loc.start.column |
goToCode(file, line, col) |
1-based col | VS Code Position expects 0-based — goToCode subtracts 1 internally |
Babel AST node.loc |
0-based col, 1-based line | Source of NodeMapService positions |
The three hardest problems encountered in phases 1–3 — root cause, investigation path, and fix.
Symptom: clicking any element in the Next.js preview did nothing. No error, no navigation, no log. The click registered visually (element highlighted) but VS Code editor never moved.
Investigation: Added console logging to the debug script (debug-nextjs-click-v2.ts) filtering for pendingSource, goToCode, navigate. Ran it via launchVSCode(). Log showed _pendingSource was being set correctly after each click. But goToCode was never called.
Root cause: _onPreviewSelectionChange() in SyncPositionService had this logical order:
// BEFORE (buggy) const component = this._getCurrentComponent(); // ← returns undefined (no tsx open) if (!component) return; // ← early return const pendingSource = this._pendingSource; // ← never reached this._pendingSource = null;
In Next.js the user opens the preview first, then clicks. No .tsx file is open in the editor yet — so _getCurrentComponent() returns undefined, the method returns early, and _pendingSource is never consumed. The source location is silently dropped.
Fix: moved _pendingSource extraction to before any guards. The fast path runs before _getCurrentComponent() is ever checked:
// AFTER (fixed)
const pendingSource = this._pendingSource; // ← extract first
this._pendingSource = null;
if (pendingSource) {
await goToCode(pendingSource.fileName, pendingSource.line, pendingSource.column + 1);
return; // ← early return AFTER navigation, not before
}
const component = this._getCurrentComponent(); // ← legacy fallback only
if (!component) return;
Lesson: "check state before guards" is a common logical ordering bug. The regression test now explicitly exercises this case: create service with getCurrentComponent: () => undefined, fire a click with source — verify goToCode was called.
Symptom: source map resolver threw TypeError: cannot read mappings of undefined on Next.js projects. Standard source maps worked fine.
Investigation: fetched the source map URL from a Turbopack chunk directly in Chrome DevTools. The JSON had this structure:
{
"version": 3,
"sections": [
{ "offset": { "line": 0, "column": 0 }, "map": { "sources": [...], "mappings": "..." } },
{ "offset": { "line": 47, "column": 0 }, "map": { "sources": [...], "mappings": "..." } }
]
// no top-level "mappings" key — resolver crashed here
}
This is the "indexed source map" format (Source Map spec §6.3) — Turbopack uses it to concatenate multiple file source maps into one chunk map without remapping all the line numbers. Each section covers a contiguous line range starting at offset.line.
Fix: detect sections format by checking for map.sections array. Find the right section for the query line (last section where section.offset.line <= queryLine), subtract the section's line offset from the query, then run standard VLQ lookup on that section's sub-map.
Extra bug: initial fix had an off-by-one — section offsets are 0-based but the resolver was treating them as 1-based. Caught by a test case with a two-section map where the second section started exactly at line 10.
Symptom: on a freshly loaded Next.js preview, the first click on any element produced no navigation. Second click on the same element worked fine. Deterministic.
Investigation: Added timing logs to iframe-interaction.ts. First click log showed sourceMapResult: null — the source map fetch returned nothing. 300ms later: sourcemap loaded. The second click succeeded because the map was now cached.
Root cause: Next.js / Turbopack serves source maps lazily — the .map file is only generated and served when first requested. The request triggered by the click handler resolved after the handler had already returned null and dropped the click.
Fix: store the click as pendingClick when source map returns null. Poll every 200ms for up to 5 seconds. When the source map becomes available (or is already fetched on retry), resume resolution and fire the postMessage. Cleanup on success or timeout:
if (!sourceMap) {
pendingClick = { target, timestamp: Date.now() };
return; // will retry via interval
}
// ... in retry interval:
if (Date.now() - pendingClick.timestamp > 5000) {
pendingClick = null; // timeout
return;
}
const map = await tryFetchSourceMap(url);
if (map) { resolvePendingClick(map); }
Four root causes prevented element clicking in Next.js 16.2.1 + React 19.1.0 projects. Each required a different fix layer.
Symptom: extractServerChunkFrames returned 0 frames for all RSC elements.
Root cause: React 19.1 changed _debugStack format from Server/file:///... to about://React/Server/file:///.... The regex /(?:^Server\/|^)(file:\/\/\/)/ didn't match the new prefix.
Fix: raw.indexOf('file:///') — find file:/// anywhere in the URL, not at start.
Symptom: First click on any element returned null. Only worked after retry.
Root cause: IIFE runs in <head> before React. Without DevTools hook, warming used load event which fires before hydration — no fibers on DOM yet, cache empty.
Fix: Install __REACT_DEVTOOLS_GLOBAL_HOOK__ with proper inject() returning renderer IDs. React calls onCommitFiberRoot during hydration → warming fires with fibers.
Symptom: All elements selected as outer div. Source cache had 1 entry with 5 elements.
Root cause: resolveViaClientSourceMap walks up fiber return chain. For RSC, all children found the same ancestor's cached entry (layout.tsx body).
Fix: Resolution order: own-server → client → chain-server. resolveOwnServerSourceMap checks only the fiber's own _debugStack for per-element precision.
Symptom: StyleReadService ENOENT for /Users/.../app/page.tsx.
Root cause: source-map-resolver.ts stripped leading / from all URLs. For file:///abs/path this turned absolute paths into Users/abs/path → doubled when prepended with workspace root.
Fix: Check u.protocol === 'file:' → keep u.pathname as-is (absolute). HTTP URLs → strip leading / (workspace-relative).
Symptom: Extension selected wrong elements in React 19 projects. Clicking toolbar div showed SVG in inspector.
Investigation: Launched research agent to study React DevTools source (facebook/react). Analyzed packages/react/src/jsx/ReactJSXElement.js and DevTools backend.
Root cause: React 19 jsxDEV creates Error("react-stack-top-frame") INTERNALLY — ignores __source/__self from @vitejs/plugin-react. exports.jsxDEV accepts only 4 args, not 6. Stack trace line:column are COMPILED positions from Vite JSX transform.
Key insights from DevTools source:
Error.prepareStackTrace for V8 structured CallSite objects + symbolicateSource() for source map resolution — same approach we now useownerStackLimit (10,000) throttles Error creation — after limit, shared placeholder usedgetLineNumber() (call site) vs getEnclosingLineNumber() (function definition) — we correctly use call siteFix: Extended warmClientChunk to fetch Vite inline source maps (base64 data: URI), decode, and resolve compiled→source positions. Verified: 34 positions resolved, compiled L43:31 → source L32:10.
Without this research, would have gone down the wrong path of parsing __source from compiled output instead of proper source map resolution.
Symptom: ReactAdapter.detect() returned false for all 16 retries. Tracer never initialized. Canvas selection completely dead.
Root cause: iframe elements use iframe window's HTMLElement class, not parent window's. Cross-realm instanceof ALWAYS returns false.
Fix: Replaced instanceof HTMLElement with nodeType === 1. Also moved detection to useElementTracer.ts because Bun HTML import cache refused to serve updated react-adapter.ts.
// Before (BROKEN cross-realm):
if (el instanceof HTMLElement) return el;
// After (works everywhere):
if (el && el.nodeType === 1) return el;
Problem: Clicking <button> inside Button.tsx selected first Button instance ("Dark" instead of "←"). All 6 instances shared Button.tsx:31:7.
Three iterations:
Final: Explicit renderedFile from componentPath. Direct for rendered file, walk-up for imported. Shared in resolve-source.ts.
Symptom: resolveClickLocal returned null. 27 node maps, but lookup always missed.
Root cause: Server: /Users/.../test-repo/src/App.tsx. Docker: /app/src/App.tsx. Map.get() never matched.
Fix: Convert to container paths at populateNodeMaps. Also fixed Vite source map sources: ["TweetComposer.tsx"] (no path) — prepend directory from URL.