Files
ComfyUI_frontend/docs/architecture/change-tracker.md
jaeone94 a1e6fb36d2 refactor: harden ChangeTracker lifecycle with self-defending API (#10816)
## 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
2026-04-16 12:54:12 +00:00

7.1 KiB

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

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:

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