From f5b12949d3c0ba2ee683c20ef43bba3294a7843d Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 12 Mar 2026 17:14:33 +0100 Subject: [PATCH] refactoring --- frontend/docs/IMPROVEMENTS.md | 23 +- .../app/canvas/CanvasContextMenuContent.tsx | 62 +++++ frontend/src/app/canvas/CanvasPage.tsx | 219 ++---------------- .../src/app/canvas/useCanvasConnectionPath.ts | 174 ++++++++++++++ .../nodes/render/useRenderingNodeState.ts | 112 ++------- frontend/src/lib/graph/renderingSignatures.ts | 142 ++++++++++++ 6 files changed, 430 insertions(+), 302 deletions(-) create mode 100644 frontend/src/app/canvas/CanvasContextMenuContent.tsx create mode 100644 frontend/src/app/canvas/useCanvasConnectionPath.ts create mode 100644 frontend/src/lib/graph/renderingSignatures.ts diff --git a/frontend/docs/IMPROVEMENTS.md b/frontend/docs/IMPROVEMENTS.md index 42f5568..e0924ef 100644 --- a/frontend/docs/IMPROVEMENTS.md +++ b/frontend/docs/IMPROVEMENTS.md @@ -21,6 +21,16 @@ This doc summarizes recent improvements and suggested next steps for readability - **rendering.ts** documents the 3-step pipeline, how to add a source/output type, and points to `templateRefs.ts` for shared helpers. +### 4. Rendering signatures (pure module) + +- **Added** `lib/graph/renderingSignatures.ts`: `buildConnectedNodeIds`, `buildSourceSignatures` (connectedNodeIds + all five signatures + sourceSignature). +- **Refactored** `useRenderingNodeState` to call `buildSourceSignatures` in a single useMemo; hook is shorter and signature logic is testable in isolation. + +### 5. Canvas split + +- **CanvasContextMenuContent** (`app/canvas/CanvasContextMenuContent.tsx`): context menu content (Create Node grouped by classification, Paste). CanvasPage passes `onCreateNode` and `onPaste`. +- **useCanvasConnectionPath** (`app/canvas/useCanvasConnectionPath.ts`): all connection-path state and callbacks (updating/trigger/paused/error node ids, path node ids, start/end update, add/remove paused/error). CanvasPage calls the hook with `edges` and passes the result into FlowContext. + ## Design patterns in use | Pattern | Where | @@ -33,15 +43,8 @@ This doc summarizes recent improvements and suggested next steps for readability ## Suggested next steps -1. **CanvasPage** (~950 lines): Split into smaller units, e.g.: - - `useCanvasGraph()` or similar for graph state and connection rules. - - A dedicated component for the context menu (add node, paste, etc.). - - Keeps CanvasPage as composition + layout. +1. **CanvasPage**: Further split optional: e.g. `useCanvasGraph()` for graph state + persistence + connection rules, so the page is mostly composition and layout. -2. **useRenderingNodeState**: Consider extracting: - - Signature building (config/edges/variables/functions/data) into a pure function or small module, e.g. `buildSourceSignatures(nodes, edges, id, incomingIds, getConfigContent)`. - - Makes the hook easier to read and the logic testable in isolation. +2. **Config types**: If you add more output types (e.g. Mermaid), consider a small registry API (`registerConfigType`, `getConfigType`) instead of a single large `CONFIG_TYPES` array. -3. **Config types**: If you add more output types (e.g. Mermaid), consider a small registry API (`registerConfigType`, `getConfigType`) instead of a single large `CONFIG_TYPES` array, so extensions can register without editing the core list. - -4. **Consistent node shape in lib**: `templateRefs` uses `EdgeLike` / `NodeLike`; other graph code uses inline `{ source, target }` or `nodes as ...`. You could standardize on the same minimal types where appropriate to reduce casts. +3. **Consistent node shape in lib**: `templateRefs` and `renderingSignatures` use `EdgeLike` / `NodeLike`; standardize where appropriate to reduce casts. diff --git a/frontend/src/app/canvas/CanvasContextMenuContent.tsx b/frontend/src/app/canvas/CanvasContextMenuContent.tsx new file mode 100644 index 0000000..19809e7 --- /dev/null +++ b/frontend/src/app/canvas/CanvasContextMenuContent.tsx @@ -0,0 +1,62 @@ +/** + * Context menu content for the canvas: Create Node (grouped by classification) and Paste. + * Used inside CanvasPage so the menu structure lives in one place. + */ + +import React from 'react' +import { + ContextMenuContent, + ContextMenuItem, + ContextMenuGroup, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from '@/components/ui/context-menu' +import { ClipboardPaste } from 'lucide-react' +import { getRegisteredNodeTypesGroupedByClassification } from '@/lib/graph/nodeRegistry' + +export type CanvasContextMenuContentProps = { + onCreateNode: (type: string) => void + onPaste: () => void +} + +export function CanvasContextMenuContent({ onCreateNode, onPaste }: CanvasContextMenuContentProps) { + const groups = getRegisteredNodeTypesGroupedByClassification() + return ( + + + + Create Node + + {groups.map( + (group, groupIndex) => + group.types.length > 0 && ( + + {groupIndex > 0 && } + + + {group.label} + + {group.types.map((desc) => ( + onCreateNode(desc.id)}> + {desc.menuIcon} + {desc.menuLabel} + + ))} + + + ) + )} + + + + + + Paste + + + + ) +} diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index 965fca4..1c1ee3c 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -27,18 +27,9 @@ import FlowContext from '@/lib/graph/flowContext' import { useTheme } from '@/lib/themeContext' import { usePlatform } from '@/app/kosmos/KosmosContext' import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuLabel, - ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuTrigger, - ContextMenuGroup, -} from '@/components/ui/context-menu' +import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu' +import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent' +import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath' import { CanvasMenubar } from '@/app/canvas/CanvasMenubar' import { createContextualNode } from '@/app/canvas/ContextualZoomNode' import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts' @@ -56,17 +47,15 @@ import { DialogContent, DialogTitle, } from '@/components/ui/dialog' -import { ClipboardPaste, FolderOpen, FileStack, CircleDotDashed } from 'lucide-react' +import { FolderOpen, FileStack, CircleDotDashed } from 'lucide-react' import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils' import { getRegisteredNodeTypes, - getRegisteredNodeTypesGroupedByClassification, getRegisteredNodeTypeIds, getDefaultStyle, getNodeType, isConnectionAllowed, } from '@/lib/graph/nodeRegistry' -import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath' import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import { toast } from 'sonner' import { @@ -78,8 +67,6 @@ import { const SNAP_GRID: [number, number] = [15, 15] const DUPLICATE_OFFSET = { x: 30, y: 30 } -/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */ -const CONNECTION_PATH_UPDATE_MIN_MS = 1500 const snapToGrid = (x: number, y: number): { x: number; y: number } => ({ x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0], y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1], @@ -267,82 +254,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) { const [isSelecting, setIsSelecting] = React.useState(false) const [ariaAnnouncement, setAriaAnnouncement] = React.useState(null) const [fullscreenNodeId, setFullscreenNodeId] = React.useState(null) - const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = React.useState([]) - const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = React.useState([]) - const pathUpdateNodeIdsRef = useRef>(new Set()) - const pathUpdateStartTimeRef = useRef(null) - const pathUpdateEndTimeoutRef = useRef | null>(null) - - const [connectionPathPausedNodeIds, setConnectionPathPausedNodeIds] = React.useState([]) - const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = React.useState([]) - const connectionPathPausedNodeIdsRef = useRef([]) - connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds - - const clearPathUpdateSession = useCallback(() => { - setConnectionPathUpdatingNodeIds([]) - if (connectionPathPausedNodeIdsRef.current.length === 0) { - setConnectionPathTriggerNodeIds([]) - setConnectionPathPausedNodeIds([]) - } - }, []) - - const startConnectionPathUpdate = useCallback((nodeId: string) => { - const ref = pathUpdateNodeIdsRef.current - ref.add(nodeId) - if (ref.size === 1) { - pathUpdateStartTimeRef.current = Date.now() - if (pathUpdateEndTimeoutRef.current != null) { - clearTimeout(pathUpdateEndTimeoutRef.current) - pathUpdateEndTimeoutRef.current = null - } - } - setConnectionPathUpdatingNodeIds(Array.from(ref)) - }, []) - - const endConnectionPathUpdate = useCallback((nodeId: string) => { - const ref = pathUpdateNodeIdsRef.current - ref.delete(nodeId) - if (ref.size > 0) { - setConnectionPathUpdatingNodeIds(Array.from(ref)) - return - } - const startedAt = pathUpdateStartTimeRef.current ?? 0 - const elapsed = Date.now() - startedAt - const remaining = Math.max(0, CONNECTION_PATH_UPDATE_MIN_MS - elapsed) - if (remaining === 0) { - clearPathUpdateSession() - } else { - pathUpdateEndTimeoutRef.current = setTimeout(() => { - pathUpdateEndTimeoutRef.current = null - clearPathUpdateSession() - }, remaining) - } - }, [clearPathUpdateSession]) - - const pathTriggerBatchRef = useRef>(new Set()) - const pathTriggerScheduledRef = useRef(false) - const addConnectionPathTrigger = useCallback((nodeId: string) => { - pathTriggerBatchRef.current.add(nodeId) - if (pathTriggerScheduledRef.current) return - pathTriggerScheduledRef.current = true - requestAnimationFrame(() => { - pathTriggerScheduledRef.current = false - const batch = new Set(pathTriggerBatchRef.current) - pathTriggerBatchRef.current = new Set() - if (batch.size === 0) return - setConnectionPathTriggerNodeIds((prev) => { - const next = new Set(prev) - batch.forEach((id) => next.add(id)) - return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next) - }) - }) - }, []) - - React.useEffect(() => () => { - if (pathUpdateEndTimeoutRef.current != null) { - clearTimeout(pathUpdateEndTimeoutRef.current) - } - }, []) + const connectionPath = useCanvasConnectionPath(edges) const nodesRef = useRef(nodes) nodesRef.current = nodes @@ -551,50 +463,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) { flowActionsRef.current?.pasteAtViewportCenter?.() }, []) - const connectionPathNodeIds = useMemo( - () => - getPathNodeIds( - edges, - connectionPathUpdatingNodeIds, - connectionPathTriggerNodeIds, - connectionPathPausedNodeIds - ), - [edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds] - ) - - const connectionPathPausedSegmentNodeIds = useMemo( - () => - getPausedSegmentNodeIds( - edges, - connectionPathNodeIds, - connectionPathTriggerNodeIds, - connectionPathPausedNodeIds - ), - [edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds] - ) - - const connectionPathActiveSegmentNodeIds = useMemo(() => { - const active = new Set(connectionPathNodeIds) - connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id)) - return active - }, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds]) - - const addConnectionPathPausedNode = useCallback((nodeId: string) => { - setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId])) - }, []) - - const removeConnectionPathPausedNode = useCallback((nodeId: string) => { - setConnectionPathPausedNodeIds((prev) => prev.filter((id) => id !== nodeId)) - }, []) - - const addConnectionPathError = useCallback((nodeId: string) => { - setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId])) - }, []) - - const removeConnectionPathError = useCallback((nodeId: string) => { - setConnectionPathErrorNodeIds((prev) => prev.filter((id) => id !== nodeId)) - }, []) - const flowContextValue = useMemo( () => ({ nodes, @@ -609,20 +477,20 @@ export function CanvasPage({ projectId }: CanvasPageProps) { flowActionsRef, fullscreenNodeId, setFullscreenNodeId, - connectionPathUpdatingNodeIds, - connectionPathTriggerNodeIds, - addConnectionPathTrigger, - connectionPathNodeIds, - connectionPathPausedSegmentNodeIds, - connectionPathActiveSegmentNodeIds, - connectionPathPausedNodeIds, - addConnectionPathPausedNode, - removeConnectionPathPausedNode, - connectionPathErrorNodeIds, - addConnectionPathError, - removeConnectionPathError, - startConnectionPathUpdate, - endConnectionPathUpdate, + connectionPathUpdatingNodeIds: connectionPath.connectionPathUpdatingNodeIds, + connectionPathTriggerNodeIds: connectionPath.connectionPathTriggerNodeIds, + addConnectionPathTrigger: connectionPath.addConnectionPathTrigger, + connectionPathNodeIds: connectionPath.connectionPathNodeIds, + connectionPathPausedSegmentNodeIds: connectionPath.connectionPathPausedSegmentNodeIds, + connectionPathActiveSegmentNodeIds: connectionPath.connectionPathActiveSegmentNodeIds, + connectionPathPausedNodeIds: connectionPath.connectionPathPausedNodeIds, + addConnectionPathPausedNode: connectionPath.addConnectionPathPausedNode, + removeConnectionPathPausedNode: connectionPath.removeConnectionPathPausedNode, + connectionPathErrorNodeIds: connectionPath.connectionPathErrorNodeIds, + addConnectionPathError: connectionPath.addConnectionPathError, + removeConnectionPathError: connectionPath.removeConnectionPathError, + startConnectionPathUpdate: connectionPath.startConnectionPathUpdate, + endConnectionPathUpdate: connectionPath.endConnectionPathUpdate, }), [ nodes, @@ -637,20 +505,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) { flowActionsRef, fullscreenNodeId, setFullscreenNodeId, - connectionPathUpdatingNodeIds, - connectionPathTriggerNodeIds, - addConnectionPathTrigger, - connectionPathNodeIds, - connectionPathPausedSegmentNodeIds, - connectionPathActiveSegmentNodeIds, - connectionPathPausedNodeIds, - addConnectionPathPausedNode, - removeConnectionPathPausedNode, - connectionPathErrorNodeIds, - addConnectionPathError, - removeConnectionPathError, - startConnectionPathUpdate, - endConnectionPathUpdate, + connectionPath, ] ) @@ -900,39 +755,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) { - - - - Create Node - - {getRegisteredNodeTypesGroupedByClassification().map( - (group, groupIndex) => - group.types.length > 0 && ( - - {groupIndex > 0 && } - - - {group.label} - - {group.types.map((desc) => ( - createNode(desc.id)}> - {desc.menuIcon} - {desc.menuLabel} - - ))} - - - ) - )} - - - - pasteNode()}> - - Paste - - - + {fullscreenNodeId && ( + connectionPathPausedSegmentNodeIds: Set + connectionPathActiveSegmentNodeIds: Set + startConnectionPathUpdate: (nodeId: string) => void + endConnectionPathUpdate: (nodeId: string) => void + addConnectionPathTrigger: (nodeId: string) => void + addConnectionPathPausedNode: (nodeId: string) => void + removeConnectionPathPausedNode: (nodeId: string) => void + addConnectionPathError: (nodeId: string) => void + removeConnectionPathError: (nodeId: string) => void +} + +export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionPathResult { + const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = useState([]) + const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = useState([]) + const [connectionPathPausedNodeIds, setConnectionPathPausedNodeIds] = useState([]) + const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState([]) + + const pathUpdateNodeIdsRef = useRef>(new Set()) + const pathUpdateStartTimeRef = useRef(null) + const pathUpdateEndTimeoutRef = useRef | null>(null) + const connectionPathPausedNodeIdsRef = useRef([]) + connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds + + const pathTriggerBatchRef = useRef>(new Set()) + const pathTriggerScheduledRef = useRef(false) + + const clearPathUpdateSession = useCallback(() => { + setConnectionPathUpdatingNodeIds([]) + if (connectionPathPausedNodeIdsRef.current.length === 0) { + setConnectionPathTriggerNodeIds([]) + setConnectionPathPausedNodeIds([]) + } + }, []) + + const startConnectionPathUpdate = useCallback((nodeId: string) => { + const ref = pathUpdateNodeIdsRef.current + ref.add(nodeId) + if (ref.size === 1) { + pathUpdateStartTimeRef.current = Date.now() + if (pathUpdateEndTimeoutRef.current != null) { + clearTimeout(pathUpdateEndTimeoutRef.current) + pathUpdateEndTimeoutRef.current = null + } + } + setConnectionPathUpdatingNodeIds(Array.from(ref)) + }, []) + + const endConnectionPathUpdate = useCallback((nodeId: string) => { + const ref = pathUpdateNodeIdsRef.current + ref.delete(nodeId) + if (ref.size > 0) { + setConnectionPathUpdatingNodeIds(Array.from(ref)) + return + } + const startedAt = pathUpdateStartTimeRef.current ?? 0 + const elapsed = Date.now() - startedAt + const remaining = Math.max(0, CONNECTION_PATH_UPDATE_MIN_MS - elapsed) + if (remaining === 0) { + clearPathUpdateSession() + } else { + pathUpdateEndTimeoutRef.current = setTimeout(() => { + pathUpdateEndTimeoutRef.current = null + clearPathUpdateSession() + }, remaining) + } + }, [clearPathUpdateSession]) + + const addConnectionPathTrigger = useCallback((nodeId: string) => { + pathTriggerBatchRef.current.add(nodeId) + if (pathTriggerScheduledRef.current) return + pathTriggerScheduledRef.current = true + requestAnimationFrame(() => { + pathTriggerScheduledRef.current = false + const batch = new Set(pathTriggerBatchRef.current) + pathTriggerBatchRef.current = new Set() + if (batch.size === 0) return + setConnectionPathTriggerNodeIds((prev) => { + const next = new Set(prev) + batch.forEach((id) => next.add(id)) + return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next) + }) + }) + }, []) + + useEffect( + () => () => { + if (pathUpdateEndTimeoutRef.current != null) { + clearTimeout(pathUpdateEndTimeoutRef.current) + } + }, + [] + ) + + const connectionPathNodeIds = useMemo( + () => + getPathNodeIds( + edges, + connectionPathUpdatingNodeIds, + connectionPathTriggerNodeIds, + connectionPathPausedNodeIds + ), + [edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds] + ) + + const connectionPathPausedSegmentNodeIds = useMemo( + () => + getPausedSegmentNodeIds( + edges, + connectionPathNodeIds, + connectionPathTriggerNodeIds, + connectionPathPausedNodeIds + ), + [edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds] + ) + + const connectionPathActiveSegmentNodeIds = useMemo(() => { + const active = new Set(connectionPathNodeIds) + connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id)) + return active + }, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds]) + + const addConnectionPathPausedNode = useCallback((nodeId: string) => { + setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId])) + }, []) + + const removeConnectionPathPausedNode = useCallback((nodeId: string) => { + setConnectionPathPausedNodeIds((prev) => prev.filter((id) => id !== nodeId)) + }, []) + + const addConnectionPathError = useCallback((nodeId: string) => { + setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId])) + }, []) + + const removeConnectionPathError = useCallback((nodeId: string) => { + setConnectionPathErrorNodeIds((prev) => prev.filter((id) => id !== nodeId)) + }, []) + + return { + connectionPathUpdatingNodeIds, + connectionPathTriggerNodeIds, + connectionPathPausedNodeIds, + connectionPathErrorNodeIds, + connectionPathNodeIds, + connectionPathPausedSegmentNodeIds, + connectionPathActiveSegmentNodeIds, + startConnectionPathUpdate, + endConnectionPathUpdate, + addConnectionPathTrigger, + addConnectionPathPausedNode, + removeConnectionPathPausedNode, + addConnectionPathError, + removeConnectionPathError, + } +} diff --git a/frontend/src/components/nodes/render/useRenderingNodeState.ts b/frontend/src/components/nodes/render/useRenderingNodeState.ts index 0273544..8fe8aca 100644 --- a/frontend/src/components/nodes/render/useRenderingNodeState.ts +++ b/frontend/src/components/nodes/render/useRenderingNodeState.ts @@ -20,7 +20,7 @@ import { processSvgDisplay, stripTemplateSyntax, } from '@/lib/graph/rendering' -import { isReachable, resolveExtendsRef, getTemplateRefs } from '@/lib/graph/templateRefs' +import { buildSourceSignatures } from '@/lib/graph/renderingSignatures' import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering' export type RenderingNodeData = { @@ -136,101 +136,25 @@ export function useRenderingNodeState( ? agentOutputMarkdown : '' - const connectedNodeIds = useMemo(() => { - const edgeList = edges as { source: string; target: string }[] - const nodeList = nodes as { id: string; type?: string; data?: unknown }[] - const out = new Set() - const addConfigRefs = (nodeId: string, visited: Set) => { - if (visited.has(nodeId)) return - const node = nodeList.find((n) => n.id === nodeId && n.type === 'config') - if (!node) return - visited.add(nodeId) - out.add(nodeId) - const content = getConfigContent((node.data ?? undefined) as Record | undefined) - for (const ref of getTemplateRefs(content)) { - const refId = resolveExtendsRef(nodeList, ref) - if ( - refId && - nodeList.some((n) => n.id === refId && n.type === 'config') && - isReachable(edgeList, refId, id) - ) { - addConfigRefs(refId, visited) - } - } - } - const configVisited = new Set() - for (const nid of incomingIds) { - const node = nodeList.find((n) => n.id === nid) - if (node?.type === 'config') addConfigRefs(nid, configVisited) - else out.add(nid) - } - for (const e of edgeList) { - if (out.has(e.target)) out.add(e.source) - } - return out - }, [nodes, edges, id, incomingIds]) - - const configSignature = useMemo( + const signatures = useMemo( () => - (nodes as { id: string; type?: string; data?: unknown }[]) - .filter((n) => n.type === 'config' && connectedNodeIds.has(n.id)) - .map((n) => `${n.id}:${(n.data as { title?: string })?.title ?? ''}:${getConfigContent(n.data as Record)}`) - .sort() - .join('|'), - [nodes, connectedNodeIds] - ) - const edgesSignature = useMemo( - () => - (edges as { source: string; target: string }[]) - .filter( - (e) => - connectedNodeIds.has(e.source) && - (connectedNodeIds.has(e.target) || e.target === id) - ) - .map((e) => `${e.source}->${e.target}`) - .sort() - .join('|'), - [edges, connectedNodeIds, id] - ) - const variablesSignature = useMemo( - () => - (nodes as { id: string; type?: string; data?: { value?: unknown } }[]) - .filter((n) => n.type === 'variable' && connectedNodeIds.has(n.id)) - .map((n) => `${n.id}:${n.data?.value}`) - .sort() - .join('|'), - [nodes, connectedNodeIds] - ) - const functionsSignature = useMemo( - () => - (nodes as { id: string; type?: string; data?: { body?: string } }[]) - .filter((n) => n.type === 'function' && connectedNodeIds.has(n.id)) - .map((n) => `${n.id}:${n.data?.body ?? ''}`) - .sort() - .join('|'), - [nodes, connectedNodeIds] - ) - const dataSignature = useMemo( - () => - (nodes as { id: string; type?: string; data?: { rows?: unknown[]; hiddenColumns?: unknown[] } }[]) - .filter((n) => n.type === 'data' && connectedNodeIds.has(n.id)) - .map((n) => `${n.id}:${JSON.stringify(n.data?.rows ?? [])}:${JSON.stringify(n.data?.hiddenColumns ?? [])}`) - .sort() - .join('|'), - [nodes, connectedNodeIds] - ) - - const sourceSignature = useMemo( - () => - JSON.stringify({ - configSignature, - edgesSignature, - variablesSignature, - functionsSignature, - dataSignature, - }), - [configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature] + buildSourceSignatures( + nodes as { id: string; type?: string; data?: unknown }[], + edges as { source: string; target: string }[], + id, + incomingIds + ), + [nodes, edges, id, incomingIds] ) + const { + connectedNodeIds, + configSignature, + edgesSignature, + variablesSignature, + functionsSignature, + dataSignature, + sourceSignature, + } = signatures const lastRunSourceSignature = data?.lastRunSourceSignature diff --git a/frontend/src/lib/graph/renderingSignatures.ts b/frontend/src/lib/graph/renderingSignatures.ts new file mode 100644 index 0000000..41ef7d1 --- /dev/null +++ b/frontend/src/lib/graph/renderingSignatures.ts @@ -0,0 +1,142 @@ +/** + * Pure functions to build "connected node ids" and source signatures for the + * rendering pipeline. Used by useRenderingNodeState so the hook stays thin and + * this logic is testable in isolation. + */ + +import { getConfigContent } from '@/lib/graph/rendering' +import { isReachable, resolveExtendsRef, getTemplateRefs } from '@/lib/graph/templateRefs' + +export type NodeLike = { id: string; type?: string; data?: unknown } +export type EdgeLike = { source: string; target: string } + +/** + * Collects all node ids that affect the render: direct incoming nodes plus + * config nodes reached via template refs (extends/include/import) that are + * reachable to the render node. + */ +export function buildConnectedNodeIds( + nodes: NodeLike[], + edges: EdgeLike[], + renderNodeId: string, + incomingIds: string[], + getContent: (data: Record | undefined) => string = getConfigContent +): Set { + const out = new Set() + const addConfigRefs = (nodeId: string, visited: Set) => { + if (visited.has(nodeId)) return + const node = nodes.find((n) => n.id === nodeId && n.type === 'config') + if (!node) return + visited.add(nodeId) + out.add(nodeId) + const content = getContent((node.data ?? undefined) as Record | undefined) + for (const ref of getTemplateRefs(content)) { + const refId = resolveExtendsRef(nodes, ref) + if ( + refId && + nodes.some((n) => n.id === refId && n.type === 'config') && + isReachable(edges, refId, renderNodeId) + ) { + addConfigRefs(refId, visited) + } + } + } + const configVisited = new Set() + for (const nid of incomingIds) { + const node = nodes.find((n) => n.id === nid) + if (node?.type === 'config') addConfigRefs(nid, configVisited) + else out.add(nid) + } + for (const e of edges) { + if (out.has(e.target)) out.add(e.source) + } + return out +} + +export type SourceSignatures = { + connectedNodeIds: Set + configSignature: string + edgesSignature: string + variablesSignature: string + functionsSignature: string + dataSignature: string + sourceSignature: string +} + +/** + * Builds connected node set and all signatures used to decide when to re-run + * the resolve → render pipeline. + */ +export function buildSourceSignatures( + nodes: NodeLike[], + edges: EdgeLike[], + renderNodeId: string, + incomingIds: string[], + getContent: (data: Record | undefined) => string = getConfigContent +): SourceSignatures { + const connectedNodeIds = buildConnectedNodeIds( + nodes, + edges, + renderNodeId, + incomingIds, + getContent + ) + + const configSignature = nodes + .filter((n) => n.type === 'config' && connectedNodeIds.has(n.id)) + .map( + (n) => + `${n.id}:${(n.data as { title?: string })?.title ?? ''}:${getContent(n.data as Record)}` + ) + .sort() + .join('|') + + const edgesSignature = edges + .filter( + (e) => + connectedNodeIds.has(e.source) && + (connectedNodeIds.has(e.target) || e.target === renderNodeId) + ) + .map((e) => `${e.source}->${e.target}`) + .sort() + .join('|') + + const variablesSignature = nodes + .filter((n) => n.type === 'variable' && connectedNodeIds.has(n.id)) + .map((n) => `${n.id}:${(n.data as { value?: unknown })?.value}`) + .sort() + .join('|') + + const functionsSignature = nodes + .filter((n) => n.type === 'function' && connectedNodeIds.has(n.id)) + .map((n) => `${n.id}:${(n.data as { body?: string })?.body ?? ''}`) + .sort() + .join('|') + + const dataSignature = nodes + .filter((n) => n.type === 'data' && connectedNodeIds.has(n.id)) + .map( + (n) => + `${n.id}:${JSON.stringify((n.data as { rows?: unknown[] })?.rows ?? [])}:${JSON.stringify((n.data as { hiddenColumns?: unknown[] })?.hiddenColumns ?? [])}` + ) + .sort() + .join('|') + + const sourceSignature = JSON.stringify({ + configSignature, + edgesSignature, + variablesSignature, + functionsSignature, + dataSignature, + }) + + return { + connectedNodeIds, + configSignature, + edgesSignature, + variablesSignature, + functionsSignature, + dataSignature, + sourceSignature, + } +}