feat: introdce store

This commit is contained in:
2026-03-13 00:32:19 +01:00
parent 2d8f13aebb
commit fc265266c7
17 changed files with 2859 additions and 83 deletions

View File

@@ -0,0 +1,218 @@
# Canvas state: design pattern for controlled, centralized, debuggable flow
This doc proposes a **store + commands + selectors** pattern so canvas state is:
- **Controlled** every change goes through one place
- **Centralized** one store holds graph, path, and UI slices
- **Predictable** same action → same state transition; easy to reason about
- **Easier to debug** log commands, inspect store, optional time-travel
It complements [CANVAS_PERFORMANCE_OPTIONS.md](./CANVAS_PERFORMANCE_OPTIONS.md) and [state.ts](../src/lib/graph/state.ts).
---
## 1. Core idea: single store + commands + selectors
### 1.1 Single store (one source of truth)
Keep all canvas-related state in **one store** with **slices**:
```
Store
├── graph: { nodes, edges } // current graph (with history if needed)
├── path: ConnectionPathState // trigger/updating/paused/error node IDs
├── ui: FlowUIState // renaming, fullscreen, connectionFrom, etc.
└── (optional) history: HistoryState // undo/redo stack
```
- **No duplicate sources**: nodes/edges live only in the store, not in context + refs.
- **Reads**: components get data via **selectors** (e.g. `useStore(s => s.graph.nodes)` or `useStore(selectPathForEdge, edgeId)`).
- **Writes**: only via **commands** (e.g. `dispatch({ type: 'graph/setNodes', payload: updater })`).
### 1.2 Commands (controlled mutations)
Every mutation is a **command** (action):
- **Graph**: `graph/setNodes`, `graph/setEdges`, `graph/applySilent` (position), `graph/undo`, `graph/redo`
- **Path**: `path/addTrigger`, `path/startUpdate`, `path/endUpdate`, `path/setPaused`, `path/setError`
- **UI**: `ui/setRenaming`, `ui/setFullscreen`, `ui/setConnectionFrom`
Benefits:
- **Predictable**: one command → one reducer → one new state; no scattered `setState` in hooks.
- **Traceable**: log every command (and payload) in dev; replay or inspect.
- **Testable**: test reducers with command + prev state → next state.
- **Time-travel (optional)**: store past states or inverse deltas per command for debug UI.
### 1.3 Selectors (derived state and subscriptions)
**Selectors** are pure functions `(state) => value`. They:
- **Derive** values (e.g. path node IDs from trigger/updating/paused).
- **Scope** data (e.g. “incoming edges for node X”, “connection status for edge Y”).
- **Stabilize** references when the logical value hasnt changed (e.g. same path IDs → same Set reference).
Components **subscribe via selectors**:
- `useStore(selectGraph)` → re-render when `graph` slice changes.
- `useStore(selectPathNodeIds)` → re-render only when path node IDs change.
- `useStore(selectConnectionStatusForEdge, edgeId)` → re-render only when that edges status changes.
So:
- **Centralized**: all reads go through the store.
- **Predictable**: same state in → same selector out.
- **Performance**: only components whose selected value changed re-render (with a store that supports shallow equality, e.g. Zustand).
---
## 2. Data structures
### 2.1 Store shape (TypeScript)
```ts
// Slices match current concepts; easy to migrate from existing state.ts + useCanvasConnectionPath.
interface CanvasStore {
graph: {
nodes: AppNode[]
edges: AppEdge[]
}
path: ConnectionPathState // from state.ts
ui: FlowUIState
// optional, for undo/redo
_history?: {
past: HistoryDelta[]
future: HistoryDelta[]
}
}
```
### 2.2 Commands (discriminated union)
```ts
type CanvasCommand =
| { type: 'graph/setNodes'; payload: AppNode[] | ((prev: AppNode[]) => AppNode[]) }
| { type: 'graph/setEdges'; payload: AppEdge[] | ((prev: AppEdge[]) => AppEdge[]) }
| { type: 'graph/applySilent'; payload: (prev: GraphState) => GraphState }
| { type: 'path/addTrigger'; payload: string }
| { type: 'path/startUpdate'; payload: string }
| { type: 'path/endUpdate'; payload: string }
| { type: 'path/setPaused'; payload: { nodeId: string; paused: boolean } }
| { type: 'path/setError'; payload: { nodeId: string; error: boolean } }
| { type: 'ui/setRenaming'; payload: string | null }
| { type: 'ui/setFullscreen'; payload: string | null }
// ...
```
Single dispatcher:
```ts
function dispatch(cmd: CanvasCommand): void
```
### 2.3 Selectors (examples)
```ts
// Raw slices
const selectGraph = (s: CanvasStore) => s.graph
const selectPath = (s: CanvasStore) => s.path
// Stable derived path sets (same ref if same IDs)
const selectPathNodeIds = (s: CanvasStore) => getPathNodeIds(s.graph.edges, s.path...)
// Per-edge status (for AnimatedEdge) only changes when this edges status changes
const selectConnectionStatusForEdge = (s: CanvasStore, source: string, target: string) =>
getConnectionStatus({ source, target, pathNodeIds: s.path.connectionPathNodeIds, ... })
// Per-node: “am I on path?” (for BaseNode)
const selectPathRoleForNode = (s: CanvasStore, nodeId: string) =>
getConnectionPathRole(nodeId, s.path)
```
Use with a store that supports **selector + equality** so components only re-render when the selected value actually changes (e.g. Zustands `useStore(selector, shallowEqual)` or custom `useSelector`).
---
## 3. Why this helps
| Goal | How the pattern helps |
|------|------------------------|
| **Controlled** | All writes go through `dispatch(cmd)`. No ad-hoc `setState` in hooks or context. |
| **Centralized** | One store; no split between context, refs, and local state for the same concept. |
| **Predictable** | One command → one reducer → one new state. Order of updates is explicit. |
| **Easier to debug** | Log commands; inspect store (e.g. Redux DevTools or a simple `store.getState()` logger); optional time-travel by replaying or reverting commands. |
| **Fewer redraws** | Selectors + equality checks mean components only re-render when their slice or derived value changes. |
| **Clear data flow** | Data flow is “store → selectors → components” and “events → commands → store”; no implicit propagation. |
---
## 4. Implementation options
### Option A: Zustand (recommended for React)
- **Store**: `create<CanvasStore>()` with a `dispatch` that applies commands and updates the store.
- **Selectors**: `useCanvasStore(selectPathNodeIds)` etc.; Zustand re-renders only when the selected value changes (with shallow or custom equality).
- **Commands**: either one `setState` that takes a reducer, or a separate `dispatch` that maps commands to `setState` calls.
- **Debug**: middleware that logs commands and state (or use Redux DevTools with a small adapter).
### Option B: Redux Toolkit
- **Store**: one RTK store; slices: `graph`, `path`, `ui`.
- **Commands**: RTK actions; reducers are pure and easy to test.
- **Selectors**: `createSelector` for derived state; `useSelector` for subscriptions.
- **Debug**: Redux DevTools out of the box (time-travel, action log, state diff).
### Option C: Minimal custom store (no new deps)
- **Store**: a single `useReducer` (or `useState` + reducer) at the top (e.g. CanvasPage or a provider).
- **Commands**: dispatch to the reducer; reducer returns new state by slice.
- **Selectors**: pass store (or state) to a `useSelector(store, selector, equality)` hook that subscribes and only re-renders when the selected value changes (e.g. by comparing with `Object.is` or shallow compare).
- **Debug**: log `dispatch` and state in dev; optional snapshot history in the reducer.
---
## 5. Migration path from current setup
1. **Introduce the store** (e.g. Zustand or RTK) next to existing context; keep feeding React Flow and current consumers from the store so behavior stays the same.
2. **Move graph state** from `useGraphStateWithHistory` into the store (graph slice + history if needed); keep `setNodes`/`setEdges` as commands that update the store.
3. **Move path state** from `useCanvasConnectionPath` into the store (path slice); replace path context with `useStore(selectPath...)` or per-edge/per-node selectors.
4. **Move UI state** from CanvasPage `useState` into the store (ui slice); replace FlowUIContext with store selectors.
5. **Remove redundant context** (GraphContext, ConnectionPathContext, FlowUIContext) once all reads go through selectors and all writes through commands.
6. **Add logging / DevTools** for commands and state; add optional time-travel if desired.
This can be done slice-by-slice (e.g. path first, then graph, then UI) to keep changes small and testable.
---
## 6. Implementation (Zustand)
The store is implemented under `frontend/src/app/canvas/`:
| File | Purpose |
|------|---------|
| `canvasStore.types.ts` | `CanvasStore`, `CanvasCommand`, slice types |
| `canvasStore.reducer.ts` | Pure reducer + `initialCanvasStore` |
| `canvasStore.selectors.ts` | Selectors (graph, path derived sets, per-edge status, per-node role) |
| `canvasStore.ts` | Zustand store, `getCanvasStore()`, `dispatchCanvasCommand()`, `useCanvasStore()`, `useCanvasStoreDispatch()`; dev logging of commands |
| `canvasStore.index.ts` | Re-exports for consumers |
| `canvasStore.test.ts` | Test suite (reducer, selectors, store integration) |
**Run tests:** `npm run test:run` (or `npm run test` for watch) in `frontend/`.
**Usage:** Import from `@/app/canvas/canvasStore` or `@/app/canvas/canvasStore.index`:
- `dispatchCanvasCommand({ type: 'graph/setNodes', payload: nodes })`
- `useCanvasStore(selectPathNodeIds)` or `useCanvasStore(selectConnectionStatusForEdge, ...)` (selectors take state; for per-edge/per-node use a factory selector in the component)
- `useCanvasStoreDispatch()` for stable dispatch in components
Migration from existing context: feed the store from CanvasPage (or sync store ↔ existing hooks) and gradually replace context consumers with `useCanvasStore(selector)` and `dispatchCanvasCommand`. See §5 migration path.
---
## 7. Summary
- **Pattern**: one **store** (graph + path + ui), **commands** for all mutations, **selectors** for reads and derived state.
- **Data structures**: flat slices in the store; commands as a discriminated union; selectors as pure functions (state [, args]) → value.
- **Benefits**: controlled, centralized, predictable, easier to debug, and fewer unnecessary redraws via selector-based subscriptions.
- **Concrete next step**: migrate one consumer (e.g. AnimatedEdge) to `useCanvasStore(selectConnectionStatusForEdge)` with a per-edge selector and `dispatchCanvasCommand` for path updates; then remove its ConnectionPathContext dependency.

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,12 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"zustand": "^5.0.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
@@ -44,6 +47,7 @@
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@testing-library/react": "^16.0.0",
"@types/node": "^25.3.3",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
@@ -53,6 +57,7 @@
"shadcn": "^4.0.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^2.1.6"
}
}

