react-spot 0.0.1 → 0.0.2

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.2",
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
  *
@@ -769,6 +769,16 @@ export async function resolveLocation(
769
769
  sourceContent: originalSourceContent || undefined,
770
770
  };
771
771
 
772
+ // 后验证:source map 可能错误地将 workspace 包映射到 React 运行时文件,
773
+ // 此时应视为解析失败,让调用方尝试其他候选栈帧
774
+ if (isRuntimeSource(result.source)) {
775
+ if (debug) {
776
+ console.warn('Resolved to runtime source, rejecting:', result.source);
777
+ console.groupEnd();
778
+ }
779
+ return null;
780
+ }
781
+
772
782
  boundedSet(resultCache, cacheKey, { originalSource: result }, MAX_RESULT_CACHE_SIZE);
773
783
 
774
784
  if (debug) {
@@ -793,6 +803,33 @@ export async function resolveLocation(
793
803
  }
794
804
  }
795
805
 
806
+ /**
807
+ * 检查解析出的源码路径是否指向 React/框架运行时文件。
808
+ *
809
+ * 当 source map 错误地将 workspace 包的代码映射到运行时文件时,
810
+ * 此函数用于识别并拒绝这种无效结果,使调用方可以尝试其他候选帧。
811
+ */
812
+ const RUNTIME_SOURCE_PATTERNS = [
813
+ 'react-jsx-runtime',
814
+ 'react-jsx-dev-runtime',
815
+ '/compiled/react/',
816
+ '/compiled/react-dom/',
817
+ '/compiled/react-server-dom',
818
+ 'node_modules/react/cjs/',
819
+ 'node_modules/react/dist/',
820
+ 'node_modules/react-dom/cjs/',
821
+ 'node_modules/react-dom/dist/',
822
+ 'node_modules/next/dist/compiled/',
823
+ 'node_modules/next/dist/server/',
824
+ 'node_modules/next/dist/client/',
825
+ 'react-reconciler',
826
+ 'scheduler/',
827
+ ];
828
+
829
+ export function isRuntimeSource(source: string): boolean {
830
+ return RUNTIME_SOURCE_PATTERNS.some((p) => source.includes(p));
831
+ }
832
+
796
833
  /** Clears all caches (including the Next.js dev server availability flag). */
797
834
  export function clearCaches(): void {
798
835
  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,6 +221,29 @@ 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;
@@ -292,6 +318,7 @@ export function ReactSpot({
292
318
  resolveLocation: (sf, dbg) => resolveLocation(sf, dbg ?? debugRef.current),
293
319
  getComponentName,
294
320
  getStackFrame,
321
+ getAllMeaningfulFrames,
295
322
  }),
296
323
  []
297
324
  );
@@ -564,12 +591,19 @@ export function ReactSpot({
564
591
  componentName: c.componentName,
565
592
  props: c.props,
566
593
  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),
594
+ resolveSource: async () => {
595
+ if (!c.stackFrame) return null;
596
+ const r = await resolveLocation(c.stackFrame, dbg);
597
+ if (r) return { source: r.source, line: r.line, column: r.column };
598
+ // 首选帧失败,尝试 fiber 的其他候选帧
599
+ const fallbacks = getAllMeaningfulFrames(c.fiber);
600
+ for (const frame of fallbacks) {
601
+ if (frame === c.stackFrame) continue;
602
+ const fr = await resolveLocation(frame, dbg);
603
+ if (fr) return { source: fr.source, line: fr.line, column: fr.column };
604
+ }
605
+ return null;
606
+ },
573
607
  }));
574
608
  Promise.resolve(getClickTargetRef.current(handles)).then((targetIndex) => {
575
609
  const idx = targetIndex ?? 0;
@@ -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