Skip to content

Commit

Permalink
[DevTools] Use Owner Stacks to Implement View Source of a Server Comp…
Browse files Browse the repository at this point in the history
…onent (#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">
  • Loading branch information
sebmarkbage authored Aug 27, 2024
1 parent dcae56f commit e44685e
Show file tree
Hide file tree
Showing 2 changed files with 104 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,23 @@ export function getStackByFiberInDevAndProd(
}
}

export function getSourceLocationByFiber(
workTagMap: WorkTagMap,
fiber: Fiber,
currentDispatcherRef: CurrentDispatcherRef,
): null | string {
// This is like getStackByFiberInDevAndProd but just the first stack frame.
try {
const info = describeFiber(workTagMap, fiber, currentDispatcherRef);
if (info !== '') {
return info.slice(1); // skip the leading newline
}
} catch (x) {
console.error(x);
}
return null;
}

export function supportsConsoleTasks(fiber: Fiber): boolean {
// If this Fiber supports native console.createTask then we are already running
// inside a native async stack trace if it's active - meaning the DevTools is open.
Expand Down
123 changes: 87 additions & 36 deletions packages/react-devtools-shared/src/backend/fiber/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ import {
import {enableStyleXFeatures} from 'react-devtools-feature-flags';
import is from 'shared/objectIs';
import hasOwnProperty from 'shared/hasOwnProperty';

// $FlowFixMe[method-unbinding]
const toString = Object.prototype.toString;

function isError(object: mixed) {
return toString.call(object) === '[object Error]';
}

import {getStyleXData} from '../StyleX/utils';
import {createProfilingHooks} from '../profilingHooks';

Expand Down Expand Up @@ -131,7 +139,8 @@ import type {
Plugins,
} from 'react-devtools-shared/src/frontend/types';
import type {Source} from 'react-devtools-shared/src/shared/types';
import {getStackByFiberInDevAndProd} from './DevToolsFiberComponentStack';
import {getSourceLocationByFiber} from './DevToolsFiberComponentStack';
import {formatOwnerStack} from '../shared/DevToolsOwnerStack';

// Kinds
const FIBER_INSTANCE = 0;
Expand All @@ -152,7 +161,7 @@ type FiberInstance = {
previousSibling: null | DevToolsInstance, // filtered next sibling, including virtual
nextSibling: null | DevToolsInstance, // filtered next sibling, including virtual
flags: number, // Force Error/Suspense
componentStack: null | string,
source: null | string | Error | Source, // source location of this component function, or owned child stack
errors: null | Map<string, number>, // error messages and count
warnings: null | Map<string, number>, // warning messages and count
data: Fiber, // one of a Fiber pair
Expand All @@ -167,7 +176,7 @@ function createFiberInstance(fiber: Fiber): FiberInstance {
previousSibling: null,
nextSibling: null,
flags: 0,
componentStack: null,
source: null,
errors: null,
warnings: null,
data: fiber,
Expand All @@ -187,7 +196,7 @@ type VirtualInstance = {
previousSibling: null | DevToolsInstance, // filtered next sibling, including virtual
nextSibling: null | DevToolsInstance, // filtered next sibling, including virtual
flags: number,
componentStack: null | string,
source: null | string | Error | Source, // source location of this server component, or owned child stack
// Errors and Warnings happen per ReactComponentInfo which can appear in
// multiple places but we track them per stateful VirtualInstance so
// that old errors/warnings don't disappear when the instance is refreshed.
Expand All @@ -209,7 +218,7 @@ function createVirtualInstance(
previousSibling: null,
nextSibling: null,
flags: 0,
componentStack: null,
source: null,
errors: null,
warnings: null,
data: debugEntry,
Expand Down Expand Up @@ -2154,6 +2163,16 @@ export function attach(
parentInstance,
debugOwner,
);
if (
ownerInstance !== null &&
debugOwner === fiber._debugOwner &&
fiber._debugStack != null &&
ownerInstance.source === null
) {
// The new Fiber is directly owned by the ownerInstance. Therefore somewhere on
// the debugStack will be a stack frame inside the ownerInstance's source.
ownerInstance.source = fiber._debugStack;
}
const ownerID = ownerInstance === null ? 0 : ownerInstance.id;
const parentID = parentInstance ? parentInstance.id : 0;

Expand Down Expand Up @@ -2228,6 +2247,16 @@ export function attach(
// away so maybe it's not so bad.
const debugOwner = getUnfilteredOwner(componentInfo);
const ownerInstance = findNearestOwnerInstance(parentInstance, debugOwner);
if (
ownerInstance !== null &&
debugOwner === componentInfo.owner &&
componentInfo.debugStack != null &&
ownerInstance.source === null
) {
// The new Fiber is directly owned by the ownerInstance. Therefore somewhere on
// the debugStack will be a stack frame inside the ownerInstance's source.
ownerInstance.source = componentInfo.debugStack;
}
const ownerID = ownerInstance === null ? 0 : ownerInstance.id;
const parentID = parentInstance ? parentInstance.id : 0;

Expand Down Expand Up @@ -4324,7 +4353,7 @@ export function attach(

let source = null;
if (canViewSource) {
source = getSourceForFiber(fiber);
source = getSourceForFiberInstance(fiberInstance);
}

return {
Expand Down Expand Up @@ -4398,7 +4427,8 @@ export function attach(
function inspectVirtualInstanceRaw(
virtualInstance: VirtualInstance,
): InspectedElement | null {
const canViewSource = false;
const canViewSource = true;
const source = getSourceForInstance(virtualInstance);

const componentInfo = virtualInstance.data;
const key =
Expand Down Expand Up @@ -4438,9 +4468,6 @@ export function attach(
stylex: null,
};

// TODO: Support getting the source location from the owner stack.
const source = null;

return {
id: virtualInstance.id,

Expand Down Expand Up @@ -5664,39 +5691,63 @@ export function attach(
return idToDevToolsInstanceMap.has(id);
}

function getComponentStackForFiber(fiber: Fiber): string | null {
// TODO: This should really just take an DevToolsInstance directly.
let fiberInstance = fiberToFiberInstanceMap.get(fiber);
if (fiberInstance === undefined && fiber.alternate !== null) {
fiberInstance = fiberToFiberInstanceMap.get(fiber.alternate);
}
if (fiberInstance === undefined) {
// We're no longer tracking this instance.
return null;
}
if (fiberInstance.componentStack !== null) {
// Cached entry.
return fiberInstance.componentStack;
function getSourceForFiberInstance(
fiberInstance: FiberInstance,
): Source | null {
const unresolvedSource = fiberInstance.source;
if (
unresolvedSource !== null &&
typeof unresolvedSource === 'object' &&
!isError(unresolvedSource)
) {
// $FlowFixMe: isError should have refined it.
return unresolvedSource;
}
const dispatcherRef = getDispatcherRef(renderer);
if (dispatcherRef == null) {
const stackFrame =
dispatcherRef == null
? null
: getSourceLocationByFiber(
ReactTypeOfWork,
fiberInstance.data,
dispatcherRef,
);
if (stackFrame === null) {
// If we don't find a source location by throwing, try to get one
// from an owned child if possible. This is the same branch as
// for virtual instances.
return getSourceForInstance(fiberInstance);
}
const source = parseSourceFromComponentStack(stackFrame);
fiberInstance.source = source;
return source;
}

function getSourceForInstance(instance: DevToolsInstance): Source | null {
let unresolvedSource = instance.source;
if (unresolvedSource === null) {
// We don't have any source yet. We can try again later in case an owned child mounts later.
// TODO: We won't have any information here if the child is filtered.
return null;
}

return (fiberInstance.componentStack = getStackByFiberInDevAndProd(
ReactTypeOfWork,
fiber,
dispatcherRef,
));
}

function getSourceForFiber(fiber: Fiber): Source | null {
const componentStack = getComponentStackForFiber(fiber);
if (componentStack == null) {
return null;
// If we have the debug stack (the creation stack of the JSX) for any owned child of this
// component, then at the bottom of that stack will be a stack frame that is somewhere within
// the component's function body. Typically it would be the callsite of the JSX unless there's
// any intermediate utility functions. This won't point to the top of the component function
// but it's at least somewhere within it.
if (isError(unresolvedSource)) {
unresolvedSource = formatOwnerStack((unresolvedSource: any));
}
if (typeof unresolvedSource === 'string') {
const idx = unresolvedSource.lastIndexOf('\n');
const lastLine =
idx === -1 ? unresolvedSource : unresolvedSource.slice(idx + 1);
return (instance.source = parseSourceFromComponentStack(lastLine));
}

return parseSourceFromComponentStack(componentStack);
// $FlowFixMe: refined.
return unresolvedSource;
}

return {
Expand Down

0 comments on commit e44685e

Please sign in to comment.