bippy 0.5.42 → 0.6.0

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.
Files changed (50) hide show
  1. package/dist/core.cjs +1 -1
  2. package/dist/core.d.cts +28 -313
  3. package/dist/core.d.ts +28 -313
  4. package/dist/core.js +1 -1
  5. package/dist/core2.d.cts +3 -2
  6. package/dist/core2.d.ts +3 -2
  7. package/dist/get-source.cjs +19 -0
  8. package/dist/get-source.js +19 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.cts +3 -2
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.iife.js +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/install-hook-only.cjs +1 -1
  15. package/dist/install-hook-only.iife.js +1 -1
  16. package/dist/install-hook-only.js +1 -1
  17. package/dist/rdt-hook.cjs +1 -1
  18. package/dist/rdt-hook.js +1 -1
  19. package/dist/react-refresh.cjs +9 -0
  20. package/dist/react-refresh.d.cts +66 -0
  21. package/dist/react-refresh.d.ts +66 -0
  22. package/dist/react-refresh.js +9 -0
  23. package/dist/source.cjs +4 -19
  24. package/dist/source.d.cts +35 -27
  25. package/dist/source.d.ts +35 -27
  26. package/dist/source.js +4 -19
  27. package/dist/unsubscribe.d.cts +298 -0
  28. package/dist/unsubscribe.d.ts +298 -0
  29. package/package.json +14 -3
  30. package/src/core.ts +312 -334
  31. package/src/rdt-hook.ts +57 -16
  32. package/src/react-refresh/constants.ts +9 -0
  33. package/src/react-refresh/detect-hmr-transport.ts +33 -0
  34. package/src/react-refresh/index.ts +173 -0
  35. package/src/react-refresh/metro-hmr-transport.ts +188 -0
  36. package/src/react-refresh/next-webpack-hmr-transport.ts +72 -0
  37. package/src/react-refresh/normalize-hmr-file-path.ts +24 -0
  38. package/src/react-refresh/types.ts +7 -0
  39. package/src/react-refresh/vite-hmr-transport.ts +116 -0
  40. package/src/source/constants.ts +11 -0
  41. package/src/source/get-display-name-from-source.ts +3 -3
  42. package/src/source/get-source.ts +53 -9
  43. package/src/source/index.ts +17 -8
  44. package/src/source/inspect-hooks.ts +7 -4
  45. package/src/source/owner-stack.ts +193 -29
  46. package/src/source/parse-debug-stack.ts +133 -0
  47. package/src/source/parse-stack.ts +6 -105
  48. package/src/source/symbolication.ts +48 -13
  49. package/src/types.ts +20 -7
  50. package/src/unsubscribe.ts +17 -0
