react-spot 0.0.1 → 0.0.3
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-spot",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Dev-only DOM to React source opener for React 19, Next.js, and Turbopack.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"test": "node --test",
|
|
20
|
-
"demo:next": "
|
|
20
|
+
"demo:next": "pnpm --filter react-spot-next-turbopack-demo dev -- --turbo"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@jridgewell/source-map": "latest",
|
|
@@ -41,6 +41,8 @@ export interface ChainTransformContext {
|
|
|
41
41
|
) => Promise<ResolvedSourceInfo | null>;
|
|
42
42
|
getComponentName: (fiber: Fiber) => string;
|
|
43
43
|
getStackFrame: (fiber: Fiber) => string | undefined;
|
|
44
|
+
/** 获取 fiber 的所有候选栈帧,用于首选帧解析失败时回退 */
|
|
45
|
+
getAllMeaningfulFrames: (fiber: Fiber) => string[];
|
|
44
46
|
debug?: boolean;
|
|
45
47
|
}
|
|
46
48
|
|
package/src/core/fiber-utils.ts
CHANGED
|
@@ -219,6 +219,48 @@ export function getStackFrame(fiber: Fiber): string | undefined {
|
|
|
219
219
|
return meaningfulLines[frameIndex] || meaningfulLines[0] || undefined;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
/**
|
|
223
|
+
* 获取 fiber 的所有有意义栈帧(按优先级排序)。
|
|
224
|
+
*
|
|
225
|
+
* 当首选栈帧解析到 React 运行时等无效位置时,调用方可逐个尝试后续候选帧。
|
|
226
|
+
* 对于函数组件,优先返回 STACK_FRAME_INDEX 处的帧,然后是其他所有帧;
|
|
227
|
+
* 对于原生 DOM 元素,优先返回 index 0 处的帧。
|
|
228
|
+
*
|
|
229
|
+
* Args:
|
|
230
|
+
* fiber: React fiber 节点
|
|
231
|
+
*
|
|
232
|
+
* Returns:
|
|
233
|
+
* 按优先级排列的栈帧行数组,首元素与 getStackFrame 返回值一致
|
|
234
|
+
*/
|
|
235
|
+
export function getAllMeaningfulFrames(fiber: Fiber): string[] {
|
|
236
|
+
const stack = fiber._debugStack?.stack;
|
|
237
|
+
if (!stack) return [];
|
|
238
|
+
|
|
239
|
+
const lines = stack.split('\n');
|
|
240
|
+
const meaningfulLines: string[] = [];
|
|
241
|
+
for (let i = 1; i < lines.length; i++) {
|
|
242
|
+
const line = lines[i].trim();
|
|
243
|
+
if (line && !isUnresolvableFrame(line)) {
|
|
244
|
+
meaningfulLines.push(line);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (meaningfulLines.length === 0) return [];
|
|
249
|
+
|
|
250
|
+
// 将首选帧放到数组头部,其余按原始顺序跟随
|
|
251
|
+
const frameIndex = typeof fiber.type === 'string' ? 0 : STACK_FRAME_INDEX;
|
|
252
|
+
const preferred = meaningfulLines[frameIndex];
|
|
253
|
+
if (!preferred) return meaningfulLines;
|
|
254
|
+
|
|
255
|
+
const result = [preferred];
|
|
256
|
+
for (let i = 0; i < meaningfulLines.length; i++) {
|
|
257
|
+
if (i !== frameIndex) {
|
|
258
|
+
result.push(meaningfulLines[i]);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
|
|
222
264
|
/**
|
|
223
265
|
* 在 DOM 节点上查找 React fiber 内部属性。
|
|
224
266
|
*
|
|
@@ -341,24 +341,26 @@ async function resolveViaNextDevServer(
|
|
|
341
341
|
const { originalStackFrame } = first.value;
|
|
342
342
|
let sourcePath = originalStackFrame.file || frameInfo.url;
|
|
343
343
|
|
|
344
|
-
// Next.js
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
!sourcePath.includes('://')
|
|
352
|
-
) {
|
|
353
|
-
const fsRoot = getSourceRoot();
|
|
344
|
+
// Next.js/Turbopack 返回的路径有两种形式:
|
|
345
|
+
// 1. 纯相对路径:"src/app/page.tsx"(无前导 /)
|
|
346
|
+
// 2. 虚拟根相对路径:"/src/components/layout/AppShell.tsx"(有前导 / 但非真实绝对路径)
|
|
347
|
+
// 两种情况都需要拼接 sourceRoot 才能得到可打开的文件系统绝对路径。
|
|
348
|
+
// 真正的绝对路径特征:以 fsRoot 开头(如 /Users/...)或以 file:// 开头。
|
|
349
|
+
const fsRoot = getSourceRoot();
|
|
350
|
+
if (sourcePath && !sourcePath.includes('://')) {
|
|
354
351
|
if (debug) {
|
|
355
|
-
console.log('
|
|
356
|
-
sourcePath,
|
|
357
|
-
fsRoot,
|
|
358
|
-
});
|
|
352
|
+
console.log('Resolving sourcePath with sourceRoot:', { sourcePath, fsRoot });
|
|
359
353
|
}
|
|
360
354
|
if (fsRoot) {
|
|
361
|
-
sourcePath
|
|
355
|
+
if (sourcePath.startsWith(fsRoot)) {
|
|
356
|
+
// 已经是完整的文件系统绝对路径,无需处理
|
|
357
|
+
} else if (sourcePath.startsWith('/')) {
|
|
358
|
+
// 虚拟根相对路径(如 /src/...),拼接项目根
|
|
359
|
+
sourcePath = fsRoot + sourcePath;
|
|
360
|
+
} else {
|
|
361
|
+
// 纯相对路径(如 src/app/page.tsx),用 / 连接
|
|
362
|
+
sourcePath = `${fsRoot}/${sourcePath}`;
|
|
363
|
+
}
|
|
362
364
|
}
|
|
363
365
|
}
|
|
364
366
|
|
|
@@ -769,6 +771,16 @@ export async function resolveLocation(
|
|
|
769
771
|
sourceContent: originalSourceContent || undefined,
|
|
770
772
|
};
|
|
771
773
|
|
|
774
|
+
// 后验证:source map 可能错误地将 workspace 包映射到 React 运行时文件,
|
|
775
|
+
// 此时应视为解析失败,让调用方尝试其他候选栈帧
|
|
776
|
+
if (isRuntimeSource(result.source)) {
|
|
777
|
+
if (debug) {
|
|
778
|
+
console.warn('Resolved to runtime source, rejecting:', result.source);
|
|
779
|
+
console.groupEnd();
|
|
780
|
+
}
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
|
|
772
784
|
boundedSet(resultCache, cacheKey, { originalSource: result }, MAX_RESULT_CACHE_SIZE);
|
|
773
785
|
|
|
774
786
|
if (debug) {
|
|
@@ -793,6 +805,54 @@ export async function resolveLocation(
|
|
|
793
805
|
}
|
|
794
806
|
}
|
|
795
807
|
|
|
808
|
+
/**
|
|
809
|
+
* 检查解析出的源码路径是否指向第三方库或框架运行时文件(非用户代码)。
|
|
810
|
+
*
|
|
811
|
+
* 这是源码导航的核心守卫:当 source map 将位置映射到 node_modules 内的
|
|
812
|
+
* 第三方包源码时(如 next/src/client/image-component.tsx),该路径通常:
|
|
813
|
+
* 1. 在磁盘上不存在(包发布时不含原始 TS 源码)
|
|
814
|
+
* 2. 不是用户想要到达的位置(用户想去"使用处"而非"定义处")
|
|
815
|
+
*
|
|
816
|
+
* 例外:pnpm workspace 链接的包会通过 symlink 直接解析到 workspace 目录,
|
|
817
|
+
* 不经过 .pnpm/ 虚拟存储,因此不会被误判。
|
|
818
|
+
*/
|
|
819
|
+
export function isRuntimeSource(source: string): boolean {
|
|
820
|
+
// pnpm 虚拟存储中的包 → 一定是第三方依赖
|
|
821
|
+
if (source.includes('node_modules/.pnpm/')) return true;
|
|
822
|
+
|
|
823
|
+
// node_modules 内部的包源码(排除直接 symlink 到 workspace 的情况)
|
|
824
|
+
// 匹配模式:node_modules/<pkg>/src/ 或 node_modules/<pkg>/dist/ 等
|
|
825
|
+
if (source.includes('/node_modules/') && !isWorkspaceLinkedPath(source)) {
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* 判断一个包含 node_modules 的路径是否指向 workspace 链接的包。
|
|
834
|
+
*
|
|
835
|
+
* workspace 包的特征:路径中 node_modules 后面紧跟包名,
|
|
836
|
+
* 且该路径最终指向项目 workspace 内部(不含 .pnpm)。
|
|
837
|
+
* 此判断确保 monorepo 中自己的 packages 仍可正常导航。
|
|
838
|
+
*/
|
|
839
|
+
function isWorkspaceLinkedPath(source: string): boolean {
|
|
840
|
+
const fsRoot = getSourceRoot();
|
|
841
|
+
if (!fsRoot) return false;
|
|
842
|
+
|
|
843
|
+
// workspace 链接的路径解析后应以项目根目录开头,
|
|
844
|
+
// 且不经过 .pnpm 虚拟存储
|
|
845
|
+
if (source.startsWith(fsRoot) && !source.includes('.pnpm')) {
|
|
846
|
+
// 进一步检查:node_modules 之后的路径段是否又嵌套了 node_modules
|
|
847
|
+
// 如果有多层 node_modules,通常是第三方包的依赖
|
|
848
|
+
const afterFirstNodeModules = source.split('/node_modules/').slice(1);
|
|
849
|
+
if (afterFirstNodeModules.length <= 1) {
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
|
|
796
856
|
/** Clears all caches (including the Next.js dev server availability flag). */
|
|
797
857
|
export function clearCaches(): void {
|
|
798
858
|
resultCache.clear();
|
package/src/react/ReactSpot.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
TransformedEntry,
|
|
8
8
|
} from '../core/chain-transformer';
|
|
9
9
|
import { applyTransformer } from '../core/chain-transformer';
|
|
10
|
-
import { buildFiberChain, buildFiberReturnChain, getComponentName, getStackFrame, isHostFiberEntry } from '../core/fiber-utils';
|
|
10
|
+
import { buildFiberChain, buildFiberReturnChain, getAllMeaningfulFrames, getComponentName, getStackFrame, isHostFiberEntry } from '../core/fiber-utils';
|
|
11
11
|
import { configureSourceRoot, resolveLocation } from '../core/source-location-resolver';
|
|
12
12
|
import type { ClickToNodeInfo, ComponentHandle, NavigationEvent } from '../core/types';
|
|
13
13
|
import { Popover, PopoverContent, PopoverTrigger } from './components/ui/popover';
|
|
@@ -193,8 +193,11 @@ function openInEditor(
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
196
|
+
* 解析组件源码位置并跳转到编辑器。
|
|
197
|
+
*
|
|
198
|
+
* 首先尝试首选栈帧;若解析失败(如 source map 错误地映射到 React 运行时),
|
|
199
|
+
* 则从 fiber 提取所有候选帧逐个尝试,直到找到有效的用户源码位置。
|
|
200
|
+
* 此回退机制解决 monorepo workspace 包在 Turbopack 编译后 source map 链路断裂的问题。
|
|
198
201
|
*/
|
|
199
202
|
async function resolveAndNavigate(
|
|
200
203
|
component: ClickToNodeInfo,
|
|
@@ -218,12 +221,56 @@ async function resolveAndNavigate(
|
|
|
218
221
|
);
|
|
219
222
|
return true;
|
|
220
223
|
}
|
|
224
|
+
|
|
225
|
+
// 首选帧解析失败,尝试 fiber 的所有候选帧
|
|
226
|
+
const fallbackFrames = getAllMeaningfulFrames(component.fiber);
|
|
227
|
+
for (const frame of fallbackFrames) {
|
|
228
|
+
if (frame === component.stackFrame) continue;
|
|
229
|
+
const fallbackResolved = await resolveLocation(frame, debug);
|
|
230
|
+
if (fallbackResolved) {
|
|
231
|
+
if (debug) {
|
|
232
|
+
console.log('[react-spot] Resolved via fallback frame:', frame);
|
|
233
|
+
}
|
|
234
|
+
openInEditor(
|
|
235
|
+
fallbackResolved.source,
|
|
236
|
+
fallbackResolved.line,
|
|
237
|
+
fallbackResolved.column,
|
|
238
|
+
onNavigate,
|
|
239
|
+
component.componentName,
|
|
240
|
+
editorScheme,
|
|
241
|
+
debug
|
|
242
|
+
);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
221
247
|
return false;
|
|
222
248
|
} catch {
|
|
223
249
|
return false;
|
|
224
250
|
}
|
|
225
251
|
}
|
|
226
252
|
|
|
253
|
+
/**
|
|
254
|
+
* 沿 owner chain 逐个尝试解析源码位置并跳转。
|
|
255
|
+
*
|
|
256
|
+
* 解决第三方库组件(如 next/image)的场景:当用户点击由库组件渲染的 DOM 元素时,
|
|
257
|
+
* 该 DOM 的栈帧指向库内部实现(不存在或不可达),此时应沿 chain 向上
|
|
258
|
+
* 找到最近的用户代码位置(即使用该库组件的 JSX 所在行)。
|
|
259
|
+
*/
|
|
260
|
+
async function resolveAndNavigateWithChainFallback(
|
|
261
|
+
chain: ClickToNodeInfo[],
|
|
262
|
+
onNavigate?: ReactSpotProps['onNavigate'],
|
|
263
|
+
editorScheme?: string,
|
|
264
|
+
debug?: boolean
|
|
265
|
+
): Promise<boolean> {
|
|
266
|
+
for (const entry of chain) {
|
|
267
|
+
if (!entry.stackFrame) continue;
|
|
268
|
+
const success = await resolveAndNavigate(entry, onNavigate, editorScheme, debug);
|
|
269
|
+
if (success) return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
227
274
|
export function ReactSpot({
|
|
228
275
|
onNavigate,
|
|
229
276
|
sourceRoot,
|
|
@@ -292,6 +339,7 @@ export function ReactSpot({
|
|
|
292
339
|
resolveLocation: (sf, dbg) => resolveLocation(sf, dbg ?? debugRef.current),
|
|
293
340
|
getComponentName,
|
|
294
341
|
getStackFrame,
|
|
342
|
+
getAllMeaningfulFrames,
|
|
295
343
|
}),
|
|
296
344
|
[]
|
|
297
345
|
);
|
|
@@ -564,18 +612,26 @@ export function ReactSpot({
|
|
|
564
612
|
componentName: c.componentName,
|
|
565
613
|
props: c.props,
|
|
566
614
|
index: i,
|
|
567
|
-
resolveSource: () =>
|
|
568
|
-
c.stackFrame
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
615
|
+
resolveSource: async () => {
|
|
616
|
+
if (!c.stackFrame) return null;
|
|
617
|
+
const r = await resolveLocation(c.stackFrame, dbg);
|
|
618
|
+
if (r) return { source: r.source, line: r.line, column: r.column };
|
|
619
|
+
// 首选帧失败,尝试 fiber 的其他候选帧
|
|
620
|
+
const fallbacks = getAllMeaningfulFrames(c.fiber);
|
|
621
|
+
for (const frame of fallbacks) {
|
|
622
|
+
if (frame === c.stackFrame) continue;
|
|
623
|
+
const fr = await resolveLocation(frame, dbg);
|
|
624
|
+
if (fr) return { source: fr.source, line: fr.line, column: fr.column };
|
|
625
|
+
}
|
|
626
|
+
return null;
|
|
627
|
+
},
|
|
573
628
|
}));
|
|
574
629
|
Promise.resolve(getClickTargetRef.current(handles)).then((targetIndex) => {
|
|
575
630
|
const idx = targetIndex ?? 0;
|
|
576
631
|
if (idx >= 0 && idx < currentChain.length) {
|
|
577
|
-
|
|
578
|
-
|
|
632
|
+
// 从选中位置开始沿 chain 向上回退,处理库组件源码不可达的情况
|
|
633
|
+
resolveAndNavigateWithChainFallback(
|
|
634
|
+
currentChain.slice(idx),
|
|
579
635
|
onNavigateRef.current,
|
|
580
636
|
editorSchemeRef.current,
|
|
581
637
|
debugRef.current
|
|
@@ -587,8 +643,10 @@ export function ReactSpot({
|
|
|
587
643
|
const transformed = applyTransformer(currentChain, chainTransformerRef.current, ctx);
|
|
588
644
|
if (transformed.length > 0) navigateFromEntry(transformed[0]);
|
|
589
645
|
} else {
|
|
590
|
-
|
|
591
|
-
|
|
646
|
+
// 尝试解析首选目标;若失败(如第三方库组件源码不存在),
|
|
647
|
+
// 沿 owner chain 向上逐个尝试,直到找到可导航的用户代码位置
|
|
648
|
+
resolveAndNavigateWithChainFallback(
|
|
649
|
+
currentChain,
|
|
592
650
|
onNavigateRef.current,
|
|
593
651
|
editorSchemeRef.current,
|
|
594
652
|
debugRef.current
|
|
@@ -122,7 +122,8 @@ function buildLabel(value: unknown, rule: TransformerRule): string {
|
|
|
122
122
|
function buildResolveLocation(
|
|
123
123
|
stackFrame: string | undefined,
|
|
124
124
|
rule: TransformerRule,
|
|
125
|
-
ctx: ChainTransformContext
|
|
125
|
+
ctx: ChainTransformContext,
|
|
126
|
+
fiber?: Fiber
|
|
126
127
|
): (() => Promise<{ source: string; line: number; column: number } | null>) | undefined {
|
|
127
128
|
if (!stackFrame) {
|
|
128
129
|
if (ctx.debug) {
|
|
@@ -132,7 +133,23 @@ function buildResolveLocation(
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
return async () => {
|
|
135
|
-
|
|
136
|
+
let resolved = await ctx.resolveLocation(stackFrame);
|
|
137
|
+
|
|
138
|
+
// 首选帧解析失败时,尝试 fiber 的其他候选栈帧
|
|
139
|
+
if (!resolved && fiber) {
|
|
140
|
+
const fallbacks = ctx.getAllMeaningfulFrames(fiber);
|
|
141
|
+
for (const frame of fallbacks) {
|
|
142
|
+
if (frame === stackFrame) continue;
|
|
143
|
+
resolved = await ctx.resolveLocation(frame);
|
|
144
|
+
if (resolved) {
|
|
145
|
+
if (ctx.debug) {
|
|
146
|
+
console.log(LOG_PREFIX, `resolved via fallback frame for rule "${rule.name}"`);
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
136
153
|
if (!resolved) {
|
|
137
154
|
if (ctx.debug) {
|
|
138
155
|
console.warn(LOG_PREFIX, `source resolution returned null for rule "${rule.name}"`, {
|
|
@@ -234,7 +251,7 @@ function matchChildFiber(
|
|
|
234
251
|
label: buildLabel(labelValue, rule),
|
|
235
252
|
sourceEntry: info,
|
|
236
253
|
props,
|
|
237
|
-
resolveLocation: buildResolveLocation(stackFrame, rule, ctx),
|
|
254
|
+
resolveLocation: buildResolveLocation(stackFrame, rule, ctx, matched),
|
|
238
255
|
};
|
|
239
256
|
}
|
|
240
257
|
|
|
@@ -292,7 +309,7 @@ function matchDirect(
|
|
|
292
309
|
label: buildLabel(labelValue, rule),
|
|
293
310
|
sourceEntry: { ...entry, stackFrame },
|
|
294
311
|
props,
|
|
295
|
-
resolveLocation: buildResolveLocation(stackFrame, rule, ctx),
|
|
312
|
+
resolveLocation: buildResolveLocation(stackFrame, rule, ctx, entry.fiber),
|
|
296
313
|
};
|
|
297
314
|
}
|
|
298
315
|
|