@treelocator/runtime 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,15 @@ import { SvelteTreeNodeElement } from "./svelte/svelteAdapter";
4
4
  import { VueTreeNodeElement } from "./vue/vueAdapter";
5
5
  import { detectJSX, detectReact, detectSvelte, detectVue } from "@locator/shared";
6
6
  import { detectPhoenix } from "./phoenix/detectPhoenix";
7
+
8
+ /**
9
+ * Fallback React detection: check if any DOM element has __reactFiber$ keys.
10
+ * Works without React DevTools extension (where detectReact() fails because
11
+ * the renderers Map is empty).
12
+ */
13
+ function hasReactFiberKeys(element) {
14
+ return Object.keys(element).some(k => k.startsWith("__reactFiber$"));
15
+ }
7
16
  export function createTreeNode(element, adapterId) {
8
17
  // Check for explicit adapter ID first
9
18
  if (adapterId === "react") {
@@ -26,7 +35,7 @@ export function createTreeNode(element, adapterId) {
26
35
  if (detectVue()) {
27
36
  return new VueTreeNodeElement(element);
28
37
  }
29
- if (detectReact()) {
38
+ if (detectReact() || hasReactFiberKeys(element)) {
30
39
  return new ReactTreeNodeElement(element);
31
40
  }
32
41
 
@@ -3,3 +3,16 @@ export declare function findDebugSource(fiber: Fiber): {
3
3
  fiber: Fiber;
4
4
  source: Source;
5
5
  } | null;
6
+ /**
7
+ * Async version of findDebugSource that supports React 19's _debugStack.
8
+ * Falls back to synchronous _debugSource check first (React 18).
9
+ * If that fails, parses _debugStack and resolves via source maps.
10
+ */
11
+ export declare function findDebugSourceAsync(fiber: Fiber): Promise<{
12
+ fiber: Fiber;
13
+ source: Source;
14
+ } | null>;
15
+ /**
16
+ * Check if this is a React 19+ environment (has _debugStack but not _debugSource).
17
+ */
18
+ export declare function isReact19Fiber(fiber: Fiber): boolean;
@@ -1,6 +1,8 @@
1
+ import { resolveSourceFromDebugStack } from "./resolveSourceMap";
1
2
  export function findDebugSource(fiber) {
2
3
  let current = fiber;
3
4
  while (current) {
5
+ // React 18 and earlier: _debugSource is a structured object
4
6
  if (current._debugSource) {
5
7
  return {
6
8
  fiber: current,
@@ -10,4 +12,39 @@ export function findDebugSource(fiber) {
10
12
  current = current._debugOwner || null;
11
13
  }
12
14
  return null;
15
+ }
16
+
17
+ /**
18
+ * Async version of findDebugSource that supports React 19's _debugStack.
19
+ * Falls back to synchronous _debugSource check first (React 18).
20
+ * If that fails, parses _debugStack and resolves via source maps.
21
+ */
22
+ export async function findDebugSourceAsync(fiber) {
23
+ // Try synchronous path first (React 18)
24
+ const syncResult = findDebugSource(fiber);
25
+ if (syncResult) return syncResult;
26
+
27
+ // React 19: try resolving via _debugStack + source maps
28
+ let current = fiber;
29
+ while (current) {
30
+ const debugStack = current._debugStack;
31
+ if (debugStack?.stack) {
32
+ const source = await resolveSourceFromDebugStack(debugStack);
33
+ if (source) {
34
+ return {
35
+ fiber: current,
36
+ source
37
+ };
38
+ }
39
+ }
40
+ current = current._debugOwner || null;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Check if this is a React 19+ environment (has _debugStack but not _debugSource).
47
+ */
48
+ export function isReact19Fiber(fiber) {
49
+ return !fiber._debugSource && !!fiber._debugStack;
13
50
  }
@@ -1,7 +1,19 @@
1
1
  import { findDebugSource } from "./findDebugSource";
2
+
3
+ /**
4
+ * Find the React fiber key on a DOM element (e.g., "__reactFiber$abc123").
5
+ * Works across all React versions that attach fibers to DOM nodes.
6
+ */
7
+ function findFiberFromDOMElement(element) {
8
+ const fiberKey = Object.keys(element).find(k => k.startsWith("__reactFiber$"));
9
+ if (fiberKey) {
10
+ return element[fiberKey];
11
+ }
12
+ return null;
13
+ }
2
14
  export function findFiberByHtmlElement(target, shouldHaveDebugSource) {
15
+ // Try via DevTools renderers first (available when React DevTools extension is installed)
3
16
  const renderers = window.__REACT_DEVTOOLS_GLOBAL_HOOK__?.renderers;
4
- // console.log("RENDERERS: ", renderers);
5
17
  const renderersValues = renderers?.values();
6
18
  if (renderersValues) {
7
19
  for (const renderer of Array.from(renderersValues)) {
@@ -18,5 +30,15 @@ export function findFiberByHtmlElement(target, shouldHaveDebugSource) {
18
30
  }
19
31
  }
20
32
  }
33
+
34
+ // Fallback: read fiber directly from DOM element's __reactFiber$ property.
35
+ // This works without the React DevTools extension and across React 16-19.
36
+ const fiber = findFiberFromDOMElement(target);
37
+ if (fiber) {
38
+ if (shouldHaveDebugSource) {
39
+ return findDebugSource(fiber)?.fiber || null;
40
+ }
41
+ return fiber;
42
+ }
21
43
  return null;
22
44
  }
@@ -0,0 +1,29 @@
1
+ import { Source } from "@locator/shared";
2
+ /**
3
+ * Parse the React 19 _debugStack Error.stack to extract the caller's location.
4
+ *
5
+ * Stack format:
6
+ * Error: react-stack-top-frame
7
+ * at exports.jsxDEV (http://localhost:3000/_next/.../chunk.js:410:33)
8
+ * at Home (http://localhost:3000/_next/.../chunk.js:8789:416)
9
+ * at Object.react_stack_bottom_frame (...)
10
+ *
11
+ * The second frame (after jsxDEV/jsx) is the component that created the element.
12
+ */
13
+ export declare function parseDebugStack(stack: string): {
14
+ url: string;
15
+ line: number;
16
+ column: number;
17
+ } | null;
18
+ /**
19
+ * Resolve a bundled location to its original source using source maps.
20
+ * Returns null if resolution fails.
21
+ */
22
+ export declare function resolveSourceLocation(url: string, line: number, column: number): Promise<Source | null>;
23
+ /**
24
+ * Given a React 19 fiber's _debugStack, resolve the source location
25
+ * by parsing the stack trace and looking up source maps.
26
+ */
27
+ export declare function resolveSourceFromDebugStack(debugStack: {
28
+ stack?: string;
29
+ }): Promise<Source | null>;
@@ -0,0 +1,236 @@
1
+ // Cache: bundled script URL -> source map
2
+ const sourceMapCache = new Map();
3
+
4
+ /**
5
+ * Parse the React 19 _debugStack Error.stack to extract the caller's location.
6
+ *
7
+ * Stack format:
8
+ * Error: react-stack-top-frame
9
+ * at exports.jsxDEV (http://localhost:3000/_next/.../chunk.js:410:33)
10
+ * at Home (http://localhost:3000/_next/.../chunk.js:8789:416)
11
+ * at Object.react_stack_bottom_frame (...)
12
+ *
13
+ * The second frame (after jsxDEV/jsx) is the component that created the element.
14
+ */
15
+ export function parseDebugStack(stack) {
16
+ const lines = stack.split("\n");
17
+
18
+ // Find the component caller frame (skip react-stack-top-frame and jsx factory)
19
+ for (let i = 1; i < lines.length; i++) {
20
+ const line = lines[i]?.trim();
21
+ if (!line) continue;
22
+
23
+ // Skip react internal frames
24
+ if (line.includes("react-stack-top-frame") || line.includes("react_stack_bottom_frame")) {
25
+ continue;
26
+ }
27
+
28
+ // Skip JSX factory frames (jsxDEV, jsx, jsxs)
29
+ if (line.includes("jsxDEV") || line.includes("exports.jsx ") || line.includes("exports.jsxs ")) {
30
+ continue;
31
+ }
32
+
33
+ // Parse "at Name (url:line:col)" or "at url:line:col"
34
+ const match = line.match(/at\s+(?:\S+\s+)?\(?(https?:\/\/.+?):(\d+):(\d+)\)?/) || line.match(/at\s+(https?:\/\/.+?):(\d+):(\d+)/);
35
+ if (match && match[1] && match[2] && match[3]) {
36
+ return {
37
+ url: match[1],
38
+ line: parseInt(match[2], 10),
39
+ column: parseInt(match[3], 10)
40
+ };
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+ async function fetchSourceMap(scriptUrl) {
46
+ if (sourceMapCache.has(scriptUrl)) {
47
+ return sourceMapCache.get(scriptUrl);
48
+ }
49
+ const promise = (async () => {
50
+ try {
51
+ // Fetch the script to find sourceMappingURL
52
+ const scriptResp = await fetch(scriptUrl);
53
+ if (!scriptResp.ok) return null;
54
+ const scriptText = await scriptResp.text();
55
+ const match = scriptText.match(/\/\/[#@]\s*sourceMappingURL=(.+?)(?:\s|$)/);
56
+ if (!match || !match[1]) return null;
57
+
58
+ // Resolve the source map URL relative to the script URL
59
+ let mapUrl = match[1];
60
+ if (!mapUrl.startsWith("http")) {
61
+ const base = scriptUrl.substring(0, scriptUrl.lastIndexOf("/") + 1);
62
+ mapUrl = base + mapUrl;
63
+ }
64
+ const mapResp = await fetch(mapUrl);
65
+ if (!mapResp.ok) return null;
66
+ return await mapResp.json();
67
+ } catch {
68
+ return null;
69
+ }
70
+ })();
71
+ sourceMapCache.set(scriptUrl, promise);
72
+ return promise;
73
+ }
74
+
75
+ // Decode a single VLQ value from a mappings string, returns [value, charsConsumed]
76
+ const VLQ_BASE_SHIFT = 5;
77
+ const VLQ_BASE = 1 << VLQ_BASE_SHIFT; // 32
78
+ const VLQ_BASE_MASK = VLQ_BASE - 1; // 31
79
+ const VLQ_CONTINUATION_BIT = VLQ_BASE; // 32
80
+ const BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
81
+ const base64Map = new Map();
82
+ for (let i = 0; i < BASE64_CHARS.length; i++) {
83
+ base64Map.set(BASE64_CHARS[i], i);
84
+ }
85
+ function decodeVLQ(str, index) {
86
+ let result = 0;
87
+ let shift = 0;
88
+ let continuation;
89
+ let i = index;
90
+ do {
91
+ const char = str[i];
92
+ if (!char) return [0, i];
93
+ const digit = base64Map.get(char);
94
+ if (digit === undefined) return [0, i];
95
+ i++;
96
+ continuation = (digit & VLQ_CONTINUATION_BIT) !== 0;
97
+ result += (digit & VLQ_BASE_MASK) << shift;
98
+ shift += VLQ_BASE_SHIFT;
99
+ } while (continuation);
100
+
101
+ // Convert from VLQ signed
102
+ const isNegative = (result & 1) === 1;
103
+ result >>= 1;
104
+ return [isNegative ? -result : result, i];
105
+ }
106
+
107
+ /**
108
+ * Find the original source location for a generated line/column in a basic source map.
109
+ */
110
+ function resolveInBasicMap(map, targetLine, targetColumn) {
111
+ const mappings = map.mappings;
112
+ if (!mappings) return null;
113
+ let generatedLine = 1;
114
+ let generatedColumn = 0;
115
+ let sourceIndex = 0;
116
+ let sourceLine = 0;
117
+ let sourceColumn = 0;
118
+ let bestSource = null;
119
+ let i = 0;
120
+ while (i < mappings.length) {
121
+ const char = mappings[i];
122
+ if (char === ";") {
123
+ generatedLine++;
124
+ generatedColumn = 0;
125
+ i++;
126
+ continue;
127
+ }
128
+ if (char === ",") {
129
+ i++;
130
+ continue;
131
+ }
132
+
133
+ // Decode segment
134
+ let colDelta;
135
+ [colDelta, i] = decodeVLQ(mappings, i);
136
+ generatedColumn += colDelta;
137
+
138
+ // Check if there's source info (segments can be 1, 4, or 5 fields)
139
+ if (i < mappings.length && mappings[i] !== "," && mappings[i] !== ";") {
140
+ let srcDelta, lineDelta, srcColDelta;
141
+ [srcDelta, i] = decodeVLQ(mappings, i);
142
+ sourceIndex += srcDelta;
143
+ [lineDelta, i] = decodeVLQ(mappings, i);
144
+ sourceLine += lineDelta;
145
+ [srcColDelta, i] = decodeVLQ(mappings, i);
146
+ sourceColumn += srcColDelta;
147
+
148
+ // Skip optional name index
149
+ if (i < mappings.length && mappings[i] !== "," && mappings[i] !== ";") {
150
+ [, i] = decodeVLQ(mappings, i);
151
+ }
152
+
153
+ // Check if this mapping is at or before our target
154
+ if (generatedLine === targetLine && generatedColumn <= targetColumn) {
155
+ const fileName = map.sources[sourceIndex];
156
+ if (fileName) {
157
+ bestSource = {
158
+ fileName: cleanSourcePath(fileName),
159
+ lineNumber: sourceLine + 1,
160
+ // source maps are 0-indexed
161
+ columnNumber: sourceColumn
162
+ };
163
+ }
164
+ }
165
+
166
+ // If we've passed the target line, we can stop
167
+ if (generatedLine > targetLine) {
168
+ break;
169
+ }
170
+ }
171
+ }
172
+ return bestSource;
173
+ }
174
+
175
+ /**
176
+ * Clean up source file paths from source maps.
177
+ * Strips file:// protocol and common project root prefixes.
178
+ */
179
+ function cleanSourcePath(filePath) {
180
+ // Strip file:// protocol
181
+ let cleaned = filePath.replace(/^file:\/\//, "");
182
+
183
+ // Strip webpack/turbopack internal prefixes
184
+ cleaned = cleaned.replace(/^\[project\]\//, "");
185
+ cleaned = cleaned.replace(/^webpack:\/\/[^/]*\//, "");
186
+ return cleaned;
187
+ }
188
+
189
+ /**
190
+ * Resolve a bundled location to its original source using source maps.
191
+ * Returns null if resolution fails.
192
+ */
193
+ export async function resolveSourceLocation(url, line, column) {
194
+ const sourceMap = await fetchSourceMap(url);
195
+ if (!sourceMap) return null;
196
+
197
+ // Handle indexed/sectioned source maps (used by Turbopack)
198
+ if ("sections" in sourceMap && sourceMap.sections) {
199
+ const sections = sourceMap.sections;
200
+
201
+ // Find the section that contains our target line
202
+ let targetSection = null;
203
+ for (let i = sections.length - 1; i >= 0; i--) {
204
+ const section = sections[i];
205
+ if (!section) continue;
206
+ if (section.offset.line < line) {
207
+ targetSection = section;
208
+ break;
209
+ }
210
+ if (section.offset.line === line && section.offset.column <= column) {
211
+ targetSection = section;
212
+ break;
213
+ }
214
+ }
215
+ if (!targetSection) return null;
216
+
217
+ // Resolve within the section's map (adjust line/column relative to section offset)
218
+ const relLine = line - targetSection.offset.line;
219
+ const relCol = line === targetSection.offset.line ? column - targetSection.offset.column : column;
220
+ return resolveInBasicMap(targetSection.map, relLine, relCol);
221
+ }
222
+
223
+ // Handle basic source maps
224
+ return resolveInBasicMap(sourceMap, line, column);
225
+ }
226
+
227
+ /**
228
+ * Given a React 19 fiber's _debugStack, resolve the source location
229
+ * by parsing the stack trace and looking up source maps.
230
+ */
231
+ export async function resolveSourceFromDebugStack(debugStack) {
232
+ if (!debugStack?.stack) return null;
233
+ const parsed = parseDebugStack(debugStack.stack);
234
+ if (!parsed) return null;
235
+ return resolveSourceLocation(parsed.url, parsed.line, parsed.column);
236
+ }
@@ -27,7 +27,7 @@ export interface LocatorJSAPI {
27
27
  * });
28
28
  * console.log(path);
29
29
  */
30
- getPath(elementOrSelector: HTMLElement | string): string | null;
30
+ getPath(elementOrSelector: HTMLElement | string): Promise<string | null>;
31
31
  /**
32
32
  * Get raw ancestry data for an element.
33
33
  * Returns an array of objects containing component names, file paths, and line numbers.
@@ -54,7 +54,7 @@ export interface LocatorJSAPI {
54
54
  * return ancestry?.map(item => item.componentName).filter(Boolean);
55
55
  * });
56
56
  */
57
- getAncestry(elementOrSelector: HTMLElement | string): AncestryItem[] | null;
57
+ getAncestry(elementOrSelector: HTMLElement | string): Promise<AncestryItem[] | null>;
58
58
  /**
59
59
  * Get both formatted path and raw ancestry data in a single call.
60
60
  * Convenience method that combines getPath() and getAncestry().
@@ -78,10 +78,10 @@ export interface LocatorJSAPI {
78
78
  * console.log('Source files:', data.ancestry.map(a => a.filePath));
79
79
  * }
80
80
  */
81
- getPathData(elementOrSelector: HTMLElement | string): {
81
+ getPathData(elementOrSelector: HTMLElement | string): Promise<{
82
82
  path: string;
83
83
  ancestry: AncestryItem[];
84
- } | null;
84
+ } | null>;
85
85
  /**
86
86
  * Display help information about the LocatorJS API.
87
87
  * Shows usage examples and method descriptions for browser automation tools.
@@ -1,5 +1,6 @@
1
1
  import { createTreeNode } from "./adapters/createTreeNode";
2
2
  import { collectAncestry, formatAncestryChain } from "./functions/formatAncestryChain";
3
+ import { enrichAncestryWithSourceMaps } from "./functions/enrichAncestrySourceMaps";
3
4
  let adapterId;
4
5
  function resolveElement(elementOrSelector) {
5
6
  if (typeof elementOrSelector === "string") {
@@ -15,6 +16,11 @@ function getAncestryForElement(element) {
15
16
  }
16
17
  return collectAncestry(treeNode);
17
18
  }
19
+ async function getEnrichedAncestryForElement(element) {
20
+ const ancestry = getAncestryForElement(element);
21
+ if (!ancestry) return null;
22
+ return enrichAncestryWithSourceMaps(ancestry, element);
23
+ }
18
24
  const HELP_TEXT = `
19
25
  ╔═══════════════════════════════════════════════════════════════════════════╗
20
26
  ║ TreeLocatorJS Browser API ║
@@ -119,34 +125,26 @@ export function createBrowserAPI(adapterIdParam) {
119
125
  getPath(elementOrSelector) {
120
126
  const element = resolveElement(elementOrSelector);
121
127
  if (!element) {
122
- return null;
123
- }
124
- const ancestry = getAncestryForElement(element);
125
- if (!ancestry) {
126
- return null;
128
+ return Promise.resolve(null);
127
129
  }
128
- return formatAncestryChain(ancestry);
130
+ return getEnrichedAncestryForElement(element).then(ancestry => ancestry ? formatAncestryChain(ancestry) : null);
129
131
  },
130
132
  getAncestry(elementOrSelector) {
131
133
  const element = resolveElement(elementOrSelector);
132
134
  if (!element) {
133
- return null;
135
+ return Promise.resolve(null);
134
136
  }
135
- return getAncestryForElement(element);
137
+ return getEnrichedAncestryForElement(element);
136
138
  },
137
139
  getPathData(elementOrSelector) {
138
140
  const element = resolveElement(elementOrSelector);
139
141
  if (!element) {
140
- return null;
141
- }
142
- const ancestry = getAncestryForElement(element);
143
- if (!ancestry) {
144
- return null;
142
+ return Promise.resolve(null);
145
143
  }
146
- return {
144
+ return getEnrichedAncestryForElement(element).then(ancestry => ancestry ? {
147
145
  path: formatAncestryChain(ancestry),
148
146
  ancestry
149
- };
147
+ } : null);
150
148
  },
151
149
  help() {
152
150
  return HELP_TEXT;
@@ -84,8 +84,8 @@ export function MaybeOutline(props) {
84
84
  return () => _c$() ? `#${props.currentElement.id}` : "";
85
85
  })(), null);
86
86
  _$insert(_el$6, (() => {
87
- var _c$2 = _$memo(() => !!props.currentElement.className);
88
- return () => _c$2() ? `.${props.currentElement.className.split(" ")[0]}` : "";
87
+ var _c$2 = _$memo(() => !!props.currentElement.getAttribute('class'));
88
+ return () => _c$2() ? `.${props.currentElement.getAttribute('class').split(" ")[0]}` : "";
89
89
  })(), null);
90
90
  _$effect(_p$ => {
91
91
  var _v$7 = box().x + "px",
@@ -13,6 +13,7 @@ import { MaybeOutline } from "./MaybeOutline";
13
13
  import { isLocatorsOwnElement } from "../functions/isLocatorsOwnElement";
14
14
  import { Toast } from "./Toast";
15
15
  import { collectAncestry, formatAncestryChain } from "../functions/formatAncestryChain";
16
+ import { enrichAncestryWithSourceMaps } from "../functions/enrichAncestrySourceMaps";
16
17
  import { createTreeNode } from "../adapters/createTreeNode";
17
18
  import treeIconUrl from "../_generated_tree_icon";
18
19
  function Runtime(props) {
@@ -128,10 +129,22 @@ function Runtime(props) {
128
129
  const treeNode = createTreeNode(element, props.adapterId);
129
130
  if (treeNode) {
130
131
  const ancestry = collectAncestry(treeNode);
132
+
133
+ // Write immediately with component names (preserves user gesture for clipboard API)
131
134
  const formatted = formatAncestryChain(ancestry);
132
135
  navigator.clipboard.writeText(formatted).then(() => {
133
136
  setToastMessage("Copied to clipboard");
134
137
  });
138
+
139
+ // For React 19+: try to enrich with source map file paths and re-copy
140
+ enrichAncestryWithSourceMaps(ancestry, element).then(enriched => {
141
+ const enrichedFormatted = formatAncestryChain(enriched);
142
+ if (enrichedFormatted !== formatted) {
143
+ navigator.clipboard.writeText(enrichedFormatted).then(() => {
144
+ setToastMessage("Copied to clipboard");
145
+ });
146
+ }
147
+ });
135
148
  }
136
149
 
137
150
  // Deactivate toggle after click
@@ -0,0 +1,7 @@
1
+ import { AncestryItem } from "./formatAncestryChain";
2
+ /**
3
+ * Enrich ancestry items that are missing filePath by resolving via source maps.
4
+ * This is an async operation that fetches source maps for React 19 environments.
5
+ * For React 18 (where _debugSource exists), this is a no-op.
6
+ */
7
+ export declare function enrichAncestryWithSourceMaps(items: AncestryItem[], element?: HTMLElement): Promise<AncestryItem[]>;
@@ -0,0 +1,80 @@
1
+ import { resolveSourceLocation, parseDebugStack } from "../adapters/react/resolveSourceMap";
2
+ import { normalizeFilePath } from "./normalizeFilePath";
3
+
4
+ /**
5
+ * Check if any DOM element has React 19 fibers (with _debugStack instead of _debugSource).
6
+ */
7
+ function isReact19Environment() {
8
+ const el = document.querySelector("[class]") || document.body;
9
+ if (!el) return false;
10
+ const fiberKey = Object.keys(el).find(k => k.startsWith("__reactFiber$"));
11
+ if (!fiberKey) return false;
12
+ const fiber = el[fiberKey];
13
+ return !fiber?._debugSource && !!fiber?._debugStack;
14
+ }
15
+
16
+ /**
17
+ * Walk a fiber's _debugOwner chain and collect _debugStack stack traces for each component.
18
+ * Returns a map from component name to its parsed stack location.
19
+ */
20
+ function collectFiberStacks(element) {
21
+ const stacks = new Map();
22
+ const fiberKey = Object.keys(element).find(k => k.startsWith("__reactFiber$"));
23
+ if (!fiberKey) return stacks;
24
+ let fiber = element[fiberKey];
25
+
26
+ // Collect stacks from the fiber itself and its _debugOwner chain
27
+ while (fiber) {
28
+ const debugStack = fiber._debugStack;
29
+ if (debugStack?.stack) {
30
+ const parsed = parseDebugStack(debugStack.stack);
31
+ if (parsed) {
32
+ const name = fiber.type?.name || fiber.type?.displayName || fiber.type;
33
+ if (typeof name === "string") {
34
+ stacks.set(name, parsed);
35
+ }
36
+ }
37
+ }
38
+ fiber = fiber._debugOwner || null;
39
+ }
40
+ return stacks;
41
+ }
42
+
43
+ /**
44
+ * Enrich ancestry items that are missing filePath by resolving via source maps.
45
+ * This is an async operation that fetches source maps for React 19 environments.
46
+ * For React 18 (where _debugSource exists), this is a no-op.
47
+ */
48
+ export async function enrichAncestryWithSourceMaps(items, element) {
49
+ // Skip if all items already have file paths, or not React 19
50
+ const needsEnrichment = items.some(item => item.componentName && !item.filePath);
51
+ if (!needsEnrichment || !isReact19Environment()) {
52
+ return items;
53
+ }
54
+
55
+ // Collect _debugStack info from the DOM element's fiber chain
56
+ const stacks = element ? collectFiberStacks(element) : new Map();
57
+
58
+ // Resolve source maps in parallel for items missing filePath
59
+ const enriched = await Promise.all(items.map(async item => {
60
+ if (item.filePath || !item.componentName) return item;
61
+
62
+ // Find the stack trace for this component
63
+ const stack = stacks.get(item.componentName);
64
+ if (!stack) return item;
65
+ try {
66
+ const source = await resolveSourceLocation(stack.url, stack.line, stack.column);
67
+ if (source) {
68
+ return {
69
+ ...item,
70
+ filePath: normalizeFilePath(source.fileName),
71
+ line: source.lineNumber
72
+ };
73
+ }
74
+ } catch {
75
+ // Source map resolution failed — keep item as-is
76
+ }
77
+ return item;
78
+ }));
79
+ return enriched;
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treelocator/runtime",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "TreeLocatorJS runtime for component ancestry tracking. Alt+click any element to copy its component tree to clipboard. Exposes window.__treelocator__ API for browser automation (Playwright, Puppeteer, Selenium, Cypress).",
5
5
  "keywords": [
6
6
  "locator",
@@ -54,7 +54,7 @@
54
54
  "@babel/cli": "^7.25.9",
55
55
  "@babel/core": "^7.26.0",
56
56
  "@tailwindcss/forms": "^0.5.11",
57
- "@treelocator/dev-config": "^0.2.0",
57
+ "@treelocator/dev-config": "^0.3.0",
58
58
  "@types/jsdom": "^21.1.7",
59
59
  "babel-preset-solid": "^1.9.2",
60
60
  "concurrently": "^9.1.0",
@@ -73,5 +73,5 @@
73
73
  "directory": "packages/runtime"
74
74
  },
75
75
  "license": "MIT",
76
- "gitHead": "5d53daa18f4fef5e815c3fd281b899608f8673ea"
76
+ "gitHead": "afc8a534284e818665bb4d03b7fa940ec5ad5880"
77
77
  }
@@ -11,6 +11,15 @@ import {
11
11
  } from "@locator/shared";
12
12
  import { detectPhoenix } from "./phoenix/detectPhoenix";
13
13
 
14
+ /**
15
+ * Fallback React detection: check if any DOM element has __reactFiber$ keys.
16
+ * Works without React DevTools extension (where detectReact() fails because
17
+ * the renderers Map is empty).
18
+ */
19
+ function hasReactFiberKeys(element: HTMLElement): boolean {
20
+ return Object.keys(element).some((k) => k.startsWith("__reactFiber$"));
21
+ }
22
+
14
23
  export function createTreeNode(
15
24
  element: HTMLElement,
16
25
  adapterId?: string
@@ -38,7 +47,7 @@ export function createTreeNode(
38
47
  return new VueTreeNodeElement(element);
39
48
  }
40
49
 
41
- if (detectReact()) {
50
+ if (detectReact() || hasReactFiberKeys(element)) {
42
51
  return new ReactTreeNodeElement(element);
43
52
  }
44
53