Skip to content

Commit e44685e

Browse files
authored
[DevTools] Use Owner Stacks to Implement View Source of a Server Component (#30798)
We don't have the source location of Server Components on the client because we don't want to eagerly do the throw trick for all Server Components just in case. Unfortunately Node.js doesn't expose V8's API to get a source location of a function. We do have the owner stacks of the JSX that created it though and at some point we'll also show that location in DevTools. However, the realization is that if a Server Component is the owner of any child. The owner stack of that child will have the owner component's source location as its bottom stack frame. The technique I'm implementing here is to track whenever a child mounts we already have its owner. We track the first discovered owned child's stack on the owner. Then when we ask for a Source location of the owner do we parse that stack and extract the location of the bottom frame. This doesn't give us a location necessarily in the top of the function but somewhere in the function. In this case the first owned child is the Container: <img width="1107" alt="Screenshot 2024-08-22 at 10 24 42 PM" src="https://github.com/user-attachments/assets/95f32850-24a5-4151-8ce6-b7b89db68aee"> <img width="648" alt="Screenshot 2024-08-22 at 10 24 20 PM" src="https://github.com/user-attachments/assets/4bcba033-866f-4684-9beb-de09d189deff"> We can even use this technique for Fibers too. Currently I use this as a fallback in case the error technique didn't work. This covers a case where nothing errors but you still render a child. This case is actually quite common: ``` function Foo() { return <Bar />; } ``` However, for Fibers we could really just use the `inspect(function)` technique which works for all cases. At least in Chrome. Unfortunately, this technique doesn't work if a Component doesn't create any new JSX but just renders its children. It also currently doesn't work if the child is filtered since I only look up the owner if an instance is not filtered. This means that the container in the fixture can't view source by default since the host component is filtered: ``` export default function Container({children}) { return <div>{children}</div>; } ``` <img width="1107" alt="Screenshot 2024-08-22 at 10 24 35 PM" src="https://github.com/user-attachments/assets/c3f8f9c5-5add-4d35-9290-3a5079e82adc">
1 parent dcae56f commit e44685e

File tree

2 files changed

+104
-36
lines changed

2 files changed

+104
-36
lines changed

packages/react-devtools-shared/src/backend/fiber/DevToolsFiberComponentStack.js

+17
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,23 @@ export function getStackByFiberInDevAndProd(
108108
}
109109
}
110110