View File

@@ -34,7 +34,8 @@ import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath'
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
@@ -186,7 +187,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const [isSelecting, setIsSelecting] = React.useState(false)
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
const connectionPath = useCanvasConnectionPath(edges)
useEffect(() => {
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
}, [nodes, edges])
const connectionPath = useCanvasConnectionPathFromStore()
const nodesRef = useRef(nodes)
nodesRef.current = nodes

View File

@@ -0,0 +1,31 @@
/**
* Canvas store: centralized state + commands + selectors.
* Entry point for store usage.
*/
export {
getCanvasStore,
dispatchCanvasCommand,
useCanvasStore,
useCanvasStoreDispatch,
canvasStore,
} from './canvasStore'
export type { CanvasStore, CanvasCommand } from './canvasStore'
export { initialCanvasStore, canvasStoreReducer } from './canvasStore.reducer'
export type { CanvasStore as CanvasStoreState, PathSlice, UISlice, GraphSlice, ConnectionFrom } from './canvasStore.types'
export {
selectGraph,
selectPath,
selectUI,
selectNodes,
selectEdges,
selectPathNodeIds,
selectPathPausedSegmentNodeIds,
selectPathActiveSegmentNodeIds,
selectConnectionStatusForEdge,
selectPathRoleForNode,
selectRenamingNodeId,
selectFullscreenNodeId,
selectConnectionFrom,
} from './canvasStore.selectors'
export type { ConnectionPathRole } from './canvasStore.selectors'

