Files
zui/frontend/docs/CANVAS_STATE_DESIGN.md
2026-03-13 00:32:19 +01:00

219 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.