import { Component, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ErrorInfo, ReactNode } from 'react' import { ReactFlow, Background, Controls, MiniMap, useNodesState, useEdgesState, Node, Edge, Position, MarkerType, ConnectionMode, Handle, } from '@xyflow/react' import dagre from 'dagre' import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw } from 'lucide-react' import type { DependencyGraph as DependencyGraphData, GraphNode } from '../lib/types' import '@xyflow/react/dist/style.css' // Node dimensions const NODE_WIDTH = 220 const NODE_HEIGHT = 80 interface DependencyGraphProps { graphData: DependencyGraphData onNodeClick?: (nodeId: number) => void } // Error boundary to catch and recover from ReactFlow rendering errors interface ErrorBoundaryProps { children: ReactNode onReset?: () => void } interface ErrorBoundaryState { hasError: boolean error: Error | null } class GraphErrorBoundary extends Component { constructor(props: ErrorBoundaryProps) { super(props) this.state = { hasError: false, error: null } } static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { hasError: true, error } } componentDidCatch(error: Error, errorInfo: ErrorInfo) { console.error('DependencyGraph error:', error, errorInfo) } handleReset = () => { this.setState({ hasError: false, error: null }) this.props.onReset?.() } render() { if (this.state.hasError) { return (
Graph rendering error
The dependency graph encountered an issue.
) } return this.props.children } } // Custom node component function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void } }) { const statusColors = { pending: 'bg-neo-pending border-neo-border', in_progress: 'bg-neo-progress border-neo-border', done: 'bg-neo-done border-neo-border', blocked: 'bg-neo-danger/20 border-neo-danger', } const StatusIcon = () => { switch (data.status) { case 'done': return case 'in_progress': return case 'blocked': return default: return } } return ( <>
#{data.priority}
{data.name}
{data.category}
) } const nodeTypes = { feature: FeatureNode, } // Layout nodes using dagre function getLayoutedElements( nodes: Node[], edges: Edge[], direction: 'TB' | 'LR' = 'LR' ): { nodes: Node[]; edges: Edge[] } { const dagreGraph = new dagre.graphlib.Graph() dagreGraph.setDefaultEdgeLabel(() => ({})) const isHorizontal = direction === 'LR' dagreGraph.setGraph({ rankdir: direction, nodesep: 50, ranksep: 100, marginx: 50, marginy: 50, }) nodes.forEach((node) => { dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }) }) edges.forEach((edge) => { dagreGraph.setEdge(edge.source, edge.target) }) dagre.layout(dagreGraph) const layoutedNodes = nodes.map((node) => { const nodeWithPosition = dagreGraph.node(node.id) return { ...node, position: { x: nodeWithPosition.x - NODE_WIDTH / 2, y: nodeWithPosition.y - NODE_HEIGHT / 2, }, sourcePosition: isHorizontal ? Position.Right : Position.Bottom, targetPosition: isHorizontal ? Position.Left : Position.Top, } }) return { nodes: layoutedNodes, edges } } function DependencyGraphInner({ graphData, onNodeClick }: DependencyGraphProps) { const [direction, setDirection] = useState<'TB' | 'LR'>('LR') // Use ref for callback to avoid triggering re-renders when callback identity changes const onNodeClickRef = useRef(onNodeClick) useEffect(() => { onNodeClickRef.current = onNodeClick }, [onNodeClick]) // Create a stable click handler that uses the ref const handleNodeClick = useCallback((nodeId: number) => { onNodeClickRef.current?.(nodeId) }, []) // Convert graph data to React Flow format // Only recalculate when graphData or direction changes (not when onNodeClick changes) const initialElements = useMemo(() => { const nodes: Node[] = graphData.nodes.map((node) => ({ id: String(node.id), type: 'feature', position: { x: 0, y: 0 }, data: { ...node, onClick: () => handleNodeClick(node.id), }, })) const edges: Edge[] = graphData.edges.map((edge, index) => ({ id: `e${edge.source}-${edge.target}-${index}`, source: String(edge.source), target: String(edge.target), type: 'smoothstep', animated: false, style: { stroke: 'var(--color-neo-border)', strokeWidth: 2 }, markerEnd: { type: MarkerType.ArrowClosed, color: 'var(--color-neo-border)', }, })) return getLayoutedElements(nodes, edges, direction) }, [graphData, direction, handleNodeClick]) const [nodes, setNodes, onNodesChange] = useNodesState(initialElements.nodes) const [edges, setEdges, onEdgesChange] = useEdgesState(initialElements.edges) // Update layout when initialElements changes // Using a ref to track previous graph data to avoid unnecessary updates const prevGraphDataRef = useRef('') const prevDirectionRef = useRef<'TB' | 'LR'>(direction) useEffect(() => { // Create a simple hash of the graph data to detect actual changes const graphHash = JSON.stringify({ nodes: graphData.nodes.map(n => ({ id: n.id, status: n.status })), edges: graphData.edges, }) // Only update if graph data or direction actually changed if (graphHash !== prevGraphDataRef.current || direction !== prevDirectionRef.current) { prevGraphDataRef.current = graphHash prevDirectionRef.current = direction const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( initialElements.nodes, initialElements.edges, direction ) setNodes(layoutedNodes) setEdges(layoutedEdges) } }, [graphData, direction, setNodes, setEdges, initialElements]) const onLayout = useCallback( (newDirection: 'TB' | 'LR') => { setDirection(newDirection) }, [] ) // Color nodes for minimap const nodeColor = useCallback((node: Node) => { const status = (node.data as unknown as GraphNode).status switch (status) { case 'done': return 'var(--color-neo-done)' case 'in_progress': return 'var(--color-neo-progress)' case 'blocked': return 'var(--color-neo-danger)' default: return 'var(--color-neo-pending)' } }, []) if (graphData.nodes.length === 0) { return (
No features to display
Create features to see the dependency graph
) } return (
{/* Layout toggle */}
{/* Legend */}
Status
Pending
In Progress
Done
Blocked
) } // Wrapper component with error boundary for stability export function DependencyGraph({ graphData, onNodeClick }: DependencyGraphProps) { // Use a key based on graph data length to force remount on structural changes // This helps recover from corrupted ReactFlow state const [resetKey, setResetKey] = useState(0) const handleReset = useCallback(() => { setResetKey(k => k + 1) }, []) return ( ) }