refactoring: graph state

This commit is contained in:
2026-03-12 17:40:05 +01:00
parent bfd1a40332
commit 4bd2f1b458
9 changed files with 176 additions and 58 deletions

View File

@@ -48,6 +48,20 @@ This doc summarizes recent improvements and suggested next steps for readability
- **config/renderingLogic**: uses context nodes/edges directly (no casts); `setVarInContext(src: NodeLike)`.
- **useRenderingNodeState**: casts to `NodeLike[]` / `EdgeLike[]` when calling `buildSourceSignatures` (types from renderingSignatures).
### 9. Graph state, connection state, and node display state
- **lib/graph/state.ts**: Central state contracts and flow doc.
- **GraphState** in-memory nodes + edges (useGraphStateWithHistory, persisted by useCanvasGraph).
- **ConnectionPathState** type describing the slice that drives edge status and path animation (trigger/updating/paused/error sets).
- **NodeDisplayStatus** `'initial' | 'loading' | 'success' | 'error'` for node UI; **getNodeDisplayStatus({ loading, error, hasContent })**.
- **StoredGraphState** shape for save/load (version + nodes + edges).
- **FlowContext**: Module doc splits value into (1) Graph state, (2) Connection path state, (3) UI state. Same flat props, clearer sections.
- **useGraphStateWithHistory**: JSDoc explains history (past/future), setNodes vs setNodesSilent, setStateImmediate.
- **projectGraphStorage**: Uses **StoredGraphState** from state.ts; re-exports type. Doc references state flow.
- **useRenderingNodeState**: Returns **displayStatus** (for NodeStatusIndicator/empty/error UI) and **lifecycle** (updating/error/paused for useSyncConnectionStatus). Hook calls useSyncConnectionStatus(id, state.lifecycle). Type includes RenderingNodeLifecycle.
- **RenderingNode**: Uses **state.displayStatus** for NodeStatusIndicator instead of computing status locally.
- **nodeLifecycle** and **connectionStatus**: Docs reference state.ts for overall state flow.
## Design patterns in use
| Pattern | Where |

View File

