Files
ComfyUI_frontend/browser_tests/tests/changeTracker.spec.ts
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

314 lines
9.9 KiB
TypeScript

import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { WorkspaceStore } from '@e2e/types/globals'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
type ChangeTrackerDebugState = {
changeCount: number
graphMatchesActiveState: boolean
isLoadingGraph: boolean
isModified: boolean | undefined
redoQueueSize: number
restoringState: boolean
undoQueueSize: number
}
async function getChangeTrackerDebugState(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate(() => {
type ChangeTrackerClassLike = {
graphEqual: (left: unknown, right: unknown) => boolean
isLoadingGraph: boolean
}
type ChangeTrackerLike = {
_restoringState: boolean
activeState: unknown
changeCount: number
constructor: ChangeTrackerClassLike
redoQueue: unknown[]
undoQueue: unknown[]
}
type ActiveWorkflowLike = {
changeTracker?: ChangeTrackerLike
isModified?: boolean
}
const workflowStore = window.app!.extensionManager as WorkspaceStore
const workflow = workflowStore.workflow
.activeWorkflow as ActiveWorkflowLike | null
const tracker = workflow?.changeTracker
if (!workflow || !tracker) {
throw new Error('Active workflow change tracker is not available')
}
const currentState = JSON.parse(
JSON.stringify(window.app!.rootGraph.serialize())
)
return {
changeCount: tracker.changeCount,
graphMatchesActiveState: tracker.constructor.graphEqual(
tracker.activeState,
currentState
),
isLoadingGraph: tracker.constructor.isLoadingGraph,
isModified: workflow.isModified,
redoQueueSize: tracker.redoQueue.length,
restoringState: tracker._restoringState,
undoQueueSize: tracker.undoQueue.length
} satisfies ChangeTrackerDebugState
})
}
async function waitForChangeTrackerSettled(
comfyPage: ComfyPage,
expected: Pick<
ChangeTrackerDebugState,
'isModified' | 'redoQueueSize' | 'undoQueueSize'
>
) {
// Visible node flags can flip before undo finishes loadGraphData() and
// updates the tracker. Poll the tracker's own settled state so we do not
// start the next transaction while captureCanvasState() is still gated.
await expect
.poll(() => getChangeTrackerDebugState(comfyPage))
.toMatchObject({
changeCount: 0,
graphMatchesActiveState: true,
isLoadingGraph: false,
restoringState: false,
...expected
})
}
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window.app!.canvas!.emitBeforeChange()
})
}
async function afterChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window.app!.canvas!.emitAfterChange()
})
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Change Tracker', { tag: '@workflow' }, () => {
test.describe('Undo/Redo', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Can undo multiple operations', async ({ comfyPage }) => {
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(0)
// Save, confirm no errors & workflow modified flag removed
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(0)
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(0)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(2)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(0)
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await waitForChangeTrackerSettled(comfyPage, {
isModified: true,
redoQueueSize: 1,
undoQueueSize: 1
})
await comfyPage.keyboard.undo()
await expect(node).not.toBeCollapsed()
await waitForChangeTrackerSettled(comfyPage, {
isModified: false,
redoQueueSize: 2,
undoQueueSize: 0
})
})
})
test('Can group multiple change actions into a single transaction', async ({
comfyPage
}) => {
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
expect(node).toBeTruthy()
await expect(node).not.toBeCollapsed()
await expect(node).not.toBeBypassed()
// Make changes outside set
// Bypass + collapse node
await node.click('title')
await node.click('collapse')
await comfyPage.keyboard.bypass()
await expect(node).toBeCollapsed()
await expect(node).toBeBypassed()
// Undo, undo, ensure both changes undone
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).toBeCollapsed()
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
await waitForChangeTrackerSettled(comfyPage, {
isModified: false,
redoQueueSize: 2,
undoQueueSize: 0
})
// Prevent clicks registering a double-click
await comfyPage.canvasOps.clickEmptySpace()
await node.click('title')
// Run again, but within a change transaction
await beforeChange(comfyPage)
await node.click('collapse')
await comfyPage.keyboard.bypass()
await expect(node).toBeCollapsed()
await expect(node).toBeBypassed()
// End transaction
await afterChange(comfyPage)
await waitForChangeTrackerSettled(comfyPage, {
isModified: true,
redoQueueSize: 0,
undoQueueSize: 1
})
// Ensure undo reverts both changes
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
await waitForChangeTrackerSettled(comfyPage, {
isModified: false,
redoQueueSize: 1,
undoQueueSize: 0
})
})
test('Can nest multiple change transactions without adding undo steps', async ({
comfyPage
}) => {
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const bypassAndPin = async () => {
await beforeChange(comfyPage)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
await comfyPage.keyboard.press('KeyP')
await expect(node).toBePinned()
await afterChange(comfyPage)
}
const collapse = async () => {
await beforeChange(comfyPage)
await node.click('collapse', { moveMouseToEmptyArea: true })
await expect(node).toBeCollapsed()
await afterChange(comfyPage)
}
const multipleChanges = async () => {
await beforeChange(comfyPage)
// Call other actions that uses begin/endChange
await node.click('title')
await collapse()
await bypassAndPin()
await afterChange(comfyPage)
}
await multipleChanges()
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).not.toBePinned()
await expect(node).not.toBeCollapsed()
await comfyPage.keyboard.redo()
await expect(node).toBeBypassed()
await expect(node).toBePinned()
await expect(node).toBeCollapsed()
})
test('Can detect changes in workflow.extra', async ({ comfyPage }) => {
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.page.evaluate(() => {
window.app!.graph!.extra.foo = 'bar'
})
// Click empty space to trigger a change detection.
await comfyPage.canvasOps.clickEmptySpace()
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
})
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
})
test('Undo preserves viewport offset', async ({ comfyPage }) => {
// Pan to a distinct offset so we can detect drift
await comfyPage.canvasOps.pan({ x: 200, y: 150 })
const viewportBefore = await comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
})
// Make a graph change so we have something to undo
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
// Undo the collapse — viewport should be preserved
await comfyPage.keyboard.undo()
await expect(node).not.toBeCollapsed()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2_000 }
)
.toEqual({
scale: expect.closeTo(viewportBefore.scale, 2),
offset: [
expect.closeTo(viewportBefore.offset[0], 0),
expect.closeTo(viewportBefore.offset[1], 0)
]
})
})
})