react-spot 0.0.1

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.
@@ -0,0 +1,684 @@
1
+ import type { ClickToNodeInfo, Fiber } from './types';
2
+
3
+ export { isHostFiberEntry } from './types';
4
+
5
+ /**
6
+ * 从 fiber 中提取可读的组件名称。
7
+ *
8
+ * 支持函数组件、类组件、ForwardRef、Memo 包装和原生 DOM 元素。
9
+ *
10
+ * Args:
11
+ * fiber: React fiber 节点
12
+ *
13
+ * Returns:
14
+ * 组件的显示名称
15
+ */
16
+ export function getComponentName(fiber: Fiber): string {
17
+ return resolveComponentName(fiber).name;
18
+ }
19
+
20
+ interface NameResolution {
21
+ name: string;
22
+ reason: string;
23
+ }
24
+
25
+ /**
26
+ * 解析 fiber 的组件名称,同时返回解析策略说明(用于调试)。
27
+ *
28
+ * 按优先级依次尝试:displayName → function.name → toString 解析 →
29
+ * ForwardRef/Memo 包装解析 → object 属性。
30
+ */
31
+ function resolveComponentName(fiber: Fiber): NameResolution {
32
+ try {
33
+ if (typeof fiber.type === 'function') {
34
+ const func = fiber.type as Function & { displayName?: string };
35
+ if (func.displayName && typeof func.displayName === 'string') {
36
+ return { name: func.displayName, reason: 'function.displayName' };
37
+ }
38
+ if (func.name && typeof func.name === 'string' && func.name.length > 0) {
39
+ return { name: func.name, reason: 'function.name' };
40
+ }
41
+ try {
42
+ const funcStr = fiber.type.toString();
43
+ const match = funcStr.match(/^function\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
44
+ if (match?.[1]) {
45
+ return { name: match[1], reason: 'function.toString() parse' };
46
+ }
47
+ } catch {
48
+ // toString 可能在 Proxy 等场景下抛异常
49
+ }
50
+ return { name: 'Anonymous Function Component', reason: 'function type, no name/displayName' };
51
+ }
52
+
53
+ if (typeof fiber.type === 'string') {
54
+ return { name: fiber.type, reason: 'native element' };
55
+ }
56
+
57
+ if (fiber.type && typeof fiber.type === 'object') {
58
+ const obj = fiber.type as Record<string, unknown>;
59
+ // ForwardRef 检测:$$typeof + render 方法
60
+ if (obj.$$typeof && obj.render) {
61
+ const render = obj.render as Function & { name?: string; displayName?: string };
62
+ const renderName = render.name || render.displayName;
63
+ return renderName && typeof renderName === 'string'
64
+ ? { name: `ForwardRef(${renderName})`, reason: `forwardRef, render.name=${renderName}` }
65
+ : { name: 'ForwardRef(Anonymous)', reason: 'forwardRef, no render name' };
66
+ }
67
+ // Memo 检测:$$typeof + type 属性
68
+ if (obj.$$typeof && obj.type) {
69
+ const wrappedName = getComponentNameFromType(obj.type);
70
+ return wrappedName && typeof wrappedName === 'string' && wrappedName.length > 0
71
+ ? { name: `Memo(${wrappedName})`, reason: `memo, wrapped=${wrappedName}` }
72
+ : { name: 'Memo(Anonymous)', reason: 'memo, no inner name' };
73
+ }
74
+ if (obj.displayName && typeof obj.displayName === 'string') {
75
+ return { name: obj.displayName as string, reason: 'object.displayName' };
76
+ }
77
+ if (obj.name && typeof obj.name === 'string') {
78
+ return { name: obj.name as string, reason: 'object.name' };
79
+ }
80
+ return {
81
+ name: 'Component (Object Type)',
82
+ reason: `object type, $$typeof=${String(obj.$$typeof ?? 'none')}`,
83
+ };
84
+ }
85
+
86
+ if (!fiber.type) {
87
+ return { name: 'Component (No Type)', reason: 'fiber.type is falsy' };
88
+ }
89
+ return { name: 'Component Name Unknown', reason: `unexpected type: ${typeof fiber.type}` };
90
+ } catch (err) {
91
+ return { name: 'Component Name Unknown', reason: `exception: ${err}` };
92
+ }
93
+ }
94
+
95
+ function getComponentNameFromType(type: unknown): string {
96
+ try {
97
+ if (typeof type === 'string') return type;
98
+ if (typeof type === 'function') {
99
+ const func = type as Function & { displayName?: string };
100
+ return func.displayName || func.name || 'Anonymous';
101
+ }
102
+ if (type && typeof type === 'object') {
103
+ const obj = type as Record<string, unknown>;
104
+ if (obj.displayName && typeof obj.displayName === 'string') return obj.displayName;
105
+ if (obj.name && typeof obj.name === 'string') return obj.name;
106
+ }
107
+ return 'Unknown';
108
+ } catch {
109
+ return 'Unknown';
110
+ }
111
+ }
112
+
113
+ // 栈帧提取使用第 2 行有意义的帧(跳过 React 内部帧后),
114
+ // 因为第 1 行通常是 JSX 编译器生成的 fakeJSXCallSite
115
+ const STACK_FRAME_INDEX = 1;
116
+
117
+ /**
118
+ * 判断栈帧行是否为 React 运行时内部帧(无法解析为用户源码)。
119
+ */
120
+ /**
121
+ * 按路径关键词匹配的框架/运行时内部模块。
122
+ * 这些文件中的帧永远不会指向用户源码。
123
+ */
124
+ const INTERNAL_PATH_KEYWORDS = [
125
+ // React 核心运行时
126
+ 'react-dom',
127
+ 'react-server-dom',
128
+ 'react-jsx-dev-runtime',
129
+ 'react-jsx-runtime',
130
+ 'react/cjs/',
131
+ 'react/dist/',
132
+ 'react-reconciler',
133
+ 'react-client',
134
+ 'react-refresh',
135
+ // React 编译产物(node_modules 内)
136
+ 'compiled/react/',
137
+ 'compiled/react-dom/',
138
+ 'compiled/react-server-dom',
139
+ // Next.js 内部
140
+ 'next/dist/',
141
+ 'next-server',
142
+ // 调度器
143
+ 'scheduler',
144
+ // 打包器运行时
145
+ 'webpack-internal',
146
+ 'turbopack-ecmascript-runtime',
147
+ '__turbopack_',
148
+ ];
149
+
150
+ /**
151
+ * 按函数名/标识符匹配的 React 内部栈帧。
152
+ * 这些是 React 运行时在 dev 模式下注入到调用栈中的。
153
+ */
154
+ const INTERNAL_FUNCTION_KEYWORDS = [
155
+ // JSX 转换运行时函数
156
+ 'jsxDEV',
157
+ 'jsxProdSignatureRunningInDevWithDynamicChildren',
158
+ 'jsxs',
159
+ // React.createElement 系列
160
+ 'createElementWithValidation',
161
+ // React 调试栈注入
162
+ 'fakeJSXCallSite',
163
+ 'react-stack-top-frame',
164
+ 'react_stack_bottom_frame',
165
+ 'initializeElement',
166
+ 'initializeFakeStack',
167
+ 'createFakeJSXCallStack',
168
+ // React 调和器内部
169
+ 'renderWithHooks',
170
+ 'mountIndeterminateComponent',
171
+ 'beginWork',
172
+ 'performUnitOfWork',
173
+ 'workLoopSync',
174
+ 'callCallback',
175
+ 'invokeGuardedCallbackDev',
176
+ // React Server Components 内部
177
+ 'processServerComponentReturnValue',
178
+ 'attemptResolveElement',
179
+ ];
180
+
181
+ function isUnresolvableFrame(line: string): boolean {
182
+ for (const keyword of INTERNAL_PATH_KEYWORDS) {
183
+ if (line.includes(keyword)) return true;
184
+ }
185
+ for (const keyword of INTERNAL_FUNCTION_KEYWORDS) {
186
+ if (line.includes(keyword)) return true;
187
+ }
188
+ if (line.includes('<anonymous>')) return true;
189
+ return false;
190
+ }
191
+
192
+ /**
193
+ * 从 fiber 的 _debugStack 中提取有意义的栈帧行。
194
+ *
195
+ * 过滤掉 React 运行时内部帧后,取第 STACK_FRAME_INDEX 个帧。
196
+ * 该帧通常对应用户源码中的 JSX 调用位置。
197
+ *
198
+ * Args:
199
+ * fiber: React fiber 节点
200
+ *
201
+ * Returns:
202
+ * 栈帧行字符串,如 "at Button (http://…:18:26)",或 undefined
203
+ */
204
+ export function getStackFrame(fiber: Fiber): string | undefined {
205
+ const stack = fiber._debugStack?.stack;
206
+ if (!stack) return undefined;
207
+
208
+ const lines = stack.split('\n');
209
+ const meaningfulLines: string[] = [];
210
+ for (let i = 1; i < lines.length; i++) {
211
+ const line = lines[i].trim();
212
+ if (line && !isUnresolvableFrame(line)) {
213
+ meaningfulLines.push(line);
214
+ }
215
+ }
216
+ // 原生 DOM 取第 1 个有意义帧(即 JSX 标签创建点);
217
+ // 函数组件取第 2 个,跳过编译器生成的 fakeJSXCallSite
218
+ const frameIndex = typeof fiber.type === 'string' ? 0 : STACK_FRAME_INDEX;
219
+ return meaningfulLines[frameIndex] || meaningfulLines[0] || undefined;
220
+ }
221
+
222
+ /**
223
+ * 在 DOM 节点上查找 React fiber 内部属性。
224
+ *
225
+ * React 通过 __reactFiber$xxx 前缀的属性将 fiber 挂载到 DOM 节点,
226
+ * xxx 是每次挂载随机生成的 hash。
227
+ */
228
+ function findFiberElementFromNode(node: Element): Fiber | null {
229
+ const properties = Object.getOwnPropertyNames(node);
230
+ const fiberProperty = properties.find((p) => p.startsWith('__reactFiber'));
231
+ if (!fiberProperty) return null;
232
+ return (node as unknown as Record<string, Fiber>)[fiberProperty];
233
+ }
234
+
235
+ /**
236
+ * 从 React 19 虚拟 owner 的属性中提取可用的栈帧字符串。
237
+ *
238
+ * 虚拟 owner 的 `stack` 属性是 React Flight 协议的 CSV 格式:
239
+ * `callerName,bundlePath,line,col,enclosingLine,enclosingCol,isNative`
240
+ *
241
+ * 例如:`"AppShell,/Users/.../[root-of-the-server]__xxx.js,705,391,689,1,false"`
242
+ *
243
+ * 此函数将 CSV 格式转换为标准的 RSC 风格栈帧字符串(about://React/Server/file:///...),
244
+ * 使其可以复用现有的 `resolveLocation` → `resolveViaNextDevServer` 管道,
245
+ * 通过 Next.js 内置的 `__nextjs_original-stack-frames` 端点解析回原始源码位置。
246
+ */
247
+ function resolveVirtualOwnerStackFrame(
248
+ componentName: string,
249
+ node: Record<string, unknown>
250
+ ): string | undefined {
251
+ // 优先尝试 debugStack(Error 对象),其 stack 包含标准格式的帧
252
+ const debugStack = node.debugStack;
253
+ if (debugStack && typeof debugStack === 'object') {
254
+ const errStack = (debugStack as { stack?: string }).stack;
255
+ if (typeof errStack === 'string') {
256
+ const lines = errStack.split('\n');
257
+ for (const line of lines) {
258
+ const trimmed = line.trim();
259
+ if (trimmed && trimmed.startsWith('at ') && !isUnresolvableFrame(trimmed)) {
260
+ return trimmed;
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ // 解析 CSV 格式的 stack 属性
267
+ const csvStack = node.stack;
268
+ if (typeof csvStack === 'string' && csvStack.length > 0) {
269
+ // CSV 格式:callerName,bundlePath,line,col,enclosingLine,enclosingCol,isNative
270
+ // bundlePath 可能包含逗号前面有路径分隔符的情况,需要从后往前解析
271
+ const parts = csvStack.split(',');
272
+ if (parts.length >= 4) {
273
+ // 从后往前取固定字段:isNative, enclosingCol, enclosingLine, col, line
274
+ // 剩余前面的部分拼接为 callerName,bundlePath
275
+ const isNative = parts[parts.length - 1];
276
+ const enclosingCol = parts[parts.length - 2];
277
+ const enclosingLine = parts[parts.length - 3];
278
+ const col = parts[parts.length - 4];
279
+ const line = parts[parts.length - 5];
280
+
281
+ if (isNative === 'true') {
282
+ // 原生函数调用(如 Promise.all),无法解析到源码
283
+ return undefined;
284
+ }
285
+
286
+ // bundlePath 是从第二个到倒数第六个元素(callerName 是第一个)
287
+ const bundlePath = parts.slice(1, parts.length - 5).join(',');
288
+
289
+ if (bundlePath && line && col) {
290
+ const lineNum = parseInt(line, 10);
291
+ const colNum = parseInt(col, 10);
292
+ if (!isNaN(lineNum) && !isNaN(colNum) && bundlePath.includes('.next/')) {
293
+ // 构造 RSC 风格 URL,使其匹配 resolveViaNextDevServer 的 isNextjsRscUrl 检测
294
+ const encodedPath = bundlePath.replace(/\[/g, '%5B').replace(/\]/g, '%5D');
295
+ const rscUrl = `about://React/Server/file://${encodedPath}`;
296
+ return `at ${componentName} (${rscUrl}:${lineNum}:${colNum})`;
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ return undefined;
303
+ }
304
+
305
+ /**
306
+ * Next.js / React 框架内部组件名黑名单。
307
+ *
308
+ * 这些组件出现在 fiber.return 链中,但不是用户自己编写的,
309
+ * 在层级视图中显示它们只会增加干扰。列表涵盖:
310
+ * - Next.js App Router 内部组件(LayoutRouter、SegmentViewNode 等)
311
+ * - Next.js 错误/重定向边界
312
+ * - Next.js 开发工具覆盖层
313
+ * - React 本身的 Provider/Consumer 等包装
314
+ */
315
+ const FRAMEWORK_COMPONENT_NAMES = new Set([
316
+ // Next.js App Router 核心
317
+ 'AppRouter',
318
+ 'Router',
319
+ 'LayoutRouter',
320
+ 'InnerLayoutRouter',
321
+ 'OuterLayoutRouter',
322
+ 'SegmentViewNode',
323
+ 'ChildSegmentMap',
324
+ 'ChildSegment',
325
+
326
+ // Next.js 滚动与焦点管理
327
+ 'ScrollAndMaybeFocusHandler',
328
+ 'InnerScrollAndFocusHandler',
329
+ 'FocusAndScrollRef',
330
+
331
+ // Next.js 错误/边界相关
332
+ 'ErrorBoundary',
333
+ 'ErrorBoundaryHandler',
334
+ 'GlobalError',
335
+ 'RedirectBoundary',
336
+ 'RedirectErrorBoundary',
337
+ 'NotFoundBoundary',
338
+ 'NotFoundErrorBoundary',
339
+ 'LoadingBoundary',
340
+ 'HTTPAccessFallbackBoundary',
341
+ 'HTTPAccessFallbackErrorBoundary',
342
+ 'MetadataBoundary',
343
+ 'MetadataOutlet',
344
+ 'ViewportBoundary',
345
+ 'OutletBoundary',
346
+
347
+ // Next.js 路由上下文
348
+ 'PathnameContextProviderAdapter',
349
+ 'GlobalLayoutRouterContext',
350
+ 'LayoutRouterContext',
351
+
352
+ // Next.js 开发工具
353
+ 'HotReload',
354
+ 'DevOverlay',
355
+ 'ReactDevOverlay',
356
+ 'DevRootNotFoundBoundary',
357
+
358
+ // Next.js 渲染管道
359
+ 'ServerRoot',
360
+ 'Root',
361
+ 'AppTreeContext',
362
+ 'Head',
363
+ 'RenderFromTemplateContext',
364
+ 'ResolvedParams',
365
+ 'StaticGenerationSearchParamsBailoutProvider',
366
+ 'AutoScrollOnNavigation',
367
+ 'NavigateHandler',
368
+ 'MaybePostpone',
369
+ 'NotAllowed',
370
+ 'PreloadCss',
371
+ 'PreloadModule',
372
+ 'NonIndex',
373
+
374
+ // React 内部包装
375
+ 'Suspense',
376
+ 'Fragment',
377
+ 'StrictMode',
378
+ 'Profiler',
379
+ 'Provider',
380
+ 'Consumer',
381
+ 'Context',
382
+ ]);
383
+
384
+ /**
385
+ * 框架组件名的正则模式,用于匹配动态生成的或变体名称。
386
+ *
387
+ * 例如 "InnerLayoutRouter_", "Memo(LayoutRouter)" 等。
388
+ */
389
+ const FRAMEWORK_NAME_PATTERNS = [
390
+ /Boundary$/,
391
+ /^(Inner|Outer)?LayoutRouter/,
392
+ /^Segment/,
393
+ /^Scroll/,
394
+ /^Redirect/,
395
+ /^NotFound/,
396
+ /^HTTPAccess/,
397
+ /^Metadata(Boundary|Outlet)/,
398
+ /^Viewport/,
399
+ /^DevOverlay/,
400
+ /^HotReload/,
401
+ /^ReactDevOverlay/,
402
+ /^ServerRoot/,
403
+ /^AppRouter/,
404
+ /^GlobalLayoutRouter/,
405
+ /^PathnameContext/,
406
+ /^RenderFromTemplate/,
407
+ /^StaticGeneration/,
408
+ /^AutoScroll/,
409
+ /^NavigateHandler/,
410
+ /^PreloadCss/,
411
+ /^PreloadModule/,
412
+ ];
413
+
414
+ /**
415
+ * 判断 fiber 是否为用户定义的组件(而非框架/库内部组件)。
416
+ *
417
+ * 综合三层过滤策略,按优先级:
418
+ * 1. 组件名黑名单——覆盖绝大多数 Next.js/React 内部组件
419
+ * 2. 组件名正则模式——捕获名称变体(如 Memo 包装、带后缀版本)
420
+ * 3. 栈帧来源检测——兜底,检查 _debugStack 原始帧中是否全为框架代码
421
+ */
422
+ function isUserComponent(fiber: Fiber): boolean {
423
+ const rawName = getComponentName(fiber);
424
+
425
+ // 去除 ForwardRef(...) / Memo(...) 包装,提取内部组件名
426
+ const innerMatch = rawName.match(/^(?:ForwardRef|Memo)\((.+)\)$/);
427
+ const coreName = innerMatch ? innerMatch[1] : rawName;
428
+
429
+ // 策略1:精确名称匹配
430
+ if (FRAMEWORK_COMPONENT_NAMES.has(coreName)) {
431
+ return false;
432
+ }
433
+
434
+ // 策略2:正则模式匹配(捕获 LayoutRouter_ 等变体)
435
+ for (const pattern of FRAMEWORK_NAME_PATTERNS) {
436
+ if (pattern.test(coreName)) {
437
+ return false;
438
+ }
439
+ }
440
+
441
+ // 策略3:检查 _debugStack 原始文本中是否全为框架代码路径
442
+ const stack = fiber._debugStack?.stack;
443
+ if (stack) {
444
+ const lines = stack.split('\n');
445
+ let hasUserFrame = false;
446
+ let hasAnyMeaningfulFrame = false;
447
+
448
+ for (let i = 1; i < lines.length; i++) {
449
+ const line = lines[i].trim();
450
+ if (!line) continue;
451
+ // 跳过 React 运行时帧
452
+ if (isUnresolvableFrame(line)) continue;
453
+
454
+ hasAnyMeaningfulFrame = true;
455
+
456
+ // 框架代码帧的特征
457
+ if (
458
+ line.includes('node_modules') ||
459
+ line.includes('next/dist') ||
460
+ line.includes('next_dist') ||
461
+ line.includes('next-server') ||
462
+ line.includes('react-dom') ||
463
+ line.includes('react-server-dom')
464
+ ) {
465
+ continue;
466
+ }
467
+
468
+ // 找到一个非框架帧,说明该组件来自用户代码
469
+ hasUserFrame = true;
470
+ break;
471
+ }
472
+
473
+ // 有意义帧全部指向框架代码 → 是框架组件
474
+ if (hasAnyMeaningfulFrame && !hasUserFrame) {
475
+ return false;
476
+ }
477
+ }
478
+
479
+ // 通过所有过滤,视为用户组件
480
+ return true;
481
+ }
482
+
483
+ /**
484
+ * 全局调试函数:挂载到 window 上,在浏览器控制台调用即可查看指定 DOM 元素
485
+ * 的 fiber 链路原始数据。用于诊断 _debugOwner / fiber.return 链路中
486
+ * 服务端组件和框架组件的实际数据结构。
487
+ *
488
+ * 使用方式:在控制台执行 __reactSpotDump(document.querySelector('.dashboard'))
489
+ */
490
+ if (typeof window !== 'undefined') {
491
+ (window as unknown as Record<string, unknown>).__reactSpotDump = (el: Element) => {
492
+ const fiber = findFiberElementFromNode(el);
493
+ if (!fiber) {
494
+ console.log('[react-spot] No fiber found on element');
495
+ return;
496
+ }
497
+
498
+ console.group('[react-spot] === _debugOwner chain ===');
499
+ let owner: unknown = fiber;
500
+ let depth = 0;
501
+ while (owner && depth < 50) {
502
+ const f = owner as Record<string, unknown>;
503
+ console.log(`[${depth}]`, {
504
+ type: f.type,
505
+ typeName: typeof f.type === 'function' ? (f.type as Function).name : typeof f.type === 'string' ? f.type : String(f.type),
506
+ name: f.name,
507
+ env: f.env,
508
+ _debugOwner: f._debugOwner ? '(exists)' : null,
509
+ owner: f.owner ? '(exists)' : null,
510
+ stack: f._debugStack ? '(has _debugStack)' : f.stack ? '(has stack)' : null,
511
+ keys: Object.keys(f).filter(k => !k.startsWith('__')).slice(0, 20),
512
+ raw: f,
513
+ });
514
+ // React 19 虚拟 owner 可能用 .owner 而非 ._debugOwner
515
+ owner = f._debugOwner ?? f.owner ?? null;
516
+ depth++;
517
+ }
518
+ console.groupEnd();
519
+
520
+ console.group('[react-spot] === fiber.return chain (first 15) ===');
521
+ let ret: Fiber | null = fiber;
522
+ let retDepth = 0;
523
+ while (ret && retDepth < 15) {
524
+ const typeName = typeof ret.type === 'function'
525
+ ? (ret.type as Function).name
526
+ : typeof ret.type === 'string' ? ret.type : String(ret.type);
527
+ console.log(`[${retDepth}]`, typeName, {
528
+ hasDebugOwner: !!ret._debugOwner,
529
+ hasReturn: !!ret.return,
530
+ });
531
+ ret = ret.return;
532
+ retDepth++;
533
+ }
534
+ console.groupEnd();
535
+ };
536
+ }
537
+
538
+ /**
539
+ * 尝试将 owner 链上的当前节点写入 chain。
540
+ *
541
+ * 支持三种节点:原生 DOM(span/div)、用户组件 Fiber、RSC 虚拟 owner。
542
+ * 原生 DOM 仅在有可用栈帧时写入,避免无法跳转的空条目。
543
+ */
544
+ function tryPushOwnerChainEntry(chain: ClickToNodeInfo[], current: unknown): void {
545
+ const node = current as Record<string, unknown>;
546
+
547
+ if (node.type && typeof node.type === 'string') {
548
+ const fiberNode = current as Fiber;
549
+ const stackFrame = getStackFrame(fiberNode);
550
+ if (!stackFrame) return;
551
+
552
+ let props: Record<string, unknown> | undefined;
553
+ try {
554
+ if (fiberNode.memoizedProps) {
555
+ props = fiberNode.memoizedProps;
556
+ }
557
+ } catch {
558
+ props = undefined;
559
+ }
560
+
561
+ chain.push({
562
+ componentName: getComponentName(fiberNode),
563
+ stackFrame,
564
+ fiber: fiberNode,
565
+ props,
566
+ });
567
+ return;
568
+ }
569
+
570
+ if (node.type && typeof node.type !== 'string') {
571
+ const fiberNode = current as Fiber;
572
+ if (!isUserComponent(fiberNode)) return;
573
+
574
+ let props: Record<string, unknown> | undefined;
575
+ try {
576
+ if (fiberNode.memoizedProps) {
577
+ props = fiberNode.memoizedProps;
578
+ }
579
+ } catch {
580
+ props = undefined;
581
+ }
582
+
583
+ chain.push({
584
+ componentName: getComponentName(fiberNode),
585
+ stackFrame: getStackFrame(fiberNode),
586
+ fiber: fiberNode,
587
+ props,
588
+ });
589
+ return;
590
+ }
591
+
592
+ if (node.name && typeof node.name === 'string' && !node.type) {
593
+ const name = node.name as string;
594
+ if (FRAMEWORK_COMPONENT_NAMES.has(name)) return;
595
+
596
+ for (const pattern of FRAMEWORK_NAME_PATTERNS) {
597
+ if (pattern.test(name)) return;
598
+ }
599
+
600
+ const stackFrame = resolveVirtualOwnerStackFrame(name, node);
601
+
602
+ if (typeof window !== 'undefined' && (window as unknown as Record<string, unknown>).__REACT_SPOT_DEBUG__) {
603
+ console.log(`[react-spot] virtual owner: ${name}`, {
604
+ extractedFrame: stackFrame,
605
+ debugLocation: node.debugLocation,
606
+ });
607
+ }
608
+
609
+ chain.push({
610
+ componentName: name,
611
+ stackFrame,
612
+ fiber: current as Fiber,
613
+ props: undefined,
614
+ });
615
+ }
616
+ }
617
+
618
+ /**
619
+ * 从 DOM 元素开始,沿 owner 链向上遍历,构建完整的用户组件层级链。
620
+ *
621
+ * 兼容两种 owner 格式:
622
+ * 1. 标准 Fiber 对象(客户端组件)—— 有 type、_debugOwner
623
+ * 2. React 19 虚拟 owner(服务端组件跨 RSC 边界)—— 有 name、env、owner
624
+ *
625
+ * 过滤框架内部组件;原生 DOM 元素保留在 chain 中供精确定位,
626
+ * 由 UI 层自行过滤展示。
627
+ *
628
+ * Args:
629
+ * target: 被点击的 DOM 元素
630
+ *
631
+ * Returns:
632
+ * 从叶到根的完整 owner 链路(含原生 DOM)
633
+ */
634
+ export function buildFiberReturnChain(target: Element): ClickToNodeInfo[] {
635
+ const chain: ClickToNodeInfo[] = [];
636
+ const fiber = findFiberElementFromNode(target);
637
+ if (!fiber) return chain;
638
+
639
+ const seen = new WeakSet();
640
+ let current: unknown = fiber;
641
+
642
+ while (current && typeof current === 'object') {
643
+ if (seen.has(current as object)) break;
644
+ seen.add(current as object);
645
+
646
+ tryPushOwnerChainEntry(chain, current);
647
+ const node = current as Record<string, unknown>;
648
+ current = node._debugOwner ?? node.owner ?? null;
649
+ }
650
+
651
+ return chain;
652
+ }
653
+
654
+ /**
655
+ * 从 DOM 元素开始,沿 owner 链向上遍历,构建组件所有权链。
656
+ *
657
+ * 兼容标准 Fiber、原生 DOM 和 React 19 虚拟 owner(服务端组件),
658
+ * 过滤框架内部组件。完整 chain 含原生 DOM,供左键精确定位 JSX 标签。
659
+ *
660
+ * Args:
661
+ * target: 被点击的 DOM 元素
662
+ *
663
+ * Returns:
664
+ * 从 DOM 元素到根的完整 owner 链路(含原生 DOM)
665
+ */
666
+ export function buildFiberChain(target: Element): ClickToNodeInfo[] {
667
+ const chain: ClickToNodeInfo[] = [];
668
+ const fiber = findFiberElementFromNode(target);
669
+ if (!fiber) return chain;
670
+
671
+ const seen = new WeakSet();
672
+ let current: unknown = fiber;
673
+
674
+ while (current && typeof current === 'object') {
675
+ if (seen.has(current as object)) break;
676
+ seen.add(current as object);
677
+
678
+ tryPushOwnerChainEntry(chain, current);
679
+ const node = current as Record<string, unknown>;
680
+ current = node._debugOwner ?? node.owner ?? null;
681
+ }
682
+
683
+ return chain;
684
+ }