mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
fix: prevent saving active workflow content to inactive tab on close (#10745)
## Summary - Closing an inactive workflow tab and clicking "Save" overwrites that workflow with the **active** tab's content, causing permanent data loss - `saveWorkflow()` and `saveWorkflowAs()` call `checkState()` which serializes `app.rootGraph` (the active canvas) into the inactive workflow's `changeTracker.activeState` - Guard `checkState()` to only run when the workflow being saved is the active one — in both `saveWorkflow` and `saveWorkflowAs` ## Linked Issues - Fixes https://github.com/Comfy-Org/ComfyUI/issues/13230 ## Root Cause PR #9137 (commit `9fb93a5b0`, v1.41.7) added `workflow.changeTracker?.checkState()` inside `saveWorkflow()` and `saveWorkflowAs()`. `checkState()` always serializes `app.rootGraph` — the graph on the canvas. When called on an inactive tab's change tracker, it captures the active tab's data instead. ## Test plan - [x] E2E: "Closing an inactive tab with save preserves its own content" — persisted workflow B with added node, close while A is active, re-open and verify - [x] E2E: "Closing an inactive unsaved tab with save preserves its own content" — temporary workflow B with added node, close while A is active, save-as with filename, re-open and verify - [x] Manual: open A and B, edit B, switch to A, close B tab, click Save, re-open B — content should be B's not A's
This commit is contained in:
@@ -323,6 +323,174 @@ test.describe('Workflow Persistence', () => {
|
||||
expect(linkCountAfter).toBe(linkCountBefore)
|
||||
})
|
||||
|
||||
test('Closing an inactive tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const suffix = Date.now().toString(36)
|
||||
const nameA = `test-A-${suffix}`
|
||||
const nameB = `test-B-${suffix}`
|
||||
|
||||
// Save the default workflow as A
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameA)
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Create B: duplicate and save
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameB)
|
||||
|
||||
// Add a Note node in B to mark it as modified
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountB).toBe(nodeCountA + 1)
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
// Switch to A via topbar tab (making B inactive)
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Close inactive B tab via middle-click — triggers "Save before closing?"
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameB).click({
|
||||
button: 'middle'
|
||||
})
|
||||
|
||||
// Click "Save" in the dirty close dialog
|
||||
const saveButton = comfyPage.page.getByRole('button', { name: 'Save' })
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify we're still on A with A's content
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Re-open B from sidebar saved list
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(nameB).dblclick()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
// B should have the extra Note node we added, not A's node count
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Closing an inactive unsaved tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const suffix = Date.now().toString(36)
|
||||
const nameA = `test-A-${suffix}`
|
||||
const nameB = `test-B-${suffix}`
|
||||
|
||||
// Save the default workflow as A
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameA)
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Create B as an unsaved workflow with a Note node
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountB).toBe(1)
|
||||
expect(nodeCountA).not.toBe(nodeCountB)
|
||||
|
||||
// Switch to A via topbar tab (making unsaved B inactive)
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Close inactive unsaved B tab — triggers "Save before closing?"
|
||||
await comfyPage.menu.topbar
|
||||
.getWorkflowTab('Unsaved Workflow')
|
||||
.click({ button: 'middle' })
|
||||
|
||||
// Click "Save" in the dirty close dialog (scoped to dialog)
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
|
||||
// Fill in the filename dialog
|
||||
const saveDialog = comfyPage.menu.topbar.getSaveDialog()
|
||||
await saveDialog.waitFor({ state: 'visible' })
|
||||
await saveDialog.fill(nameB)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify we're still on A with A's content
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Re-open B from sidebar saved list
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(nameB).dblclick()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
// B should have 1 node (the Note), not A's node count
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Splitter panel sizes persist correctly in localStorage', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -139,7 +139,7 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
|
||||
if (isSelfOverwrite) {
|
||||
workflow.changeTracker?.checkState()
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
await saveWorkflow(workflow)
|
||||
} else {
|
||||
let target: ComfyWorkflow
|
||||
@@ -156,7 +156,7 @@ export const useWorkflowService = () => {
|
||||
app.rootGraph.extra.linearMode = isApp
|
||||
target.initialMode = isApp ? 'app' : 'graph'
|
||||
}
|
||||
target.changeTracker?.checkState()
|
||||
if (workflowStore.isActive(target)) target.changeTracker?.checkState()
|
||||
|
||||
await workflowStore.saveWorkflow(target)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export const useWorkflowService = () => {
|
||||
if (workflow.isTemporary) {
|
||||
await saveWorkflowAs(workflow)
|
||||
} else {
|
||||
workflow.changeTracker?.checkState()
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
|
||||
const isApp = workflow.initialMode === 'app'
|
||||
const expectedPath =
|
||||
|
||||
Reference in New Issue
Block a user