feat: performance 1

This commit is contained in:
2026-03-12 18:01:27 +01:00
parent 4bd2f1b458
commit 923bf4bff0
18 changed files with 404 additions and 146 deletions

View File

@@ -23,7 +23,11 @@ import {
type EdgeChange,
} from '@xyflow/react'
import { AnimatedEdge } from '@/components/graph/AnimatedEdge'
import FlowContext from '@/lib/graph/flowContext'
import {
GraphContext,
ConnectionPathContext,
FlowUIContext,
} from '@/lib/graph/flowContext'
import { useTheme } from '@/lib/themeContext'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
@@ -55,6 +59,7 @@ import {
getRegisteredNodeTypeIds,
getDefaultStyle,
getNodeType,
getConnectionLabelForTarget,
isConnectionAllowed,
} from '@/lib/graph/nodeRegistry'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
@@ -388,20 +393,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
flowActionsRef.current?.pasteAtViewportCenter?.()
}, [])
const flowContextValue = useMemo(
const graphContextValue = useMemo(
() => ({ nodes, setNodes, edges, setEdges }),
[nodes, setNodes, edges, setEdges]
)
const connectionPathContextValue = useMemo(
() => ({
nodes,
setNodes,
edges,
setEdges,
renamingNodeId,
setRenamingNodeId,
connectionFrom,
setConnectionFrom,
isValidConnection,
flowActionsRef,
fullscreenNodeId,
setFullscreenNodeId,
connectionPathUpdatingNodeIds: connectionPath.connectionPathUpdatingNodeIds,
connectionPathTriggerNodeIds: connectionPath.connectionPathTriggerNodeIds,
addConnectionPathTrigger: connectionPath.addConnectionPathTrigger,
@@ -417,11 +414,20 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
startConnectionPathUpdate: connectionPath.startConnectionPathUpdate,
endConnectionPathUpdate: connectionPath.endConnectionPathUpdate,
}),
[
nodes,
setNodes,
edges,
setEdges,
[connectionPath]
)
const flowUIContextValue = useMemo(
() => ({
renamingNodeId,
setRenamingNodeId,
connectionFrom,
setConnectionFrom,
isValidConnection,
flowActionsRef,
fullscreenNodeId,
setFullscreenNodeId,
}),
[
renamingNodeId,
setRenamingNodeId,
connectionFrom,
@@ -430,10 +436,32 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
flowActionsRef,
fullscreenNodeId,
setFullscreenNodeId,
connectionPath,
]
)
const nodesForFlow = useMemo(
() =>
nodes.map((n) => ({
...n,
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
})),
[nodes]
)
const edgesForFlow = useMemo(
() =>
edges.map((e) => {
const targetType = nodes.find((nd) => nd.id === e.target)?.type ?? ''
const connectionLabel = getConnectionLabelForTarget(targetType)
const baseData =
typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
return {
...e,
data: { ...baseData, connectionLabel },
}
}),
[edges, nodes]
)
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
const target = ev.target as HTMLElement
if (target.closest('.react-flow__node')) {
@@ -586,7 +614,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
)}
<div className="flex-1 min-h-0 relative flex flex-col">
<div className="flex-1 min-h-0 flex flex-col">
<FlowContext.Provider value={flowContextValue}>
<GraphContext.Provider value={graphContextValue}>
<ConnectionPathContext.Provider value={connectionPathContextValue}>
<FlowUIContext.Provider value={flowUIContextValue}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div
@@ -629,11 +659,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
<FlowKeyboardShortcuts />
<ReactFlow
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
nodes={nodes.map((n) => ({
...n,
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
}))}
edges={edges}
nodes={nodesForFlow}
edges={edgesForFlow}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
@@ -689,7 +716,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
onClose={() => setFullscreenNodeId(null)}
/>
)}
</FlowContext.Provider>
</FlowUIContext.Provider>
</ConnectionPathContext.Provider>
</GraphContext.Provider>
</div>
</div>
</div>

View File