View File

@@ -0,0 +1,143 @@
/**
* Pure reducer for the canvas store. One command → one state transition.
*/
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import type { CanvasStore, CanvasCommand, GraphSlice, PathSlice, UISlice } from './canvasStore.types'
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
const initialGraph: GraphSlice = {
nodes: [],
edges: [],
}
const initialPath: PathSlice = {
updatingNodeIds: [],
triggerNodeIds: [],
pausedNodeIds: [],
errorNodeIds: [],
}
const initialUI: UISlice = {
renamingNodeId: null,
fullscreenNodeId: null,
connectionFrom: null,
}
export const initialCanvasStore: CanvasStore = {
graph: initialGraph,
path: initialPath,
ui: initialUI,
}
// ---------------------------------------------------------------------------
// Reducer
// ---------------------------------------------------------------------------
function reduceGraph(prev: GraphSlice, cmd: CanvasCommand): GraphSlice {
switch (cmd.type) {
case 'graph/setNodes': {
const next =
typeof cmd.payload === 'function' ? cmd.payload(prev.nodes) : cmd.payload
return { ...prev, nodes: next }
}
case 'graph/setEdges': {
const next =
typeof cmd.payload === 'function' ? cmd.payload(prev.edges) : cmd.payload
return { ...prev, edges: next }
}
case 'graph/apply': {
return {
nodes: cmd.payload.nodes ?? prev.nodes,
edges: cmd.payload.edges ?? prev.edges,
}
}
default:
return prev
}
}
function reducePath(prev: PathSlice, cmd: CanvasCommand): PathSlice {
switch (cmd.type) {
case 'path/addTrigger': {
const id = cmd.payload
if (prev.triggerNodeIds.includes(id)) return prev
return { ...prev, triggerNodeIds: [...prev.triggerNodeIds, id] }
}
case 'path/clearTriggers':
return { ...prev, triggerNodeIds: [] }
case 'path/startUpdate': {
const id = cmd.payload
if (prev.updatingNodeIds.includes(id)) return prev
return { ...prev, updatingNodeIds: [...prev.updatingNodeIds, id] }
}
case 'path/endUpdate': {
const id = cmd.payload
return {
...prev,
updatingNodeIds: prev.updatingNodeIds.filter((x) => x !== id),
}
}
case 'path/setPaused': {
const { nodeId, paused } = cmd.payload
const has = prev.pausedNodeIds.includes(nodeId)
if (paused === has) return prev
return {
...prev,
pausedNodeIds: paused
? [...prev.pausedNodeIds, nodeId]
: prev.pausedNodeIds.filter((x) => x !== nodeId),
}
}
case 'path/setError': {
const { nodeId, error } = cmd.payload
const has = prev.errorNodeIds.includes(nodeId)
if (error === has) return prev
return {
...prev,
errorNodeIds: error
? [...prev.errorNodeIds, nodeId]
: prev.errorNodeIds.filter((x) => x !== nodeId),
}
}
case 'path/clearPathSession':
return {
...initialPath,
errorNodeIds: prev.errorNodeIds,
}
case 'path/clearErrors':
return { ...prev, errorNodeIds: [] }
default:
return prev
}
}
function reduceUI(prev: UISlice, cmd: CanvasCommand): UISlice {
switch (cmd.type) {
case 'ui/setRenaming':
return { ...prev, renamingNodeId: cmd.payload }
case 'ui/setFullscreen':
return { ...prev, fullscreenNodeId: cmd.payload }
case 'ui/setConnectionFrom':
return { ...prev, connectionFrom: cmd.payload }
default:
return prev
}
}
export function canvasStoreReducer(state: CanvasStore, command: CanvasCommand): CanvasStore {
const graph = reduceGraph(state.graph, command)
const path = reducePath(state.path, command)
const ui = reduceUI(state.ui, command)
if (
graph === state.graph &&
path === state.path &&
ui === state.ui
) {
return state
}
return { graph, path, ui }
}

View File

