From e6c1e4af0b9f39079bf0d0d99ae8b72d71ab2bde Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 28 Jun 2024 17:09:55 -0400 Subject: [PATCH 1/4] Port more function formatting logic from backend --- .../eventListeners/eventListenerUtils.ts | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/ui/actions/eventListeners/eventListenerUtils.ts b/src/ui/actions/eventListeners/eventListenerUtils.ts index dc1cab87291..a9b4a498849 100644 --- a/src/ui/actions/eventListeners/eventListenerUtils.ts +++ b/src/ui/actions/eventListeners/eventListenerUtils.ts @@ -27,6 +27,7 @@ export interface EventListenerWithFunctionInfo { firstBreakablePosition: Location; functionParameterNames: string[]; framework?: string; + classComponentName?: string; } export type FunctionWithPreview = Omit & { @@ -107,7 +108,8 @@ export const formatFunctionDetailsFromLocation = async ( replayClient: ReplayClientInterface, type: string, locationInFunction: Location | MappedLocation, - framework?: string + framework?: string, + mightBeComponent = false ): Promise => { const sourcesById = await sourcesByIdCache.readAsync(replayClient); let location: Location | undefined; @@ -131,28 +133,37 @@ export const formatFunctionDetailsFromLocation = async ( // See if we can get any better details from the parsed source outline const symbols = await sourceOutlineCache.readAsync(replayClient, location.sourceId); - const functionOutline = findFunctionOutlineForLocation(location, symbols); + let functionOutline = findFunctionOutlineForLocation(location, symbols); + const possibleMatchingClassDefinition = findClassOutlineForLocation(location, symbols); + + // Sometimes we don't find a valid function outline for this location. + // This could be because we actually got a location for an entire class component, + // or there could be some kind of other mismatch. + if (!functionOutline) { + if (mightBeComponent && possibleMatchingClassDefinition) { + // If the caller is using this to format components, see if + // we can find the `render()` method. If so, use that so we + // have _some_ function outline to work with here. + const renderFunction = symbols.functions.find( + f => + f.name === "render" && + f.location.begin.line >= possibleMatchingClassDefinition.location.begin.line && + f.location.end.line <= possibleMatchingClassDefinition.location.end.line + ); + + if (renderFunction) { + functionOutline = renderFunction; + } + } - if (!functionOutline?.breakpointLocation) { - return; + if (!functionOutline) { + return; + } } - let functionName = functionOutline.name!; + const functionName = possibleMatchingClassDefinition?.name ?? functionOutline.name ?? "Anonymous"; const functionParameterNames = functionOutline.parameters; - if (!functionName) { - // Might be an anonymous callback. This annoyingly happens with thunks. - // Let's see if we can find a parent with a reasonable name. - const currentIndex = symbols.functions.indexOf(functionOutline); - if (currentIndex > -1) { - const maybeParent = findFunctionParent(symbols.functions, currentIndex); - - if (maybeParent?.name) { - functionName = maybeParent.name; - } - } - } - return { type, sourceDetails, @@ -160,11 +171,12 @@ export const formatFunctionDetailsFromLocation = async ( locationUrl, firstBreakablePosition: { sourceId: sourceDetails?.id, - ...functionOutline.breakpointLocation, + ...functionOutline.breakpointLocation!, }, - functionName: functionName || "Anonymous()", + functionName: functionName || "Anonymous", functionParameterNames, framework, + classComponentName: possibleMatchingClassDefinition?.name, }; }; From f19ebd122bf36b407b0c31c2715b4c30f77b3a43 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 28 Jun 2024 17:50:34 -0400 Subject: [PATCH 2/4] Add caches for dep graph and formatted React parents --- packages/shared/client/types.ts | 128 +++++++++++++++++++++++- src/ui/suspense/depGraphCache.ts | 161 +++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 src/ui/suspense/depGraphCache.ts diff --git a/packages/shared/client/types.ts b/packages/shared/client/types.ts index 2f566f3bee0..9e40d55b04d 100644 --- a/packages/shared/client/types.ts +++ b/packages/shared/client/types.ts @@ -164,12 +164,132 @@ export interface TimeStampedPointWithPaintHash extends TimeStampedPoint { export type AnnotationListener = (annotation: Annotation) => void; -export interface DependencyChainStep { - code: string; - time?: number; - point?: string; +// Copied from `src/analysis/dependencies/dependencyDescription.ts` in the backend + +export interface URLLocation { + line: number; + column: number; + url: string; } +export type DependencyChainStepInfo = + | { + // The document has started to load. + code: "DocumentBeginLoad"; + url: string; + } + | { + // A script in a document began execution after all other required + // resources were received. + code: "DocumentExecuteBlockedScript"; + url: string; + } + | { + // A script in a document began execution after being downloaded. + code: "DocumentExecuteScript"; + url: string; + } + | { + // A script in a document has been scheduled for async compilation. + code: "DocumentAsyncCompileScript"; + url: string; + } + | { + // A network request referenced by a document's contents was initiated. + code: "DocumentInitiateNetworkRequest"; + url: string; + } + | { + // A script triggered a network request. + code: "ScriptInitiateNetworkRequest"; + url: string; + } + | { + // Some data has been received over the network. + code: "NetworkReceiveData"; + numBytes: number; + } + | { + // A network resource finished being received. + code: "NetworkReceiveResource"; + } + | { + // Event handlers for user input were called. + code: "DispatchInputEventHandler"; + type: string; + } + | { + // A script created a new websocket. + code: "ScriptCreateWebSocket"; + url: string; + } + | { + // A websocket connected and open handlers were called. + code: "WebSocketConnected"; + } + | { + // A script sent a message over a websocket. + code: "ScriptSendWebSocketMessage"; + } + | { + // A websocket message determined to be a response to an earlier message + // was received and message handlers were called. + code: "WebSocketMessageReceived"; + } + | { + // A promise settled and its then/catch hooks were called. + code: "PromiseSettled"; + } + | { + // React hydration has started. + code: "ReactHydrateRoot"; + } + | { + // React has rendered a component. + code: "ReactRender"; + calleeLocation?: URLLocation; + } + | { + // React was able to resume rendering after a suspense promise resolved. + code: "ReactResumeSuspendedRender"; + } + | { + // An application render function returned an existing element object for + // converting into a component. + code: "ReactReturnElement"; + } + | { + // An application render function created an element object for converting + // into a component. + code: "ReactCreateElement"; + } + | { + // An application render function called setState(). + code: "ReactCallSetState"; + } + | { + // An application render function called useEffect(). + code: "ReactCallUseEffect"; + } + | { + // An effect hook is called for the first time after the original useEffect(). + code: "ReactEffectFirstCall"; + calleeLocation?: URLLocation; + } + | { + code: "UnknownNode"; + node: unknown; + } + | { + code: "UnknownEdge"; + edge: unknown; + }; + +export type DependencyChainStep = DependencyChainStepInfo & { + time?: number; + point?: ExecutionPoint; +}; + export interface ReplayClientInterface { get loadedRegions(): LoadedRegions | null; addEventListener(type: ReplayClientEvents, handler: Function): void; diff --git a/src/ui/suspense/depGraphCache.ts b/src/ui/suspense/depGraphCache.ts new file mode 100644 index 00000000000..01e82fd5e6a --- /dev/null +++ b/src/ui/suspense/depGraphCache.ts @@ -0,0 +1,161 @@ +import { + ExecutionPoint, + Frame, + FunctionMatch, + FunctionOutline, + Location, + PauseDescription, + PauseId, + PointDescription, + PointStackFrame, + RunEvaluationResult, + TimeStampedPoint, + TimeStampedPointRange, +} from "@replayio/protocol"; +import { Cache, createCache } from "suspense"; + +import { sourceOutlineCache } from "replay-next/src/suspense/SourceOutlineCache"; +import { sourcesByIdCache, sourcesByUrlCache } from "replay-next/src/suspense/SourcesCache"; +import { + getSourceIdToDisplayForUrl, + getSourceToDisplayForUrl, +} from "replay-next/src/utils/sources"; +import { DependencyChainStep, ReplayClientInterface } from "shared/client/types"; +import { formatFunctionDetailsFromLocation } from "ui/actions/eventListeners/eventListenerUtils"; +import { findFunctionOutlineForLocation } from "ui/actions/eventListeners/jumpToCode"; + +import { formattedPointStackCache } from "./frameCache"; + +export const depGraphCache: Cache< + [replayClient: ReplayClientInterface, point: ExecutionPoint | null], + DependencyChainStep[] | null +> = createCache({ + config: { immutable: true }, + debugLabel: "depGraphCache", + getKey: ([replayClient, point]) => point ?? "null", + load: async ([replayClient, point]) => { + if (!point) { + return null; + } + const dependencies = await replayClient.getDependencies(point); + + console.log("Deps for point: ", point, dependencies); + return dependencies; + }, +}); + +interface ReactComponentStackEntry extends TimeStampedPoint { + parentLocation: Location & { url: string }; + componentName: string; +} + +export const reactComponentStackCache: Cache< + [replayClient: ReplayClientInterface, point: ExecutionPoint | null], + ReactComponentStackEntry[] | null +> = createCache({ + config: { immutable: true }, + debugLabel: "reactComponentStackCache", + getKey: ([replayClient, point]) => point ?? "null", + load: async ([replayClient, point]) => { + const dependencies = await depGraphCache.readAsync(replayClient, point); + + if (!dependencies) { + return null; + } + + const componentStack: ReactComponentStackEntry[] = []; + + const sourcesById = await sourcesByIdCache.readAsync(replayClient); + + const remainingDepEntries = dependencies.slice().reverse(); + while (remainingDepEntries.length) { + const depEntry = remainingDepEntries.shift()!; + + if (depEntry.code === "ReactRender") { + const previousEntry = remainingDepEntries.shift(); + if ( + !previousEntry || + !["ReactCreateElement", "ReactReturnElement"].includes(previousEntry!.code) + ) { + console.error( + "Expected ReactCreateElement or ReactReturnElement entry before ReactRender, got: ", + previousEntry + ); + continue; + } + + if (!previousEntry.point) { + console.error("Expected point in previous entry: ", previousEntry); + } + + const elementCreationPoint: TimeStampedPoint = { + point: previousEntry.point!, + time: previousEntry.time!, + }; + const parentLocation = depEntry.calleeLocation; + + let componentName = "Unknown"; + + if (parentLocation) { + const sourcesByUrl = await sourcesByUrlCache.readAsync(replayClient); + const sourcesForUrl = sourcesByUrl.get(parentLocation.url); + + const bestSource = getSourceToDisplayForUrl( + sourcesById, + sourcesByUrl, + parentLocation.url + ); + + if (!bestSource) { + continue; + } + + const locationInFunction: Location = { + sourceId: bestSource.sourceId, + line: parentLocation.line, + column: parentLocation.column, + }; + + const formattedFunctionDescription = await formatFunctionDetailsFromLocation( + replayClient, + "component", + locationInFunction, + undefined, + true + ); + + componentName = + formattedFunctionDescription?.classComponentName ?? + formattedFunctionDescription?.functionName ?? + "Unknown"; + + const pointStack = await formattedPointStackCache.readAsync(replayClient, { + ...elementCreationPoint, + frameDepth: 2, + }); + + let finalJumpPoint = elementCreationPoint; + + if (pointStack.allFrames.length > 1) { + // Element creation happens up one frame + const elementCreationFrame = pointStack.allFrames[1]; + finalJumpPoint = elementCreationFrame.point!; + } + + const stackEntry: ReactComponentStackEntry = { + ...finalJumpPoint, + parentLocation: { + ...locationInFunction, + url: parentLocation.url, + }, + componentName, + }; + + componentStack.push(stackEntry); + } + } + } + + return componentStack; + }, +}); From 6921986eaaf70be7535ba2065557d1d8ad8f73ad Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 28 Jun 2024 17:50:48 -0400 Subject: [PATCH 3/4] Add a dep graph prototype panel --- .../SecondaryPanes/ReactComponentStack.tsx | 129 ++++++++++++++++++ .../src/components/SecondaryPanes/index.tsx | 12 ++ 2 files changed, 141 insertions(+) create mode 100644 src/devtools/client/debugger/src/components/SecondaryPanes/ReactComponentStack.tsx diff --git a/src/devtools/client/debugger/src/components/SecondaryPanes/ReactComponentStack.tsx b/src/devtools/client/debugger/src/components/SecondaryPanes/ReactComponentStack.tsx new file mode 100644 index 00000000000..bc0e37d927c --- /dev/null +++ b/src/devtools/client/debugger/src/components/SecondaryPanes/ReactComponentStack.tsx @@ -0,0 +1,129 @@ +import { ExecutionPoint, TimeStampedPoint } from "@replayio/protocol"; +import React, { useContext, useState } from "react"; +import { useImperativeCacheValue } from "suspense"; + +import { Button } from "replay-next/components/Button"; +import Icon from "replay-next/components/Icon"; +import Loader from "replay-next/components/Loader"; +import { JsonViewer } from "replay-next/components/SyntaxHighlighter/JsonViewer"; +import { useMostRecentLoadedPause } from "replay-next/src/hooks/useMostRecentLoadedPause"; +import { ReplayClientContext } from "shared/client/ReplayClientContext"; +import { seek } from "ui/actions/timeline"; +import { useAppDispatch } from "ui/setup/hooks"; +import { depGraphCache, reactComponentStackCache } from "ui/suspense/depGraphCache"; + +import inspectorStyles from "replay-next/components/inspector/values/shared.module.css"; + +function JumpToDefinitionButton({ point }: { point?: TimeStampedPoint }) { + const dispatch = useAppDispatch(); + let onClick = point + ? () => { + dispatch(seek({ executionPoint: point.point, openSource: true, time: point.time })); + } + : undefined; + return ( + + ); +} + +export function ReactComponentStack() { + const { point, time, pauseId } = useMostRecentLoadedPause() ?? {}; + const replayClient = useContext(ReplayClientContext); + const [currentPoint, setCurrentPoint] = useState(null); + + const { status: depGraphStatus, value: depGraphValue } = useImperativeCacheValue( + depGraphCache, + replayClient, + currentPoint + ); + + const { status: reactStackStatus, value: reactStackValue } = useImperativeCacheValue( + reactComponentStackCache, + replayClient, + currentPoint + ); + + if (!pauseId || !point) { + return
Not paused at a point
; + } + + let depGraphContent: React.ReactNode = undefined; + let formattedDepGraphContent: React.ReactNode = undefined; + let reactStackContent: React.ReactNode = undefined; + + if (depGraphStatus === "rejected") { + depGraphContent =
Error loading dependencies
; + } else if (depGraphStatus === "pending") { + depGraphContent = ; + } else { + depGraphContent = ( +
+

Dependency Graph JSON

+ +
+ ); + + formattedDepGraphContent = ( +
+

Dependency Graph Formatted

+
+ {depGraphValue?.map((entry, index) => { + let jumpButton: React.ReactNode = undefined; + + if (entry.point && entry.time) { + jumpButton = ( + + ); + } + + return ( +
+ {entry.code} ({entry.time?.toFixed(2)}) {jumpButton} +
+ ); + })} +
+
+ ); + } + + if (reactStackStatus === "rejected") { + reactStackContent =
Error loading dependencies
; + } else if (reactStackStatus === "pending") { + reactStackContent = ; + } else { + reactStackContent = ( +
+

React Component Stack

+ {reactStackValue?.map((entry, index) => { + const jumpButton = entry.point ? : null; + return ( +
+
+ <{entry.componentName}> {jumpButton} +
+
+ ); + })} +
+ ); + } + + return ( +
+ + {reactStackContent} + {formattedDepGraphContent} + {depGraphContent} +
+ ); +} diff --git a/src/devtools/client/debugger/src/components/SecondaryPanes/index.tsx b/src/devtools/client/debugger/src/components/SecondaryPanes/index.tsx index fb670dff399..5acc68b88d1 100644 --- a/src/devtools/client/debugger/src/components/SecondaryPanes/index.tsx +++ b/src/devtools/client/debugger/src/components/SecondaryPanes/index.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; + import { useGraphQLUserData } from "shared/user-data/GraphQL/useGraphQLUserData"; import { useAppSelector } from "ui/setup/hooks"; @@ -8,6 +10,7 @@ import NewFrames from "./Frames/NewFrames"; import FrameTimeline from "./FrameTimeline"; import LogpointsPane from "./LogpointsPane"; import NewScopes from "./NewScopes"; +import { ReactComponentStack } from "./ReactComponentStack"; import { Accordion, AccordionPane } from "@recordreplay/accordion"; @@ -25,6 +28,7 @@ export default function SecondaryPanes() { const [logpointsVisible, setLogpointsVisible] = useGraphQLUserData( "layout_logpointsPanelExpanded" ); + const [reactStackVisible, setReactStackVisible] = useState(false); return (
@@ -55,6 +59,14 @@ export default function SecondaryPanes() { > {currentPoint && } + setReactStackVisible(!reactStackVisible)} + > + {currentPoint && } + Date: Fri, 28 Jun 2024 17:51:36 -0400 Subject: [PATCH 4/4] Remove the zombie button --- src/ui/components/LoadDependenciesButton.tsx | 53 -------------------- src/ui/components/SecondaryToolbox/index.tsx | 2 - 2 files changed, 55 deletions(-) delete mode 100644 src/ui/components/LoadDependenciesButton.tsx diff --git a/src/ui/components/LoadDependenciesButton.tsx b/src/ui/components/LoadDependenciesButton.tsx deleted file mode 100644 index a590972da5c..00000000000 --- a/src/ui/components/LoadDependenciesButton.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import classnames from "classnames"; -import { useContext } from "react"; - -import { useMostRecentLoadedPause } from "replay-next/src/hooks/useMostRecentLoadedPause"; -import { pauseIdCache } from "replay-next/src/suspense/PauseCache"; -import { TerminalContext } from "replay-next/src/contexts/TerminalContext"; -import { replayClient } from "shared/client/ReplayClientContext"; - -let gNextPointId = 1; - -export function LoadDependenciesButton() { - const { point, time, pauseId } = useMostRecentLoadedPause() ?? {}; - const { addMessage } = useContext(TerminalContext); - - const title = "Load dependencies at this point"; - - const onClick = async () => { - if (!point || !time || !pauseId) { - console.log(`Missing pause point`); - return; - } - - const steps = await replayClient.getDependencies(point); - - const pointId = gNextPointId++; - - for (const { code, point: stepPoint, time: stepTime } of steps) { - if (stepPoint && stepTime) { - const pauseId = await pauseIdCache.readAsync( - replayClient, - stepPoint, - stepTime - ); - addMessage({ - expression: `"P${pointId}: ${code}"`, - frameId: null, - pauseId, - point: stepPoint, - time: stepTime, - }); - } - } - }; - - return ( -