@@ -0,0 +1,116 @@
1
+ import { HMR_RECONNECT_DELAY_MS, VITE_WS_TOKEN_REGEX } from "./constants.js";
2
+ import { HmrTransport, HmrUpdateHandler } from "./types.js";
3
+
4
+ /**
5
+ * Extracts the accepted file paths from a raw Vite HMR WebSocket message.
6
+ * Only `js-update` entries are kept (a `css-update` swaps a stylesheet link
7
+ * without re-running modules). Returns an empty array for any other message
8
+ * shape.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * parseViteUpdatePaths(rawMessageData);
13
+ * // ["/src/app.tsx"]
14
+ * ```
15
+ */
16
+ export const parseViteUpdatePaths = (rawMessageData: string): string[] => {
17
+ let message: unknown;
18
+ try {
19
+ message = JSON.parse(rawMessageData);
20
+ } catch {
21
+ return [];
22
+ }
23
+ if (typeof message !== "object" || message === null) return [];
24
+ if (!("type" in message) || message.type !== "update") return [];
25
+ if (!("updates" in message) || !Array.isArray(message.updates)) return [];
26
+ const filePaths: string[] = [];
27
+ for (const update of message.updates) {
28
+ if (typeof update !== "object" || update === null) continue;
29
+ if (!("type" in update) || update.type !== "js-update") continue;
30
+ if (!("acceptedPath" in update) || typeof update.acceptedPath !== "string") continue;
31
+ filePaths.push(update.acceptedPath);
32
+ }
33
+ return filePaths;
34
+ };
35
+
36
+ // HACK: a standalone script is not a Vite module, so import.meta.hot is
37
+ // unavailable; open a second HMR WebSocket using the wsToken scraped from
38
+ // the dev server's own /@vite/client source.
39
+ const fetchViteWsToken = async (): Promise<string | null> => {
40
+ try {
41
+ const response = await fetch("/@vite/client");
42
+ if (!response.ok) return null;
43
+ const clientSource = await response.text();
44
+ return VITE_WS_TOKEN_REGEX.exec(clientSource)?.[1] ?? null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ };
49
+
50
+ /**
51
+ * Subscribes to the current page's Vite dev server HMR WebSocket and invokes
52
+ * `onHmrUpdate` with the updated file paths on every hot update. Reconnects
53
+ * automatically when the dev server restarts. Resolves `null` when the page
54
+ * is not served by Vite.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * const transport = await createViteHmrTransport((filePaths) => {
59
+ * console.log("hot updated:", filePaths);
60
+ * });
61
+ * transport?.dispose();
62
+ * ```
63
+ */
64
+ export const createViteHmrTransport = async (
65
+ onHmrUpdate: HmrUpdateHandler,
66
+ ): Promise<HmrTransport | null> => {
67
+ if (typeof window === "undefined" || typeof WebSocket === "undefined") return null;
68
+ const initialWsToken = await fetchViteWsToken();
69
+ if (!initialWsToken) return null;
70
+
71
+ let isDisposed = false;
72
+ let socket: WebSocket | null = null;
73
+ let reconnectTimerId: number | undefined;
74
+
75
+ const scheduleReconnect = () => {
76
+ if (isDisposed) return;
77
+ reconnectTimerId = window.setTimeout(() => {
78
+ void fetchViteWsToken().then((freshWsToken) => {
79
+ if (isDisposed) return;
80
+ if (freshWsToken) {
81
+ connect(freshWsToken);
82
+ } else {
83
+ scheduleReconnect();
84
+ }
85
+ });
86
+ }, HMR_RECONNECT_DELAY_MS);
87
+ };
88
+
89
+ const connect = (wsToken: string) => {
90
+ if (isDisposed) return;
91
+ const socketProtocol = location.protocol === "https:" ? "wss" : "ws";
92
+ const connectedSocket = new WebSocket(
93
+ `${socketProtocol}://${location.host}/?token=${wsToken}`,
94
+ "vite-hmr",
95
+ );
96
+ socket = connectedSocket;
97
+ connectedSocket.onmessage = (event) => {
98
+ const filePaths = parseViteUpdatePaths(String(event.data));
99
+ if (filePaths.length > 0) onHmrUpdate(filePaths);
100
+ };
101
+ connectedSocket.onclose = scheduleReconnect;
102
+ };
103
+
104
+ connect(initialWsToken);
105
+
106
+ return {
107
+ dispose: () => {
108
+ isDisposed = true;
109
+ window.clearTimeout(reconnectTimerId);
110
+ if (socket) {
111
+ socket.onclose = null;
112
+ socket.close();
113
+ }
114
+ },
115
+ };
116
+ };
@@ -14,6 +14,8 @@ export const INTERNAL_SCHEME_PREFIXES = [
14
14
 
15
15
  export const ABOUT_REACT_PREFIX = "about://React/";
16
16
 
17
+ export const SERVER_COMPONENT_URL_PREFIXES = ["rsc://", ABOUT_REACT_PREFIX] as const;
18
+
17
19
  export const ANONYMOUS_FILE_PATTERNS = ["<anonymous>", "eval", ""] as const;
18
20
 
19
21
  export const SOURCE_FILE_EXTENSION_REGEX = /\.(jsx|tsx|ts|js)$/;
@@ -26,3 +28,12 @@ export const QUERY_PARAMETER_PATTERN_REGEX = /^\?[\w~.-]+(?:=[^&#]*)?(?:&[\w~.-]
26
28
  export const SERVER_FRAME_MARKER = "(at Server)";
27
29
 
28
30
  export const SERVER_ENV_PATTERN = /\(at [^)]+\)$/;
31
+
32
+ export const REACT_STACK_BOTTOM_FRAME_PATTERNS = [
33
+ "react_stack_bottom_frame",
34
+ "react-stack-bottom-frame",
35
+ ] as const;
36
+
37
+ // the first frame of a _debugStack is the JSX factory itself (jsxDEV), never
38
+ // user code, matching what react's own captureOwnerStack pops
39
+ export const JSX_FACTORY_FRAME_COUNT = 1;
@@ -1,6 +1,6 @@
1
1
  import { Fiber } from "../types.js";
2
2
  import { getDisplayName } from "../core.js";
3
- import { getOwnerStack } from "./owner-stack.js";
3
+ import { getParentStack } from "./owner-stack.js";
4
4
  import { getSourceFromSourceMap, getSourceMap } from "./symbolication.js";
5
5
  import { StackFrame } from "./parse-stack.js";
6
6
 
@@ -46,8 +46,8 @@ export const getDisplayNameFromSource = async (
46
46
  cache = true,
47
47
  fetchFn?: (url: string) => Promise<Response>,
48
48
  ): Promise<string | null> => {
49
- const ownerStack = await getOwnerStack(fiber, cache, fetchFn);
50
- const stackFrame = ownerStack.filter((stackFrame) => stackFrame.fileName)[0];
49
+ const parentStackFrames = await getParentStack(fiber, cache, fetchFn);
50
+ const stackFrame = parentStackFrames.filter((innerFrame) => innerFrame.fileName)[0];
51
51
 
52
52
  if (!stackFrame?.fileName) {
53
53
  return getDisplayName(fiber.type);
@@ -10,7 +10,10 @@ import {
10
10
  BUNDLED_FILE_PATTERN_REGEX,
11
11
  QUERY_PARAMETER_PATTERN_REGEX,
12
12
  } from "./constants.js";
13
- import { getOwnerStack } from "./owner-stack.js";
13
+ import { getDefinitionFrameFromOwnedChild, getParentStack, hasDebugStack } from "./owner-stack.js";
14
+ import { parseDebugStack } from "./parse-debug-stack.js";
15
+ import { StackFrame } from "./parse-stack.js";
16
+ import { symbolicateStack } from "./symbolication.js";
14
17
 
15
18
  export const hasDebugSource = (
16
19
  fiber: Fiber,
@@ -31,9 +34,46 @@ export const hasDebugSource = (
31
34
  );
32
35
  };
33
36
 
37
+ const toFiberSource = (stackFrame: StackFrame): FiberSource | null =>
38
+ stackFrame.fileName
39
+ ? {
40
+ fileName: stackFrame.fileName,
41
+ lineNumber: stackFrame.lineNumber,
42
+ columnNumber: stackFrame.columnNumber,
43
+ functionName: stackFrame.functionName,
44
+ }
45
+ : null;
46
+
47
+ // the fiber's own _debugStack (react 19) is captured at its JSX creation
48
+ // site, so its first user-space frame IS the usage site - no need to
49
+ // re-invoke the component like the throwing trick does
50
+ const getUsageFrameFromDebugStack = (fiber: Fiber): StackFrame | null => {
51
+ if (!hasDebugStack(fiber)) {
52
+ return null;
53
+ }
54
+ const { frames, isTrusted } = parseDebugStack(fiber._debugStack);
55
+ if (!isTrusted) {
56
+ return null;
57
+ }
58
+ for (const stackFrame of frames) {
59
+ if (stackFrame.fileName) {
60
+ return stackFrame;
61
+ }
62
+ }
63
+ return null;
64
+ };
65
+
34
66
  /**
35
67
  * Returns the source of where the component is used. Available only in dev, for composite {@link Fiber}s.
36
68
  *
69
+ * Resolution order:
70
+ * 1. `_debugSource` (react <19, requires the JSX source babel transform)
71
+ * 2. the fiber's own `_debugStack` (react 19) - the exact JSX creation site
72
+ * 3. an owned child's `_debugStack` bottom frame (react 19) - a location
73
+ * inside the component's own body; works for components that the throwing
74
+ * trick cannot locate (no hooks, no props access)
75
+ * 4. the legacy owner-stack path (throwing trick re-invocation)
76
+ *
37
77
  * @example
38
78
  * ```ts
39
79
  * function Parent() {
@@ -59,16 +99,20 @@ export const getSource = async (
59
99
  return debugSource || null;
60
100
  }
61
101
 
62
- const componentStack = await getOwnerStack(fiber, cache, fetchFn);
102
+ const debugStackFrame =
103
+ getUsageFrameFromDebugStack(fiber) ?? getDefinitionFrameFromOwnedChild(fiber);
104
+ if (debugStackFrame) {
105
+ const [symbolicatedFrame] = await symbolicateStack([debugStackFrame], cache, fetchFn);
106
+ const debugStackSource = toFiberSource(symbolicatedFrame);
107
+ if (debugStackSource) {
108
+ return debugStackSource;
109
+ }
110
+ }
63
111
 
64
- for (const stackFrame of componentStack) {
112
+ const parentStackFrames = await getParentStack(fiber, cache, fetchFn);
113
+ for (const stackFrame of parentStackFrames) {
65
114
  if (stackFrame.fileName) {
66
- return {
67
- fileName: stackFrame.fileName,
68
- lineNumber: stackFrame.lineNumber,
69
- columnNumber: stackFrame.columnNumber,
70
- functionName: stackFrame.functionName,
71
- };
115
+ return toFiberSource(stackFrame);
72
116
  }
73
117
  }
74
118
  return null;
@@ -1,8 +1,17 @@
1
- export * from "./owner-stack.js";
2
- export * from "./get-source.js";
3
- export * from "./symbolication.js";
4
- export * from "./types.js";
5
- export * from "./parse-stack.js";
6
- export * from "./get-display-name-from-source.js";
7
- export * from "./inspect-hooks.js";
8
- export * from "./parse-hook-names.js";
1
+ export { formatOwnerStack, getOwnerStack, getParentStack, hasDebugStack } from "./owner-stack.js";
2
+ export { getSource, isSourceFile, normalizeFileName } from "./get-source.js";
3
+ export {
4
+ getSourceFromSourceMap,
5
+ getSourceMap,
6
+ symbolicateStack,
7
+ type DecodedSourceMapSection,
8
+ type IndexSourceMap,
9
+ type RawSourceMap,
10
+ type SourceMap,
11
+ type StandardSourceMap,
12
+ } from "./symbolication.js";
13
+ export type { FiberSource } from "./types.js";
14
+ export { parseStack, type ParseOptions, type StackFrame } from "./parse-stack.js";
15
+ export { getDisplayNameFromSource } from "./get-display-name-from-source.js";
16
+ export { getFiberHooks, type HookSource, type HooksNode, type HooksTree } from "./inspect-hooks.js";
17
+ export { parseHookNames, type HookNames } from "./parse-hook-names.js";
@@ -282,16 +282,16 @@ const dispatcherUseMemoCache = (size: number): unknown[] => {
282
282
  )?.memoCache;
283
283
  if (memoCache === null || memoCache === undefined) return [];
284
284
 
285
- let data = memoCache.data[memoCache.index];
286
- if (data === undefined) {
287
- data = memoCache.data[memoCache.index] = Array.from(
285
+ let memoCacheSlots = memoCache.data[memoCache.index];
286
+ if (memoCacheSlots === undefined) {
287
+ memoCacheSlots = memoCache.data[memoCache.index] = Array.from(
288
288
  { length: size },
289
289
  () => REACT_MEMO_CACHE_SENTINEL,
290
290
  );
291
291
  }
292
292
 
293
293
  memoCache.index++;
294
- return data;
294
+ return memoCacheSlots;
295
295
  };
296
296
 
297
297
  const dispatcherUseOptimistic = (passthrough: unknown): [unknown, () => void] => {
@@ -473,6 +473,9 @@ const findSharedIndex = (
473
473
  rootStack: StackFrame[],
474
474
  rootIndex: number,
475
475
  ): number => {
476
+ // mostLikelyAncestorIndex is cached across inspections, so it can exceed the
477
+ // bounds of a later, shorter root stack (e.g. truncated Error.stackTraceLimit)
478
+ if (rootIndex >= rootStack.length) return -1;
476
479
  const source = rootStack[rootIndex].source;
477
480
  hookSearch: for (let hookIndex = 0; hookIndex < hookStack.length; hookIndex++) {
478
481
  if (hookStack[hookIndex].source === source) {
@@ -17,8 +17,14 @@ import {
17
17
  getDisplayName,
18
18
  traverseFiber,
19
19
  } from "../core.js";
20
- import { SERVER_FRAME_MARKER, SERVER_ENV_PATTERN, ABOUT_REACT_PREFIX } from "./constants.js";
20
+ import { ServerComponentInfo } from "../types.js";
21
+ import {
22
+ SERVER_FRAME_MARKER,
23
+ SERVER_ENV_PATTERN,
24
+ SERVER_COMPONENT_URL_PREFIXES,
25
+ } from "./constants.js";
21
26
 
27
+ import { parseDebugStack } from "./parse-debug-stack.js";
22
28
  import { parseStack, StackFrame } from "./parse-stack.js";
23
29
  import { symbolicateStack } from "./symbolication.js";
24
30
 
@@ -30,6 +36,60 @@ export const hasDebugStack = (
30
36
  return fiber._debugStack instanceof Error && typeof fiber._debugStack?.stack === "string";
31
37
  };
32
38
 
39
+ const isFiberOwner = (owner: Fiber | ServerComponentInfo): owner is Fiber =>
40
+ typeof (owner as Fiber).tag === "number";
41
+
42
+ // react's typings (and bippy's Fiber, which mirrors them) declare _debugOwner
43
+ // as a Fiber, but react 19 flight sets a ReactComponentInfo object for server
44
+ // component owners
45
+ const getDebugOwner = (fiber: Fiber): Fiber | ServerComponentInfo | undefined =>
46
+ fiber._debugOwner as Fiber | ServerComponentInfo | undefined;
47
+
48
+ /**
49
+ * Locates a frame inside the fiber's own function body without invoking it:
50
+ * any child fiber owned by this fiber was created by JSX inside its body, so
51
+ * the bottom user-space frame of that child's _debugStack sits in this
52
+ * component. The enclosing line/column (V8 CallSite API) points at the
53
+ * function definition start. Requires React 19 (_debugStack).
54
+ */
55
+ export const getDefinitionFrameFromOwnedChild = (fiber: Fiber): StackFrame | null => {
56
+ let ownedChildDebugStack: Error | null = null;
57
+ traverseFiber(fiber, (childFiber) => {
58
+ if (childFiber === fiber) {
59
+ return false;
60
+ }
61
+ const childOwner = childFiber._debugOwner;
62
+ if (
63
+ (childOwner === fiber || (fiber.alternate !== null && childOwner === fiber.alternate)) &&
64
+ childFiber._debugStack instanceof Error
65
+ ) {
66
+ ownedChildDebugStack = childFiber._debugStack;
67
+ return true;
68
+ }
69
+ return false;
70
+ });
71
+ if (!ownedChildDebugStack) {
72
+ return null;
73
+ }
74
+
75
+ const { frames, isTrusted } = parseDebugStack(ownedChildDebugStack);
76
+ if (!isTrusted) {
77
+ return null;
78
+ }
79
+ for (let frameIndex = frames.length - 1; frameIndex >= 0; frameIndex--) {
80
+ const stackFrame = frames[frameIndex];
81
+ if (!stackFrame.fileName) {
82
+ continue;
83
+ }
84
+ return {
85
+ ...stackFrame,
86
+ lineNumber: stackFrame.enclosingLineNumber || stackFrame.lineNumber,
87
+ columnNumber: stackFrame.enclosingColumnNumber || stackFrame.columnNumber,
88
+ };
89
+ }
90
+ return null;
91
+ };
92
+
33
93
  const getCurrentDispatcher = (): null | React.RefObject<unknown> => {
34
94
  const rdtHook = getRDTHook();
35
95
  for (const renderer of [...Array.from(_renderers), ...Array.from(rdtHook.renderers.values())]) {
@@ -68,6 +128,10 @@ export const describeDebugInfoFrame = (name: string, env?: string): string => {
68
128
 
69
129
  let reEntry = false;
70
130
 
131
+ // Computing a frame throws and parses two full error stacks, so cache per
132
+ // component type like React DevTools does.
133
+ const componentFrameCache = new WeakMap<React.ComponentType<unknown>, string>();
134
+
71
135
  // https://github.com/facebook/react/blob/f739642745577a8e4dcb9753836ac3589b9c590a/packages/react-devtools-shared/src/backend/shared/DevToolsComponentStackFrame.js#L22
72
136
  const describeNativeComponentFrame = (
73
137
  component: React.ComponentType<unknown>,
@@ -77,6 +141,11 @@ const describeNativeComponentFrame = (
77
141
  return "";
78
142
  }
79
143
 
144
+ const cachedFrame = componentFrameCache.get(component);
145
+ if (cachedFrame !== undefined) {
146
+ return cachedFrame;
147
+ }
148
+
80
149
  const previousPrepareStackTrace = Error.prepareStackTrace;
81
150
  // HACK: V8 API allows undefined but bun-types declares it as non-optional
82
151
  (Error as { prepareStackTrace?: typeof Error.prepareStackTrace }).prepareStackTrace = undefined;
@@ -257,6 +326,7 @@ const describeNativeComponentFrame = (
257
326
  stackFrame = stackFrame.replace("<anonymous>", displayName);
258
327
  }
259
328
  // Return the line we found.
329
+ componentFrameCache.set(component, stackFrame);
260
330
  return stackFrame;
261
331
  }
262
332
  } while (sampleIndex >= 1 && controlIndex >= 0);
@@ -277,6 +347,7 @@ const describeNativeComponentFrame = (
277
347
 
278
348
  const componentName = component ? getDisplayName(component) : "";
279
349
  const syntheticFrame = componentName ? describeBuiltInComponentFrame(componentName) : "";
350
+ componentFrameCache.set(component, syntheticFrame);
280
351
  return syntheticFrame;
281
352
  };
282
353
 
@@ -334,11 +405,11 @@ export const describeFiber = (fiber: Fiber, childFiber: Fiber | null): string =>
334
405
  };
335
406
 
336
407
  /**
337
- * react 19 introduces the _debugStack property, which we can use to grab the stack.
338
- * however, for versions that don't have this property, we need to construct
339
- * a "fake" version of the owner stack
408
+ * Builds a component-stack string by walking the fiber `return` chain, the
409
+ * pre-react-19 substitute for `_debugStack` (which is why the frame locations
410
+ * come from re-invocation in {@link describeFiber} rather than a real stack).
340
411
  */
341
- export const getFallbackOwnerStack = (thisFiber: Fiber): string => {
412
+ export const getFallbackParentStack = (thisFiber: Fiber): string => {
342
413
  try {
343
414
  let componentStack = "";
344
415
  let currentFiber: Fiber | null = thisFiber;
@@ -403,21 +474,21 @@ export const formatOwnerStack = (stack: string): string => {
403
474
  // don't want/need
404
475
  formattedStack = formattedStack.slice(29);
405
476
  }
406
- let idx = formattedStack.indexOf("\n");
407
- if (idx !== -1) {
477
+ const firstNewlineIndex = formattedStack.indexOf("\n");
478
+ if (firstNewlineIndex !== -1) {
408
479
  // pop the JSX frame
409
- formattedStack = formattedStack.slice(idx + 1);
480
+ formattedStack = formattedStack.slice(firstNewlineIndex + 1);
410
481
  }
411
- idx = Math.max(
482
+ let bottomFrameIndex = Math.max(
412
483
  formattedStack.indexOf("react_stack_bottom_frame"),
413
484
  formattedStack.indexOf("react-stack-bottom-frame"),
414
485
  );
415
- if (idx !== -1) {
416
- idx = formattedStack.lastIndexOf("\n", idx);
486
+ if (bottomFrameIndex !== -1) {
487
+ bottomFrameIndex = formattedStack.lastIndexOf("\n", bottomFrameIndex);
417
488
  }
418
- if (idx !== -1) {
489
+ if (bottomFrameIndex !== -1) {
419
490
  // cut off everything after the bottom frame since it'll be internals.
420
- formattedStack = formattedStack.slice(0, idx);
491
+ formattedStack = formattedStack.slice(0, bottomFrameIndex);
421
492
  } else {
422
493
  // we didn't find any internal callsite out to user space.
423
494
  // This means that this was called outside an owner or the owner is fully internal.
@@ -427,17 +498,14 @@ export const formatOwnerStack = (stack: string): string => {
427
498
  return formattedStack;
428
499
  };
429
500
 
430
- interface OwnerStackEntry {
501
+ interface DebugStackEntry {
431
502
  componentName: string;
432
503
  stackFrames: StackFrame[];
433
504
  }
434
505
 
435
506
  const isReactServerComponentFrame = (stackFrame: StackFrame): boolean =>
436
507
  Boolean(
437
- stackFrame.functionName &&
438
- stackFrame.fileName &&
439
- (stackFrame.fileName.startsWith("rsc://") ||
440
- stackFrame.fileName.startsWith(ABOUT_REACT_PREFIX)),
508
+ stackFrame.functionName && stackFrame.fileName && isServerComponentUrl(stackFrame.fileName),
441
509
  );
442
510
 
443
511
  const areStackFramesEqual = (firstFrame: StackFrame, secondFrame: StackFrame): boolean =>
@@ -446,12 +514,12 @@ const areStackFramesEqual = (firstFrame: StackFrame, secondFrame: StackFrame): b
446
514
  firstFrame.columnNumber === secondFrame.columnNumber;
447
515
 
448
516
  const buildFunctionNameToRscFramesMap = (
449
- ownerStackEntries: OwnerStackEntry[],
517
+ debugStackEntries: DebugStackEntry[],
450
518
  ): Map<string, StackFrame[]> => {
451
519
  const functionNameToRscFrames = new Map<string, StackFrame[]>();
452
520
 
453
- for (const ownerEntry of ownerStackEntries) {
454
- for (const stackFrame of ownerEntry.stackFrames) {
521
+ for (const debugStackEntry of debugStackEntries) {
522
+ for (const stackFrame of debugStackEntry.stackFrames) {
455
523
  if (!isReactServerComponentFrame(stackFrame)) continue;
456
524
 
457
525
  const functionName = stackFrame.functionName!;
@@ -501,8 +569,55 @@ const getEnrichedServerStackFrame = (
501
569
  };
502
570
  };
503
571
 
504
- const getOwnerStackEntries = (rootFiber: Fiber): OwnerStackEntry[] => {
505
- const ownerStackEntries: OwnerStackEntry[] = [];
572
+ const isServerComponentUrl = (url: string): boolean =>
573
+ SERVER_COMPONENT_URL_PREFIXES.some((prefix) => url.startsWith(prefix));
574
+
575
+ // flight installs fake server frames (rsc:// or about://React/ urls) into
576
+ // client-side debug stacks, so server detection must be per-frame
577
+ const markFlightServerFrame = (stackFrame: StackFrame): StackFrame =>
578
+ !stackFrame.isServer && stackFrame.fileName && isServerComponentUrl(stackFrame.fileName)
579
+ ? { ...stackFrame, isServer: true }
580
+ : stackFrame;
581
+
582
+ /**
583
+ * Builds owner-chain stack frames from the _debugStack errors React 19
584
+ * attaches at JSX creation, walking `_debugOwner` across both client fibers
585
+ * and server components (ReactComponentInfo, which chains via `.owner` and
586
+ * carries `.debugStack`). This is exact - no re-invoking components, no
587
+ * name-matching heuristics - but requires React 19.
588
+ */
589
+ const getOwnerStackFromDebugStacks = (fiber: Fiber): StackFrame[] => {
590
+ const ownerStackFrames: StackFrame[] = [];
591
+ let owner: Fiber | ServerComponentInfo | null | undefined = fiber;
592
+ while (owner) {
593
+ if (isFiberOwner(owner)) {
594
+ const ownerFiber: Fiber = owner;
595
+ owner = getDebugOwner(ownerFiber);
596
+ if (owner && hasDebugStack(ownerFiber)) {
597
+ const { frames, isTrusted } = parseDebugStack(ownerFiber._debugStack);
598
+ if (isTrusted) {
599
+ for (const stackFrame of frames) {
600
+ ownerStackFrames.push(markFlightServerFrame(stackFrame));
601
+ }
602
+ }
603
+ }
604
+ } else {
605
+ const serverOwner: ServerComponentInfo = owner;
606
+ owner = serverOwner.owner;
607
+ // server stacks are captured and pre-trimmed by flight, so they carry
608
+ // no bottom-frame sentinel and are trusted as-is
609
+ if (owner && serverOwner.debugStack instanceof Error) {
610
+ for (const serverFrame of parseDebugStack(serverOwner.debugStack).frames) {
611
+ ownerStackFrames.push({ ...serverFrame, isServer: true });
612
+ }
613
+ }
614
+ }
615
+ }
616
+ return ownerStackFrames;
617
+ };
618
+
619
+ const getDebugStackEntries = (rootFiber: Fiber): DebugStackEntry[] => {
620
+ const debugStackEntries: DebugStackEntry[] = [];
506
621
 
507
622
  traverseFiber(
508
623
  rootFiber,
@@ -514,7 +629,7 @@ const getOwnerStackEntries = (rootFiber: Fiber): OwnerStackEntry[] => {
514
629
  ? getDisplayName(currentFiber.type) || "<anonymous>"
515
630
  : currentFiber.type;
516
631
 
517
- ownerStackEntries.push({
632
+ debugStackEntries.push({
518
633
  componentName,
519
634
  stackFrames: parseStack(formatOwnerStack(currentFiber._debugStack?.stack)),
520
635
  });
@@ -522,17 +637,24 @@ const getOwnerStackEntries = (rootFiber: Fiber): OwnerStackEntry[] => {
522
637
  true,
523
638
  );
524
639
 
525
- return ownerStackEntries;
640
+ return debugStackEntries;
526
641
  };
527
642
 
528
- export const getOwnerStack = async (
643
+ /**
644
+ * Returns a stack of ALL ancestor components in the render tree (the fiber's
645
+ * `return` chain), including wrappers that render `{children}` without having
646
+ * created this fiber's JSX. Locations come from re-invoking each component
647
+ * with a throwing dispatcher; server frames are enriched from debug stacks by
648
+ * name matching. Works on every React version.
649
+ */
650
+ export const getParentStack = async (
529
651
  fiber: Fiber,
530
652
  shouldCache = true,
531
653
  fetchFunction?: (url: string) => Promise<Response>,
532
654
  ): Promise<StackFrame[]> => {
533
- const ownerStackEntries = getOwnerStackEntries(fiber);
534
- const fallbackStackFrames = parseStack(getFallbackOwnerStack(fiber));
535
- const functionNameToRscFrames = buildFunctionNameToRscFramesMap(ownerStackEntries);
655
+ const debugStackEntries = getDebugStackEntries(fiber);
656
+ const fallbackStackFrames = parseStack(getFallbackParentStack(fiber));
657
+ const functionNameToRscFrames = buildFunctionNameToRscFramesMap(debugStackEntries);
536
658
  const functionNameToUsageIndex = new Map<string, number>();
537
659
 
538
660
  const enrichedStackFrames = fallbackStackFrames.map((stackFrame): StackFrame => {
@@ -559,3 +681,45 @@ export const getOwnerStack = async (
559
681
 
560
682
  return symbolicateStack(deduplicatedStackFrames, shouldCache, fetchFunction);
561
683
  };
684
+
685
+ // an owner frame is only actionable if it can point an editor somewhere:
686
+ // it needs a file location and must not be ignore-listed bundler/framework code
687
+ const isLocatableFrame = (stackFrame: StackFrame): boolean =>
688
+ Boolean(stackFrame.fileName) && !stackFrame.isIgnoreListed;
689
+
690
+ /**
691
+ * Returns the stack of components that CREATED this fiber's JSX (the
692
+ * `_debugOwner` chain), with exact creation-site locations from React 19's
693
+ * `_debugStack` errors - including server component owners. Wrappers that
694
+ * merely render `{children}` do not appear; use {@link getParentStack} for
695
+ * the full render-tree ancestry. Falls back to {@link getParentStack} on
696
+ * React <19, when no trusted debug stacks exist, or when the owner chain
697
+ * yields no locatable frames (so callers always get the most useful stack
698
+ * available).
699
+ */
700
+ export const getOwnerStack = async (
701
+ fiber: Fiber,
702
+ shouldCache = true,
703
+ fetchFunction?: (url: string) => Promise<Response>,
704
+ ): Promise<StackFrame[]> => {
705
+ const debugStackFrames = getOwnerStackFromDebugStacks(fiber);
706
+ if (debugStackFrames.length > 0) {
707
+ // the owner chain does not include the fiber itself, but bippy's stacks
708
+ // always start with the fiber's own frame
709
+ const selfFrame: StackFrame = getDefinitionFrameFromOwnedChild(fiber) ?? {};
710
+ selfFrame.functionName = getDisplayName(fiber.type) ?? selfFrame.functionName;
711
+ const symbolicatedFrames = await symbolicateStack(
712
+ [selfFrame, ...debugStackFrames],
713
+ shouldCache,
714
+ fetchFunction,
715
+ );
716
+ const hasLocatableOwnerFrame = symbolicatedFrames.some(
717
+ (stackFrame, frameIndex) => frameIndex > 0 && isLocatableFrame(stackFrame),
718
+ );
719
+ if (hasLocatableOwnerFrame) {
720
+ return symbolicatedFrames;
721
+ }
722
+ }
723
+
724
+ return getParentStack(fiber, shouldCache, fetchFunction);
725
+ };