@@ -0,0 +1,129 @@
/**
* Selectors for the canvas store. Pure (state) => value.
* Derived path sets (pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds) are computed here.
*/
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
import type { CanvasStore } from './canvasStore.types'
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path' | null
// ---------------------------------------------------------------------------
// Raw slices
// ---------------------------------------------------------------------------
export function selectGraph(state: CanvasStore) {
return state.graph
}
export function selectPath(state: CanvasStore) {
return state.path
}
export function selectUI(state: CanvasStore) {
return state.ui
}
export function selectNodes(state: CanvasStore) {
return state.graph.nodes
}
export function selectEdges(state: CanvasStore) {
return state.graph.edges
}
// ---------------------------------------------------------------------------
// Derived path (Sets) depend on graph.edges + path primitive arrays
// ---------------------------------------------------------------------------
const emptySet = new Set<string>()
function edgesAsGraphEdges(edges: CanvasStore['graph']['edges']) {
return edges.map((e) => ({ source: e.source, target: e.target }))
}
export function selectPathNodeIds(state: CanvasStore): Set<string> {
const { edges } = state.graph
const { updatingNodeIds, triggerNodeIds, pausedNodeIds } = state.path
return getPathNodeIds(
edgesAsGraphEdges(edges),
updatingNodeIds,
triggerNodeIds,
pausedNodeIds
)
}
export function selectPathPausedSegmentNodeIds(state: CanvasStore): Set<string> {
const pathNodeIds = selectPathNodeIds(state)
const { edges } = state.graph
const { triggerNodeIds, pausedNodeIds } = state.path
return getPausedSegmentNodeIds(
edgesAsGraphEdges(edges),
pathNodeIds,
triggerNodeIds,
pausedNodeIds
)
}
export function selectPathActiveSegmentNodeIds(state: CanvasStore): Set<string> {
const pathNodeIds = selectPathNodeIds(state)
const pausedSegment = selectPathPausedSegmentNodeIds(state)
const active = new Set(pathNodeIds)
pausedSegment.forEach((id) => active.delete(id))
return active
}
// ---------------------------------------------------------------------------
// Per-edge connection status (for AnimatedEdge)
// ---------------------------------------------------------------------------
export function selectConnectionStatusForEdge(
state: CanvasStore,
source: string,
target: string
): ConnectionStatus {
const pathNodeIds = selectPathNodeIds(state)
const pausedSegmentNodeIds = selectPathPausedSegmentNodeIds(state)
const activeSegmentNodeIds = selectPathActiveSegmentNodeIds(state)
const errorTargetNodeIds = new Set(state.path.errorNodeIds)
return getConnectionStatus({
source,
target,
pathNodeIds,
pausedSegmentNodeIds,
activeSegmentNodeIds,
errorTargetNodeIds,
})
}
// ---------------------------------------------------------------------------
// Per-node path role (for BaseNode styling)
// ---------------------------------------------------------------------------
export function selectPathRoleForNode(
state: CanvasStore,
nodeId: string
): ConnectionPathRole {
const pathNodeIds = selectPathNodeIds(state)
if (!pathNodeIds.has(nodeId)) return null
if (state.path.triggerNodeIds.includes(nodeId)) return 'trigger'
if (state.path.updatingNodeIds.includes(nodeId)) return 'updating'
return 'on-path'
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
export function selectRenamingNodeId(state: CanvasStore): string | null {
return state.ui.renamingNodeId
}
export function selectFullscreenNodeId(state: CanvasStore): string | null {
return state.ui.fullscreenNodeId
}
export function selectConnectionFrom(state: CanvasStore) {
return state.ui.connectionFrom
}

View File

@@ -0,0 +1,399 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
initialCanvasStore,
canvasStoreReducer,
getCanvasStore,
dispatchCanvasCommand,
} from './canvasStore'
import {
selectPathNodeIds,
selectPathPausedSegmentNodeIds,
selectPathActiveSegmentNodeIds,
selectConnectionStatusForEdge,
selectPathRoleForNode,
selectNodes,
selectEdges,
selectRenamingNodeId,
selectFullscreenNodeId,
selectConnectionFrom,
} from './canvasStore.selectors'
import type { CanvasStore, CanvasCommand } from './canvasStore.types'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeNode(id: string, type = 'config'): AppNode {
return {
id,
type,
position: { x: 0, y: 0 },
data: {},
}
}
function makeEdge(id: string, source: string, target: string): AppEdge {
return { id, source, target }
}
// ---------------------------------------------------------------------------
// Reducer: graph commands
// ---------------------------------------------------------------------------
describe('canvasStoreReducer', () => {
describe('graph commands', () => {
it('graph/setNodes replaces nodes', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a')],
edges: [],
},
}
const next = canvasStoreReducer(state, {
type: 'graph/setNodes',
payload: [makeNode('b'), makeNode('c')],
})
expect(next.graph.nodes).toHaveLength(2)
expect(next.graph.nodes.map((n) => n.id)).toEqual(['b', 'c'])
expect(next.graph.edges).toEqual(state.graph.edges)
})
it('graph/setNodes with updater function', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a'), makeNode('b')],
edges: [],
},
}
const next = canvasStoreReducer(state, {
type: 'graph/setNodes',
payload: (prev) => prev.filter((n) => n.id !== 'a'),
})
expect(next.graph.nodes).toHaveLength(1)
expect(next.graph.nodes[0].id).toBe('b')
})
it('graph/setEdges replaces edges', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [makeEdge('e1', 'a', 'b')],
},
}
const next = canvasStoreReducer(state, {
type: 'graph/setEdges',
payload: [makeEdge('e2', 'b', 'c')],
})
expect(next.graph.edges).toHaveLength(1)
expect(next.graph.edges[0].id).toBe('e2')
})
it('graph/apply updates nodes and edges', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a')],
edges: [makeEdge('e1', 'a', 'b')],
},
}
const next = canvasStoreReducer(state, {
type: 'graph/apply',
payload: { nodes: [makeNode('x')] },
})
expect(next.graph.nodes).toHaveLength(1)
expect(next.graph.nodes[0].id).toBe('x')
expect(next.graph.edges).toEqual(state.graph.edges)
})
})
describe('path commands', () => {
it('path/addTrigger adds node id', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'path/addTrigger',
payload: 'n1',
})
expect(next.path.triggerNodeIds).toEqual(['n1'])
})
it('path/addTrigger is idempotent', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: { ...initialCanvasStore.path, triggerNodeIds: ['n1'] },
}
const next = canvasStoreReducer(state, {
type: 'path/addTrigger',
payload: 'n1',
})
expect(next).toBe(state)
})
it('path/clearTriggers empties triggerNodeIds', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: { ...initialCanvasStore.path, triggerNodeIds: ['a', 'b'] },
}
const next = canvasStoreReducer(state, { type: 'path/clearTriggers' })
expect(next.path.triggerNodeIds).toEqual([])
})
it('path/startUpdate adds to updatingNodeIds', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'path/startUpdate',
payload: 'n1',
})
expect(next.path.updatingNodeIds).toEqual(['n1'])
})
it('path/endUpdate removes from updatingNodeIds', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: {
...initialCanvasStore.path,
updatingNodeIds: ['n1', 'n2'],
},
}
const next = canvasStoreReducer(state, {
type: 'path/endUpdate',
payload: 'n1',
})
expect(next.path.updatingNodeIds).toEqual(['n2'])
})
it('path/setPaused adds and removes paused node', () => {
const state: CanvasStore = { ...initialCanvasStore }
let next = canvasStoreReducer(state, {
type: 'path/setPaused',
payload: { nodeId: 'n1', paused: true },
})
expect(next.path.pausedNodeIds).toEqual(['n1'])
next = canvasStoreReducer(next, {
type: 'path/setPaused',
payload: { nodeId: 'n1', paused: false },
})
expect(next.path.pausedNodeIds).toEqual([])
})
it('path/setError adds and removes error node', () => {
const state: CanvasStore = { ...initialCanvasStore }
let next = canvasStoreReducer(state, {
type: 'path/setError',
payload: { nodeId: 'n1', error: true },
})
expect(next.path.errorNodeIds).toEqual(['n1'])
next = canvasStoreReducer(next, {
type: 'path/setError',
payload: { nodeId: 'n1', error: false },
})
expect(next.path.errorNodeIds).toEqual([])
})
it('path/clearPathSession resets updating, trigger, paused; keeps error', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: {
updatingNodeIds: ['u1'],
triggerNodeIds: ['t1'],
pausedNodeIds: ['p1'],
errorNodeIds: ['e1'],
},
}
const next = canvasStoreReducer(state, { type: 'path/clearPathSession' })
expect(next.path.updatingNodeIds).toEqual([])
expect(next.path.triggerNodeIds).toEqual([])
expect(next.path.pausedNodeIds).toEqual([])
expect(next.path.errorNodeIds).toEqual(['e1'])
})
})
describe('ui commands', () => {
it('ui/setRenaming updates renamingNodeId', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'ui/setRenaming',
payload: 'node-1',
})
expect(next.ui.renamingNodeId).toBe('node-1')
})
it('ui/setFullscreen updates fullscreenNodeId', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'ui/setFullscreen',
payload: 'node-2',
})
expect(next.ui.fullscreenNodeId).toBe('node-2')
})
it('ui/setConnectionFrom updates connectionFrom', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'ui/setConnectionFrom',
payload: { nodeId: 'n1', sourceHandle: 'out' },
})
expect(next.ui.connectionFrom).toEqual({ nodeId: 'n1', sourceHandle: 'out' })
})
})
describe('no-op returns same reference', () => {
it('path/addTrigger with existing id returns state', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: { ...initialCanvasStore.path, triggerNodeIds: ['a'] },
}
const next = canvasStoreReducer(state, { type: 'path/addTrigger', payload: 'a' })
expect(next).toBe(state)
})
})
})
// ---------------------------------------------------------------------------
// Selectors
// ---------------------------------------------------------------------------
describe('canvasStore selectors', () => {
it('selectNodes and selectEdges return graph slice', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a')],
edges: [makeEdge('e1', 'a', 'b')],
},
}
expect(selectNodes(state)).toHaveLength(1)
expect(selectEdges(state)).toHaveLength(1)
})
it('selectPathNodeIds derives path from edges and path arrays', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a'), makeNode('b'), makeNode('c')],
edges: [
makeEdge('e1', 'a', 'b'),
makeEdge('e2', 'b', 'c'),
],
},
path: {
updatingNodeIds: ['c'],
triggerNodeIds: ['a'],
pausedNodeIds: [],
errorNodeIds: [],
},
}
const pathIds = selectPathNodeIds(state)
expect(pathIds.has('a')).toBe(true)
expect(pathIds.has('b')).toBe(true)
expect(pathIds.has('c')).toBe(true)
})
it('selectConnectionStatusForEdge returns default when edge not on path', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: { nodes: [], edges: [makeEdge('e1', 'a', 'b')] },
path: initialCanvasStore.path,
}
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('default')
})
it('selectConnectionStatusForEdge returns error when target has error', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [makeEdge('e1', 'a', 'b')],
},
path: {
...initialCanvasStore.path,
triggerNodeIds: ['a'],
updatingNodeIds: ['b'],
pausedNodeIds: [],
errorNodeIds: ['b'],
},
}
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('error')
})
it('selectPathRoleForNode returns trigger when node in triggerNodeIds', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [makeEdge('e1', 'a', 'b')],
},
path: {
...initialCanvasStore.path,
triggerNodeIds: ['a'],
updatingNodeIds: ['b'],
pausedNodeIds: [],
errorNodeIds: [],
},
}
expect(selectPathRoleForNode(state, 'a')).toBe('trigger')
expect(selectPathRoleForNode(state, 'b')).toBe('updating')
expect(selectPathRoleForNode(state, 'x')).toBe(null)
})
it('selectRenamingNodeId, selectFullscreenNodeId, selectConnectionFrom return ui slice', () => {
const state: CanvasStore = {
...initialCanvasStore,
ui: {
renamingNodeId: 'r1',
fullscreenNodeId: 'f1',
connectionFrom: { nodeId: 'c1' },
},
}
expect(selectRenamingNodeId(state)).toBe('r1')
expect(selectFullscreenNodeId(state)).toBe('f1')
expect(selectConnectionFrom(state)).toEqual({ nodeId: 'c1' })
})
})
// ---------------------------------------------------------------------------
// Store integration (dispatch + getState)
// ---------------------------------------------------------------------------
describe('canvas store integration', () => {
beforeEach(() => {
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [] })
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [] })
dispatchCanvasCommand({ type: 'path/clearPathSession' })
dispatchCanvasCommand({ type: 'path/clearTriggers' })
dispatchCanvasCommand({ type: 'path/clearErrors' })
dispatchCanvasCommand({ type: 'ui/setRenaming', payload: null })
dispatchCanvasCommand({ type: 'ui/setFullscreen', payload: null })
dispatchCanvasCommand({ type: 'ui/setConnectionFrom', payload: null })
})
it('dispatch graph/setNodes updates getCanvasStore().graph.nodes', () => {
const node = makeNode('test-1')
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [node] })
const state = getCanvasStore()
expect(state.graph.nodes).toHaveLength(1)
expect(state.graph.nodes[0].id).toBe('test-1')
})
it('dispatch path/addTrigger updates path and selectPathNodeIds', () => {
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [makeNode('a'), makeNode('b')] })
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [makeEdge('e1', 'a', 'b')] })
dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'a' })
dispatchCanvasCommand({ type: 'path/startUpdate', payload: 'b' })
const state = getCanvasStore()
expect(state.path.triggerNodeIds).toContain('a')
expect(state.path.updatingNodeIds).toContain('b')
const pathIds = selectPathNodeIds(state)
expect(pathIds.has('a')).toBe(true)
expect(pathIds.has('b')).toBe(true)
})
it('dispatch ui/setFullscreen updates getCanvasStore().ui', () => {
dispatchCanvasCommand({ type: 'ui/setFullscreen', payload: 'full-node' })
const state = getCanvasStore()
expect(state.ui.fullscreenNodeId).toBe('full-node')
})
})

