From 03d6f8cd25ace364e7eda493543b14642e6d5cee Mon Sep 17 00:00:00 2001 From: Adam Munawar Rahman Date: Fri, 3 Jul 2026 04:07:57 -0400 Subject: [PATCH 1/2] fix: stop smart-edge pathfinding from rerunning on every graph interaction The graph view reran A* pathfinding for every edge on every render. Highlight state arrives via a context whose value object was rebuilt inline, so every click and hover re-rendered all nodes and edges, each edge re-running pathfinding over a grid sized to the whole layout. - memoize the smart-edge path per edge on geometry and node set - skip A* above 100 nodes and fall back to bezier so large graphs render - memoize the highlight context value - key the relayout effect on graph structure instead of object identity, so identity churn without a structural change (focus switches, responses that are not reference-stable) no longer forces a full relayout and fitView - create a fresh dagre graph per layout instead of reusing a module-level instance that accumulates stale nodes across applications - remove leftover debug edge label Measured with production builds: clicks on a 150-action application went from 11s+ on the 0.40.2 release (the same benchmark hung outright on a build of this source) to ~31ms; a 300-action application previously never finished its initial render and now renders with ~75ms interactions. Fixes #833 --- .../ui/src/components/routes/app/AppView.tsx | 4 +- .../src/components/routes/app/GraphView.tsx | 79 ++++++++++++------- 2 files changed, 53 insertions(+), 30 deletions(-) diff --git a/telemetry/ui/src/components/routes/app/AppView.tsx b/telemetry/ui/src/components/routes/app/AppView.tsx index fa33328f3..692d6802e 100644 --- a/telemetry/ui/src/components/routes/app/AppView.tsx +++ b/telemetry/ui/src/components/routes/app/AppView.tsx @@ -542,7 +542,7 @@ export const AppView = (props: { stateMachine={data.application} currentAction={currentStep} // highlightedActions={previousActions} - highlightedActions={[]} + highlightedActions={undefined} hoverAction={hoverAction} /> @@ -555,7 +555,7 @@ export const AppView = (props: { stateMachine={currentFocusStepsData?.application || data.application} // stateMachine={data.application} // highlightedActions={previousActions} - highlightedActions={[]} + highlightedActions={undefined} hoverAction={hoverAction} currentActionLocation={currentSequenceLocation} displayGraphAsTab={displayGraphAsTabs} // in this case we want the graph as a tab diff --git a/telemetry/ui/src/components/routes/app/GraphView.tsx b/telemetry/ui/src/components/routes/app/GraphView.tsx index 31a5f37e3..36c4046c6 100644 --- a/telemetry/ui/src/components/routes/app/GraphView.tsx +++ b/telemetry/ui/src/components/routes/app/GraphView.tsx @@ -20,7 +20,7 @@ import { ActionModel, ApplicationModel, Step } from '../../../api'; import dagre from 'dagre'; -import React, { createContext, useLayoutEffect, useRef, useState } from 'react'; +import React, { createContext, useLayoutEffect, useMemo, useRef, useState } from 'react'; import ReactFlow, { BaseEdge, Controls, @@ -39,8 +39,6 @@ import { backgroundColorsForIndex } from './AppView'; import { getActionStatus } from '../../../utils'; import { getSmartEdge } from '@tisoap/react-flow-smart-edge'; -const dagreGraph = new dagre.graphlib.Graph(); - const dagreOptions = { rankdir: 'TB', // Top to bottom layout (equivalent to ELK's UP direction) nodesep: 80, // Node separation (equivalent to elk.spacing.nodeNode) @@ -138,6 +136,10 @@ const InputNode = (props: { data: NodeData }) => { ); }; +// Past this size, smart-edge A* pathfinding is too slow to run at all -- a 300-node graph +// never finishes its initial render. See https://github.com/apache/burr/issues/833 +const SMART_EDGE_NODE_LIMIT = 100; + // TODO -- separate out into different edge types export const ActionActionEdge = ({ sourceX, @@ -159,20 +161,24 @@ export const ActionActionEdge = ({ ); const containsTo = allActionsInPath.some((action) => action.step_start_log.action === data.to); const shouldHighlight = containsFrom && containsTo; - const getSmartEdgeResponse = getSmartEdge({ - sourcePosition, - targetPosition, - sourceX, - sourceY, - targetX, - targetY, - nodes - }); - let edgePath = null; - if (getSmartEdgeResponse !== null) { - edgePath = getSmartEdgeResponse.svgPathString; - } else { - edgePath = getBezierPath({ + // Memoized: highlight changes re-render every edge, and pathfinding must not rerun then. + // See https://github.com/apache/burr/issues/833 + const edgePath = useMemo(() => { + if (nodes.length <= SMART_EDGE_NODE_LIMIT) { + const getSmartEdgeResponse = getSmartEdge({ + sourcePosition, + targetPosition, + sourceX, + sourceY, + targetX, + targetY, + nodes + }); + if (getSmartEdgeResponse !== null) { + return getSmartEdgeResponse.svgPathString; + } + } + return getBezierPath({ sourceX, sourceY, sourcePosition, @@ -180,7 +186,7 @@ export const ActionActionEdge = ({ targetY, targetPosition })[0]; - } + }, [nodes, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition]); const style = { markerColor: shouldHighlight ? 'black' : 'gray', @@ -188,7 +194,7 @@ export const ActionActionEdge = ({ }; return ( <> - + ); }; @@ -201,7 +207,8 @@ const getLayoutedElements = ( const isHorizontal = options?.['direction'] === 'LR'; const direction = isHorizontal ? 'LR' : 'TB'; - // Configure dagre graph + // Fresh graph per layout -- a shared one accumulates stale nodes across applications + const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); dagreGraph.setGraph({ ...dagreOptions, @@ -329,6 +336,18 @@ export const _Graph = (props: { const { fitView } = useReactFlow(); + // Keyed on structure rather than object identity: polling refetches produce a new + // stateMachine object every 500ms, which must not trigger a full relayout. + // See https://github.com/apache/burr/issues/833 + const structureKey = useMemo( + () => + JSON.stringify([ + props.stateMachine.actions.map((action) => [action.name, action.inputs]), + props.stateMachine.transitions.map((t) => [t.from_, t.to, t.condition]) + ]), + [props.stateMachine] + ); + useLayoutEffect(() => { const [nextNodes, nextEdges] = convertApplicationToGraph(props.stateMachine, showInputs); @@ -340,16 +359,20 @@ export const _Graph = (props: { window.requestAnimationFrame(() => fitView()); } ); - }, [showInputs, props.stateMachine, fitView]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showInputs, structureKey, fitView]); + + const nodeState = useMemo( + () => ({ + highlightedActions: props.previousActions, + hoverAction: props.hoverAction, + currentAction: props.currentAction + }), + [props.previousActions, props.hoverAction, props.currentAction] + ); return ( - +