111+
export function getSourceLocationByFiber(
112+
workTagMap: WorkTagMap,
113+
fiber: Fiber,
114+
currentDispatcherRef: CurrentDispatcherRef,
115+
): null | string {
116+
// This is like getStackByFiberInDevAndProd but just the first stack frame.
117+
try {
118+
const info = describeFiber(workTagMap, fiber, currentDispatcherRef);
119+
if (info !== '') {
120+
return info.slice(1); // skip the leading newline
121+
}
122+
} catch (x) {
123+
console.error(x);
124+
}
125+
return null;
126+
}
127+
111128
export function supportsConsoleTasks(fiber: Fiber): boolean {
112129
// If this Fiber supports native console.createTask then we are already running
113130
// inside a native async stack trace if it's active - meaning the DevTools is open.

packages/react-devtools-shared/src/backend/fiber/renderer.js

+87-36
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ import {
101101
import {enableStyleXFeatures} from 'react-devtools-feature-flags';
102102
import is from 'shared/objectIs';
103103
import hasOwnProperty from 'shared/hasOwnProperty';
104+
105+
// $FlowFixMe[method-unbinding]
106+
const toString = Object.prototype.toString;
107+
108+
function isError(object: mixed) {
109+
return toString.call(object) === '[object Error]';
110+
}
111+
104112
import {getStyleXData} from '../StyleX/utils';
105113
import {createProfilingHooks} from '../profilingHooks';
106114

@@ -131,7 +139,8 @@ import type {
131139
Plugins,
132140
} from 'react-devtools-shared/src/frontend/types';
133141
import type {Source} from 'react-devtools-shared/src/shared/types';
134-
import {getStackByFiberInDevAndProd} from './DevToolsFiberComponentStack';
142+
import {getSourceLocationByFiber} from './DevToolsFiberComponentStack';
143+
import {formatOwnerStack} from '../shared/DevToolsOwnerStack';
135144

136145
// Kinds
137146
const FIBER_INSTANCE = 0;
@@ -152,7 +161,7 @@ type FiberInstance = {
152161
previousSibling: null | DevToolsInstance, // filtered next sibling, including virtual
153162
nextSibling: null | DevToolsInstance, // filtered next sibling, including virtual
154163
flags: number, // Force Error/Suspense
155-
componentStack: null | string,
164+
source: null | string | Error | Source, // source location of this component function, or owned child stack
156165
errors: null | Map<string, number>, // error messages and count
157166
warnings: null | Map<string, number>, // warning messages and count
158167
data: Fiber, // one of a Fiber pair
@@ -167,7 +176,7 @@ function createFiberInstance(fiber: Fiber): FiberInstance {
167176
previousSibling: null,
168177
nextSibling: null,
169178
flags: 0,
170-
componentStack: null,
179+
source: null,
171180
errors: null,
172181
warnings: null,
173182
data: fiber,
@@ -187,7 +196,7 @@ type VirtualInstance = {
187196
previousSibling: null | DevToolsInstance, // filtered next sibling, including virtual
188197
nextSibling: null | DevToolsInstance, // filtered next sibling, including virtual
189198
flags: number,
190-
componentStack: null | string,
199+
source: null | string | Error | Source, // source location of this server component, or owned child stack
191200
// Errors and Warnings happen per ReactComponentInfo which can appear in
192201
// multiple places but we track them per stateful VirtualInstance so
193202
// that old errors/warnings don't disappear when the instance is refreshed.
@@ -209,7 +218,7 @@ function createVirtualInstance(
209218
previousSibling: null,
210219
nextSibling: null,
211220
flags: 0,
212-
componentStack: null,
221+
source: null,
213222
errors: null,
214223
warnings: null,
215224
data: debugEntry,
@@ -2154,6 +2163,16 @@ export function attach(
21542163
parentInstance,
21552164
debugOwner,
21562165
);
2166+
if (
2167+
ownerInstance !== null &&
2168+
debugOwner === fiber._debugOwner &&
2169+
fiber._debugStack != null &&
2170+
ownerInstance.source === null
2171+
) {
2172+
// The new Fiber is directly owned by the ownerInstance. Therefore somewhere on
2173+
// the debugStack will be a stack frame inside the ownerInstance's source.
2174+
ownerInstance.source = fiber._debugStack;
2175+
}
21572176
const ownerID = ownerInstance === null ? 0 : ownerInstance.id;
21582177
const parentID = parentInstance ? parentInstance.id : 0;
21592178

@@ -2228,6 +2247,16 @@ export function attach(
22282247
// away so maybe it's not so bad.
22292248
const debugOwner = getUnfilteredOwner(componentInfo);
22302249
const ownerInstance = findNearestOwnerInstance(parentInstance, debugOwner);
2250+
if (
2251+
ownerInstance !== null &&
2252+
debugOwner === componentInfo.owner &&
2253+
componentInfo.debugStack != null &&
2254+
ownerInstance.source === null
2255+
) {
2256+
// The new Fiber is directly owned by the ownerInstance. Therefore somewhere on
2257+
// the debugStack will be a stack frame inside the ownerInstance's source.
2258+
ownerInstance.source = componentInfo.debugStack;
2259+
}
22312260
const ownerID = ownerInstance === null ? 0 : ownerInstance.id;
22322261
const parentID = parentInstance ? parentInstance.id : 0;
22332262

@@ -4324,7 +4353,7 @@ export function attach(
43244353

43254354
let source = null;
43264355
if (canViewSource) {
4327-
source = getSourceForFiber(fiber);
4356+
source = getSourceForFiberInstance(fiberInstance);
43284357
}
43294358

43304359
return {
@@ -4398,7 +4427,8 @@ export function attach(
43984427
function inspectVirtualInstanceRaw(
43994428
virtualInstance: VirtualInstance,
44004429
): InspectedElement | null {
4401-
const canViewSource = false;
4430+
const canViewSource = true;
4431+
const source = getSourceForInstance(virtualInstance);
44024432

44034433
const componentInfo = virtualInstance.data;
44044434
const key =
@@ -4438,9 +4468,6 @@ export function attach(
44384468
stylex: null,
44394469
};
44404470

4441-
// TODO: Support getting the source location from the owner stack.
4442-
const source = null;
4443-
44444471
return {
44454472
id: virtualInstance.id,
44464473

@@ -5664,39 +5691,63 @@ export function attach(
56645691
return idToDevToolsInstanceMap.has(id);
56655692
}
56665693

5667-
function getComponentStackForFiber(fiber: Fiber): string | null {
5668-
// TODO: This should really just take an DevToolsInstance directly.
5669-
let fiberInstance = fiberToFiberInstanceMap.get(fiber);
5670-
if (fiberInstance === undefined && fiber.alternate !== null) {
5671-
fiberInstance = fiberToFiberInstanceMap.get(fiber.alternate);
5672-
}
5673-
if (fiberInstance === undefined) {
5674-
// We're no longer tracking this instance.
5675-
return null;
5676-
}
5677-
if (fiberInstance.componentStack !== null) {
5678-
// Cached entry.
5679-
return fiberInstance.componentStack;
5694+
function getSourceForFiberInstance(
5695+
fiberInstance: FiberInstance,
5696+
): Source | null {
5697+
const unresolvedSource = fiberInstance.source;
5698+
if (
5699+
unresolvedSource !== null &&
5700+
typeof unresolvedSource === 'object' &&
5701+
!isError(unresolvedSource)
5702+
) {
5703+
// $FlowFixMe: isError should have refined it.
5704+
return unresolvedSource;
56805705
}
56815706
const dispatcherRef = getDispatcherRef(renderer);
5682-
if (dispatcherRef == null) {
5707+
const stackFrame =
5708+
dispatcherRef == null
5709+
? null
5710+
: getSourceLocationByFiber(
5711+
ReactTypeOfWork,
5712+
fiberInstance.data,
5713+
dispatcherRef,
5714+
);
5715+
if (stackFrame === null) {
5716+
// If we don't find a source location by throwing, try to get one
5717+
// from an owned child if possible. This is the same branch as
5718+
// for virtual instances.
5719+
return getSourceForInstance(fiberInstance);
5720+
}
5721+
const source = parseSourceFromComponentStack(stackFrame);
5722+
fiberInstance.source = source;
5723+
return source;
5724+
}
5725+
5726+
function getSourceForInstance(instance: DevToolsInstance): Source | null {
5727+
let unresolvedSource = instance.source;
5728+
if (unresolvedSource === null) {
5729+
// We don't have any source yet. We can try again later in case an owned child mounts later.
5730+
// TODO: We won't have any information here if the child is filtered.
56835731
return null;
56845732
}
56855733

5686-
return (fiberInstance.componentStack = getStackByFiberInDevAndProd(
5687-
ReactTypeOfWork,
5688-
fiber,
5689-
dispatcherRef,
5690-
));
5691-
}
5692-
5693-
function getSourceForFiber(fiber: Fiber): Source | null {
5694-
const componentStack = getComponentStackForFiber(fiber);
5695-
if (componentStack == null) {
5696-
return null;
5734+
// If we have the debug stack (the creation stack of the JSX) for any owned child of this
5735+
// component, then at the bottom of that stack will be a stack frame that is somewhere within
5736+
// the component's function body. Typically it would be the callsite of the JSX unless there's
5737+
// any intermediate utility functions. This won't point to the top of the component function
5738+
// but it's at least somewhere within it.
5739+
if (isError(unresolvedSource)) {
5740+
unresolvedSource = formatOwnerStack((unresolvedSource: any));
5741+
}
5742+
if (typeof unresolvedSource === 'string') {
5743+
const idx = unresolvedSource.lastIndexOf('\n');
5744+
const lastLine =
5745+
idx === -1 ? unresolvedSource : unresolvedSource.slice(idx + 1);
5746+
return (instance.source = parseSourceFromComponentStack(lastLine));
56975747
}
56985748

5699-
return parseSourceFromComponentStack(componentStack);
5749+
// $FlowFixMe: refined.
5750+
return unresolvedSource;
57005751
}
57015752

57025753
return {

0 commit comments

Comments
 (0)