Compare commits

...

6 Commits

Author SHA1 Message Date
Christian Byrne
20f9cf0c2c Merge branch 'main' into batch-dispatch/issue-7840 2026-05-04 13:28:42 -07:00
bymyself
11e95aa67b test: pin isActive postcondition with silent loadGraphData regression
The two existing closeWorkflow regression tests only cover thrown errors
from app.loadGraphData. The actual #7840 regression is the silent path
where loadGraphData() catches the configure() error internally and
resolves without ever switching the active workflow.

Without this case the suite would still pass if someone removed the
!workflowStore.isActive(workflow) postcondition from trySwitch() and
kept only the catch block.

Mock loadGraphData to resolve unconditionally, leaving activeWorkflow
on the closing workflow, and assert closeWorkflow returns false and
keeps the tab open.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11389#discussion_r3128091229
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11389#discussion_r3128117574
2026-05-01 21:05:28 -07:00
bymyself
9e95f25ce3 fix: route single-tab close through trySwitch for failure resilience
Previously the last-tab path in closeWorkflow called loadDefaultWorkflow()
directly. loadGraphData() only swallows configure() errors; it can still
reject from validation, extension hooks, or node-replacement loading,
which would throw out of closeWorkflow instead of returning false like
the multi-tab path does.

Wrap the call in trySwitch() so the last-tab case follows the same
'do not close if we failed to activate a replacement' contract.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11389#discussion_r3128117872
2026-05-01 21:04:40 -07:00
bymyself
780b58617b revert: remove A1111 import changes (split to separate PR)
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11389#discussion_r3107775864
2026-04-20 02:25:45 -07:00
bymyself
f9b21782d8 refactor: extract switchAwayFrom helper for closeWorkflow readability
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11389#discussion_r3107775265
2026-04-20 02:25:24 -07:00
bymyself
91e442e863 fix: prevent tab removal on failed workflow switch and fix A1111 import overwriting current tab (#7840)
- closeWorkflow: wrap replacement switch in try-catch with postcondition
  check; fall back to loadDefaultWorkflow if first switch fails; return
  false without removing the tab if both fail
- handleFile (A1111): clean graph before import, properly await
  importA1111() and afterLoadNewGraph()
- Add 2 regression tests for closeWorkflow resilience

Fixes #7840
2026-04-18 21:34:12 -07:00
2 changed files with 130 additions and 11 deletions

View File

@@ -1222,4 +1222,95 @@ describe('useWorkflowService', () => {
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
})
})
describe('closeWorkflow', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let service: ReturnType<typeof useWorkflowService>
beforeEach(() => {
workflowStore = useWorkflowStore()
service = useWorkflowService()
})
function createAndRegister(
path: string,
index: number
): LoadedComfyWorkflow {
const workflow = new ComfyWorkflowClass({
path,
modified: Date.now(),
size: 100
})
workflow.changeTracker = createMockChangeTracker()
workflow.content = '{}'
workflow.originalContent = '{}'
workflowStore.attachWorkflow(workflow, index)
return workflow as LoadedComfyWorkflow
}
it('does not close tab when switching to replacement fails', async () => {
const active = createAndRegister('workflows/active.json', 0)
createAndRegister('workflows/other.json', 1)
workflowStore.activeWorkflow = active as LoadedComfyWorkflow
vi.mocked(app.loadGraphData).mockRejectedValue(
new Error('configure failed')
)
const result = await service.closeWorkflow(active, {
warnIfUnsaved: false
})
expect(result).toBe(false)
expect(workflowStore.isOpen(active)).toBe(true)
})
it('falls back to default workflow when replacement throws', async () => {
const active = createAndRegister('workflows/active.json', 0)
createAndRegister('workflows/other.json', 1)
workflowStore.activeWorkflow = active as LoadedComfyWorkflow
let callCount = 0
vi.mocked(app.loadGraphData).mockImplementation(async () => {
callCount++
if (callCount === 1) {
throw new Error('replacement failed')
}
workflowStore.activeWorkflow = createAndRegister(
'workflows/Unsaved Workflow.json',
2
)
})
const result = await service.closeWorkflow(active, {
warnIfUnsaved: false
})
expect(result).toBe(true)
expect(app.loadGraphData).toHaveBeenCalledTimes(2)
})
// Regression for #7840 silent path: loadGraphData() resolves but the active
// workflow stays unchanged because configure() errors are caught internally.
// closeWorkflow must still refuse to remove the tab. This test exists to
// pin the !workflowStore.isActive(workflow) postcondition in trySwitch().
it('does not close tab when loadGraphData resolves but active workflow does not change', async () => {
const active = createAndRegister('workflows/active.json', 0)
createAndRegister('workflows/other.json', 1)
workflowStore.activeWorkflow = active as LoadedComfyWorkflow
// Both the replacement switch and the default-workflow fallback resolve
// without changing activeWorkflow — simulating loadGraphData swallowing
// configure() errors internally.
vi.mocked(app.loadGraphData).mockResolvedValue(undefined)
const result = await service.closeWorkflow(active, {
warnIfUnsaved: false
})
expect(result).toBe(false)
expect(workflowStore.isOpen(active)).toBe(true)
expect(workflowStore.isActive(active)).toBe(true)
})
})
})

View File

@@ -267,6 +267,35 @@ export const useWorkflowService = () => {
})
}
async function trySwitch(
action: () => Promise<void>,
workflow: ComfyWorkflow
): Promise<boolean> {
try {
await action()
return !workflowStore.isActive(workflow)
} catch (error) {
console.error('Failed to switch workflow', error)
return false
}
}
async function switchAwayFrom(workflow: ComfyWorkflow): Promise<boolean> {
const replacement =
workflowStore.getMostRecentWorkflow() ??
workflowStore.openedWorkflowIndexShift(1)
if (replacement) {
const switched = await trySwitch(
() => openWorkflow(replacement),
workflow
)
if (switched) return true
}
return trySwitch(() => loadDefaultWorkflow(), workflow)
}
/**
* Close a workflow with confirmation if there are unsaved changes
* @param workflow The workflow to close
@@ -296,19 +325,18 @@ export const useWorkflowService = () => {
workflowDraftStore.removeDraft(workflow.path)
// If this is the last workflow, create a new default temporary workflow
// If this is the last workflow, create a new default temporary workflow.
// Route through trySwitch so a rejection from loadGraphData
// (validation / extension hooks / node-replacement loading) keeps the tab
// open instead of throwing, matching the multi-tab contract below.
if (workflowStore.openWorkflows.length === 1) {
await loadDefaultWorkflow()
const switched = await trySwitch(() => loadDefaultWorkflow(), workflow)
if (!switched) return false
}
// If this is the active workflow, load the most recent workflow from history
if (workflowStore.isActive(workflow)) {
const mostRecentWorkflow = workflowStore.getMostRecentWorkflow()
if (mostRecentWorkflow) {
await openWorkflow(mostRecentWorkflow)
} else {
// Fallback to next workflow if no history
await loadNextOpenedWorkflow()
}
// If this is the active workflow, switch to another before closing
else if (workflowStore.isActive(workflow)) {
const didSwitch = await switchAwayFrom(workflow)
if (!didSwitch) return false
}
await workflowStore.closeWorkflow(workflow)