Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial POC UI for Dependency Graph interactions #10591

Merged
merged 4 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 124 additions & 4 deletions packages/shared/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<button
className={inspectorStyles.IconButton}
data-test-name="JumpToDefinitionButton"
onClick={onClick}
title="Jump to point"
>
<Icon className={inspectorStyles.Icon} type="view-function-source" />
</button>
);
}

export function ReactComponentStack() {
const { point, time, pauseId } = useMostRecentLoadedPause() ?? {};
const replayClient = useContext(ReplayClientContext);
const [currentPoint, setCurrentPoint] = useState<ExecutionPoint | null>(null);

const { status: depGraphStatus, value: depGraphValue } = useImperativeCacheValue(
depGraphCache,
replayClient,
currentPoint
);

const { status: reactStackStatus, value: reactStackValue } = useImperativeCacheValue(
reactComponentStackCache,
replayClient,
currentPoint
);

if (!pauseId || !point) {
return <div>Not paused at a point</div>;
}

let depGraphContent: React.ReactNode = undefined;
let formattedDepGraphContent: React.ReactNode = undefined;
let reactStackContent: React.ReactNode = undefined;

if (depGraphStatus === "rejected") {
depGraphContent = <div>Error loading dependencies</div>;
} else if (depGraphStatus === "pending") {
depGraphContent = <Loader />;
} else {
depGraphContent = (
<div className="m-1 grow border ">
<h3 className="text-sm font-bold">Dependency Graph JSON</h3>
<JsonViewer jsonText={JSON.stringify(depGraphValue, null, 2)} />
</div>
);

formattedDepGraphContent = (
<div className="m-1 grow border ">
<h3 className="text-sm font-bold">Dependency Graph Formatted</h3>
<div className="m-1 flex flex-col">
{depGraphValue?.map((entry, index) => {
let jumpButton: React.ReactNode = undefined;

if (entry.point && entry.time) {
jumpButton = (
<JumpToDefinitionButton point={{ point: entry.point, time: entry.time }} />
);
}

return (
<div key={index} className="m-1 flex ">
{entry.code} ({entry.time?.toFixed(2)}) {jumpButton}
</div>
);
})}
</div>
</div>
);
}

if (reactStackStatus === "rejected") {
reactStackContent = <div>Error loading dependencies</div>;
} else if (reactStackStatus === "pending") {
reactStackContent = <Loader />;
} else {
reactStackContent = (
<div className="m-1 flex grow flex-col border">
<h3 className="text-sm font-bold">React Component Stack</h3>
{reactStackValue?.map((entry, index) => {
const jumpButton = entry.point ? <JumpToDefinitionButton point={entry} /> : null;
return (
<div key={index} className="m-1 flex flex-col">
<div title={entry.parentLocation.url}>
&lt;{entry.componentName}&gt; {jumpButton}
</div>
</div>
);
})}
</div>
);
}

return (
<div className="react-component-stack flex flex-col">
<Button className="self-start" onClick={() => setCurrentPoint(point)}>
Load dependencies
</Button>
{reactStackContent}
{formattedDepGraphContent}
{depGraphContent}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useState } from "react";

import { useGraphQLUserData } from "shared/user-data/GraphQL/useGraphQLUserData";
import { useAppSelector } from "ui/setup/hooks";

Expand All @@ -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";

Expand All @@ -25,6 +28,7 @@ export default function SecondaryPanes() {
const [logpointsVisible, setLogpointsVisible] = useGraphQLUserData(
"layout_logpointsPanelExpanded"
);
const [reactStackVisible, setReactStackVisible] = useState(false);

return (
<div className="secondary-panes-wrapper">
Expand Down Expand Up @@ -55,6 +59,14 @@ export default function SecondaryPanes() {
>
{currentPoint && <NewFrames point={currentPoint} time={currentTime} panel="debugger" />}
</AccordionPane>
<AccordionPane
header="Dependency Graph Prototype"
className="react-component-stack-pane"
expanded={reactStackVisible}
onToggle={() => setReactStackVisible(!reactStackVisible)}
>
{currentPoint && <ReactComponentStack />}
</AccordionPane>
<AccordionPane
header="Scopes"
className="scopes-pane"
Expand Down
52 changes: 32 additions & 20 deletions src/ui/actions/eventListeners/eventListenerUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface EventListenerWithFunctionInfo {
firstBreakablePosition: Location;
functionParameterNames: string[];
framework?: string;
classComponentName?: string;
}

export type FunctionWithPreview = Omit<ProtocolObject, "preview"> & {
Expand Down Expand Up @@ -107,7 +108,8 @@ export const formatFunctionDetailsFromLocation = async (
replayClient: ReplayClientInterface,
type: string,
locationInFunction: Location | MappedLocation,
framework?: string
framework?: string,
mightBeComponent = false
): Promise<FormattedEventListener | undefined> => {
const sourcesById = await sourcesByIdCache.readAsync(replayClient);
let location: Location | undefined;
Expand All @@ -131,40 +133,50 @@ 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,
location,
locationUrl,
firstBreakablePosition: {
sourceId: sourceDetails?.id,
...functionOutline.breakpointLocation,
...functionOutline.breakpointLocation!,
},
functionName: functionName || "Anonymous()",
functionName: functionName || "Anonymous",
functionParameterNames,
framework,
classComponentName: possibleMatchingClassDefinition?.name,
};
};

Expand Down
Loading
Loading