react-spot 0.0.6 → 0.0.8
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.
- package/package.json +1 -1
- package/src/core/fiber-utils.ts +150 -177
- package/src/core/source-location-resolver.ts +33 -8
- package/src/react/ReactSpot.tsx +36 -16
package/package.json
CHANGED
package/src/core/fiber-utils.ts
CHANGED
|
@@ -2,10 +2,15 @@ import type { ClickToNodeInfo, Fiber } from './types';
|
|
|
2
2
|
|
|
3
3
|
export { isHostFiberEntry } from './types';
|
|
4
4
|
|
|
5
|
+
// 避免对同一 fiber 重复执行 type 检测逻辑,
|
|
6
|
+
// 在 owner chain 遍历中同一 fiber 可能被 isUserComponent、getStackFrame、push 等多处调用
|
|
7
|
+
const componentNameCache = new WeakMap<Fiber, string>();
|
|
8
|
+
|
|
5
9
|
/**
|
|
6
10
|
* 从 fiber 中提取可读的组件名称。
|
|
7
11
|
*
|
|
8
12
|
* 支持函数组件、类组件、ForwardRef、Memo 包装和原生 DOM 元素。
|
|
13
|
+
* 结果通过 WeakMap 缓存,同一 fiber 只解析一次。
|
|
9
14
|
*
|
|
10
15
|
* Args:
|
|
11
16
|
* fiber: React fiber 节点
|
|
@@ -14,7 +19,11 @@ export { isHostFiberEntry } from './types';
|
|
|
14
19
|
* 组件的显示名称
|
|
15
20
|
*/
|
|
16
21
|
export function getComponentName(fiber: Fiber): string {
|
|
17
|
-
|
|
22
|
+
const cached = componentNameCache.get(fiber);
|
|
23
|
+
if (cached !== undefined) return cached;
|
|
24
|
+
const name = resolveComponentName(fiber).name;
|
|
25
|
+
componentNameCache.set(fiber, name);
|
|
26
|
+
return name;
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
interface NameResolution {
|
|
@@ -186,61 +195,138 @@ const INTERNAL_FUNCTION_KEYWORDS = [
|
|
|
186
195
|
'attemptResolveElement',
|
|
187
196
|
];
|
|
188
197
|
|
|
198
|
+
// 将所有内部关键词预编译为单个正则,利用引擎内部 alternation 优化(trie/Aho-Corasick),
|
|
199
|
+
// 单次 test 替代逐一 includes,在高频栈帧过滤场景下性能提升约 5-10x
|
|
200
|
+
const INTERNAL_FRAME_RE = new RegExp(
|
|
201
|
+
[...INTERNAL_PATH_KEYWORDS, ...INTERNAL_FUNCTION_KEYWORDS, '<anonymous>']
|
|
202
|
+
.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
203
|
+
.join('|')
|
|
204
|
+
);
|
|
205
|
+
|
|
189
206
|
function isUnresolvableFrame(line: string): boolean {
|
|
190
|
-
|
|
191
|
-
if (line.includes(keyword)) return true;
|
|
192
|
-
}
|
|
193
|
-
for (const keyword of INTERNAL_FUNCTION_KEYWORDS) {
|
|
194
|
-
if (line.includes(keyword)) return true;
|
|
195
|
-
}
|
|
196
|
-
if (line.includes('<anonymous>')) return true;
|
|
197
|
-
return false;
|
|
207
|
+
return INTERNAL_FRAME_RE.test(line);
|
|
198
208
|
}
|
|
199
209
|
|
|
200
210
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* 过滤掉 React 运行时内部帧后,取第 STACK_FRAME_INDEX 个帧。
|
|
204
|
-
* 该帧通常对应用户源码中的 JSX 调用位置。
|
|
211
|
+
* fiber 栈帧解析缓存结构。
|
|
205
212
|
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
213
|
+
* 一次解析同时产出 preferredFrame(首选帧)和 allFrames(按优先级排列的全部帧),
|
|
214
|
+
* 供 getStackFrame / getAllMeaningfulFrames / isUserComponent 共享,
|
|
215
|
+
* 避免同一 fiber 的 _debugStack.stack 被重复 split + filter。
|
|
216
|
+
*/
|
|
217
|
+
interface ParsedStackInfo {
|
|
218
|
+
preferredFrame: string | undefined;
|
|
219
|
+
allFrames: string[];
|
|
220
|
+
meaningfulLines: string[];
|
|
221
|
+
hasUserFrame: boolean;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// WeakMap 以 fiber 对象为 key,GC 友好,不会阻止 fiber 被回收
|
|
225
|
+
const parsedStackCache = new WeakMap<Fiber, ParsedStackInfo>();
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 一次性解析 fiber 的 _debugStack,并缓存解析结果。
|
|
208
229
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
230
|
+
* 内部完成:栈字符串分割、运行时帧过滤、自身帧检测、首选帧确定、
|
|
231
|
+
* 用户帧判断。所有需要栈信息的函数共享此缓存,避免重复解析。
|
|
211
232
|
*/
|
|
212
|
-
|
|
233
|
+
function getParsedStack(fiber: Fiber): ParsedStackInfo {
|
|
234
|
+
const cached = parsedStackCache.get(fiber);
|
|
235
|
+
if (cached) return cached;
|
|
236
|
+
|
|
213
237
|
const stack = fiber._debugStack?.stack;
|
|
214
|
-
if (!stack)
|
|
238
|
+
if (!stack) {
|
|
239
|
+
const empty: ParsedStackInfo = {
|
|
240
|
+
preferredFrame: undefined,
|
|
241
|
+
allFrames: [],
|
|
242
|
+
meaningfulLines: [],
|
|
243
|
+
hasUserFrame: true,
|
|
244
|
+
};
|
|
245
|
+
parsedStackCache.set(fiber, empty);
|
|
246
|
+
return empty;
|
|
247
|
+
}
|
|
215
248
|
|
|
216
249
|
const lines = stack.split('\n');
|
|
217
250
|
const meaningfulLines: string[] = [];
|
|
251
|
+
let hasUserFrame = false;
|
|
252
|
+
let hasAnyMeaningfulFrame = false;
|
|
253
|
+
|
|
218
254
|
for (let i = 1; i < lines.length; i++) {
|
|
219
255
|
const line = lines[i].trim();
|
|
220
|
-
if (
|
|
221
|
-
|
|
256
|
+
if (!line) continue;
|
|
257
|
+
if (isUnresolvableFrame(line)) continue;
|
|
258
|
+
|
|
259
|
+
meaningfulLines.push(line);
|
|
260
|
+
hasAnyMeaningfulFrame = true;
|
|
261
|
+
|
|
262
|
+
// 顺带检测是否存在非框架帧(用于 isUserComponent 判定)
|
|
263
|
+
if (
|
|
264
|
+
!hasUserFrame &&
|
|
265
|
+
!line.includes('node_modules') &&
|
|
266
|
+
!line.includes('next/dist') &&
|
|
267
|
+
!line.includes('next_dist') &&
|
|
268
|
+
!line.includes('next-server') &&
|
|
269
|
+
!line.includes('react-dom') &&
|
|
270
|
+
!line.includes('react-server-dom')
|
|
271
|
+
) {
|
|
272
|
+
hasUserFrame = true;
|
|
222
273
|
}
|
|
223
274
|
}
|
|
224
275
|
|
|
225
|
-
|
|
276
|
+
// 确定首选帧
|
|
277
|
+
let preferredFrame: string | undefined;
|
|
278
|
+
let preferredIndex = 0;
|
|
226
279
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
280
|
+
if (meaningfulLines.length > 0) {
|
|
281
|
+
if (typeof fiber.type === 'string') {
|
|
282
|
+
// 原生 DOM 元素直接取第一个帧
|
|
283
|
+
preferredFrame = meaningfulLines[0];
|
|
284
|
+
} else {
|
|
285
|
+
// 函数组件:检测自身帧
|
|
286
|
+
const componentName = getComponentName(fiber);
|
|
287
|
+
if (meaningfulLines.length > 1 && isSelfFrame(meaningfulLines[0], componentName)) {
|
|
288
|
+
preferredIndex = STACK_FRAME_INDEX_FALLBACK;
|
|
289
|
+
}
|
|
290
|
+
preferredFrame = meaningfulLines[preferredIndex] || meaningfulLines[0];
|
|
291
|
+
}
|
|
230
292
|
}
|
|
231
293
|
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
294
|
+
// 构建按优先级排列的完整帧列表
|
|
295
|
+
const allFrames: string[] = [];
|
|
296
|
+
if (preferredFrame) {
|
|
297
|
+
allFrames.push(preferredFrame);
|
|
298
|
+
for (let i = 0; i < meaningfulLines.length; i++) {
|
|
299
|
+
if (i !== preferredIndex) {
|
|
300
|
+
allFrames.push(meaningfulLines[i]);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
241
303
|
}
|
|
242
304
|
|
|
243
|
-
|
|
305
|
+
// hasUserFrame 仅在确实有 meaningful 帧时才有意义
|
|
306
|
+
const result: ParsedStackInfo = {
|
|
307
|
+
preferredFrame,
|
|
308
|
+
allFrames,
|
|
309
|
+
meaningfulLines,
|
|
310
|
+
hasUserFrame: !hasAnyMeaningfulFrame || hasUserFrame,
|
|
311
|
+
};
|
|
312
|
+
parsedStackCache.set(fiber, result);
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 从 fiber 的 _debugStack 中提取首选栈帧行。
|
|
318
|
+
*
|
|
319
|
+
* 利用统一解析缓存,避免重复字符串分割和过滤。
|
|
320
|
+
* 返回的帧通常对应用户源码中的 JSX 调用位置。
|
|
321
|
+
*
|
|
322
|
+
* Args:
|
|
323
|
+
* fiber: React fiber 节点
|
|
324
|
+
*
|
|
325
|
+
* Returns:
|
|
326
|
+
* 栈帧行字符串,如 "at Button (http://…:18:26)",或 undefined
|
|
327
|
+
*/
|
|
328
|
+
export function getStackFrame(fiber: Fiber): string | undefined {
|
|
329
|
+
return getParsedStack(fiber).preferredFrame;
|
|
244
330
|
}
|
|
245
331
|
|
|
246
332
|
/**
|
|
@@ -252,64 +338,34 @@ export function getStackFrame(fiber: Fiber): string | undefined {
|
|
|
252
338
|
function isSelfFrame(frameLine: string, componentName: string): boolean {
|
|
253
339
|
// 提取帧中的函数名:匹配 "at FuncName (" 或 "FuncName@" 格式
|
|
254
340
|
const atMatch = frameLine.match(/^at\s+([^\s(]+)/);
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
|
|
341
|
+
const frameName = atMatch?.[1] ?? frameLine.match(/^([^@]+)@/)?.[1];
|
|
342
|
+
if (!frameName) return false;
|
|
343
|
+
|
|
344
|
+
// 直接匹配
|
|
345
|
+
if (frameName === componentName) return true;
|
|
346
|
+
|
|
347
|
+
// Memo(InnerName) / ForwardRef(InnerName) 包装:
|
|
348
|
+
// getComponentName 返回 "Memo(Foo)" 但 React 19 自身帧用的是内部函数名 "Foo"
|
|
349
|
+
const innerMatch = componentName.match(/^(?:Memo|ForwardRef)\((.+)\)$/);
|
|
350
|
+
if (innerMatch && frameName === innerMatch[1]) return true;
|
|
351
|
+
|
|
262
352
|
return false;
|
|
263
353
|
}
|
|
264
354
|
|
|
265
355
|
/**
|
|
266
356
|
* 获取 fiber 的所有有意义栈帧(按优先级排序)。
|
|
267
357
|
*
|
|
358
|
+
* 利用统一解析缓存,首元素与 getStackFrame 返回值一致。
|
|
268
359
|
* 当首选栈帧解析到 React 运行时等无效位置时,调用方可逐个尝试后续候选帧。
|
|
269
|
-
* 对于函数组件,优先返回 STACK_FRAME_INDEX 处的帧,然后是其他所有帧;
|
|
270
|
-
* 对于原生 DOM 元素,优先返回 index 0 处的帧。
|
|
271
360
|
*
|
|
272
361
|
* Args:
|
|
273
362
|
* fiber: React fiber 节点
|
|
274
363
|
*
|
|
275
364
|
* Returns:
|
|
276
|
-
*
|
|
365
|
+
* 按优先级排列的栈帧行数组
|
|
277
366
|
*/
|
|
278
367
|
export function getAllMeaningfulFrames(fiber: Fiber): string[] {
|
|
279
|
-
|
|
280
|
-
if (!stack) return [];
|
|
281
|
-
|
|
282
|
-
const lines = stack.split('\n');
|
|
283
|
-
const meaningfulLines: string[] = [];
|
|
284
|
-
for (let i = 1; i < lines.length; i++) {
|
|
285
|
-
const line = lines[i].trim();
|
|
286
|
-
if (line && !isUnresolvableFrame(line)) {
|
|
287
|
-
meaningfulLines.push(line);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (meaningfulLines.length === 0) return [];
|
|
292
|
-
|
|
293
|
-
// 确定首选帧索引:与 getStackFrame 保持一致的智能检测逻辑
|
|
294
|
-
let preferredIndex = 0;
|
|
295
|
-
if (typeof fiber.type !== 'string' && meaningfulLines.length > 1) {
|
|
296
|
-
const componentName = getComponentName(fiber);
|
|
297
|
-
if (isSelfFrame(meaningfulLines[0], componentName)) {
|
|
298
|
-
preferredIndex = STACK_FRAME_INDEX_FALLBACK;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const preferred = meaningfulLines[preferredIndex];
|
|
303
|
-
if (!preferred) return meaningfulLines;
|
|
304
|
-
|
|
305
|
-
// 将首选帧放到数组头部,其余按原始顺序跟随
|
|
306
|
-
const result = [preferred];
|
|
307
|
-
for (let i = 0; i < meaningfulLines.length; i++) {
|
|
308
|
-
if (i !== preferredIndex) {
|
|
309
|
-
result.push(meaningfulLines[i]);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return result;
|
|
368
|
+
return getParsedStack(fiber).allFrames;
|
|
313
369
|
}
|
|
314
370
|
|
|
315
371
|
/**
|
|
@@ -475,34 +531,12 @@ const FRAMEWORK_COMPONENT_NAMES = new Set([
|
|
|
475
531
|
]);
|
|
476
532
|
|
|
477
533
|
/**
|
|
478
|
-
*
|
|
534
|
+
* 框架组件名的合并正则,用于匹配动态生成的或变体名称。
|
|
535
|
+
* 将 17 个独立 RegExp 合并为单次 test,减少正则引擎启动开销。
|
|
479
536
|
*
|
|
480
537
|
* 例如 "InnerLayoutRouter_", "Memo(LayoutRouter)" 等。
|
|
481
538
|
*/
|
|
482
|
-
const
|
|
483
|
-
/Boundary$/,
|
|
484
|
-
/^(Inner|Outer)?LayoutRouter/,
|
|
485
|
-
/^Segment/,
|
|
486
|
-
/^Scroll/,
|
|
487
|
-
/^Redirect/,
|
|
488
|
-
/^NotFound/,
|
|
489
|
-
/^HTTPAccess/,
|
|
490
|
-
/^Metadata(Boundary|Outlet)/,
|
|
491
|
-
/^Viewport/,
|
|
492
|
-
/^DevOverlay/,
|
|
493
|
-
/^HotReload/,
|
|
494
|
-
/^ReactDevOverlay/,
|
|
495
|
-
/^ServerRoot/,
|
|
496
|
-
/^AppRouter/,
|
|
497
|
-
/^GlobalLayoutRouter/,
|
|
498
|
-
/^PathnameContext/,
|
|
499
|
-
/^RenderFromTemplate/,
|
|
500
|
-
/^StaticGeneration/,
|
|
501
|
-
/^AutoScroll/,
|
|
502
|
-
/^NavigateHandler/,
|
|
503
|
-
/^PreloadCss/,
|
|
504
|
-
/^PreloadModule/,
|
|
505
|
-
];
|
|
539
|
+
const FRAMEWORK_NAME_RE = /Boundary$|^(Inner|Outer)?LayoutRouter|^Segment|^Scroll|^Redirect|^NotFound|^HTTPAccess|^Metadata(Boundary|Outlet)|^Viewport|^DevOverlay|^HotReload|^ReactDevOverlay|^ServerRoot|^AppRouter|^GlobalLayoutRouter|^PathnameContext|^RenderFromTemplate|^StaticGeneration|^AutoScroll|^NavigateHandler|^PreloadCss|^PreloadModule/;
|
|
506
540
|
|
|
507
541
|
/**
|
|
508
542
|
* 判断 fiber 是否为用户定义的组件(而非框架/库内部组件)。
|
|
@@ -525,48 +559,15 @@ function isUserComponent(fiber: Fiber): boolean {
|
|
|
525
559
|
}
|
|
526
560
|
|
|
527
561
|
// 策略2:正则模式匹配(捕获 LayoutRouter_ 等变体)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
return false;
|
|
531
|
-
}
|
|
562
|
+
if (FRAMEWORK_NAME_RE.test(coreName)) {
|
|
563
|
+
return false;
|
|
532
564
|
}
|
|
533
565
|
|
|
534
|
-
// 策略3
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
let hasAnyMeaningfulFrame = false;
|
|
540
|
-
|
|
541
|
-
for (let i = 1; i < lines.length; i++) {
|
|
542
|
-
const line = lines[i].trim();
|
|
543
|
-
if (!line) continue;
|
|
544
|
-
// 跳过 React 运行时帧
|
|
545
|
-
if (isUnresolvableFrame(line)) continue;
|
|
546
|
-
|
|
547
|
-
hasAnyMeaningfulFrame = true;
|
|
548
|
-
|
|
549
|
-
// 框架代码帧的特征
|
|
550
|
-
if (
|
|
551
|
-
line.includes('node_modules') ||
|
|
552
|
-
line.includes('next/dist') ||
|
|
553
|
-
line.includes('next_dist') ||
|
|
554
|
-
line.includes('next-server') ||
|
|
555
|
-
line.includes('react-dom') ||
|
|
556
|
-
line.includes('react-server-dom')
|
|
557
|
-
) {
|
|
558
|
-
continue;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// 找到一个非框架帧,说明该组件来自用户代码
|
|
562
|
-
hasUserFrame = true;
|
|
563
|
-
break;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// 有意义帧全部指向框架代码 → 是框架组件
|
|
567
|
-
if (hasAnyMeaningfulFrame && !hasUserFrame) {
|
|
568
|
-
return false;
|
|
569
|
-
}
|
|
566
|
+
// 策略3:利用统一栈解析缓存判断是否存在用户帧,
|
|
567
|
+
// 避免重复 split + filter(已在 getParsedStack 中完成)
|
|
568
|
+
const parsed = getParsedStack(fiber);
|
|
569
|
+
if (!parsed.hasUserFrame) {
|
|
570
|
+
return false;
|
|
570
571
|
}
|
|
571
572
|
|
|
572
573
|
// 通过所有过滤,视为用户组件
|
|
@@ -685,10 +686,7 @@ function tryPushOwnerChainEntry(chain: ClickToNodeInfo[], current: unknown): voi
|
|
|
685
686
|
if (node.name && typeof node.name === 'string' && !node.type) {
|
|
686
687
|
const name = node.name as string;
|
|
687
688
|
if (FRAMEWORK_COMPONENT_NAMES.has(name)) return;
|
|
688
|
-
|
|
689
|
-
for (const pattern of FRAMEWORK_NAME_PATTERNS) {
|
|
690
|
-
if (pattern.test(name)) return;
|
|
691
|
-
}
|
|
689
|
+
if (FRAMEWORK_NAME_RE.test(name)) return;
|
|
692
690
|
|
|
693
691
|
const stackFrame = resolveVirtualOwnerStackFrame(name, node);
|
|
694
692
|
|
|
@@ -724,7 +722,7 @@ function tryPushOwnerChainEntry(chain: ClickToNodeInfo[], current: unknown): voi
|
|
|
724
722
|
* Returns:
|
|
725
723
|
* 从叶到根的完整 owner 链路(含原生 DOM)
|
|
726
724
|
*/
|
|
727
|
-
export function
|
|
725
|
+
export function buildFiberChain(target: Element): ClickToNodeInfo[] {
|
|
728
726
|
const chain: ClickToNodeInfo[] = [];
|
|
729
727
|
const fiber = findFiberElementFromNode(target);
|
|
730
728
|
if (!fiber) return chain;
|
|
@@ -745,33 +743,8 @@ export function buildFiberReturnChain(target: Element): ClickToNodeInfo[] {
|
|
|
745
743
|
}
|
|
746
744
|
|
|
747
745
|
/**
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
* 兼容标准 Fiber、原生 DOM 和 React 19 虚拟 owner(服务端组件),
|
|
751
|
-
* 过滤框架内部组件。完整 chain 含原生 DOM,供左键精确定位 JSX 标签。
|
|
746
|
+
* buildFiberChain 的别名,保持 API 兼容。
|
|
752
747
|
*
|
|
753
|
-
*
|
|
754
|
-
* target: 被点击的 DOM 元素
|
|
755
|
-
*
|
|
756
|
-
* Returns:
|
|
757
|
-
* 从 DOM 元素到根的完整 owner 链路(含原生 DOM)
|
|
748
|
+
* 两者逻辑完全一致——沿 owner 链遍历并构建组件层级链。
|
|
758
749
|
*/
|
|
759
|
-
export
|
|
760
|
-
const chain: ClickToNodeInfo[] = [];
|
|
761
|
-
const fiber = findFiberElementFromNode(target);
|
|
762
|
-
if (!fiber) return chain;
|
|
763
|
-
|
|
764
|
-
const seen = new WeakSet();
|
|
765
|
-
let current: unknown = fiber;
|
|
766
|
-
|
|
767
|
-
while (current && typeof current === 'object') {
|
|
768
|
-
if (seen.has(current as object)) break;
|
|
769
|
-
seen.add(current as object);
|
|
770
|
-
|
|
771
|
-
tryPushOwnerChainEntry(chain, current);
|
|
772
|
-
const node = current as Record<string, unknown>;
|
|
773
|
-
current = node._debugOwner ?? node.owner ?? null;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
return chain;
|
|
777
|
-
}
|
|
750
|
+
export const buildFiberReturnChain = buildFiberChain;
|
|
@@ -22,6 +22,8 @@ export interface ResolvedSourceInfo extends OriginalSourceInfo {
|
|
|
22
22
|
interface CachedSourceMapData {
|
|
23
23
|
sourceContent: string;
|
|
24
24
|
sourceMapContent: string;
|
|
25
|
+
// 缓存已解析的 JSON 对象,避免每次 mapToOriginalSource 时重复 JSON.parse
|
|
26
|
+
parsedSourceMap: Record<string, unknown>;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
interface CachedResult {
|
|
@@ -536,13 +538,16 @@ export async function resolveSourceMap(
|
|
|
536
538
|
/**
|
|
537
539
|
* Maps a generated position to the original source via source map.
|
|
538
540
|
* Returns the raw source path as-is — the caller resolves it (see `resolveSourcePath`).
|
|
541
|
+
*
|
|
542
|
+
* 接受可选的预解析 JSON 对象以跳过 JSON.parse(L2 缓存命中时直接传入)。
|
|
539
543
|
*/
|
|
540
544
|
export async function mapToOriginalSource(
|
|
541
545
|
frameInfo: StackFrameInfo,
|
|
542
|
-
sourceMapContent: string
|
|
546
|
+
sourceMapContent: string,
|
|
547
|
+
parsedMap?: Record<string, unknown>
|
|
543
548
|
): Promise<{ info: OriginalSourceInfo; sourceRoot?: string } | null> {
|
|
544
549
|
try {
|
|
545
|
-
const sourceMap = JSON.parse(sourceMapContent);
|
|
550
|
+
const sourceMap = parsedMap ?? JSON.parse(sourceMapContent);
|
|
546
551
|
|
|
547
552
|
const consumer = new SourceMapConsumer(sourceMap);
|
|
548
553
|
|
|
@@ -563,7 +568,7 @@ export async function mapToOriginalSource(
|
|
|
563
568
|
column: originalPosition.column,
|
|
564
569
|
name: originalPosition.name || undefined,
|
|
565
570
|
},
|
|
566
|
-
sourceRoot: sourceMap.sourceRoot,
|
|
571
|
+
sourceRoot: (sourceMap as Record<string, unknown>).sourceRoot as string | undefined,
|
|
567
572
|
};
|
|
568
573
|
}
|
|
569
574
|
|
|
@@ -574,13 +579,18 @@ export async function mapToOriginalSource(
|
|
|
574
579
|
}
|
|
575
580
|
}
|
|
576
581
|
|
|
577
|
-
/**
|
|
582
|
+
/**
|
|
583
|
+
* Retrieves the original source content embedded in the source map, if available.
|
|
584
|
+
*
|
|
585
|
+
* 接受可选的预解析 JSON 对象以跳过重复 JSON.parse。
|
|
586
|
+
*/
|
|
578
587
|
export async function getOriginalSourceContent(
|
|
579
588
|
originalInfo: OriginalSourceInfo,
|
|
580
|
-
sourceMapContent: string
|
|
589
|
+
sourceMapContent: string,
|
|
590
|
+
parsedMap?: Record<string, unknown>
|
|
581
591
|
): Promise<string | null> {
|
|
582
592
|
try {
|
|
583
|
-
const sourceMap = JSON.parse(sourceMapContent);
|
|
593
|
+
const sourceMap = parsedMap ?? JSON.parse(sourceMapContent);
|
|
584
594
|
const consumer = new SourceMapConsumer(sourceMap);
|
|
585
595
|
|
|
586
596
|
const sourceContent = consumer.sourceContentFor(originalInfo.source);
|
|
@@ -720,9 +730,23 @@ export async function resolveLocation(
|
|
|
720
730
|
|
|
721
731
|
if (debug) console.log('Source map resolved (length:', sourceMapContent.length, ')');
|
|
722
732
|
|
|
733
|
+
// 预解析 JSON 并缓存,后续同 URL 的不同位置查询可直接复用,
|
|
734
|
+
// 避免每次 mapToOriginalSource 重复 JSON.parse + SourceMapConsumer 构建
|
|
735
|
+
let parsedSourceMap: Record<string, unknown>;
|
|
736
|
+
try {
|
|
737
|
+
parsedSourceMap = JSON.parse(sourceMapContent);
|
|
738
|
+
} catch {
|
|
739
|
+
if (debug) {
|
|
740
|
+
console.warn('Failed to parse source map JSON for:', effectiveUrl);
|
|
741
|
+
console.groupEnd();
|
|
742
|
+
}
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
|
|
723
746
|
sourceMapData = {
|
|
724
747
|
sourceContent: sourceResult.content,
|
|
725
748
|
sourceMapContent,
|
|
749
|
+
parsedSourceMap,
|
|
726
750
|
};
|
|
727
751
|
boundedSet(sourceMapCache, url, sourceMapData, MAX_SOURCE_MAP_CACHE_SIZE);
|
|
728
752
|
if (url !== effectiveUrl) {
|
|
@@ -732,7 +756,7 @@ export async function resolveLocation(
|
|
|
732
756
|
if (debug) console.log('L2 cache hit for:', url);
|
|
733
757
|
}
|
|
734
758
|
|
|
735
|
-
const mapResult = await mapToOriginalSource(frameInfo, sourceMapData.sourceMapContent);
|
|
759
|
+
const mapResult = await mapToOriginalSource(frameInfo, sourceMapData.sourceMapContent, sourceMapData.parsedSourceMap);
|
|
736
760
|
if (!mapResult) {
|
|
737
761
|
if (debug) {
|
|
738
762
|
console.warn('Source map lookup returned no result for position', { line, column });
|
|
@@ -763,7 +787,8 @@ export async function resolveLocation(
|
|
|
763
787
|
// Use the raw (pre-resolved) path for content lookup — that's what the source map indexes by
|
|
764
788
|
const originalSourceContent = await getOriginalSourceContent(
|
|
765
789
|
{ ...originalInfo, source: rawSource },
|
|
766
|
-
sourceMapData.sourceMapContent
|
|
790
|
+
sourceMapData.sourceMapContent,
|
|
791
|
+
sourceMapData.parsedSourceMap
|
|
767
792
|
);
|
|
768
793
|
|
|
769
794
|
const result: ResolvedSourceInfo = {
|
package/src/react/ReactSpot.tsx
CHANGED
|
@@ -222,19 +222,27 @@ async function resolveAndNavigate(
|
|
|
222
222
|
return true;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
225
|
+
// 首选帧解析失败,并行解析所有候选帧以减少网络等待时间。
|
|
226
|
+
// 通过 allSettled 获取所有结果,按原始优先级顺序取第一个成功的。
|
|
227
|
+
const fallbackFrames = getAllMeaningfulFrames(component.fiber)
|
|
228
|
+
.filter((frame) => frame !== component.stackFrame);
|
|
229
|
+
|
|
230
|
+
if (fallbackFrames.length === 0) return false;
|
|
231
|
+
|
|
232
|
+
const results = await Promise.allSettled(
|
|
233
|
+
fallbackFrames.map((frame) => resolveLocation(frame, debug))
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
for (let i = 0; i < results.length; i++) {
|
|
237
|
+
const result = results[i];
|
|
238
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
231
239
|
if (debug) {
|
|
232
|
-
console.log('[react-spot] Resolved via fallback frame:',
|
|
240
|
+
console.log('[react-spot] Resolved via fallback frame:', fallbackFrames[i]);
|
|
233
241
|
}
|
|
234
242
|
openInEditor(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
result.value.source,
|
|
244
|
+
result.value.line,
|
|
245
|
+
result.value.column,
|
|
238
246
|
onNavigate,
|
|
239
247
|
component.componentName,
|
|
240
248
|
editorScheme,
|
|
@@ -558,11 +566,18 @@ export function ReactSpot({
|
|
|
558
566
|
// 闭包变量:保存当前悬停元素的 fiber 链路,供左键点击时使用,
|
|
559
567
|
// 避免 React state 的异步更新导致点击时读到过期数据
|
|
560
568
|
let currentChain: ClickToNodeInfo[] = [];
|
|
569
|
+
// 缓存上次悬停的 DOM 元素引用,相同元素时跳过整个 chain 重算,
|
|
570
|
+
// 在高频 mousemove 场景(60fps+)下消除 90%+ 冗余计算
|
|
571
|
+
let lastHoveredEl: HTMLElement | null = null;
|
|
561
572
|
|
|
562
573
|
const onMouseMove = (e: MouseEvent) => {
|
|
563
574
|
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
|
|
564
575
|
if (!el || el.closest('[data-react-spot-overlay]')) return;
|
|
565
576
|
|
|
577
|
+
// 同一元素无需重新构建 fiber chain 和重绘高亮
|
|
578
|
+
if (el === lastHoveredEl) return;
|
|
579
|
+
lastHoveredEl = el;
|
|
580
|
+
|
|
566
581
|
const rect = el.getBoundingClientRect();
|
|
567
582
|
currentChain = buildFiberChain(el);
|
|
568
583
|
|
|
@@ -616,12 +631,17 @@ export function ReactSpot({
|
|
|
616
631
|
if (!c.stackFrame) return null;
|
|
617
632
|
const r = await resolveLocation(c.stackFrame, dbg);
|
|
618
633
|
if (r) return { source: r.source, line: r.line, column: r.column };
|
|
619
|
-
//
|
|
620
|
-
const fallbacks = getAllMeaningfulFrames(c.fiber)
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
634
|
+
// 首选帧失败,并行解析所有候选帧
|
|
635
|
+
const fallbacks = getAllMeaningfulFrames(c.fiber)
|
|
636
|
+
.filter((frame) => frame !== c.stackFrame);
|
|
637
|
+
if (fallbacks.length === 0) return null;
|
|
638
|
+
const results = await Promise.allSettled(
|
|
639
|
+
fallbacks.map((frame) => resolveLocation(frame, dbg))
|
|
640
|
+
);
|
|
641
|
+
for (const result of results) {
|
|
642
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
643
|
+
return { source: result.value.source, line: result.value.line, column: result.value.column };
|
|
644
|
+
}
|
|
625
645
|
}
|
|
626
646
|
return null;
|
|
627
647
|
},
|