@@ -1,18 +1,17 @@
import React, { useContext, useMemo } from 'react'
import React, { useContext, useMemo, memo } from 'react'
import {
BaseEdge,
getBezierPath,
type EdgeProps,
} from '@xyflow/react'
import FlowContext from '@/lib/graph/flowContext'
import { ConnectionPathContext } from '@/lib/graph/flowContext'
import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
import { getConnectionLabelForTarget } from '@/lib/graph/nodeRegistry'
const EDGE_STROKE_WIDTH = 2
const DOT_MARKER_R = 1.5
const EMPTY_PATH_NODE_IDS = new Set<string>()
export function AnimatedEdge({
function AnimatedEdgeInner({
id,
source,
sourceX,
@@ -25,9 +24,9 @@ export function AnimatedEdge({
label: labelProp,
interactionWidth,
target,
data,
}: EdgeProps) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const ctx = useContext(ConnectionPathContext)
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
const pausedSegmentNodeIds = ctx?.connectionPathPausedSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
const activeSegmentNodeIds = ctx?.connectionPathActiveSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
@@ -35,12 +34,8 @@ export function AnimatedEdge({
() => new Set(ctx?.connectionPathErrorNodeIds ?? []),
[ctx?.connectionPathErrorNodeIds]
)
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
const derivedLabel = useMemo(
() => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined),
[targetNode?.type]
)
const label = labelProp ?? derivedLabel
const label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
const connectionStatus = useMemo(
() =>
@@ -146,3 +141,23 @@ export function AnimatedEdge({
</>
)
}
function edgePropsAreEqual(prev: EdgeProps, next: EdgeProps): boolean {
return (
prev.id === next.id &&
prev.source === next.source &&
prev.target === next.target &&
prev.sourceX === next.sourceX &&
prev.sourceY === next.sourceY &&
prev.targetX === next.targetX &&
prev.targetY === next.targetY &&
prev.sourcePosition === next.sourcePosition &&
prev.targetPosition === next.targetPosition &&
prev.style === next.style &&
prev.label === next.label &&
(prev.data as { connectionLabel?: string } | undefined)?.connectionLabel ===
(next.data as { connectionLabel?: string } | undefined)?.connectionLabel
)
}
export const AnimatedEdge = memo(AnimatedEdgeInner, edgePropsAreEqual)

View File

@@ -2,8 +2,7 @@ import type { ComponentProps, ReactNode } from "react";
import { NodeResizer } from "@xyflow/react";
import { useContext } from "react";
import FlowContext from "@/lib/graph/flowContext";
import { useConnectionPathRole } from "@/lib/graph/flowContext";
import { FlowUIContext, useConnectionPathRole } from "@/lib/graph/flowContext";
import { cn } from "@/lib/utils";
/** Default min size for resizable nodes (used by NodeResizer). */
@@ -37,8 +36,8 @@ export function BaseNode({
resizeConstraints,
...props
}: BaseNodeProps) {
const flowContext = useContext(FlowContext);
const isFullscreenInstance = Boolean(nodeId && flowContext?.fullscreenNodeId === nodeId);
const flowUIContext = useContext(FlowUIContext);
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
const connectionPathRole = useConnectionPathRole(nodeId);
const hasSize =
dimensions &&

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useContext, useEffect } from 'react'
import { useReactFlow } from '@xyflow/react'
import type { Node } from '@xyflow/react'
import FlowContext from '@/lib/graph/flowContext'
import { GraphContext, FlowUIContext } from '@/lib/graph/flowContext'
import { getNextNodeId, getDefaultDataForType } from '@/lib/graph/flowUtils'
import { getDefaultStyle, getRegisteredNodeTypeIds } from '@/lib/graph/nodeRegistry'
@@ -14,11 +14,12 @@ function isMod(ev: KeyboardEvent) {
/** Must be rendered inside ReactFlowProvider and FlowContext. Handles Escape, ⌘C, ⌘V, ⌘D, ⌘0. */
export function FlowKeyboardShortcuts() {
const { fitView, screenToFlowPosition } = useReactFlow()
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
const setConnectionFrom = ctx?.setConnectionFrom
const flowActionsRef = ctx?.flowActionsRef
const graphCtx = useContext(GraphContext)
const uiCtx = useContext(FlowUIContext)
const nodes = graphCtx?.nodes ?? []
const setNodes = graphCtx?.setNodes
const setConnectionFrom = uiCtx?.setConnectionFrom
const flowActionsRef = uiCtx?.flowActionsRef
const pasteAtViewportCenter = useCallback(async () => {
if (!setNodes || !screenToFlowPosition) return

View File

@@ -1,5 +1,5 @@
import React, { useContext, useMemo } from 'react'
import FlowContext from '@/lib/graph/flowContext'
import { GraphContext } from '@/lib/graph/flowContext'
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
import { NodeHelpPopover } from '@/components/graph/NodeHelpPopover'
import { getNodeType, getNodeClassificationLabel } from '@/lib/graph/nodeRegistry'
@@ -11,7 +11,7 @@ type Props = {
}
export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props) {
const ctx = useContext(FlowContext)
const ctx = useContext(GraphContext)
const edges = ctx?.edges ?? []
const { inputs, outputs } = useMemo(() => {

View File

@@ -1,7 +1,7 @@
import React, { useContext } from 'react'
import { Handle, Position } from '@xyflow/react'
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
import FlowContext from '@/lib/graph/flowContext'
import { FlowUIContext } from '@/lib/graph/flowContext'
import { cn } from '@/lib/utils'
type NodeHandleProps = {
@@ -11,7 +11,7 @@ type NodeHandleProps = {
}
export function InputHandle({ id, nodeId }: NodeHandleProps) {
const ctx = useContext(FlowContext)
const ctx = useContext(FlowUIContext)
const connectionFrom = ctx?.connectionFrom ?? null
const isValidConnection = ctx?.isValidConnection
const isConnecting = Boolean(nodeId && connectionFrom && connectionFrom.nodeId !== nodeId)

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
import FlowContext from '@/lib/graph/flowContext'
import { GraphContext, FlowUIContext } from '@/lib/graph/flowContext'
import type { AppNode } from '@/lib/graph/nodeTypes'
import { replaceNodeIdInGraph } from '@/lib/graph/flowUtils'
@@ -9,13 +9,14 @@ type Props = {
}
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
const edges = ctx?.edges ?? []
const setEdges = ctx?.setEdges
const renamingNodeId = ctx?.renamingNodeId ?? null
const setRenamingNodeId = ctx?.setRenamingNodeId
const graphCtx = useContext(GraphContext)
const uiCtx = useContext(FlowUIContext)
const nodes = graphCtx?.nodes ?? []
const setNodes = graphCtx?.setNodes
const edges = graphCtx?.edges ?? []
const setEdges = graphCtx?.setEdges
const renamingNodeId = uiCtx?.renamingNodeId ?? null
const setRenamingNodeId = uiCtx?.setRenamingNodeId
const [inputValue, setInputValue] = useState(nodeId)
const inputRef = useRef<HTMLInputElement>(null)

View File

@@ -4,7 +4,7 @@
* it is taken from getNodeType(nodeType)?.getNodeMenuExtraContent?.(nodeId, data).
*/
import React, { useCallback, useContext, useMemo } from 'react'
import FlowContext from '@/lib/graph/flowContext'
import { GraphContext, FlowUIContext } from '@/lib/graph/flowContext'
import { getNodeType } from '@/lib/graph/nodeRegistry'
import {
Menubar,
@@ -43,12 +43,13 @@ type Props = {
}
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent: nodeMenuExtraContentProp, outputMenuContent, dataMenuContent }: Props) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
const setEdges = ctx?.setEdges
const graphCtx = useContext(GraphContext)
const uiCtx = useContext(FlowUIContext)
const nodes = graphCtx?.nodes ?? []
const setNodes = graphCtx?.setNodes
const setEdges = graphCtx?.setEdges
const edges = ctx?.edges ?? []
const edges = graphCtx?.edges ?? []
const node = nodes.find((n: any) => n.id === nodeId)
const nodeMenuExtraContent = useMemo(
() => nodeMenuExtraContentProp ?? getNodeType(nodeType)?.getNodeMenuExtraContent?.(nodeId, node?.data ?? {}),
@@ -64,8 +65,8 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
}, [nodeId, setNodes, setEdges])
const onRename = useCallback(() => {
ctx?.setRenamingNodeId?.(nodeId)
}, [nodeId, ctx])
uiCtx?.setRenamingNodeId?.(nodeId)
}, [nodeId, uiCtx])
return (
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b border-b-secondary shadow-none rounded-none text-muted-foreground">

View File

@@ -14,7 +14,7 @@ import { NodeMenubar } from '@/components/graph/NodeMenubar'
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
import FlowContext from '@/lib/graph/flowContext'
import { FlowUIContext } from '@/lib/graph/flowContext'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { getNodeType } from '@/lib/graph/nodeRegistry'
import { Bot } from 'lucide-react'
@@ -34,8 +34,8 @@ export type AgentNodeData = {
type Props = AbstractNodeProps<AgentNodeData>
function AgentNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const flowUIContext = useContext(FlowUIContext)
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})

View File

@@ -44,7 +44,7 @@ import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
import { NodeMenubar } from '@/components/graph/NodeMenubar'
import FlowContext from '@/lib/graph/flowContext'
import { FlowUIContext } from '@/lib/graph/flowContext'
import { getNodeType } from '@/lib/graph/nodeRegistry'
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string }
@@ -52,8 +52,8 @@ export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; titl
type Props = AbstractNodeProps<ConfigNodeData>
function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const flowUIContext = useContext(FlowUIContext)
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('config')?.supportsFullscreen
const configTypeId = getConfigTypeId(data ?? {})
const configType = getConfigType(configTypeId)

View File

@@ -13,7 +13,7 @@ import {
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
import { NodeMenubar } from '@/components/graph/NodeMenubar'
import FlowContext from '@/lib/graph/flowContext'
import { FlowUIContext } from '@/lib/graph/flowContext'
import { getNodeType } from '@/lib/graph/nodeRegistry'
import { OutputHandle } from '@/components/graph/NodeHandles'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
@@ -52,8 +52,8 @@ export type DataNodeData = {
type Props = AbstractNodeProps<DataNodeData>
function DataNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const flowUIContext = useContext(FlowUIContext)
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('data')?.supportsFullscreen
const { updateData } = useAbstractNode<DataNodeData>(id, data ?? {})
const rows = data?.rows ?? []

View File

@@ -19,7 +19,7 @@ import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
import { NodeMenubar } from '@/components/graph/NodeMenubar'
import FlowContext from '@/lib/graph/flowContext'
import { FlowUIContext } from '@/lib/graph/flowContext'
import { getNodeType } from '@/lib/graph/nodeRegistry'
import { MenubarItem, MenubarShortcut } from '@/components/ui/menubar'
import { Kbd } from '@/components/ui/kbd'
@@ -30,8 +30,8 @@ export type FunctionNodeData = { body?: string }
type Props = AbstractNodeProps<FunctionNodeData>
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const flowUIContext = useContext(FlowUIContext)
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('function')?.supportsFullscreen
const bodyValue = data?.body ?? ''
const { theme } = useTheme()

View File

@@ -8,7 +8,7 @@ import {
BaseNodeFooter,
BaseNodeHeaderRow,
} from '@/components/graph/BaseNode'
import FlowContext from '@/lib/graph/flowContext'
import { FlowUIContext } from '@/lib/graph/flowContext'
import { getNodeType } from '@/lib/graph/nodeRegistry'
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
@@ -48,8 +48,8 @@ type Props = AbstractNodeProps<RenderingNodeData>
type ViewMode = 'preview' | 'raw'
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const flowUIContext = useContext(FlowUIContext)
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
const state = useRenderingNodeState(id, data)

View File

@@ -6,7 +6,7 @@
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useAbstractNode } from '@/lib/graph/abstractNode'
import FlowContext from '@/lib/graph/flowContext'
import { ConnectionPathContext } from '@/lib/graph/flowContext'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
@@ -187,8 +187,8 @@ export function useRenderingNodeState(
const lastManualRunTriggerRef = useRef(0)
const manualRunTriggerSyncedRef = useRef(false)
const flowContext = useContext(FlowContext)
const triggerNodeIds = flowContext?.connectionPathTriggerNodeIds ?? []
const pathCtx = useContext(ConnectionPathContext)
const triggerNodeIds = pathCtx?.connectionPathTriggerNodeIds ?? []
const hasPendingInputs =
effectiveUpdateMode === 'manual' &&
!loading &&

View File

@@ -16,7 +16,7 @@
*/
import React, { useCallback, useContext, useMemo } from 'react'
import FlowContext from './flowContext'
import { GraphContext, ConnectionPathContext } from './flowContext'
import { nodePropsAreEqual } from './flowUtils'
import type { AppNode } from './nodeTypes'
@@ -71,13 +71,14 @@ export function useAbstractNode<TData = Record<string, unknown>>(
id: string,
data: TData
): AbstractNodeContext<TData> {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const edges = ctx?.edges ?? []
const setNodes = ctx?.setNodes
const setEdges = ctx?.setEdges
const graphCtx = useContext(GraphContext)
const pathCtx = useContext(ConnectionPathContext)
const nodes = graphCtx?.nodes ?? []
const edges = graphCtx?.edges ?? []
const setNodes = graphCtx?.setNodes
const setEdges = graphCtx?.setEdges
const addConnectionPathTrigger = ctx?.addConnectionPathTrigger
const addConnectionPathTrigger = pathCtx?.addConnectionPathTrigger
const updateData = useCallback(
(partial: Partial<TData>) => {
if (!setNodes) return

View File

@@ -1,18 +1,13 @@
/**
* FlowContext provides graph state, connection path state, and UI state to nodes and edges.
* Flow state is split into three contexts to reduce re-renders:
*
* ## 1. Graph state (nodes, edges)
* - nodes, setNodes, edges, setEdges owned by useCanvasGraph (history + persistence).
* - **GraphContext** nodes, edges, setNodes, setEdges. Changes on every graph edit.
* - **ConnectionPathContext** path/trigger/updating/paused/error state and callbacks.
* Only edges and path-aware nodes need this; graph edits don't change it.
* - **FlowUIContext** renaming, fullscreen, connectionFrom, flowActionsRef, isValidConnection.
*
* ## 2. Connection path state (edge visuals and path animation)
* - Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus() in nodeLifecycle.
* - Context holds: connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathNodeIds,
* connectionPathPausedSegmentNodeIds, connectionPathActiveSegmentNodeIds, connectionPathPausedNodeIds,
* connectionPathErrorNodeIds, plus add/remove/start/end callbacks.
* - Edges use these in getConnectionStatus() for color (default / updating / paused / error). See connectionStatus.ts.
*
* ## 3. UI state
* - renamingNodeId, fullscreenNodeId, connectionFrom (drag-from handle), flowActionsRef, isValidConnection.
* Consumers subscribe only to what they need so that e.g. connection path ticks
* don't re-render every node, and node position changes don't re-render every edge.
*/
import React, { useMemo } from 'react'
@@ -21,58 +16,89 @@ import type { AppNode, AppEdge } from './nodeTypes'
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
/** Role of a node in the current connection path update: pushing data, receiving/loading, or just on path. */
/** Role of a node in the current connection path update. */
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path'
export type FlowActions = {
pasteAtViewportCenter: () => void
fitView: () => void
pasteAtViewportCenter: () => void
fitView: () => void
}
export type FlowContextValue = {
// ---- Graph state ----
nodes: AppNode[]
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
edges: AppEdge[]
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
// ---------------------------------------------------------------------------
// Graph context (nodes, edges, setters)
// ---------------------------------------------------------------------------
// ---- UI state ----
renamingNodeId: string | null
setRenamingNodeId: (id: string | null) => void
connectionFrom: ConnectionFrom
setConnectionFrom: (v: ConnectionFrom) => void
isValidConnection: (connection: Connection) => boolean
flowActionsRef: React.MutableRefObject<FlowActions | null>
fullscreenNodeId: string | null
setFullscreenNodeId: (id: string | null) => void
// ---- Connection path state (edge status and path animation; see state.ts ConnectionPathState) ----
connectionPathUpdatingNodeIds: string[]
connectionPathTriggerNodeIds: string[]
addConnectionPathTrigger: (nodeId: string) => void
connectionPathNodeIds: Set<string>
connectionPathPausedSegmentNodeIds: Set<string>
connectionPathActiveSegmentNodeIds: Set<string>
connectionPathPausedNodeIds: string[]
addConnectionPathPausedNode: (nodeId: string) => void
removeConnectionPathPausedNode: (nodeId: string) => void
connectionPathErrorNodeIds: string[]
addConnectionPathError: (nodeId: string) => void
removeConnectionPathError: (nodeId: string) => void
startConnectionPathUpdate: (nodeId: string) => void
endConnectionPathUpdate: (nodeId: string) => void
export type GraphContextValue = {
nodes: AppNode[]
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
edges: AppEdge[]
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
}
const GraphContext = React.createContext<GraphContextValue | null>(null)
export { GraphContext }
// ---------------------------------------------------------------------------
// Connection path context (edge status and path animation)
// ---------------------------------------------------------------------------
export type ConnectionPathContextValue = {
connectionPathUpdatingNodeIds: string[]
connectionPathTriggerNodeIds: string[]
addConnectionPathTrigger: (nodeId: string) => void
connectionPathNodeIds: Set<string>
connectionPathPausedSegmentNodeIds: Set<string>
connectionPathActiveSegmentNodeIds: Set<string>
connectionPathPausedNodeIds: string[]
addConnectionPathPausedNode: (nodeId: string) => void
removeConnectionPathPausedNode: (nodeId: string) => void
connectionPathErrorNodeIds: string[]
addConnectionPathError: (nodeId: string) => void
removeConnectionPathError: (nodeId: string) => void
startConnectionPathUpdate: (nodeId: string) => void
endConnectionPathUpdate: (nodeId: string) => void
}
const ConnectionPathContext = React.createContext<ConnectionPathContextValue | null>(null)
export { ConnectionPathContext }
// ---------------------------------------------------------------------------
// UI context (renaming, fullscreen, connection drag, shortcuts)
// ---------------------------------------------------------------------------
export type FlowUIContextValue = {
renamingNodeId: string | null
setRenamingNodeId: (id: string | null) => void
connectionFrom: ConnectionFrom
setConnectionFrom: (v: ConnectionFrom) => void
isValidConnection: (connection: Connection) => boolean
flowActionsRef: React.MutableRefObject<FlowActions | null>
fullscreenNodeId: string | null
setFullscreenNodeId: (id: string | null) => void
}
const FlowUIContext = React.createContext<FlowUIContextValue | null>(null)
export { FlowUIContext }
// ---------------------------------------------------------------------------
// Legacy single context (for gradual migration or components that need everything)
// ---------------------------------------------------------------------------
export type FlowContextValue = GraphContextValue & ConnectionPathContextValue & FlowUIContextValue
const FlowContext = React.createContext<FlowContextValue | null>(null)
export default FlowContext
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
/**
* Returns this node's role in the current path update for styling (pushing vs receiving).
* Use with BaseNode's connectionPathRole prop or data-path-role for CSS.
* Returns this node's role in the current path update for styling.
* Uses only ConnectionPathContext so nodes don't re-render on graph changes.
*/
export function useConnectionPathRole(nodeId: string | undefined): ConnectionPathRole | null {
const ctx = React.useContext(FlowContext)
const ctx = React.useContext(ConnectionPathContext)
return useMemo(() => {
if (!nodeId) return null
const triggers = ctx?.connectionPathTriggerNodeIds

View File

@@ -20,7 +20,7 @@
*/
import { useContext, useEffect, useRef } from 'react'
import FlowContext from './flowContext'
import { ConnectionPathContext } from './flowContext'
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
@@ -53,7 +53,7 @@ export function useSyncConnectionStatus(
nodeId: string,
state: NodeConnectionStatusState
): void {
const ctx = useContext(FlowContext)
const ctx = useContext(ConnectionPathContext)
const { updating, error, paused } = state
const prevRef = useRef({ updating: false, error: false, paused: false })