From e44685e4f196f9e19c3729ab2b3772a40428ac1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 26 Aug 2024 20:50:43 -0400 Subject: [PATCH] [DevTools] Use Owner Stacks to Implement View Source of a Server Component (#30798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Screenshot 2024-08-22 at 10 24 42 PM Screenshot 2024-08-22 at 10 24 20 PM 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 ; } ``` 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
{children}
; } ``` Screenshot 2024-08-22 at 10 24 35 PM --- .../fiber/DevToolsFiberComponentStack.js | 17 +++ .../src/backend/fiber/renderer.js | 123 +++++++++++++----- 2 files changed, 104 insertions(+), 36 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/DevToolsFiberComponentStack.js b/packages/react-devtools-shared/src/backend/fiber/DevToolsFiberComponentStack.js index 5a1fc4ee5337d..17475cab2860f 100644 --- a/packages/react-devtools-shared/src/backend/fiber/DevToolsFiberComponentStack.js +++ b/packages/react-devtools-shared/src/backend/fiber/DevToolsFiberComponentStack.js @@ -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. diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 57eafa176f550..ac722a019cd4f 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -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'; @@ -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; @@ -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, // error messages and count warnings: null | Map, // warning messages and count data: Fiber, // one of a Fiber pair @@ -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, @@ -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. @@ -209,7 +218,7 @@ function createVirtualInstance( previousSibling: null, nextSibling: null, flags: 0, - componentStack: null, + source: null, errors: null, warnings: null, data: debugEntry, @@ -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; @@ -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; @@ -4324,7 +4353,7 @@ export function attach( let source = null; if (canViewSource) { - source = getSourceForFiber(fiber); + source = getSourceForFiberInstance(fiberInstance); } return { @@ -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 = @@ -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, @@ -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 {