View File

@@ -0,0 +1,74 @@
/**
* Centralized canvas store (Zustand). Single source of truth for graph, path, and UI.
* Mutate only via dispatch(command); read via useCanvasStore(selector) or getCanvasStore().
*/
import { createStore, useStore } from 'zustand'
import type { CanvasStore as CanvasStoreState, CanvasCommand } from './canvasStore.types'
import { initialCanvasStore, canvasStoreReducer } from './canvasStore.reducer'
// ---------------------------------------------------------------------------
// Store type (state + dispatch)
// ---------------------------------------------------------------------------
export type CanvasStoreWithDispatch = CanvasStoreState & {
dispatch: (command: CanvasCommand) => void
}
// ---------------------------------------------------------------------------
// Create store
// ---------------------------------------------------------------------------
function createCanvasStore() {
return createStore<CanvasStoreWithDispatch>((set, get) => {
const applyCommand = (command: CanvasCommand) => {
const prev = get()
const next = canvasStoreReducer(prev, command)
if (next === prev) return
set({ ...next, dispatch: prev.dispatch })
}
const dispatch: (command: CanvasCommand) => void =
typeof import.meta !== 'undefined' && import.meta.env?.DEV
? (command) => {
// eslint-disable-next-line no-console
console.log('[canvas]', command.type, command.payload)
applyCommand(command)
}
: applyCommand
return { ...initialCanvasStore, dispatch }
})
}
const canvasStore = createCanvasStore()
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Get current state (for use outside React or in selectors). Excludes dispatch. */
export function getCanvasStore(): CanvasStoreState {
const s = canvasStore.getState()
return { graph: s.graph, path: s.path, ui: s.ui }
}
/** Dispatch a command. Use for all mutations. */
export function dispatchCanvasCommand(command: CanvasCommand): void {
canvasStore.getState().dispatch(command)
}
/**
* Subscribe to the store. Pass a selector to re-render only when the selected value changes.
* For object/array selectors consider useShallow from 'zustand/react/shallow' to avoid unnecessary re-renders.
*/
export function useCanvasStore<T>(selector: (state: CanvasStoreState) => T): T {
return useStore(canvasStore, selector)
}
/** Hook that returns dispatch (stable reference). */
export function useCanvasStoreDispatch(): (command: CanvasCommand) => void {
return useStore(canvasStore, (s) => s.dispatch, Object.is)
}
export { canvasStore, initialCanvasStore, canvasStoreReducer }
export type { CanvasStoreState as CanvasStore }
export type { CanvasCommand }

