diff --git a/frontend/docs/CANVAS_STATE_DESIGN.md b/frontend/docs/CANVAS_STATE_DESIGN.md new file mode 100644 index 0000000..b9d01b4 --- /dev/null +++ b/frontend/docs/CANVAS_STATE_DESIGN.md @@ -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 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()` 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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e6da2f4..e6d67a5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,9 +41,11 @@ "react-simple-code-editor": "^0.14.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zustand": "^5.0.2" }, "devDependencies": { + "@testing-library/react": "^16.0.0", "@types/node": "^25.3.3", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", @@ -53,7 +55,8 @@ "shadcn": "^4.0.0", "tailwindcss": "^3.4.0", "typescript": "^5.0.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^2.1.6" } }, "node_modules/@alloc/quick-lru": { @@ -507,6 +510,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -3072,6 +3085,55 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -3084,6 +3146,14 @@ "path-browserify": "^1.0.1" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3264,6 +3334,92 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wireweave/core": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@wireweave/core/-/core-2.6.0.tgz", @@ -3285,6 +3441,34 @@ "react-dom": ">=17" } }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@xyflow/system": { "version": "0.0.75", "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", @@ -3462,12 +3646,33 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -3663,6 +3868,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3734,6 +3949,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -3747,6 +3979,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4253,6 +4495,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -4316,6 +4568,17 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -4344,6 +4607,14 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -4461,6 +4732,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4547,6 +4825,16 @@ "node": ">=4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4607,6 +4895,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -5628,6 +5926,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5647,6 +5952,27 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-to-jsx": { "version": "9.7.9", "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.7.9.tgz", @@ -6306,6 +6632,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6535,6 +6878,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -6698,6 +7082,14 @@ "react": "^18.2.0" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -7288,6 +7680,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7337,6 +7736,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7347,6 +7753,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -7607,6 +8020,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -7633,6 +8053,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.24", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz", @@ -8005,6 +8455,1109 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -8031,6 +9584,23 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -8253,20 +9823,18 @@ } }, "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, "engines": { - "node": ">=12.7.0" + "node": ">=12.20.0" }, "peerDependencies": { - "@types/react": ">=16.8", + "@types/react": ">=18.0.0", "immer": ">=9.0.6", - "react": ">=16.8" + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" }, "peerDependenciesMeta": { "@types/react": { @@ -8277,6 +9845,9 @@ }, "react": { "optional": true + }, + "use-sync-external-store": { + "optional": true } } } diff --git a/frontend/package.json b/frontend/package.json index 447090a..eca1457 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index 2152c98..b6429c6 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -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(null) const [fullscreenNodeId, setFullscreenNodeId] = React.useState(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 diff --git a/frontend/src/app/canvas/canvasStore.index.ts b/frontend/src/app/canvas/canvasStore.index.ts new file mode 100644 index 0000000..3aee198 --- /dev/null +++ b/frontend/src/app/canvas/canvasStore.index.ts @@ -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' diff --git a/frontend/src/app/canvas/canvasStore.reducer.ts b/frontend/src/app/canvas/canvasStore.reducer.ts new file mode 100644 index 0000000..9b59fad --- /dev/null +++ b/frontend/src/app/canvas/canvasStore.reducer.ts @@ -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 } +} diff --git a/frontend/src/app/canvas/canvasStore.selectors.ts b/frontend/src/app/canvas/canvasStore.selectors.ts new file mode 100644 index 0000000..34625cb --- /dev/null +++ b/frontend/src/app/canvas/canvasStore.selectors.ts @@ -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() + +function edgesAsGraphEdges(edges: CanvasStore['graph']['edges']) { + return edges.map((e) => ({ source: e.source, target: e.target })) +} + +export function selectPathNodeIds(state: CanvasStore): Set { + const { edges } = state.graph + const { updatingNodeIds, triggerNodeIds, pausedNodeIds } = state.path + return getPathNodeIds( + edgesAsGraphEdges(edges), + updatingNodeIds, + triggerNodeIds, + pausedNodeIds + ) +} + +export function selectPathPausedSegmentNodeIds(state: CanvasStore): Set { + 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 { + 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 +} diff --git a/frontend/src/app/canvas/canvasStore.test.ts b/frontend/src/app/canvas/canvasStore.test.ts new file mode 100644 index 0000000..222aa03 --- /dev/null +++ b/frontend/src/app/canvas/canvasStore.test.ts @@ -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') + }) +}) diff --git a/frontend/src/app/canvas/canvasStore.ts b/frontend/src/app/canvas/canvasStore.ts new file mode 100644 index 0000000..e2a8bdf --- /dev/null +++ b/frontend/src/app/canvas/canvasStore.ts @@ -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((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(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 } diff --git a/frontend/src/app/canvas/canvasStore.types.ts b/frontend/src/app/canvas/canvasStore.types.ts new file mode 100644 index 0000000..ccc458b --- /dev/null +++ b/frontend/src/app/canvas/canvasStore.types.ts @@ -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 } diff --git a/frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts b/frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts new file mode 100644 index 0000000..d252c70 --- /dev/null +++ b/frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts @@ -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 | 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 + ) +} diff --git a/frontend/src/components/graph/AnimatedEdge.tsx b/frontend/src/components/graph/AnimatedEdge.tsx index 4750cb4..742c25a 100644 --- a/frontend/src/components/graph/AnimatedEdge.tsx +++ b/frontend/src/components/graph/AnimatedEdge.tsx @@ -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() 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, diff --git a/frontend/src/components/graph/BaseNode.tsx b/frontend/src/components/graph/BaseNode.tsx index 09b9772..2cf5f43 100644 --- a/frontend/src/components/graph/BaseNode.tsx +++ b/frontend/src/components/graph/BaseNode.tsx @@ -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 && diff --git a/frontend/src/components/nodes/render/useRenderingNodeState.ts b/frontend/src/components/nodes/render/useRenderingNodeState.ts index e2cc60b..a75561c 100644 --- a/frontend/src/components/nodes/render/useRenderingNodeState.ts +++ b/frontend/src/components/nodes/render/useRenderingNodeState.ts @@ -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) diff --git a/frontend/src/lib/graph/abstractNode.ts b/frontend/src/lib/graph/abstractNode.ts index d765c6f..d5f9a23 100644 --- a/frontend/src/lib/graph/abstractNode.ts +++ b/frontend/src/lib/graph/abstractNode.ts @@ -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>( data: TData ): AbstractNodeContext { 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>( n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n ) as AppNode[] ) - addTriggerRef.current?.(id) + dispatchCanvasCommand({ type: 'path/addTrigger', payload: id }) }, [id, setNodes] ) diff --git a/frontend/src/lib/graph/nodeLifecycle.ts b/frontend/src/lib/graph/nodeLifecycle.ts index 5a3ba9f..1ed0714 100644 --- a/frontend/src/lib/graph/nodeLifecycle.ts +++ b/frontend/src/lib/graph/nodeLifecycle.ts @@ -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]) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..30fe93d --- /dev/null +++ b/frontend/vitest.config.ts @@ -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'), + }, + }, +})