From 62e1c4808c35efc766d1941d76c6f4a09c4d7f5b Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 17 Dec 2025 10:59:12 -0700 Subject: [PATCH 1/7] switch to dagre --- .../(no-sidebar)/devtools/devtools-client.tsx | 200 ++++++++++-------- docs/site/package.json | 3 +- pnpm-lock.yaml | 32 ++- 3 files changed, 140 insertions(+), 95 deletions(-) diff --git a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx index 9bbaf5bac304d..63763496983e6 100644 --- a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx +++ b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx @@ -21,7 +21,7 @@ import { type Edge, type NodeMouseHandler, } from "reactflow"; -import Elk from "elkjs/lib/elk.bundled.js"; +import Dagre from "@dagrejs/dagre"; import { Package } from "lucide-react"; import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock"; import { createCssVariablesTheme } from "shiki"; @@ -87,8 +87,6 @@ type GraphView = "packages" | "tasks"; // Selection mode: none -> direct (first click) -> blocks (second click) -> dependsOn (third click) -> none (fourth click) type SelectionMode = "none" | "direct" | "blocks" | "dependsOn"; -const elk = new Elk(); - // Turbo node and edge types const nodeTypes = { turbo: TurboNode, @@ -118,74 +116,114 @@ function calculateNodeWidth(data: TurboNodeData): number { return Math.max(MIN_NODE_WIDTH, contentWidth + NODE_PADDING_X); } -// ELK layout function -async function getLayoutedElements( +// Dagre layout function - significantly faster than ELK for DAGs +function getLayoutedElements( nodes: Array>, edges: Array -): Promise<{ nodes: Array; edges: Array }> { +): { nodes: Array; edges: Array } { if (nodes.length === 0) { return { nodes: [], edges: [] }; } - // Calculate width for each node based on its content - const nodeWidths = new Map(); + // Create a new directed graph + const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + + // Configure the graph layout + g.setGraph({ + rankdir: "TB", // Top to bottom (equivalent to ELK's DOWN direction) + nodesep: NODE_SPACING, // Horizontal spacing between nodes + ranksep: 150, // Vertical spacing between layers + marginx: 50, + marginy: 50, + }); + + // Add nodes with their dimensions for (const node of nodes) { - nodeWidths.set(node.id, calculateNodeWidth(node.data)); + const width = calculateNodeWidth(node.data); + g.setNode(node.id, { width, height: NODE_HEIGHT }); } - const graph = { - id: "root", - layoutOptions: { - "elk.algorithm": "layered", - "elk.direction": "DOWN", - "elk.spacing.nodeNode": String(NODE_SPACING), - "elk.layered.spacing.nodeNodeBetweenLayers": "150", - "elk.spacing.componentComponent": "150", - "elk.layered.spacing.edgeNodeBetweenLayers": "50", - "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", - }, - children: nodes.map((node) => ({ - id: node.id, - width: nodeWidths.get(node.id) ?? MIN_NODE_WIDTH, - height: NODE_HEIGHT, - })), - edges: edges.map((edge, i) => ({ - id: `e${i}`, - sources: [edge.source], - targets: [edge.target], - })), - }; + // Add edges + for (const edge of edges) { + g.setEdge(edge.source, edge.target); + } - const layoutedGraph = await elk.layout(graph); + // Run the layout algorithm + Dagre.layout(g); + // Map the layout results back to React Flow nodes return { nodes: nodes.map((node) => { - const layoutedNode = layoutedGraph.children?.find( - (n: { id: string; x?: number; y?: number }) => n.id === node.id - ); + const nodeWithPosition = g.node(node.id); + // Dagre returns center coordinates, React Flow uses top-left + // Adjust by subtracting half the width/height + const width = calculateNodeWidth(node.data); return { ...node, - position: { x: layoutedNode?.x ?? 0, y: layoutedNode?.y ?? 0 }, + position: { + x: (nodeWithPosition?.x ?? 0) - width / 2, + y: (nodeWithPosition?.y ?? 0) - NODE_HEIGHT / 2, + }, }; }), edges, }; } +// Adjacency maps for graph traversal - built once per edge set +interface AdjacencyMaps { + // dependency -> dependents (for finding what a node blocks/affects) + dependentsMap: Map>; + // dependent -> dependencies (for finding what a node depends on) + dependenciesMap: Map>; + // node -> all direct neighbors (both directions) + neighborsMap: Map>; +} + +// Build adjacency maps once from edges - O(E) where E is number of edges +function buildAdjacencyMaps(edges: Array): AdjacencyMaps { + const dependentsMap = new Map>(); + const dependenciesMap = new Map>(); + const neighborsMap = new Map>(); + + for (const edge of edges) { + // edge.source depends on edge.target + // So edge.target has edge.source as a dependent + const dependents = dependentsMap.get(edge.target) ?? []; + dependents.push(edge.source); + dependentsMap.set(edge.target, dependents); + + // edge.source depends on edge.target + const dependencies = dependenciesMap.get(edge.source) ?? []; + dependencies.push(edge.target); + dependenciesMap.set(edge.source, dependencies); + + // Build neighbors (both directions) + const sourceNeighbors = neighborsMap.get(edge.source) ?? new Set(); + sourceNeighbors.add(edge.target); + neighborsMap.set(edge.source, sourceNeighbors); + + const targetNeighbors = neighborsMap.get(edge.target) ?? new Set(); + targetNeighbors.add(edge.source); + neighborsMap.set(edge.target, targetNeighbors); + } + + return { dependentsMap, dependenciesMap, neighborsMap }; +} + // Get direct dependencies (nodes directly connected to the selected node) +// Uses pre-built adjacency maps for O(1) neighbor lookup function getDirectDependencies( nodeId: string, - edges: Array + adjacencyMaps: AdjacencyMaps ): Set { const connected = new Set(); connected.add(nodeId); - for (const edge of edges) { - if (edge.source === nodeId) { - connected.add(edge.target); - } - if (edge.target === nodeId) { - connected.add(edge.source); + const neighbors = adjacencyMaps.neighborsMap.get(nodeId); + if (neighbors) { + for (const neighbor of neighbors) { + connected.add(neighbor); } } @@ -196,29 +234,20 @@ function getDirectDependencies( // If package A changes, then all packages that depend on A (directly or transitively) are affected. // In the edge model: edge.source depends on edge.target (arrow points from dependent to dependency) // So we traverse "upstream" - following edges backwards from target to source +// Uses pre-built adjacency maps - no more rebuilding on every call function getAffectedNodes( nodeId: string, - edges: Array + adjacencyMaps: AdjacencyMaps ): Set { const affected = new Set(); affected.add(nodeId); - // Build an adjacency list for reverse traversal (dependency -> dependents) - const dependentsMap = new Map>(); - for (const edge of edges) { - // edge.source depends on edge.target - // So edge.target has edge.source as a dependent - const dependents = dependentsMap.get(edge.target) || []; - dependents.push(edge.source); - dependentsMap.set(edge.target, dependents); - } - // BFS to find all transitively affected nodes const queue = [nodeId]; while (queue.length > 0) { const current = queue.shift(); if (current === undefined) continue; - const dependents = dependentsMap.get(current) || []; + const dependents = adjacencyMaps.dependentsMap.get(current) ?? []; for (const dependent of dependents) { if (!affected.has(dependent)) { @@ -235,25 +264,20 @@ function getAffectedNodes( // These are the packages that, if changed, would cause the selected node's hash to change. // In the edge model: edge.source depends on edge.target // So we traverse "downstream" - following edges from source to target -function getAffectsNodes(nodeId: string, edges: Array): Set { +// Uses pre-built adjacency maps - no more rebuilding on every call +function getAffectsNodes( + nodeId: string, + adjacencyMaps: AdjacencyMaps +): Set { const affects = new Set(); affects.add(nodeId); - // Build an adjacency list for forward traversal (dependent -> dependencies) - const dependenciesMap = new Map>(); - for (const edge of edges) { - // edge.source depends on edge.target - const dependencies = dependenciesMap.get(edge.source) || []; - dependencies.push(edge.target); - dependenciesMap.set(edge.source, dependencies); - } - // BFS to find all transitive dependencies const queue = [nodeId]; while (queue.length > 0) { const current = queue.shift(); if (current === undefined) continue; - const dependencies = dependenciesMap.get(current) || []; + const dependencies = adjacencyMaps.dependenciesMap.get(current) ?? []; for (const dependency of dependencies) { if (!affects.has(dependency)) { @@ -601,6 +625,10 @@ function DevtoolsContent() { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- reactflow types are imperfect const [edges, setEdges, onEdgesChange] = useEdgesState([]); + // Memoize adjacency maps - rebuilt only when rawEdges changes + // This avoids O(E) map rebuilding on every node selection/mode change + const adjacencyMaps = useMemo(() => buildAdjacencyMaps(rawEdges), [rawEdges]); + // Calculate which nodes/edges should be highlighted based on selection const { highlightedNodes, highlightedEdges } = useMemo(() => { if (!selectedNode || selectionMode === "none") { @@ -609,17 +637,17 @@ function DevtoolsContent() { let visibleNodes: Set; if (selectionMode === "direct") { - visibleNodes = getDirectDependencies(selectedNode, rawEdges); + visibleNodes = getDirectDependencies(selectedNode, adjacencyMaps); } else if (selectionMode === "blocks") { - visibleNodes = getAffectedNodes(selectedNode, rawEdges); + visibleNodes = getAffectedNodes(selectedNode, adjacencyMaps); } else { - visibleNodes = getAffectsNodes(selectedNode, rawEdges); + visibleNodes = getAffectsNodes(selectedNode, adjacencyMaps); } const visibleEdges = getConnectedEdges(visibleNodes, rawEdges); return { highlightedNodes: visibleNodes, highlightedEdges: visibleEdges }; - }, [selectedNode, selectionMode, rawEdges]); + }, [selectedNode, selectionMode, rawEdges, adjacencyMaps]); // Apply highlighting to nodes and edges useEffect(() => { @@ -738,7 +766,7 @@ function DevtoolsContent() { // Convert package graph to React Flow elements const updatePackageGraphElements = useCallback( - async (state: GraphState) => { + (state: GraphState) => { // Filter to only nodes that have connections const connectedIds = getConnectedNodeIds(state.packageGraph.edges); const connectedPackages = state.packageGraph.nodes.filter((pkg) => @@ -768,7 +796,7 @@ function DevtoolsContent() { ); const { nodes: layoutedNodes, edges: layoutedEdges } = - await getLayoutedElements(flowNodes, flowEdges); + getLayoutedElements(flowNodes, flowEdges); setBaseNodes(layoutedNodes); setBaseEdges(layoutedEdges); @@ -783,7 +811,7 @@ function DevtoolsContent() { // Convert task graph to React Flow elements const updateTaskGraphElements = useCallback( - async (state: GraphState) => { + (state: GraphState) => { // Filter to only nodes that have connections const connectedIds = getConnectedNodeIds(state.taskGraph.edges); const connectedTasks = state.taskGraph.nodes.filter((task) => @@ -811,7 +839,7 @@ function DevtoolsContent() { })); const { nodes: layoutedNodes, edges: layoutedEdges } = - await getLayoutedElements(flowNodes, flowEdges); + getLayoutedElements(flowNodes, flowEdges); setBaseNodes(layoutedNodes); setBaseEdges(layoutedEdges); @@ -826,14 +854,14 @@ function DevtoolsContent() { // Update flow elements when view or graph state changes const updateFlowElements = useCallback( - async (state: GraphState, currentView: GraphView) => { + (state: GraphState, currentView: GraphView) => { // Clear selection when switching views or updating (don't reset viewport, layout will handle it) clearSelection(false); if (currentView === "packages") { - await updatePackageGraphElements(state); + updatePackageGraphElements(state); } else { - await updateTaskGraphElements(state); + updateTaskGraphElements(state); } }, [updatePackageGraphElements, updateTaskGraphElements, clearSelection] @@ -844,7 +872,7 @@ function DevtoolsContent() { (newView: GraphView) => { setView(newView); if (graphState) { - void updateFlowElements(graphState, newView); + updateFlowElements(graphState, newView); } }, [graphState, updateFlowElements] @@ -1069,15 +1097,15 @@ function DevtoolsContent() { // Focus on the appropriate nodes for the new mode let nodesToFocus: Set; if (mode === "direct") { - nodesToFocus = getDirectDependencies(selectedNode, rawEdges); + nodesToFocus = getDirectDependencies(selectedNode, adjacencyMaps); } else if (mode === "blocks") { - nodesToFocus = getAffectedNodes(selectedNode, rawEdges); + nodesToFocus = getAffectedNodes(selectedNode, adjacencyMaps); } else { - nodesToFocus = getAffectsNodes(selectedNode, rawEdges); + nodesToFocus = getAffectsNodes(selectedNode, adjacencyMaps); } focusOnNodes(nodesToFocus); }, - [selectedNode, rawEdges, focusOnNodes] + [selectedNode, adjacencyMaps, focusOnNodes] ); // Handle sidebar node click @@ -1088,12 +1116,12 @@ function DevtoolsContent() { if (selectionMode === "direct") { setSelectionMode("blocks"); // Focus on nodes that this blocks (dependents) - const blocked = getAffectedNodes(nodeId, rawEdges); + const blocked = getAffectedNodes(nodeId, adjacencyMaps); focusOnNodes(blocked); } else if (selectionMode === "blocks") { setSelectionMode("dependsOn"); // Focus on nodes that this depends on - const dependencies = getAffectsNodes(nodeId, rawEdges); + const dependencies = getAffectsNodes(nodeId, adjacencyMaps); focusOnNodes(dependencies); } else if (selectionMode === "dependsOn") { clearSelection(true); @@ -1102,11 +1130,11 @@ function DevtoolsContent() { setSelectedNode(nodeId); setSelectionMode("direct"); // Focus on direct dependencies - const direct = getDirectDependencies(nodeId, rawEdges); + const direct = getDirectDependencies(nodeId, adjacencyMaps); focusOnNodes(direct); } }, - [selectedNode, selectionMode, rawEdges, focusOnNodes, clearSelection] + [selectedNode, selectionMode, adjacencyMaps, focusOnNodes, clearSelection] ); // No port provided - show instructions diff --git a/docs/site/package.json b/docs/site/package.json index 6fa309ba55f05..2eef4870c2f0d 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -22,6 +22,7 @@ "collect-examples-data": "node --experimental-strip-types ./scripts/collect-examples-data.ts" }, "dependencies": { + "@dagrejs/dagre": "^1.1.8", "@flags-sdk/vercel": "^0.1.8", "@heroicons/react": "1.0.6", "@radix-ui/react-collapsible": "1.1.3", @@ -39,7 +40,6 @@ "class-variance-authority": "0.7.1", "clsx": "2.1.1", "copy-to-clipboard": "3.3.3", - "elkjs": "0.9.3", "fast-glob": "3.3.3", "flags": "^4.0.2", "framer-motion": "12.2.0", @@ -70,6 +70,7 @@ "@turbo/eslint-config": "workspace:*", "@turbo/tsconfig": "workspace:^", "@turbo/types": "workspace:*", + "@types/dagre": "^0.7.53", "@types/mdx": "2.0.13", "@types/node": "20.11.30", "@types/react": "18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b77a880d890b..7963b707ba6f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: docs/site: dependencies: + '@dagrejs/dagre': + specifier: ^1.1.8 + version: 1.1.8 '@flags-sdk/vercel': specifier: ^0.1.8 version: 0.1.8(flags@4.0.2(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) @@ -129,9 +132,6 @@ importers: copy-to-clipboard: specifier: 3.3.3 version: 3.3.3 - elkjs: - specifier: 0.9.3 - version: 0.9.3 fast-glob: specifier: 3.3.3 version: 3.3.3 @@ -217,6 +217,9 @@ importers: '@turbo/types': specifier: workspace:* version: link:../../packages/turbo-types + '@types/dagre': + specifier: ^0.7.53 + version: 0.7.53 '@types/mdx': specifier: 2.0.13 version: 2.0.13 @@ -1468,6 +1471,13 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dagrejs/dagre@1.1.8': + resolution: {integrity: sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==} + + '@dagrejs/graphlib@2.2.4': + resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==} + engines: {node: '>17.0.0'} + '@edge-runtime/cookies@5.0.2': resolution: {integrity: sha512-Sd8LcWpZk/SWEeKGE8LT6gMm5MGfX/wm+GPnh1eBEtCpya3vYqn37wYknwAHw92ONoyyREl1hJwxV/Qx2DWNOg==} engines: {node: '>=16'} @@ -3524,6 +3534,9 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/dagre@0.7.53': + resolution: {integrity: sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -4961,9 +4974,6 @@ packages: electron-to-chromium@1.5.114: resolution: {integrity: sha512-DFptFef3iktoKlFQK/afbo274/XNWD00Am0xa7M8FZUepHlHT8PEuiNBoRfFHbH1okqN58AlhbJ4QTkcnXorjA==} - elkjs@0.9.3: - resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} - emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} @@ -9712,6 +9722,12 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dagrejs/dagre@1.1.8': + dependencies: + '@dagrejs/graphlib': 2.2.4 + + '@dagrejs/graphlib@2.2.4': {} + '@edge-runtime/cookies@5.0.2': {} '@emnapi/runtime@0.45.0': @@ -11779,6 +11795,8 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 + '@types/dagre@0.7.53': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 0.7.34 @@ -13303,8 +13321,6 @@ snapshots: electron-to-chromium@1.5.114: {} - elkjs@0.9.3: {} - emittery@0.13.1: {} emoji-regex-xs@1.0.0: {} From 6b1c856582a594a1893a585a5152bbf73400c2cb Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 17 Dec 2025 11:51:15 -0700 Subject: [PATCH 2/7] much better layouts --- .../(no-sidebar)/devtools/devtools-client.tsx | 251 +++++++++++++++--- docs/site/package.json | 2 - pnpm-lock.yaml | 24 -- 3 files changed, 211 insertions(+), 66 deletions(-) diff --git a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx index 63763496983e6..c7e54a562f5a0 100644 --- a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx +++ b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx @@ -21,7 +21,6 @@ import { type Edge, type NodeMouseHandler, } from "reactflow"; -import Dagre from "@dagrejs/dagre"; import { Package } from "lucide-react"; import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock"; import { createCssVariablesTheme } from "shiki"; @@ -102,21 +101,125 @@ const defaultEdgeOptions = { // Constants for node sizing const NODE_HEIGHT = 70; -const NODE_PADDING_X = 60; // Padding for icon, margins, and handle areas const MIN_NODE_WIDTH = 150; -const CHAR_WIDTH = 9.6; // Approximate character width for "Fira Mono" at 16px -const SUBTITLE_CHAR_WIDTH = 7.2; // Approximate character width at 12px -const NODE_SPACING = 50; // Consistent spacing between nodes +const MAX_NODE_WIDTH = 250; // Cap node width for layout purposes -// Calculate node width based on content +// Calculate node width based on content (capped for compact layout) function calculateNodeWidth(data: TurboNodeData): number { - const titleWidth = data.title.length * CHAR_WIDTH; - const subtitleWidth = (data.subtitle?.length ?? 0) * SUBTITLE_CHAR_WIDTH; - const contentWidth = Math.max(titleWidth, subtitleWidth); - return Math.max(MIN_NODE_WIDTH, contentWidth + NODE_PADDING_X); + // Use a simpler calculation - base width plus a bit for longer names + // Cap at MAX_NODE_WIDTH to prevent overly wide layouts + const charWidth = 8; + const padding = 50; + const titleWidth = data.title.length * charWidth + padding; + return Math.min(MAX_NODE_WIDTH, Math.max(MIN_NODE_WIDTH, titleWidth)); } -// Dagre layout function - significantly faster than ELK for DAGs +// Calculate dependency depth for each node (for vertical layering) +function calculateDepths( + nodeIds: Set, + edges: Array +): Map { + const depths = new Map(); + const incomingEdges = new Map>(); + + // Build incoming edge map (target -> sources) + for (const edge of edges) { + if (!incomingEdges.has(edge.target)) { + incomingEdges.set(edge.target, []); + } + incomingEdges.get(edge.target)!.push(edge.source); + } + + // Find root nodes (no incoming edges) + const roots: Array = []; + for (const id of nodeIds) { + if (!incomingEdges.has(id) || incomingEdges.get(id)!.length === 0) { + roots.push(id); + depths.set(id, 0); + } + } + + // BFS to calculate depths + const queue = [...roots]; + while (queue.length > 0) { + const current = queue.shift()!; + const currentDepth = depths.get(current) ?? 0; + + for (const edge of edges) { + if (edge.source === current) { + const targetDepth = depths.get(edge.target); + if (targetDepth === undefined || targetDepth < currentDepth + 1) { + depths.set(edge.target, currentDepth + 1); + queue.push(edge.target); + } + } + } + } + + // Handle any disconnected nodes + for (const id of nodeIds) { + if (!depths.has(id)) { + depths.set(id, 0); + } + } + + return depths; +} + +// Calculate total width of a row of nodes +function calculateRowWidth( + nodesInRow: Array<{ node: Node; width: number }>, + horizontalSpacing: number +): number { + return nodesInRow.reduce( + (sum, n) => sum + n.width + horizontalSpacing, + -horizontalSpacing + ); +} + +// Split nodes into sub-rows such that no sub-row exceeds maxWidth +function splitIntoSubRows( + nodesAtDepth: Array<{ node: Node; width: number }>, + maxWidth: number, + horizontalSpacing: number +): Array; width: number }>> { + if (nodesAtDepth.length === 0) return []; + + const subRows: Array; width: number }>> = + []; + let currentRow: Array<{ node: Node; width: number }> = []; + let currentRowWidth = 0; + + for (const nodeInfo of nodesAtDepth) { + const nodeWidthWithSpacing = + nodeInfo.width + (currentRow.length > 0 ? horizontalSpacing : 0); + + // If adding this node would exceed maxWidth and we have at least one node, + // start a new sub-row + if ( + currentRowWidth + nodeWidthWithSpacing > maxWidth && + currentRow.length > 0 + ) { + subRows.push(currentRow); + currentRow = [nodeInfo]; + currentRowWidth = nodeInfo.width; + } else { + currentRow.push(nodeInfo); + currentRowWidth += nodeWidthWithSpacing; + } + } + + // Don't forget the last row + if (currentRow.length > 0) { + subRows.push(currentRow); + } + + return subRows; +} + +// Simple manual layout - positions nodes by depth with no overlap +// Rows at depth N+1 cannot exceed 1.5x the width of depth N; if they would, +// they are split into multiple sub-rows function getLayoutedElements( nodes: Array>, edges: Array @@ -125,45 +228,113 @@ function getLayoutedElements( return { nodes: [], edges: [] }; } - // Create a new directed graph - const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + const nodeIds = new Set(nodes.map((n) => n.id)); + const depths = calculateDepths(nodeIds, edges); - // Configure the graph layout - g.setGraph({ - rankdir: "TB", // Top to bottom (equivalent to ELK's DOWN direction) - nodesep: NODE_SPACING, // Horizontal spacing between nodes - ranksep: 150, // Vertical spacing between layers - marginx: 50, - marginy: 50, - }); - - // Add nodes with their dimensions + // Group nodes by depth + const nodesByDepth = new Map< + number, + Array<{ node: Node; width: number }> + >(); for (const node of nodes) { - const width = calculateNodeWidth(node.data); - g.setNode(node.id, { width, height: NODE_HEIGHT }); + const depth = depths.get(node.id) ?? 0; + if (!nodesByDepth.has(depth)) { + nodesByDepth.set(depth, []); + } + nodesByDepth.get(depth)!.push({ + node, + width: calculateNodeWidth(node.data), + }); } - // Add edges - for (const edge of edges) { - g.setEdge(edge.source, edge.target); + // Layout constants + const verticalSpacing = NODE_HEIGHT + 40; + const horizontalSpacing = 80; // Extra spacing to account for node borders/shadows + const widthMultiplier = 1.75; // Max width ratio compared to reference width + const minBaselineWidth = 1000; // Minimum width baseline to prevent over-constraining small graphs + + // Get sorted depth levels (ascending: 0, 1, 2, ...) + const sortedDepths = Array.from(nodesByDepth.keys()).sort((a, b) => a - b); + + // First pass: calculate max allowed widths by going from deepest to shallowest + // The deepest level (highest depth number) has no constraint, and each level + // above it is constrained to 1.5x the level below it + const maxWidthByDepth = new Map(); + let nextRowWidth = 0; // Width of the row "below" (higher depth number) + + for (let i = sortedDepths.length - 1; i >= 0; i--) { + const depth = sortedDepths[i]; + const nodesAtDepth = nodesByDepth.get(depth)!; + const naturalWidth = calculateRowWidth(nodesAtDepth, horizontalSpacing); + + // Max allowed is 1.5x the row below, or Infinity for the deepest level + const maxAllowedWidth = + nextRowWidth > 0 ? nextRowWidth * widthMultiplier : Infinity; + + maxWidthByDepth.set(depth, maxAllowedWidth); + + // For the next iteration (shallower depth), use the effective width + // If we split this row, use maxAllowedWidth as the reference + // Enforce minimum baseline to prevent over-constraining small graphs + const effectiveWidth = + naturalWidth > maxAllowedWidth ? maxAllowedWidth : naturalWidth; + nextRowWidth = Math.max(effectiveWidth, minBaselineWidth); } - // Run the layout algorithm - Dagre.layout(g); + // Second pass: position nodes using the calculated constraints + const positions = new Map(); + let currentY = 0; + + for (const depth of sortedDepths) { + const nodesAtDepth = nodesByDepth.get(depth)!; + const naturalWidth = calculateRowWidth(nodesAtDepth, horizontalSpacing); + const maxAllowedWidth = maxWidthByDepth.get(depth) ?? Infinity; + + // Determine if we need to split into sub-rows + let subRows: Array; width: number }>>; + if (naturalWidth > maxAllowedWidth) { + subRows = splitIntoSubRows( + nodesAtDepth, + maxAllowedWidth, + horizontalSpacing + ); + } else { + subRows = [nodesAtDepth]; + } + + // DEBUG: Log what's happening + console.log( + `Depth ${depth}: ${ + nodesAtDepth.length + } nodes, naturalWidth=${naturalWidth.toFixed(0)}, ` + + `maxAllowed=${ + maxAllowedWidth === Infinity ? "Infinity" : maxAllowedWidth.toFixed(0) + }, ` + + `split into ${subRows.length} sub-rows` + ); + + // Position each sub-row + for (const subRow of subRows) { + const subRowWidth = calculateRowWidth(subRow, horizontalSpacing); + + // Center the sub-row horizontally + let x = -subRowWidth / 2; + + for (const { node, width } of subRow) { + positions.set(node.id, { x, y: currentY }); + x += width + horizontalSpacing; + } + + currentY += verticalSpacing; + } + } - // Map the layout results back to React Flow nodes return { nodes: nodes.map((node) => { - const nodeWithPosition = g.node(node.id); - // Dagre returns center coordinates, React Flow uses top-left - // Adjust by subtracting half the width/height - const width = calculateNodeWidth(node.data); + const pos = positions.get(node.id) ?? { x: 0, y: 0 }; return { ...node, - position: { - x: (nodeWithPosition?.x ?? 0) - width / 2, - y: (nodeWithPosition?.y ?? 0) - NODE_HEIGHT / 2, - }, + position: pos, }; }), edges, @@ -945,7 +1116,7 @@ function DevtoolsContent() { // Update flow elements when graphState or view changes useEffect(() => { if (graphState) { - void updateFlowElements(graphState, view); + updateFlowElements(graphState, view); } }, [graphState, view, updateFlowElements]); diff --git a/docs/site/package.json b/docs/site/package.json index 2eef4870c2f0d..bb99c0f849852 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -22,7 +22,6 @@ "collect-examples-data": "node --experimental-strip-types ./scripts/collect-examples-data.ts" }, "dependencies": { - "@dagrejs/dagre": "^1.1.8", "@flags-sdk/vercel": "^0.1.8", "@heroicons/react": "1.0.6", "@radix-ui/react-collapsible": "1.1.3", @@ -70,7 +69,6 @@ "@turbo/eslint-config": "workspace:*", "@turbo/tsconfig": "workspace:^", "@turbo/types": "workspace:*", - "@types/dagre": "^0.7.53", "@types/mdx": "2.0.13", "@types/node": "20.11.30", "@types/react": "18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7963b707ba6f0..f041454f6286a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,9 +78,6 @@ importers: docs/site: dependencies: - '@dagrejs/dagre': - specifier: ^1.1.8 - version: 1.1.8 '@flags-sdk/vercel': specifier: ^0.1.8 version: 0.1.8(flags@4.0.2(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) @@ -217,9 +214,6 @@ importers: '@turbo/types': specifier: workspace:* version: link:../../packages/turbo-types - '@types/dagre': - specifier: ^0.7.53 - version: 0.7.53 '@types/mdx': specifier: 2.0.13 version: 2.0.13 @@ -1471,13 +1465,6 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@dagrejs/dagre@1.1.8': - resolution: {integrity: sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==} - - '@dagrejs/graphlib@2.2.4': - resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==} - engines: {node: '>17.0.0'} - '@edge-runtime/cookies@5.0.2': resolution: {integrity: sha512-Sd8LcWpZk/SWEeKGE8LT6gMm5MGfX/wm+GPnh1eBEtCpya3vYqn37wYknwAHw92ONoyyREl1hJwxV/Qx2DWNOg==} engines: {node: '>=16'} @@ -3534,9 +3521,6 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} - '@types/dagre@0.7.53': - resolution: {integrity: sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==} - '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -9722,12 +9706,6 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@dagrejs/dagre@1.1.8': - dependencies: - '@dagrejs/graphlib': 2.2.4 - - '@dagrejs/graphlib@2.2.4': {} - '@edge-runtime/cookies@5.0.2': {} '@emnapi/runtime@0.45.0': @@ -11795,8 +11773,6 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 - '@types/dagre@0.7.53': {} - '@types/debug@4.1.12': dependencies: '@types/ms': 0.7.34 From ae8e9dbf2e400e7a5b1eb701b17724d944cd83c1 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 17 Dec 2025 12:32:21 -0700 Subject: [PATCH 3/7] hype --- Cargo.lock | 2 - crates/turborepo-devtools/Cargo.toml | 4 - crates/turborepo-devtools/src/graph.rs | 308 +----------------- crates/turborepo-devtools/src/server.rs | 54 ++- crates/turborepo-devtools/src/types.rs | 29 ++ crates/turborepo-lib/src/commands/devtools.rs | 10 +- crates/turborepo-lib/src/devtools.rs | 187 +++++++++++ crates/turborepo-lib/src/lib.rs | 1 + 8 files changed, 264 insertions(+), 331 deletions(-) create mode 100644 crates/turborepo-lib/src/devtools.rs diff --git a/Cargo.lock b/Cargo.lock index 9d15ac769b3fb..efe0a5e22d7fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6773,8 +6773,6 @@ name = "turborepo-devtools" version = "0.1.0" dependencies = [ "axum 0.7.5", - "biome_json_parser", - "biome_json_syntax", "futures", "ignore", "notify", diff --git a/crates/turborepo-devtools/Cargo.toml b/crates/turborepo-devtools/Cargo.toml index 3bfd66ad89589..6dfce83b0c759 100644 --- a/crates/turborepo-devtools/Cargo.toml +++ b/crates/turborepo-devtools/Cargo.toml @@ -20,10 +20,6 @@ tower-http = { version = "0.5.2", features = ["cors"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -# JSON/JSONC parsing -biome_json_parser = { workspace = true } -biome_json_syntax = { workspace = true } - # File watching ignore = "0.4.22" notify = { workspace = true } diff --git a/crates/turborepo-devtools/src/graph.rs b/crates/turborepo-devtools/src/graph.rs index 2e836626e001b..c8e7643303fa4 100644 --- a/crates/turborepo-devtools/src/graph.rs +++ b/crates/turborepo-devtools/src/graph.rs @@ -3,129 +3,15 @@ //! Converts the internal PackageGraph (petgraph-based) to our //! serializable PackageGraphData format for sending over WebSocket. -use std::collections::HashSet; - -use biome_json_parser::JsonParserOptions; -use biome_json_syntax::JsonRoot; -use tracing::debug; -use turbopath::AbsoluteSystemPath; use turborepo_repository::package_graph::{ PackageGraph, PackageName, PackageNode as RepoPackageNode, }; -use crate::types::{GraphEdge, PackageGraphData, PackageNode, TaskGraphData, TaskNode}; +use crate::types::{GraphEdge, PackageGraphData, PackageNode}; /// Identifier used for the root package in the graph pub const ROOT_PACKAGE_ID: &str = "__ROOT__"; -/// Reads task names from turbo.json at the repository root. -/// Returns a set of task names (without package prefixes like "build", not -/// "pkg#build"). Returns an empty set if turbo.json cannot be read or parsed. -pub fn read_pipeline_tasks(repo_root: &AbsoluteSystemPath) -> HashSet { - let turbo_json_path = repo_root.join_component("turbo.json"); - let turbo_jsonc_path = repo_root.join_component("turbo.jsonc"); - - // Try turbo.json first, then turbo.jsonc - let contents = turbo_json_path - .read_to_string() - .or_else(|_| turbo_jsonc_path.read_to_string()); - - match contents { - Ok(contents) => parse_pipeline_tasks(&contents), - Err(e) => { - debug!("Could not read turbo.json: {}", e); - HashSet::new() - } - } -} - -/// Parses turbo.json content and extracts task names. -/// Task names like "build" or "pkg#build" are normalized to just the task part. -fn parse_pipeline_tasks(contents: &str) -> HashSet { - // Use Biome's JSONC parser which handles comments natively - let parse_result = - biome_json_parser::parse_json(contents, JsonParserOptions::default().with_allow_comments()); - - if parse_result.has_errors() { - debug!( - "Failed to parse turbo.json: {:?}", - parse_result.diagnostics() - ); - return HashSet::new(); - } - - let root: JsonRoot = parse_result.tree(); - - // Navigate to the "tasks" object and extract its keys - extract_task_keys_from_json(&root) -} - -/// Extracts task keys from a parsed JSON root. -/// Returns task names normalized (without package prefixes). -fn extract_task_keys_from_json(root: &JsonRoot) -> HashSet { - use biome_json_syntax::AnyJsonValue; - - // Get the root value (should be an object) - let Some(value) = root.value().ok() else { - return HashSet::new(); - }; - - let AnyJsonValue::JsonObjectValue(obj) = value else { - return HashSet::new(); - }; - - // Find the "tasks" member - for member in obj.json_member_list() { - let Ok(member) = member else { continue }; - let Ok(name) = member.name() else { continue }; - - if get_member_name_text(&name) == "tasks" { - let Ok(tasks_value) = member.value() else { - continue; - }; - - if let AnyJsonValue::JsonObjectValue(tasks_obj) = tasks_value { - let mut task_names = HashSet::new(); - extract_keys_from_object(&tasks_obj, &mut task_names); - return task_names; - } - } - } - - HashSet::new() -} - -/// Helper to get the text content of a JSON member name -fn get_member_name_text(name: &biome_json_syntax::JsonMemberName) -> String { - // The name is a string literal, we need to extract the text without quotes - name.inner_string_text() - .map(|t| t.to_string()) - .unwrap_or_default() -} - -/// Extracts keys from a JSON object and normalizes task names -fn extract_keys_from_object( - obj: &biome_json_syntax::JsonObjectValue, - task_names: &mut HashSet, -) { - for member in obj.json_member_list() { - let Ok(member) = member else { continue }; - let Ok(name) = member.name() else { continue }; - - let task_name = get_member_name_text(&name); - - // Strip package prefix if present (e.g., "pkg#build" -> "build") - // Also handle root tasks like "//#build" -> "build" - let normalized = if let Some(pos) = task_name.find('#') { - task_name[pos + 1..].to_string() - } else { - task_name - }; - - task_names.insert(normalized); - } -} - /// Converts a PackageGraph to our serializable PackageGraphData format. pub fn package_graph_to_data(pkg_graph: &PackageGraph) -> PackageGraphData { let mut nodes = Vec::new(); @@ -183,93 +69,6 @@ pub fn package_graph_to_data(pkg_graph: &PackageGraph) -> PackageGraphData { PackageGraphData { nodes, edges } } -/// Converts a PackageGraph to a task-level graph. -/// -/// Creates a node for each package#script combination found in the monorepo. -/// Edges are created based on package dependencies - if package A depends on -/// package B, then for tasks defined in `pipeline_tasks`, A#task depends on -/// B#task. -/// -/// The `pipeline_tasks` parameter should contain task names from turbo.json's -/// tasks configuration. Use `read_pipeline_tasks` to obtain these from the -/// repository's turbo.json file. -pub fn task_graph_to_data( - pkg_graph: &PackageGraph, - pipeline_tasks: &HashSet, -) -> TaskGraphData { - let mut nodes = Vec::new(); - let mut edges = Vec::new(); - - // First pass: collect all tasks and create nodes - for (name, info) in pkg_graph.packages() { - let package_id = match name { - PackageName::Root => ROOT_PACKAGE_ID.to_string(), - PackageName::Other(n) => n.clone(), - }; - - for (script_name, script_cmd) in info.package_json.scripts.iter() { - let task_id = format!("{}#{}", package_id, script_name); - nodes.push(TaskNode { - id: task_id, - package: package_id.clone(), - task: script_name.clone(), - script: script_cmd.value.clone(), - }); - } - } - - // Second pass: create edges based on package dependencies - // For tasks defined in turbo.json, if package A depends on package B, - // then A#task -> B#task - for (name, info) in pkg_graph.packages() { - let package_id = match name { - PackageName::Root => ROOT_PACKAGE_ID.to_string(), - PackageName::Other(n) => n.clone(), - }; - - let pkg_node = RepoPackageNode::Workspace(name.clone()); - - if let Some(deps) = pkg_graph.immediate_dependencies(&pkg_node) { - for dep in deps { - // Skip the synthetic Root node - if matches!(dep, RepoPackageNode::Root) { - continue; - } - - let dep_id = match dep { - RepoPackageNode::Root => continue, - RepoPackageNode::Workspace(dep_name) => match dep_name { - PackageName::Root => ROOT_PACKAGE_ID.to_string(), - PackageName::Other(n) => n.clone(), - }, - }; - - // Get scripts from the dependency package - let dep_info = match dep { - RepoPackageNode::Root => continue, - RepoPackageNode::Workspace(dep_name) => pkg_graph.package_info(dep_name), - }; - - if let Some(dep_info) = dep_info { - // For pipeline tasks that exist in both packages, create edges - for script in info.package_json.scripts.keys() { - if pipeline_tasks.contains(script) - && dep_info.package_json.scripts.contains_key(script) - { - edges.push(GraphEdge { - source: format!("{}#{}", package_id, script), - target: format!("{}#{}", dep_id, script), - }); - } - } - } - } - } - } - - TaskGraphData { nodes, edges } -} - #[cfg(test)] mod tests { use super::*; @@ -278,109 +77,4 @@ mod tests { fn test_root_package_id() { assert_eq!(ROOT_PACKAGE_ID, "__ROOT__"); } - - #[test] - fn test_parse_pipeline_tasks_basic() { - let turbo_json = r#" - { - "tasks": { - "build": {}, - "test": {}, - "lint": {} - } - } - "#; - let tasks = parse_pipeline_tasks(turbo_json); - assert!(tasks.contains("build")); - assert!(tasks.contains("test")); - assert!(tasks.contains("lint")); - assert_eq!(tasks.len(), 3); - } - - #[test] - fn test_parse_pipeline_tasks_with_package_prefix() { - let turbo_json = r#" - { - "tasks": { - "build": {}, - "web#build": {}, - "//#test": {} - } - } - "#; - let tasks = parse_pipeline_tasks(turbo_json); - // Both "build" and "web#build" should normalize to "build" - assert!(tasks.contains("build")); - assert!(tasks.contains("test")); - // Should only have 2 unique task names after normalization - assert_eq!(tasks.len(), 2); - } - - #[test] - fn test_parse_pipeline_tasks_with_comments() { - let turbo_json = r#" - { - // This is a comment - "tasks": { - "build": {}, /* inline comment */ - "compile": {} - } - } - "#; - let tasks = parse_pipeline_tasks(turbo_json); - assert!(tasks.contains("build")); - assert!(tasks.contains("compile")); - assert_eq!(tasks.len(), 2); - } - - #[test] - fn test_parse_pipeline_tasks_empty() { - let turbo_json = r#" - { - "tasks": {} - } - "#; - let tasks = parse_pipeline_tasks(turbo_json); - // Empty tasks object should return empty set - assert!(tasks.is_empty()); - } - - #[test] - fn test_parse_pipeline_tasks_no_tasks_key() { - let turbo_json = r#" - { - "globalEnv": ["NODE_ENV"] - } - "#; - let tasks = parse_pipeline_tasks(turbo_json); - // No tasks key should return empty set - assert!(tasks.is_empty()); - } - - #[test] - fn test_parse_pipeline_tasks_invalid_json() { - let turbo_json = r#"{ invalid json }"#; - let tasks = parse_pipeline_tasks(turbo_json); - // Invalid JSON should return empty set - assert!(tasks.is_empty()); - } - - #[test] - fn test_parse_pipeline_tasks_custom_tasks() { - let turbo_json = r#" - { - "tasks": { - "compile": {}, - "bundle": {}, - "deploy": {} - } - } - "#; - let tasks = parse_pipeline_tasks(turbo_json); - assert!(tasks.contains("compile")); - assert!(tasks.contains("bundle")); - assert!(tasks.contains("deploy")); - // Should NOT contain defaults since we found tasks - assert!(!tasks.contains("lint")); - } } diff --git a/crates/turborepo-devtools/src/server.rs b/crates/turborepo-devtools/src/server.rs index 443b957a8a8f1..e54e664880011 100644 --- a/crates/turborepo-devtools/src/server.rs +++ b/crates/turborepo-devtools/src/server.rs @@ -27,8 +27,8 @@ use turbopath::AbsoluteSystemPathBuf; use turborepo_repository::{package_graph::PackageGraphBuilder, package_json::PackageJson}; use crate::{ - graph::{package_graph_to_data, read_pipeline_tasks, task_graph_to_data}, - types::{GraphState, ServerMessage}, + graph::package_graph_to_data, + types::{GraphState, ServerMessage, TaskGraphBuilder}, watcher::{DevtoolsWatcher, WatchEvent}, }; @@ -53,6 +53,9 @@ pub enum ServerError { #[error("File watcher error: {0}")] Watcher(#[from] crate::watcher::WatchError), + + #[error("Failed to build task graph: {0}")] + TaskGraph(String), } /// Shared state for the WebSocket server @@ -65,15 +68,24 @@ struct AppState { } /// The devtools WebSocket server -pub struct DevtoolsServer { +pub struct DevtoolsServer { repo_root: AbsoluteSystemPathBuf, port: u16, + task_graph_builder: T, } -impl DevtoolsServer { - /// Creates a new devtools server for the given repository - pub fn new(repo_root: AbsoluteSystemPathBuf, port: u16) -> Self { - Self { repo_root, port } +impl DevtoolsServer { + /// Creates a new devtools server with a task graph builder. + /// + /// The task graph builder should use the same logic as `turbo run` + /// to ensure consistency between what the devtools shows and what + /// turbo actually executes. + pub fn new(repo_root: AbsoluteSystemPathBuf, port: u16, task_graph_builder: T) -> Self { + Self { + repo_root, + port, + task_graph_builder, + } } /// Returns the port the server will listen on @@ -84,7 +96,8 @@ impl DevtoolsServer { /// Run the server until shutdown pub async fn run(self) -> Result<(), ServerError> { // Build initial graph state - let initial_state = build_graph_state(&self.repo_root).await?; + let initial_state = + build_graph_state(&self.repo_root, &self.task_graph_builder).await?; let graph_state = Arc::new(RwLock::new(initial_state)); let (update_tx, _) = broadcast::channel::<()>(16); @@ -96,12 +109,16 @@ impl DevtoolsServer { let graph_state_clone = graph_state.clone(); let update_tx_clone = update_tx.clone(); let repo_root_clone = self.repo_root.clone(); + let task_graph_builder = Arc::new(self.task_graph_builder); + let task_graph_builder_clone = task_graph_builder.clone(); tokio::spawn(async move { while let Ok(event) = watch_rx.recv().await { match event { WatchEvent::FilesChanged => { info!("Files changed, rebuilding graph..."); - match build_graph_state(&repo_root_clone).await { + match build_graph_state(&repo_root_clone, task_graph_builder_clone.as_ref()) + .await + { Ok(new_state) => { *graph_state_clone.write().await = new_state; // Notify all connected clients @@ -236,7 +253,10 @@ async fn handle_socket(socket: WebSocket, state: AppState) { } /// Build the current graph state from the repository -async fn build_graph_state(repo_root: &AbsoluteSystemPathBuf) -> Result { +async fn build_graph_state( + repo_root: &AbsoluteSystemPathBuf, + task_graph_builder: &dyn TaskGraphBuilder, +) -> Result { // Load root package.json let root_package_json_path = repo_root.join_component("package.json"); let root_package_json = PackageJson::load(&root_package_json_path) @@ -251,12 +271,14 @@ async fn build_graph_state(repo_root: &AbsoluteSystemPathBuf) -> Result Result Pin> + Send + '_>>; +} diff --git a/crates/turborepo-lib/src/commands/devtools.rs b/crates/turborepo-lib/src/commands/devtools.rs index 85ab9b2bd7190..55a0bc4d75e68 100644 --- a/crates/turborepo-lib/src/commands/devtools.rs +++ b/crates/turborepo-lib/src/commands/devtools.rs @@ -6,7 +6,7 @@ use turbopath::AbsoluteSystemPathBuf; use turborepo_devtools::{find_available_port, DevtoolsServer}; -use crate::cli; +use crate::{cli, devtools::ProperTaskGraphBuilder}; // In production, use the hosted devtools UI // For local development, set TURBO_DEVTOOLS_LOCAL=1 to use localhost:3000 @@ -25,8 +25,12 @@ pub async fn run( // Find available port let port = find_available_port(port); - // Create server - let server = DevtoolsServer::new(repo_root, port); + // Create the task graph builder that uses EngineBuilder + // This ensures the devtools shows the same task graph as `turbo run` + let task_graph_builder = ProperTaskGraphBuilder::new(repo_root.clone()); + + // Create server with the task graph builder + let server = DevtoolsServer::new(repo_root, port, task_graph_builder); let url = format!("{}?port={}", DEVTOOLS_URL, port); diff --git a/crates/turborepo-lib/src/devtools.rs b/crates/turborepo-lib/src/devtools.rs new file mode 100644 index 0000000000000..0307f0e1316eb --- /dev/null +++ b/crates/turborepo-lib/src/devtools.rs @@ -0,0 +1,187 @@ +//! Devtools integration for turborepo-lib. +//! +//! This module provides the proper task graph building implementation +//! for the devtools server, using the same logic as `turbo run`. + +use std::future::Future; +use std::pin::Pin; + +use tracing::debug; +use turbopath::AbsoluteSystemPathBuf; +use turborepo_devtools::{GraphEdge, TaskGraphBuilder, TaskGraphData, TaskGraphError, TaskNode}; +use turborepo_repository::{ + package_graph::{PackageGraph, PackageGraphBuilder, PackageName}, + package_json::PackageJson, +}; +use turborepo_task_id::TaskName; + +use crate::{ + config::CONFIG_FILE, + engine::{EngineBuilder, TaskNode as EngineTaskNode}, + turbo_json::{TurboJsonLoader, TurboJsonReader}, +}; + +/// Task graph builder that uses the proper `EngineBuilder` logic. +/// +/// This implementation builds task graphs using the same logic as `turbo run`, +/// ensuring consistency between what the devtools shows and what turbo actually +/// executes. +pub struct ProperTaskGraphBuilder { + repo_root: AbsoluteSystemPathBuf, +} + +impl ProperTaskGraphBuilder { + /// Create a new proper task graph builder + pub fn new(repo_root: AbsoluteSystemPathBuf) -> Self { + Self { repo_root } + } + + /// Build the package graph for the repository + async fn build_package_graph(&self) -> Result { + let root_package_json_path = self.repo_root.join_component("package.json"); + let root_package_json = PackageJson::load(&root_package_json_path) + .map_err(|e| TaskGraphError::BuildError(format!("Failed to load package.json: {e}")))?; + + PackageGraphBuilder::new(&self.repo_root, root_package_json) + .with_allow_no_package_manager(true) + .build() + .await + .map_err(|e| TaskGraphError::BuildError(format!("Failed to build package graph: {e}"))) + } + + /// Build the task graph using EngineBuilder + fn build_engine_task_graph( + &self, + pkg_graph: &PackageGraph, + ) -> Result { + // Create turbo json loader + let root_turbo_json_path = self.repo_root.join_component(CONFIG_FILE); + let reader = TurboJsonReader::new(self.repo_root.clone()); + let loader = TurboJsonLoader::workspace( + reader, + root_turbo_json_path.clone(), + pkg_graph.packages(), + ); + + // Determine if this is a single package repo + let is_single = pkg_graph.len() == 1; + + // Collect all workspaces + let workspaces: Vec = pkg_graph + .packages() + .map(|(name, _)| name.clone()) + .collect(); + + // Collect all root tasks from turbo.json AND root package.json scripts + // For devtools, we want to show all tasks including root tasks + let mut root_tasks: Vec> = loader + .load(&PackageName::Root) + .map(|turbo_json| { + turbo_json + .tasks + .keys() + .map(|name| name.clone().into_owned()) + .collect() + }) + .unwrap_or_default(); + + // Also add all scripts from root package.json as potential root tasks + // This ensures tasks like //#build:ts are allowed even if not in turbo.json + if let Some(root_pkg_json) = pkg_graph.package_json(&PackageName::Root) { + for script_name in root_pkg_json.scripts.keys() { + let task_name = TaskName::from(format!("//#{}",script_name)).into_owned(); + if !root_tasks.contains(&task_name) { + root_tasks.push(task_name); + } + } + } + + // Build engine with all tasks + // We use `add_all_tasks` to get the complete task graph for visualization + let engine = EngineBuilder::new(&self.repo_root, pkg_graph, &loader, is_single) + .with_workspaces(workspaces) + .with_root_tasks(root_tasks) + .add_all_tasks() + .do_not_validate_engine() // Don't validate for devtools visualization + .build() + .map_err(|e| { + TaskGraphError::BuildError(format!("Failed to build task graph: {e}")) + })?; + + // Convert engine to TaskGraphData + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + + // Collect task nodes + for task_node in engine.tasks() { + match task_node { + EngineTaskNode::Root => { + // Skip the synthetic root node in the output + } + EngineTaskNode::Task(task_id) => { + let package = task_id.package().to_string(); + let task = task_id.task().to_string(); + let id = task_id.to_string(); + + // Get script from package.json + let script = pkg_graph + .package_json(&PackageName::from(task_id.package())) + .and_then(|pj| pj.scripts.get(task_id.task())) + .map(|s| s.value.clone()) + .unwrap_or_default(); + + nodes.push(TaskNode { + id, + package, + task, + script, + }); + } + } + } + + // Collect edges from dependencies + for task_node in engine.tasks() { + if let EngineTaskNode::Task(task_id) = task_node { + if let Some(deps) = engine.dependencies(task_id) { + for dep in deps { + if let EngineTaskNode::Task(dep_id) = dep { + edges.push(GraphEdge { + source: task_id.to_string(), + target: dep_id.to_string(), + }); + } + // Skip edges to Root node + } + } + } + } + + debug!( + "Built task graph with {} nodes and {} edges", + nodes.len(), + edges.len() + ); + + Ok(TaskGraphData { nodes, edges }) + } +} + +impl TaskGraphBuilder for ProperTaskGraphBuilder { + fn build_task_graph( + &self, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let pkg_graph = self.build_package_graph().await?; + self.build_engine_task_graph(&pkg_graph) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Tests would go here - we can verify that the ProperTaskGraphBuilder + // produces the same results as a real turbo run would +} diff --git a/crates/turborepo-lib/src/lib.rs b/crates/turborepo-lib/src/lib.rs index 65b8ed29b9819..0784b7e0fc37e 100644 --- a/crates/turborepo-lib/src/lib.rs +++ b/crates/turborepo-lib/src/lib.rs @@ -18,6 +18,7 @@ mod cli; mod commands; mod config; mod daemon; +pub mod devtools; mod diagnostics; mod engine; From 0b038bbc5e8c508594ce13b7a5c9284653f4820b Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 17 Dec 2025 12:32:47 -0700 Subject: [PATCH 4/7] hype --- crates/turborepo-devtools/src/server.rs | 8 +++----- crates/turborepo-devtools/src/types.rs | 3 +-- crates/turborepo-lib/src/devtools.rs | 22 +++++++--------------- 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/crates/turborepo-devtools/src/server.rs b/crates/turborepo-devtools/src/server.rs index e54e664880011..d1e0cb1316dd6 100644 --- a/crates/turborepo-devtools/src/server.rs +++ b/crates/turborepo-devtools/src/server.rs @@ -96,8 +96,7 @@ impl DevtoolsServer { /// Run the server until shutdown pub async fn run(self) -> Result<(), ServerError> { // Build initial graph state - let initial_state = - build_graph_state(&self.repo_root, &self.task_graph_builder).await?; + let initial_state = build_graph_state(&self.repo_root, &self.task_graph_builder).await?; let graph_state = Arc::new(RwLock::new(initial_state)); let (update_tx, _) = broadcast::channel::<()>(16); @@ -274,7 +273,8 @@ async fn build_graph_state( // Convert package graph to serializable format let package_graph = package_graph_to_data(&pkg_graph); - // Build task graph using the provided builder (which uses proper turbo run logic) + // Build task graph using the provided builder (which uses proper turbo run + // logic) let task_graph = task_graph_builder .build_task_graph() .await @@ -287,5 +287,3 @@ async fn build_graph_state( turbo_version: env!("CARGO_PKG_VERSION").to_string(), }) } - - diff --git a/crates/turborepo-devtools/src/types.rs b/crates/turborepo-devtools/src/types.rs index a795c261272a1..512302672b122 100644 --- a/crates/turborepo-devtools/src/types.rs +++ b/crates/turborepo-devtools/src/types.rs @@ -3,8 +3,7 @@ //! These types define the messages exchanged between the CLI server //! and the web client, as well as the graph data structures. -use std::future::Future; -use std::pin::Pin; +use std::{future::Future, pin::Pin}; use serde::{Deserialize, Serialize}; diff --git a/crates/turborepo-lib/src/devtools.rs b/crates/turborepo-lib/src/devtools.rs index 0307f0e1316eb..0fde10c754eb8 100644 --- a/crates/turborepo-lib/src/devtools.rs +++ b/crates/turborepo-lib/src/devtools.rs @@ -3,8 +3,7 @@ //! This module provides the proper task graph building implementation //! for the devtools server, using the same logic as `turbo run`. -use std::future::Future; -use std::pin::Pin; +use std::{future::Future, pin::Pin}; use tracing::debug; use turbopath::AbsoluteSystemPathBuf; @@ -57,20 +56,15 @@ impl ProperTaskGraphBuilder { // Create turbo json loader let root_turbo_json_path = self.repo_root.join_component(CONFIG_FILE); let reader = TurboJsonReader::new(self.repo_root.clone()); - let loader = TurboJsonLoader::workspace( - reader, - root_turbo_json_path.clone(), - pkg_graph.packages(), - ); + let loader = + TurboJsonLoader::workspace(reader, root_turbo_json_path.clone(), pkg_graph.packages()); // Determine if this is a single package repo let is_single = pkg_graph.len() == 1; // Collect all workspaces - let workspaces: Vec = pkg_graph - .packages() - .map(|(name, _)| name.clone()) - .collect(); + let workspaces: Vec = + pkg_graph.packages().map(|(name, _)| name.clone()).collect(); // Collect all root tasks from turbo.json AND root package.json scripts // For devtools, we want to show all tasks including root tasks @@ -89,7 +83,7 @@ impl ProperTaskGraphBuilder { // This ensures tasks like //#build:ts are allowed even if not in turbo.json if let Some(root_pkg_json) = pkg_graph.package_json(&PackageName::Root) { for script_name in root_pkg_json.scripts.keys() { - let task_name = TaskName::from(format!("//#{}",script_name)).into_owned(); + let task_name = TaskName::from(format!("//#{}", script_name)).into_owned(); if !root_tasks.contains(&task_name) { root_tasks.push(task_name); } @@ -104,9 +98,7 @@ impl ProperTaskGraphBuilder { .add_all_tasks() .do_not_validate_engine() // Don't validate for devtools visualization .build() - .map_err(|e| { - TaskGraphError::BuildError(format!("Failed to build task graph: {e}")) - })?; + .map_err(|e| TaskGraphError::BuildError(format!("Failed to build task graph: {e}")))?; // Convert engine to TaskGraphData let mut nodes = Vec::new(); From aa0283bea2425457e8670c28ff92b880beec2a9a Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 17 Dec 2025 12:40:04 -0700 Subject: [PATCH 5/7] account for jsonc --- crates/turborepo-lib/src/devtools.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/turborepo-lib/src/devtools.rs b/crates/turborepo-lib/src/devtools.rs index 0fde10c754eb8..f78e4f407e9e0 100644 --- a/crates/turborepo-lib/src/devtools.rs +++ b/crates/turborepo-lib/src/devtools.rs @@ -15,7 +15,7 @@ use turborepo_repository::{ use turborepo_task_id::TaskName; use crate::{ - config::CONFIG_FILE, + config::resolve_turbo_config_path, engine::{EngineBuilder, TaskNode as EngineTaskNode}, turbo_json::{TurboJsonLoader, TurboJsonReader}, }; @@ -54,7 +54,8 @@ impl ProperTaskGraphBuilder { pkg_graph: &PackageGraph, ) -> Result { // Create turbo json loader - let root_turbo_json_path = self.repo_root.join_component(CONFIG_FILE); + let root_turbo_json_path = resolve_turbo_config_path(&self.repo_root) + .map_err(|e| TaskGraphError::BuildError(format!("{e}")))?; let reader = TurboJsonReader::new(self.repo_root.clone()); let loader = TurboJsonLoader::workspace(reader, root_turbo_json_path.clone(), pkg_graph.packages()); From 70adc7aadf91a91d9d746218074fc92f4ec1f0c7 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 17 Dec 2025 12:41:34 -0700 Subject: [PATCH 6/7] WIP a1f09 --- .../app/(no-sidebar)/devtools/devtools-client.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx index c7e54a562f5a0..aee42a93c06a8 100644 --- a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx +++ b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx @@ -302,17 +302,6 @@ function getLayoutedElements( subRows = [nodesAtDepth]; } - // DEBUG: Log what's happening - console.log( - `Depth ${depth}: ${ - nodesAtDepth.length - } nodes, naturalWidth=${naturalWidth.toFixed(0)}, ` + - `maxAllowed=${ - maxAllowedWidth === Infinity ? "Infinity" : maxAllowedWidth.toFixed(0) - }, ` + - `split into ${subRows.length} sub-rows` - ); - // Position each sub-row for (const subRow of subRows) { const subRowWidth = calculateRowWidth(subRow, horizontalSpacing); From b8adde093b11b102de6f589b142362573c7f57b4 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 17 Dec 2025 12:47:05 -0700 Subject: [PATCH 7/7] WIP 99be7 --- .../(no-sidebar)/devtools/devtools-client.tsx | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx index aee42a93c06a8..1843256ed2204 100644 --- a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx +++ b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx @@ -124,16 +124,19 @@ function calculateDepths( // Build incoming edge map (target -> sources) for (const edge of edges) { - if (!incomingEdges.has(edge.target)) { - incomingEdges.set(edge.target, []); + const existing = incomingEdges.get(edge.target); + if (existing) { + existing.push(edge.source); + } else { + incomingEdges.set(edge.target, [edge.source]); } - incomingEdges.get(edge.target)!.push(edge.source); } // Find root nodes (no incoming edges) const roots: Array = []; for (const id of nodeIds) { - if (!incomingEdges.has(id) || incomingEdges.get(id)!.length === 0) { + const incoming = incomingEdges.get(id); + if (!incoming || incoming.length === 0) { roots.push(id); depths.set(id, 0); } @@ -142,7 +145,8 @@ function calculateDepths( // BFS to calculate depths const queue = [...roots]; while (queue.length > 0) { - const current = queue.shift()!; + const current = queue.shift(); + if (current === undefined) continue; const currentDepth = depths.get(current) ?? 0; for (const edge of edges) { @@ -238,13 +242,13 @@ function getLayoutedElements( >(); for (const node of nodes) { const depth = depths.get(node.id) ?? 0; - if (!nodesByDepth.has(depth)) { - nodesByDepth.set(depth, []); + const existing = nodesByDepth.get(depth); + const nodeInfo = { node, width: calculateNodeWidth(node.data) }; + if (existing) { + existing.push(nodeInfo); + } else { + nodesByDepth.set(depth, [nodeInfo]); } - nodesByDepth.get(depth)!.push({ - node, - width: calculateNodeWidth(node.data), - }); } // Layout constants @@ -264,7 +268,7 @@ function getLayoutedElements( for (let i = sortedDepths.length - 1; i >= 0; i--) { const depth = sortedDepths[i]; - const nodesAtDepth = nodesByDepth.get(depth)!; + const nodesAtDepth = nodesByDepth.get(depth) ?? []; const naturalWidth = calculateRowWidth(nodesAtDepth, horizontalSpacing); // Max allowed is 1.5x the row below, or Infinity for the deepest level @@ -286,7 +290,7 @@ function getLayoutedElements( let currentY = 0; for (const depth of sortedDepths) { - const nodesAtDepth = nodesByDepth.get(depth)!; + const nodesAtDepth = nodesByDepth.get(depth) ?? []; const naturalWidth = calculateRowWidth(nodesAtDepth, horizontalSpacing); const maxAllowedWidth = maxWidthByDepth.get(depth) ?? Infinity;