219 lines
10 KiB
Markdown
219 lines
10 KiB
Markdown
# 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 hasn’t 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 edge’s 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 edge’s 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. Zustand’s `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.
|