View File

@@ -0,0 +1,68 @@
/**
* Canvas store: types for the centralized store (graph + path + ui).
* All mutations go through commands; reads go through selectors.
*/
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
// ---------------------------------------------------------------------------
// Graph slice
// ---------------------------------------------------------------------------
export type GraphSlice = {
nodes: AppNode[]
edges: AppEdge[]
}
// ---------------------------------------------------------------------------
// Path slice (primitive arrays; derived Sets are in selectors)
// ---------------------------------------------------------------------------
export type PathSlice = {
updatingNodeIds: string[]
triggerNodeIds: string[]
pausedNodeIds: string[]
errorNodeIds: string[]
}
// ---------------------------------------------------------------------------
// UI slice
// ---------------------------------------------------------------------------
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
export type UISlice = {
renamingNodeId: string | null
fullscreenNodeId: string | null
connectionFrom: ConnectionFrom
}
// ---------------------------------------------------------------------------
// Full store
// ---------------------------------------------------------------------------
export type CanvasStore = {
graph: GraphSlice
path: PathSlice
ui: UISlice
}
// ---------------------------------------------------------------------------
// Commands (discriminated union)
// ---------------------------------------------------------------------------
export type CanvasCommand =
| { type: 'graph/setNodes'; payload: AppNode[] | ((prev: AppNode[]) => AppNode[]) }
| { type: 'graph/setEdges'; payload: AppEdge[] | ((prev: AppEdge[]) => AppEdge[]) }
| { type: 'graph/apply'; payload: { nodes?: AppNode[]; edges?: AppEdge[] } }
| { type: 'path/addTrigger'; payload: string }
| { type: 'path/clearTriggers' }
| { type: 'path/startUpdate'; payload: string }
| { type: 'path/endUpdate'; payload: string }
| { type: 'path/setPaused'; payload: { nodeId: string; paused: boolean } }
| { type: 'path/setError'; payload: { nodeId: string; error: boolean } }
| { type: 'path/clearPathSession' }
| { type: 'path/clearErrors' }
| { type: 'ui/setRenaming'; payload: string | null }
| { type: 'ui/setFullscreen'; payload: string | null }
| { type: 'ui/setConnectionFrom'; payload: ConnectionFrom }

View File

