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,
+ }
+}