@@ -1,8 +1,11 @@
/**
* Per-project graph persistence (localStorage).
* Used by CanvasPage to load/save and by ProjectsTablePage for export.
* Saves and loads StoredGraphState (version + nodes + edges). Used by useCanvasGraph and export.
*/
import type { StoredGraphState } from '@/lib/graph/state'
export type { StoredGraphState }
export const PROJECT_FILE_EXT = '.zui.json'
export const PROJECT_VERSION = 1
@@ -12,22 +15,20 @@ export function getGraphStorageKey(projectId: string): string {
return `${GRAPH_KEY_PREFIX}${projectId}`
}
export type GraphState = { version: number; nodes: unknown[]; edges: unknown[] }
export function loadGraphFromStorage(projectId: string): GraphState | null {
export function loadGraphFromStorage(projectId: string): StoredGraphState | null {
try {
const raw = localStorage.getItem(getGraphStorageKey(projectId))
if (!raw) return null
const data = JSON.parse(raw) as unknown
if (!data || typeof data !== 'object' || !Array.isArray((data as GraphState).nodes) || !Array.isArray((data as GraphState).edges))
if (!data || typeof data !== 'object' || !Array.isArray((data as StoredGraphState).nodes) || !Array.isArray((data as StoredGraphState).edges))
return null
return data as GraphState
return data as StoredGraphState
} catch {
return null
}
}
export function saveGraphToStorage(projectId: string, state: GraphState): void {
export function saveGraphToStorage(projectId: string, state: StoredGraphState): void {
localStorage.setItem(getGraphStorageKey(projectId), JSON.stringify(state))
}

View File

@@ -62,14 +62,6 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
? { width, height }
: undefined
const status = state.loading
? 'loading'
: state.error
? 'error'
: state.renderedContent
? 'success'
: 'initial'
const { theme } = useTheme()
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [viewMode])
@@ -138,7 +130,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
}
return (
<NodeStatusIndicator status={status} variant="overlay" width={dimensions?.width} height={dimensions?.height}>
<NodeStatusIndicator status={state.displayStatus} variant="overlay" width={dimensions?.width} height={dimensions?.height}>
<BaseNode
className="min-w-96 min-h-[320px]"
dimensions={dimensions}

View File

@@ -21,6 +21,7 @@ import {
stripTemplateSyntax,
} from '@/lib/graph/rendering'
import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph/renderingSignatures'
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
export type RenderingNodeData = {
@@ -38,7 +39,19 @@ const DEFAULT_VIEWPORT_WIDTH = 1200
const DEFAULT_VIEWPORT_HEIGHT = 800
const RENDER_DEBOUNCE_MS = 250
/** Lifecycle state to pass to useSyncConnectionStatus so edge status (updating/paused/error) stays in sync. */
export type RenderingNodeLifecycle = {
updating: boolean
error: boolean
paused: boolean
}
export type RenderingNodeState = {
/** For NodeStatusIndicator and empty/error UI. Derived from loading, error, renderedContent. */
displayStatus: NodeDisplayStatus
/** Pass to useSyncConnectionStatus so edges show correct status (blue/yellow/red). */
lifecycle: RenderingNodeLifecycle
// Connection & run control
incomingIds: string[]
effectiveUpdateMode: 'auto' | 'manual'
@@ -183,7 +196,11 @@ export function useRenderingNodeState(
incomingIds.length > 0 &&
sourceSignature !== lastRunSourceSignature
useSyncConnectionStatus(id, { updating: loading, error: error != null, paused: hasPendingInputs })
const lifecycle = useMemo<RenderingNodeLifecycle>(
() => ({ updating: loading, error: error != null, paused: hasPendingInputs }),
[loading, error, hasPendingInputs]
)
useSyncConnectionStatus(id, lifecycle)
useEffect(() => {
if (incomingIds.length === 0) {
@@ -472,7 +489,14 @@ export function useRenderingNodeState(
const sourceData = useMemo(() => (srcNode?.data as Record<string, unknown>) ?? {}, [srcNode?.data])
const sourceNodeType = (srcNode?.type as string) ?? null
const displayStatus = useMemo<NodeDisplayStatus>(
() => getNodeDisplayStatus({ loading, error, hasContent: !!renderedContent }),
[loading, error, renderedContent]
)
return {
displayStatus,
lifecycle,
incomingIds,
effectiveUpdateMode,
runTrigger,

View File

@@ -1,6 +1,11 @@
import { useCallback, useRef, useState } from 'react'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
/**
* In-memory graph state. History is a past/future stack of GraphState snapshots.
* setNodes/setEdges push current state to past; setNodesSilent/setEdgesSilent update without history.
* setStateImmediate replaces state and clears history (e.g. load example, import).
*/
export type GraphState = { nodes: AppNode[]; edges: AppEdge[] }
function cloneState(state: GraphState): GraphState {

View File

@@ -2,9 +2,8 @@
* Connection status: visual state of an edge (color/class).
* Priority when multiple apply: error > paused > updating > default.
*
* Status is derived from node lifecycle state: nodes report updating / paused / error
* via useSyncConnectionStatus() in nodeLifecycle.ts, which updates FlowContext sets.
* Edges read those sets here to pick the single status per edge.
* State flow: nodes report lifecycle via useSyncConnectionStatus() (nodeLifecycle.ts) → FlowContext
* holds the sets → edges pass those sets into getConnectionStatus() here. See lib/graph/state.ts.
*/
export type ConnectionStatus = 'default' | 'updating' | 'paused' | 'error'

View File

@@ -1,3 +1,20 @@
/**
* FlowContext provides graph state, connection path state, and UI state to nodes and edges.
*
* ## 1. Graph state (nodes, edges)
* - nodes, setNodes, edges, setEdges owned by useCanvasGraph (history + persistence).
*
* ## 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.
*/
import React, { useMemo } from 'react'
import type { Connection } from '@xyflow/react'
import type { AppNode, AppEdge } from './nodeTypes'
@@ -13,48 +30,36 @@ export type FlowActions = {
}
export type FlowContextValue = {
// ---- Graph state ----
nodes: AppNode[]
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
edges: AppEdge[]
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
// ---- UI state ----
renamingNodeId: string | null
setRenamingNodeId: (id: string | null) => void
/** Set when user starts dragging from an output handle; cleared on connect end. Used to highlight valid targets. */
connectionFrom: ConnectionFrom
setConnectionFrom: (v: ConnectionFrom) => void
isValidConnection: (connection: Connection) => boolean
/** Set by FlowKeyboardShortcuts so Node menubar can trigger paste / fit view. */
flowActionsRef: React.MutableRefObject<FlowActions | null>
/** When set, graph centers on this node and a fullscreen dialog shows the node. Cleared on close. */
fullscreenNodeId: string | null
setFullscreenNodeId: (id: string | null) => void
/** Node ids currently updating (e.g. render loading, agent running). Only edges on those paths show the ant trail. */
// ---- Connection path state (edge status and path animation; see state.ts ConnectionPathState) ----
connectionPathUpdatingNodeIds: string[]
/** Node ids that triggered the current update (e.g. variable/config that changed). Path is restricted to downstream(trigger) ∩ upstream(updating). */
connectionPathTriggerNodeIds: string[]
/** Call when this node's output changed and may trigger downstream updates (e.g. variable value, config content). */
addConnectionPathTrigger: (nodeId: string) => void
/** All node ids on the path of an update. Edges with both endpoints in this set animate. */
connectionPathNodeIds: Set<string>
/** Path nodes in the "paused" segment (from trigger up to Archon on hold). Those edges are yellow. */
connectionPathPausedSegmentNodeIds: Set<string>
/** Path nodes not in the paused segment (downstream of pause). Only those edges are blue (updating). */
connectionPathActiveSegmentNodeIds: Set<string>
/** Nodes that are on hold (e.g. Renderer in manual mode waiting for Run). */
connectionPathPausedNodeIds: string[]
/** Add this node as paused (on hold); remove when user continues. */
addConnectionPathPausedNode: (nodeId: string) => void
/** Remove this node from paused. */
removeConnectionPathPausedNode: (nodeId: string) => void
/** Node ids that have an error (e.g. Render node). Incoming edges show error status (red). */
connectionPathErrorNodeIds: string[]
/** Call when this node has an error; remove when error is cleared. */
addConnectionPathError: (nodeId: string) => void
/** Call when this node's error is cleared. */
removeConnectionPathError: (nodeId: string) => void
/** Call when a path update starts for this node. Animation runs at least CONNECTION_PATH_UPDATE_MIN_MS. */
startConnectionPathUpdate: (nodeId: string) => void
/** Call when a path update ends for this node. If min duration not reached, animation continues until then. */
endConnectionPathUpdate: (nodeId: string) => void
}

View File

@@ -2,30 +2,21 @@
* Node lifecycle: contract that nodes implement so the graph can show the right
* connection status (edge colors) and path animation.
*
* ## State flow
*
* Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus(id, state).
* This hook updates FlowContext sets; edges read them via getConnectionStatus() in connectionStatus.ts.
* See lib/graph/state.ts for the overall state flow (graph state, connection path state).
*
* ## Lifecycle phases (conceptual)
*
* - **Idle** Node is not on an active path; edges to/from it use default style.
* - **Trigger** Node's output changed (e.g. config content, variable value). Reported
* automatically when the node calls `updateData()` from useAbstractNode. Downstream
* path is computed from triggers + updating nodes.
* - **Updating** Node is doing async work (e.g. renderer loading, agent run triggered by renderer).
* Report `updating: true` at start, `updating: false` when done. Incoming/outgoing
* edges on the path show "updating" (blue).
* - **Paused** Node is on hold (e.g. renderer in manual mode waiting for Run after inputs changed).
* Report `paused: true` when waiting, `paused: false` when not. Edges in the paused
* segment show "paused" (yellow).
* - **Error** Node has an error to show. Report `error: true` when error is set,
* `error: false` when cleared. Incoming edges to this node show "error" (red).
* - **Idle** Node is not on an active path; edges use default style.
* - **Trigger** Node's output changed; path is computed from triggers + updating nodes.
* - **Updating** Node is doing async work. Report `updating: true` → false. Path edges show blue.
* - **Paused** Node is on hold (e.g. manual mode waiting for Run). Report `paused: true` → false. Segment shows yellow.
* - **Error** Node has an error. Report `error: true` → false. Incoming edges show red.
*
* ## Connection status integration
*
* Edge status is derived in getConnectionStatus() from the sets that this lifecycle
* feeds: connectionPathUpdatingNodeIds, connectionPathPausedNodeIds,
* connectionPathErrorNodeIds (plus path/paused segment from graphPath). Priority:
* error > paused > updating > default.
*
* Nodes that can be updating, paused, or in error should call useSyncConnectionStatus()
* with their current state so edges update correctly.
* Priority for edge status: error > paused > updating > default.
*/
import { useContext, useEffect, useRef } from 'react'

View File

@@ -0,0 +1,87 @@
/**
* Central state contracts for the graph: in-memory graph, connection path (edge visuals),
* and node display status. Use these types to keep state flow predictable and readable.
*
* ## State flow (high level)
*
* 1. **Graph state** (nodes, edges) Owned by useGraphStateWithHistory, persisted by useCanvasGraph.
* Updates via setNodes/setEdges (with undo) or setNodesSilent/setEdgesSilent (no history).
*
* 2. **Connection path state** Owned by useCanvasConnectionPath, provided via FlowContext.
* Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus; the hook
* updates context sets. Edges read those sets and getConnectionStatus() to decide color (default / updating / paused / error).
*
* 3. **Node display state** Each node type derives its own UI state (e.g. loading, success, error, initial).
* Rendering node uses displayStatus from useRenderingNodeState; other nodes can use getNodeDisplayStatus().
*/
import type { AppNode, AppEdge } from './nodeTypes'
// ---------------------------------------------------------------------------
// Graph state (in-memory)
// ---------------------------------------------------------------------------
/** In-memory graph: nodes and edges. Updated via setNodes/setEdges; history in useGraphStateWithHistory. */
export type GraphState = {
nodes: AppNode[]
edges: AppEdge[]
}
// ---------------------------------------------------------------------------
// Connection path state (edge visuals and path animation)
// ---------------------------------------------------------------------------
/**
* Slice of FlowContext that drives edge status and path animation.
* Nodes report updating/error/paused via useSyncConnectionStatus; this state is derived from those reports.
* Edges use pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds, errorTargetNodeIds in getConnectionStatus().
*/
export type ConnectionPathState = {
/** Node ids currently doing async work (e.g. render loading). Path edges show "updating" (blue). */
connectionPathUpdatingNodeIds: string[]
/** Node ids that triggered the update (e.g. config changed). Path = downstream(trigger) ∩ upstream(updating). */
connectionPathTriggerNodeIds: string[]
/** All node ids on the path. Edges with both endpoints here animate. */
connectionPathNodeIds: Set<string>
/** Path nodes in the paused segment (yellow edges). */
connectionPathPausedSegmentNodeIds: Set<string>
/** Path nodes in the active segment (blue edges). */
connectionPathActiveSegmentNodeIds: Set<string>
/** Node ids on hold (e.g. render in manual mode). */
connectionPathPausedNodeIds: string[]
/** Node ids with error (incoming edges show red). */
connectionPathErrorNodeIds: string[]
}
// ---------------------------------------------------------------------------
// Node display status (for UI: status indicator, empty state)
// ---------------------------------------------------------------------------
/** Status for node UI: what to show in NodeStatusIndicator and empty/error states. */
export type NodeDisplayStatus = 'initial' | 'loading' | 'success' | 'error'
/**
* Derive display status from node state. Use in nodes to drive NodeStatusIndicator and empty/error UI.
* Priority: loading > error > hasContent (success) > initial.
*/
export function getNodeDisplayStatus(state: {
loading: boolean
error: unknown
hasContent: boolean
}): NodeDisplayStatus {
if (state.loading) return 'loading'
if (state.error != null) return 'error'
if (state.hasContent) return 'success'
return 'initial'
}
// ---------------------------------------------------------------------------
// Stored graph state (persistence)
// ---------------------------------------------------------------------------
/** Shape of graph state when saved/loaded (e.g. localStorage). version for future migrations. */
export type StoredGraphState = {
version: number
nodes: unknown[]
edges: unknown[]
}