@@ -0,0 +1,154 @@
/**
* Connection-path state and callbacks backed by the canvas store.
* Replaces useCanvasConnectionPath when the store is the source of truth for path.
* Sync graph to store (nodes, edges) from CanvasPage so path selectors have current edges.
*
* Subscribes only to path and edges (stable refs); derived Sets are computed in useMemo
* so getSnapshot stays stable and we avoid "Maximum update depth" / getSnapshot loops.
*/
import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
dispatchCanvasCommand,
useCanvasStore,
} from '@/app/canvas/canvasStore'
import {
selectPathRoleForNode,
type ConnectionPathRole,
} from '@/app/canvas/canvasStore.selectors'
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
import type { UseCanvasConnectionPathResult } from './useCanvasConnectionPath'
const CONNECTION_PATH_UPDATE_TAIL_MS = 200
function edgesAsGraphEdges(
edges: Array<{ source: string; target: string }>
): Array<{ source: string; target: string }> {
return edges.map((e) => ({ source: e.source, target: e.target }))
}
export function useCanvasConnectionPathFromStore(): UseCanvasConnectionPathResult {
const path = useCanvasStore((s) => s.path)
const edges = useCanvasStore((s) => s.graph.edges)
const pathNodeIds = useMemo(
() =>
getPathNodeIds(
edgesAsGraphEdges(edges),
path.updatingNodeIds,
path.triggerNodeIds,
path.pausedNodeIds
),
[
edges,
path.updatingNodeIds,
path.triggerNodeIds,
path.pausedNodeIds,
]
)
const connectionPathPausedSegmentNodeIds = useMemo(
() =>
getPausedSegmentNodeIds(
edgesAsGraphEdges(edges),
pathNodeIds,
path.triggerNodeIds,
path.pausedNodeIds
),
[edges, pathNodeIds, path.triggerNodeIds, path.pausedNodeIds]
)
const connectionPathActiveSegmentNodeIds = useMemo(() => {
const active = new Set(pathNodeIds)
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
return active
}, [pathNodeIds, connectionPathPausedSegmentNodeIds])
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevUpdatingLengthRef = useRef(path.updatingNodeIds.length)
const startConnectionPathUpdate = useCallback((nodeId: string) => {
dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
}, [])
const endConnectionPathUpdate = useCallback((nodeId: string) => {
dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
}, [])
const addConnectionPathTrigger = useCallback((nodeId: string) => {
dispatchCanvasCommand({ type: 'path/addTrigger', payload: nodeId })
}, [])
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
dispatchCanvasCommand({
type: 'path/setPaused',
payload: { nodeId, paused: true },
})
}, [])
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
dispatchCanvasCommand({
type: 'path/setPaused',
payload: { nodeId, paused: false },
})
}, [])
const addConnectionPathError = useCallback((nodeId: string) => {
dispatchCanvasCommand({
type: 'path/setError',
payload: { nodeId, error: true },
})
}, [])
const removeConnectionPathError = useCallback((nodeId: string) => {
dispatchCanvasCommand({
type: 'path/setError',
payload: { nodeId, error: false },
})
}, [])
useEffect(() => {
const prev = prevUpdatingLengthRef.current
const now = path.updatingNodeIds.length
prevUpdatingLengthRef.current = now
if (prev > 0 && now === 0) {
if (pathUpdateEndTimeoutRef.current != null) {
clearTimeout(pathUpdateEndTimeoutRef.current)
}
pathUpdateEndTimeoutRef.current = setTimeout(() => {
pathUpdateEndTimeoutRef.current = null
dispatchCanvasCommand({ type: 'path/clearPathSession' })
}, CONNECTION_PATH_UPDATE_TAIL_MS)
}
return () => {
if (pathUpdateEndTimeoutRef.current != null) {
clearTimeout(pathUpdateEndTimeoutRef.current)
}
}
}, [path.updatingNodeIds.length])
return {
connectionPathUpdatingNodeIds: path.updatingNodeIds,
connectionPathTriggerNodeIds: path.triggerNodeIds,
connectionPathPausedNodeIds: path.pausedNodeIds,
connectionPathErrorNodeIds: path.errorNodeIds,
connectionPathNodeIds: pathNodeIds,
connectionPathPausedSegmentNodeIds: connectionPathPausedSegmentNodeIds,
connectionPathActiveSegmentNodeIds: connectionPathActiveSegmentNodeIds,
startConnectionPathUpdate,
endConnectionPathUpdate,
addConnectionPathTrigger,
addConnectionPathPausedNode,
removeConnectionPathPausedNode,
addConnectionPathError,
removeConnectionPathError,
}
}
/**
* Path role for a node (trigger / updating / on-path) from the store.
* Use in node components so they only re-render when their path role changes.
*/
export function useConnectionPathRoleFromStore(nodeId: string | undefined): ConnectionPathRole {
return useCanvasStore((s) =>
nodeId != null ? selectPathRoleForNode(s, nodeId) : null
)
}

View File

