mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
## Summary Harden the `ChangeTracker` lifecycle to eliminate the class of bugs where an inactive workflow's tracker silently captures the wrong graph state. Renames `checkState()` to `captureCanvasState()` with a self-defending assertion, introduces `deactivate()` and `prepareForSave()` lifecycle methods, and closes a latent undo-history corruption bug discovered during code review. ## Background ComfyUI supports multiple workflows open as tabs, but only one canvas (`app.rootGraph`) exists at a time. When the user switches tabs, the old workflow's graph is unloaded and the new one is loaded into this shared canvas. The old `checkState()` method serialized `app.rootGraph` into `activeState` to track changes for undo/redo. It had no awareness of *which* workflow it belonged to -- if called on an inactive tab's tracker, it would capture the active tab's graph data and silently overwrite the inactive workflow's state. This caused permanent data loss (fixed in PR #10745 with caller-side `isActive` guards). The caller-side guards were fragile: every new call site had to remember to add the guard, and forgetting would reintroduce the same silent data corruption. Additionally, `beforeLoadNewGraph` only called `store()` (viewport/outputs) without `checkState()`, meaning canvas state could be stale if a tab switch happened without a preceding mouseup event. ### Before (fragile) ``` saveWorkflow(workflow): if (isActive(workflow)) <-- caller must remember this guard workflow.changeTracker.checkState() <-- name implies "read", actually writes ... beforeLoadNewGraph(): activeWorkflow.changeTracker.store() <-- only saves viewport, NOT graph state ``` ### After (self-defending) ``` saveWorkflow(workflow): workflow.changeTracker.prepareForSave() <-- handles active/inactive internally ... beforeLoadNewGraph(): activeWorkflow.changeTracker.deactivate() <-- captures graph + viewport together ``` ## Changes - Rename `checkState` to `captureCanvasState` with active-tracker assertion - Add `deactivate()` and `prepareForSave()` lifecycle methods - Fix undo-history corruption: `captureCanvasState()` guarded by `_restoringState` - Fix viewport regression during undo: `deactivate()` skips `captureCanvasState()` during undo/redo but always calls `store()` to preserve viewport (regression from PR #10247) - Log inactive tracker warnings unconditionally at warn level (not DEV-only) - Deprecated `checkState()` wrapper for extension compatibility - Rename `checkState` to `captureCanvasState` in `useWidgetSelectActions` composable - Add `appModeStore.ts` to manual call sites documentation - Add `checkState()` deprecation note to architecture docs - Add 16 unit tests covering all guard conditions, lifecycle methods, and undo behavior - Add E2E test: "Undo preserves viewport offset" ## New ChangeTracker Public API | Method | Caller | Purpose | |--------|--------|---------| | `captureCanvasState()` | Event handlers, UI interactions | Snapshots canvas into activeState, pushes undo. Asserts active tracker. | | `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()` (skipped during undo/redo) + `store()`. Freezes state for tab switch. | | `prepareForSave()` | Save paths only | Active: `captureCanvasState()`. Inactive: no-op. | | `checkState()` | **Deprecated** -- extensions only | Wrapper that delegates to `captureCanvasState()` with deprecation warning. | | `store()` | Internal to `deactivate()` | Saves viewport, outputs, subgraph navigation. | | `restore()` | `afterLoadNewGraph` | Restores viewport, outputs, subgraph navigation. | | `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks as "clean"). | ## Test plan - [x] Unit tests: 16 tests covering all guard conditions, state capture, undo queue behavior - [x] E2E test: "Undo preserves viewport offset" verifies no viewport drift on undo - [x] E2E test: "Prevents captureCanvasState from corrupting workflow state during tab switch" - [x] Existing E2E: "Closing an inactive tab with save preserves its own content" - [ ] Manual: rapidly switch tabs during undo/redo, verify no viewport drift - [ ] Manual: verify extensions calling `checkState()` see deprecation warning in console
118 lines
7.1 KiB
Markdown
118 lines
7.1 KiB
Markdown
# Change Tracker (Undo/Redo System)
|
|
|
|
The `ChangeTracker` class (`src/scripts/changeTracker.ts`) manages undo/redo
|
|
history by comparing serialized graph snapshots.
|
|
|
|
## How It Works
|
|
|
|
`captureCanvasState()` is the core method. It:
|
|
|
|
1. Serializes the current graph via `app.rootGraph.serialize()`
|
|
2. Deep-compares the result against the last known `activeState`
|
|
3. If different, pushes `activeState` onto `undoQueue` and replaces it
|
|
|
|
**It is not reactive.** Changes to the graph (widget values, node positions,
|
|
links, etc.) are only captured when `captureCanvasState()` is explicitly triggered.
|
|
|
|
**INVARIANT:** `captureCanvasState()` asserts that it is called on the active
|
|
workflow's tracker. Calling it on an inactive tracker logs a warning and
|
|
returns early, preventing cross-workflow data corruption.
|
|
|
|
## Automatic Triggers
|
|
|
|
These are set up once in `ChangeTracker.init()`:
|
|
|
|
| Trigger | Event / Hook | What It Catches |
|
|
| ----------------------------------- | -------------------------------------------------- | --------------------------------------------------- |
|
|
| Keyboard (non-modifier, non-repeat) | `window` `keydown` | Shortcuts, typing in canvas |
|
|
| Modifier key release | `window` `keyup` | Releasing Ctrl/Shift/Alt/Meta |
|
|
| Mouse click | `window` `mouseup` | General clicks on native DOM |
|
|
| Canvas mouse up | `LGraphCanvas.processMouseUp` override | LiteGraph canvas interactions |
|
|
| Number/string dialog | `LGraphCanvas.prompt` override | Dialog popups for editing widgets |
|
|
| Context menu close | `LiteGraph.ContextMenu.close` override | COMBO widget menus in LiteGraph |
|
|
| Active input element | `bindInput` (change/input/blur on focused element) | Native HTML input edits |
|
|
| Prompt queued | `api` `promptQueued` event | Dynamic widget changes on queue |
|
|
| Graph cleared | `api` `graphCleared` event | Full graph clear |
|
|
| Transaction end | `litegraph:canvas` `after-change` event | Batched operations via `beforeChange`/`afterChange` |
|
|
|
|
## When You Must Call `captureCanvasState()` Manually
|
|
|
|
The automatic triggers above are designed around LiteGraph's native DOM
|
|
rendering. They **do not cover**:
|
|
|
|
- **Vue-rendered widgets** — Vue handles events internally without triggering
|
|
native DOM events that the tracker listens to (e.g., `mouseup` on a Vue
|
|
dropdown doesn't bubble the same way as a native LiteGraph widget click)
|
|
- **Programmatic graph mutations** — Any code that modifies the graph outside
|
|
of user interaction (e.g., applying a template, pasting nodes, aligning)
|
|
- **Async operations** — File uploads, API calls that change widget values
|
|
after the initial user gesture
|
|
|
|
### Pattern for Manual Calls
|
|
|
|
```typescript
|
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
|
|
|
// After mutating the graph:
|
|
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
|
```
|
|
|
|
### Existing Manual Call Sites
|
|
|
|
These locations call `captureCanvasState()` directly:
|
|
|
|
- `WidgetSelectDropdown.vue` — After dropdown selection and file upload
|
|
- `ColorPickerButton.vue` — After changing node colors
|
|
- `NodeSearchBoxPopover.vue` — After adding a node from search
|
|
- `builderViewOptions.ts` — After setting default view
|
|
- `useSelectionOperations.ts` — After align, copy, paste, duplicate, group
|
|
- `useSelectedNodeActions.ts` — After pin, bypass, collapse
|
|
- `useGroupMenuOptions.ts` — After group operations
|
|
- `useSubgraphOperations.ts` — After subgraph enter/exit
|
|
- `useCanvasRefresh.ts` — After canvas refresh
|
|
- `useCoreCommands.ts` — After metadata/subgraph commands
|
|
- `appModeStore.ts` — After app mode transitions
|
|
|
|
`workflowService.ts` calls `captureCanvasState()` indirectly via
|
|
`deactivate()` and `prepareForSave()` (see Lifecycle Methods below).
|
|
|
|
> **Deprecated:** `checkState()` is an alias for `captureCanvasState()` kept
|
|
> for extension compatibility. Extension authors should migrate to
|
|
> `captureCanvasState()`. See the `@deprecated` JSDoc on the method.
|
|
|
|
## Lifecycle Methods
|
|
|
|
| Method | Caller | Purpose |
|
|
| ---------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
| `captureCanvasState()` | Event handlers, UI interactions | Snapshots canvas into activeState, pushes undo. Asserts active tracker. |
|
|
| `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()` (skipped during undo/redo) + `store()`. Freezes state for tab switch. Must be called while this workflow is still active. |
|
|
| `prepareForSave()` | Save paths only | Active: calls `captureCanvasState()`. Inactive: no-op (state was frozen by `deactivate()`). |
|
|
| `store()` | Internal to `deactivate()` | Saves viewport scale/offset, node outputs, subgraph navigation. |
|
|
| `restore()` | `afterLoadNewGraph` | Restores viewport, outputs, subgraph navigation. |
|
|
| `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks workflow as "clean"). |
|
|
|
|
## Transaction Guards
|
|
|
|
For operations that make multiple changes that should be a single undo entry:
|
|
|
|
```typescript
|
|
changeTracker.beforeChange()
|
|
// ... multiple graph mutations ...
|
|
changeTracker.afterChange() // calls captureCanvasState() when nesting count hits 0
|
|
```
|
|
|
|
The `litegraph:canvas` custom event also supports this with `before-change` /
|
|
`after-change` sub-types.
|
|
|
|
## Key Invariants
|
|
|
|
- `captureCanvasState()` asserts it is called on the active workflow's tracker;
|
|
inactive trackers get an early return (and a warning log)
|
|
- `captureCanvasState()` is a no-op during `loadGraphData` (guarded by
|
|
`isLoadingGraph`) to prevent cross-workflow corruption
|
|
- `captureCanvasState()` is a no-op during undo/redo (guarded by
|
|
`_restoringState`) to prevent undo history corruption
|
|
- `captureCanvasState()` is a no-op when `changeCount > 0` (inside a transaction)
|
|
- `undoQueue` is capped at 50 entries (`MAX_HISTORY`)
|
|
- `graphEqual` ignores node order and `ds` (pan/zoom) when comparing
|