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.1",
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": "npm --prefix examples/next-turbopack run dev -- --turbo"
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
 
@@ -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 returns project-relative paths (e.g. "src/app/page.tsx").
345
- // Convert to absolute using the configured source root so the editor
346
- // can open the file directly.
347
- if (
348
- sourcePath &&
349
- !sourcePath.startsWith('/') &&
350
- !sourcePath.startsWith('file://') &&
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('Source path is relative, resolving with sourceRoot:', {
356
- sourcePath,
357
- fsRoot,
358
- });
352
+ console.log('Resolving sourcePath with sourceRoot:', { sourcePath, fsRoot });
359
353
  }
360
354
  if (fsRoot) {
361
- sourcePath = `${fsRoot}/${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();
@@ -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
- * Resolves the source location for a single component and opens the editor.
197
- * Delegates to the resolver's own two-level cache.
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
- ? resolveLocation(c.stackFrame, dbg).then((r) =>
570
- r ? { source: r.source, line: r.line, column: r.column } : null
571
- )
572
- : Promise.resolve(null),
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
- resolveAndNavigate(
578
- currentChain[idx],
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
- resolveAndNavigate(
591
- target,
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
- const resolved = await ctx.resolveLocation(stackFrame);
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