@@ -1,15 +1,15 @@
import React, { useContext, useMemo, memo } from 'react'
import React, { memo } from 'react'
import {
BaseEdge,
getBezierPath,
type EdgeProps,
} from '@xyflow/react'
import { ConnectionPathContext } from '@/lib/graph/flowContext'
import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
import { useCanvasStore } from '@/app/canvas/canvasStore'
import { selectConnectionStatusForEdge } from '@/app/canvas/canvasStore.selectors'
import { CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
const EDGE_STROKE_WIDTH = 2
const DOT_MARKER_R = 1.5
const EMPTY_PATH_NODE_IDS = new Set<string>()
function AnimatedEdgeInner({
id,
@@ -26,38 +26,13 @@ function AnimatedEdgeInner({
target,
data,
}: EdgeProps) {
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
const errorTargetNodeIds = useMemo(
() => new Set(ctx?.connectionPathErrorNodeIds ?? []),
[ctx?.connectionPathErrorNodeIds]
)
const label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
const connectionStatus = useMemo(
() =>
getConnectionStatus({
source,
target,
pathNodeIds,
pausedSegmentNodeIds,
activeSegmentNodeIds,
errorTargetNodeIds,
}),
[
source,
target,
pathNodeIds,
pausedSegmentNodeIds,
activeSegmentNodeIds,
errorTargetNodeIds,
]
const connectionStatus = useCanvasStore((s) =>
selectConnectionStatusForEdge(s, source, target)
)
const statusClass = CONNECTION_STATUS_CLASS[connectionStatus]
const label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
sourceX,
sourceY,

View File

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

View File

@@ -4,10 +4,10 @@
* pipeline interface.
*/
import { useCallback, useContext, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useAbstractNode } from '@/lib/graph/abstractNode'
import { ConnectionPathContext } from '@/lib/graph/flowContext'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
@@ -189,10 +189,7 @@ export function useRenderingNodeState(
const lastManualRunTriggerRef = useRef(0)
const manualRunTriggerSyncedRef = useRef(false)
const pathCtx = useContext(ConnectionPathContext)
const pathCtxRef = useRef(pathCtx)
pathCtxRef.current = pathCtx
const triggerNodeIds = pathCtx?.connectionPathTriggerNodeIds ?? []
const triggerNodeIds = useCanvasStore((s) => s.path.triggerNodeIds)
const updateDataRef = useRef(updateData)
updateDataRef.current = updateData
@@ -265,7 +262,7 @@ export function useRenderingNodeState(
let cancelled = false
const run = async () => {
pathCtxRef.current?.addConnectionPathTrigger?.(id)
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
loadingStartedAtRef.current = Date.now()
setLoading(true)
setError(null)

View File

@@ -15,8 +15,9 @@
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
*/
import React, { useCallback, useContext, useMemo, useRef } from 'react'
import { GraphContext, ConnectionPathContext } from './flowContext'
import React, { useCallback, useContext, useMemo } from 'react'
import { GraphContext } from './flowContext'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { nodePropsAreEqual } from './flowUtils'
import type { AppNode } from './nodeTypes'
@@ -81,9 +82,6 @@ export function useAbstractNode<TData = Record<string, unknown>>(
data: TData
): AbstractNodeContext<TData> {
const graphCtx = useContext(GraphContext)
const pathCtx = useContext(ConnectionPathContext)
const addTriggerRef = useRef(pathCtx?.addConnectionPathTrigger)
addTriggerRef.current = pathCtx?.addConnectionPathTrigger
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
const edges = graphCtx?.edges ?? []
const setNodes = graphCtx?.setNodes
@@ -97,7 +95,7 @@ export function useAbstractNode<TData = Record<string, unknown>>(
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
) as AppNode[]
)
addTriggerRef.current?.(id)
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
},
[id, setNodes]
)

View File

@@ -5,8 +5,7 @@
* ## 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).
* This hook dispatches to the canvas store; edges read path state via selectors.
*
* ## Lifecycle phases (conceptual)
*
@@ -19,14 +18,14 @@
* Priority for edge status: error > paused > updating > default.
*/
import { useContext, useEffect, useRef } from 'react'
import { ConnectionPathContext } from './flowContext'
import { useEffect, useRef } from 'react'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
/**
* State that drives connection status for this node.
* Pass the current values from your node; the hook syncs them to FlowContext.
* Pass the current values from your node; the hook syncs them to the canvas store.
*/
export type NodeConnectionStatusState = {
/** Node is doing async work (e.g. loading, running). Incoming/outgoing path edges show blue. */
@@ -38,9 +37,9 @@ export type NodeConnectionStatusState = {
}
/**
* Syncs this node's lifecycle state to FlowContext so connection status (edge colors)
* Syncs this node's lifecycle state to the canvas store so connection status (edge colors)
* and path animation are correct. Call once per node with the current updating/error/paused
* state; the hook will add/remove this node from the appropriate sets.
* state; the hook will dispatch path commands to add/remove this node from the appropriate sets.
*
* Use in any node that can be updating, in error, or paused:
*
@@ -53,39 +52,33 @@ export function useSyncConnectionStatus(
nodeId: string,
state: NodeConnectionStatusState
): void {
const ctx = useContext(ConnectionPathContext)
const ctxRef = useRef(ctx)
ctxRef.current = ctx
const { updating, error, paused } = state
const prevRef = useRef({ updating: false, error: false, paused: false })
useEffect(() => {
const pathCtx = ctxRef.current
const prev = prevRef.current
const nowUpdating = Boolean(updating)
const nowError = Boolean(error)
const nowPaused = Boolean(paused)
if (prev.updating !== nowUpdating) {
if (nowUpdating) pathCtx?.startConnectionPathUpdate?.(nodeId)
else pathCtx?.endConnectionPathUpdate?.(nodeId)
if (nowUpdating) dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
else dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
prev.updating = nowUpdating
}
if (prev.error !== nowError) {
if (nowError) pathCtx?.addConnectionPathError?.(nodeId)
else pathCtx?.removeConnectionPathError?.(nodeId)
dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: nowError } })
prev.error = nowError
}
if (prev.paused !== nowPaused) {
if (nowPaused) pathCtx?.addConnectionPathPausedNode?.(nodeId)
else pathCtx?.removeConnectionPathPausedNode?.(nodeId)
dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: nowPaused } })
prev.paused = nowPaused
}
return () => {
if (prevRef.current.updating) pathCtx?.endConnectionPathUpdate?.(nodeId)
if (prevRef.current.error) pathCtx?.removeConnectionPathError?.(nodeId)
if (prevRef.current.paused) pathCtx?.removeConnectionPathPausedNode?.(nodeId)
if (prevRef.current.updating) dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
if (prevRef.current.error) dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: false } })
if (prevRef.current.paused) dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: false } })
prevRef.current = { updating: false, error: false, paused: false }
}
}, [nodeId, updating, error, paused])

15
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
environment: 'node',
globals: true,
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})