mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
5 Commits
glary/refa
...
glary/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93959cb865 | ||
|
|
9e954a910e | ||
|
|
cb75f4e92d | ||
|
|
7a54e27397 | ||
|
|
5e8defb166 |
104
browser_tests/tests/nodeOutputClearingOnRemove.spec.ts
Normal file
104
browser_tests/tests/nodeOutputClearingOnRemove.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function getNodeOutputImageCount(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<number> {
|
||||
return await comfyPage.page.evaluate(
|
||||
(id) => window.app!.nodeOutputs?.[id]?.images?.length ?? 0,
|
||||
nodeId
|
||||
)
|
||||
}
|
||||
|
||||
async function seedNodeOutput(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<void> {
|
||||
await comfyPage.page.evaluate((id) => {
|
||||
window.app!.nodeOutputs[id] = {
|
||||
images: [
|
||||
{ filename: 'seeded-preview.png', subfolder: '', type: 'output' }
|
||||
]
|
||||
}
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
test.describe('Node output cleanup on removal', { tag: '@workflow' }, () => {
|
||||
test('Deleting a node clears its outputs from the store', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Pressing delete left previews behind because nodeOutputStore did not listen to onNodeRemoved'
|
||||
})
|
||||
|
||||
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
|
||||
expect(node).toBeTruthy()
|
||||
const nodeId = String(node.id)
|
||||
|
||||
await seedNodeOutput(comfyPage, nodeId)
|
||||
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
|
||||
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(0)
|
||||
})
|
||||
|
||||
test('Undoing a node addition clears outputs produced for the removed node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Undo removed the node but left its preview rendered because the removal lifecycle did not invalidate nodeOutputStore'
|
||||
})
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
|
||||
const addedNodeId = await comfyPage.page.evaluate(() => {
|
||||
const litegraph = window.LiteGraph
|
||||
if (!litegraph) throw new Error('LiteGraph is not available on window')
|
||||
const graph = window.app!.graph
|
||||
const registered = litegraph.registered_node_types
|
||||
const typeName = registered['LoadImage']
|
||||
? 'LoadImage'
|
||||
: registered['PreviewImage']
|
||||
? 'PreviewImage'
|
||||
: undefined
|
||||
if (!typeName) {
|
||||
throw new Error('No suitable node type registered for the test')
|
||||
}
|
||||
const node = litegraph.createNode(typeName)
|
||||
if (!node) throw new Error('Failed to create test node')
|
||||
graph.add(node)
|
||||
return String(node.id)
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
|
||||
await seedNodeOutput(comfyPage, addedNodeId)
|
||||
await expect
|
||||
.poll(() => getNodeOutputImageCount(comfyPage, addedNodeId))
|
||||
.toBe(1)
|
||||
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
await comfyPage.keyboard.undo()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialNodeCount)
|
||||
await expect
|
||||
.poll(() => getNodeOutputImageCount(comfyPage, addedNodeId))
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -143,6 +143,7 @@ import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { installNodeOutputClearingHooks } from '@/composables/graph/useNodeOutputClearingHooks'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
@@ -249,11 +250,16 @@ const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
|
||||
// Error-clearing hooks run regardless of rendering mode (Vue or legacy canvas).
|
||||
let cleanupErrorHooks: (() => void) | null = null
|
||||
let cleanupNodeOutputHooks: (() => void) | null = null
|
||||
watch(
|
||||
() => canvasStore.currentGraph,
|
||||
(graph) => {
|
||||
cleanupErrorHooks?.()
|
||||
cleanupErrorHooks = graph ? installErrorClearingHooks(graph) : null
|
||||
cleanupNodeOutputHooks?.()
|
||||
cleanupNodeOutputHooks = graph
|
||||
? installNodeOutputClearingHooks(graph)
|
||||
: null
|
||||
}
|
||||
)
|
||||
|
||||
@@ -538,6 +544,9 @@ onMounted(async () => {
|
||||
// Install error-clearing hooks on the initial graph
|
||||
if (comfyApp.canvas?.graph) {
|
||||
cleanupErrorHooks = installErrorClearingHooks(comfyApp.canvas.graph)
|
||||
cleanupNodeOutputHooks = installNodeOutputClearingHooks(
|
||||
comfyApp.canvas.graph
|
||||
)
|
||||
}
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
@@ -597,6 +606,8 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
cleanupErrorHooks?.()
|
||||
cleanupErrorHooks = null
|
||||
cleanupNodeOutputHooks?.()
|
||||
cleanupNodeOutputHooks = null
|
||||
vueNodeLifecycle.cleanup()
|
||||
})
|
||||
function forwardPanEvent(e: PointerEvent) {
|
||||
|
||||
208
src/composables/graph/useNodeOutputClearingHooks.test.ts
Normal file
208
src/composables/graph/useNodeOutputClearingHooks.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installNodeOutputClearingHooks } from '@/composables/graph/useNodeOutputClearingHooks'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
function seedOutputForLocator(locatorId: string) {
|
||||
app.nodeOutputs[locatorId] = {
|
||||
images: [{ filename: 'preview.png', type: 'output', subfolder: '' }]
|
||||
}
|
||||
}
|
||||
|
||||
describe('installNodeOutputClearingHooks', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
app.nodeOutputs = {}
|
||||
app.nodePreviewImages = {}
|
||||
})
|
||||
|
||||
it('removes outputs for a root-level node when it is removed from the graph', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
|
||||
const locatorId = String(node.id)
|
||||
seedOutputForLocator(locatorId)
|
||||
expect(app.nodeOutputs[locatorId]).toBeDefined()
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(node)
|
||||
|
||||
expect(app.nodeOutputs[locatorId]).toBeUndefined()
|
||||
expect(useNodeOutputStore().nodeOutputs[locatorId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes outputs for a subgraph interior node using subgraphUuid:nodeId locator', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('LoadImage')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
const rootGraph = subgraphNode.graph as LGraph
|
||||
rootGraph.add(subgraphNode)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
|
||||
const interiorLocator = `${subgraph.id}:${interiorNode.id}`
|
||||
seedOutputForLocator(interiorLocator)
|
||||
expect(app.nodeOutputs[interiorLocator]).toBeDefined()
|
||||
|
||||
installNodeOutputClearingHooks(subgraph)
|
||||
subgraph.remove(interiorNode)
|
||||
|
||||
expect(app.nodeOutputs[interiorLocator]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not affect outputs for other nodes that remain in the graph', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const removed = new LGraphNode('LoadImage')
|
||||
const kept = new LGraphNode('LoadImage')
|
||||
graph.add(removed)
|
||||
graph.add(kept)
|
||||
|
||||
const removedLocator = String(removed.id)
|
||||
const keptLocator = String(kept.id)
|
||||
seedOutputForLocator(removedLocator)
|
||||
seedOutputForLocator(keptLocator)
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(removed)
|
||||
|
||||
expect(app.nodeOutputs[removedLocator]).toBeUndefined()
|
||||
expect(app.nodeOutputs[keptLocator]).toBeDefined()
|
||||
})
|
||||
|
||||
it('chains with existing onNodeRemoved callbacks', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
let calledWith: LGraphNode | undefined
|
||||
graph.onNodeRemoved = (node) => {
|
||||
calledWith = node
|
||||
}
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
seedOutputForLocator(String(node.id))
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(node)
|
||||
|
||||
expect(calledWith).toBe(node)
|
||||
expect(app.nodeOutputs[String(node.id)]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('restores original onNodeRemoved when cleanup is called', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const original = () => undefined
|
||||
graph.onNodeRemoved = original
|
||||
|
||||
const cleanup = installNodeOutputClearingHooks(graph)
|
||||
expect(graph.onNodeRemoved).not.toBe(original)
|
||||
|
||||
cleanup()
|
||||
expect(graph.onNodeRemoved).toBe(original)
|
||||
})
|
||||
|
||||
it('clears interior node outputs when a subgraph container is removed from the root graph', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('LoadImage')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
const rootGraph = subgraphNode.graph as LGraph
|
||||
rootGraph.add(subgraphNode)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
|
||||
const subgraphNodeLocator = String(subgraphNode.id)
|
||||
const interiorLocator = `${subgraph.id}:${interiorNode.id}`
|
||||
seedOutputForLocator(subgraphNodeLocator)
|
||||
seedOutputForLocator(interiorLocator)
|
||||
|
||||
installNodeOutputClearingHooks(rootGraph)
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(app.nodeOutputs[subgraphNodeLocator]).toBeUndefined()
|
||||
expect(app.nodeOutputs[interiorLocator]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('also prunes the active workflow change tracker output cache so undo cannot resurrect the entry', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
const locator = String(node.id)
|
||||
seedOutputForLocator(locator)
|
||||
|
||||
const trackerCache: Record<string, unknown> = {
|
||||
[locator]: { images: [{ filename: 'preview.png' }] }
|
||||
}
|
||||
vi.spyOn(useWorkflowStore(), 'activeWorkflow', 'get').mockReturnValue({
|
||||
changeTracker: { nodeOutputs: trackerCache }
|
||||
} as never)
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(node)
|
||||
|
||||
expect(app.nodeOutputs[locator]).toBeUndefined()
|
||||
expect(trackerCache[locator]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the tracker cache during workflow tab switch teardown', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
const locator = String(node.id)
|
||||
seedOutputForLocator(locator)
|
||||
|
||||
const trackerCache: Record<string, unknown> = {
|
||||
[locator]: { images: [{ filename: 'preview.png' }] }
|
||||
}
|
||||
vi.spyOn(useWorkflowStore(), 'activeWorkflow', 'get').mockReturnValue({
|
||||
changeTracker: { nodeOutputs: trackerCache, _restoringState: false }
|
||||
} as never)
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
try {
|
||||
graph.remove(node)
|
||||
} finally {
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
}
|
||||
|
||||
expect(trackerCache[locator]).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not throw when the removal hook fires for an already-cleared node', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
const locator = String(node.id)
|
||||
seedOutputForLocator(locator)
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(node)
|
||||
expect(() => graph.onNodeRemoved?.(node)).not.toThrow()
|
||||
expect(app.nodeOutputs[locator]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
68
src/composables/graph/useNodeOutputClearingHooks.ts
Normal file
68
src/composables/graph/useNodeOutputClearingHooks.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { getExecutionIdForNodeInGraph } from '@/utils/graphTraversalUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
function isTabSwitchTeardown(): boolean {
|
||||
const tracker = useWorkflowStore().activeWorkflow?.changeTracker
|
||||
return ChangeTracker.isLoadingGraph && !tracker?._restoringState
|
||||
}
|
||||
|
||||
function dropTrackerCacheEntry(execId: string) {
|
||||
if (isTabSwitchTeardown()) return
|
||||
const tracked = useWorkflowStore().activeWorkflow?.changeTracker?.nodeOutputs
|
||||
if (tracked) delete tracked[execId]
|
||||
}
|
||||
|
||||
function clearInteriorOutputs(
|
||||
subgraphNode: SubgraphNode,
|
||||
execIdPrefix: string
|
||||
) {
|
||||
const subgraph: Subgraph | undefined = subgraphNode.subgraph
|
||||
if (!subgraph) return
|
||||
|
||||
const store = useNodeOutputStore()
|
||||
for (const interior of subgraph.nodes) {
|
||||
store.removeOutputsByLocatorId(`${subgraph.id}:${interior.id}`)
|
||||
const interiorExecId = `${execIdPrefix}:${interior.id}`
|
||||
dropTrackerCacheEntry(interiorExecId)
|
||||
if (interior.isSubgraphNode()) {
|
||||
clearInteriorOutputs(interior, interiorExecId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function installNodeOutputClearingHooks(graph: LGraph): () => void {
|
||||
const originalOnNodeRemoved = graph.onNodeRemoved
|
||||
|
||||
graph.onNodeRemoved = function (node: LGraphNode) {
|
||||
try {
|
||||
const store = useNodeOutputStore()
|
||||
const { nodeIdToNodeLocatorId } = useWorkflowStore()
|
||||
const locatorId = isSubgraph(graph)
|
||||
? nodeIdToNodeLocatorId(node.id, graph)
|
||||
: String(node.id)
|
||||
store.removeOutputsByLocatorId(locatorId)
|
||||
|
||||
const execId = app.rootGraph
|
||||
? getExecutionIdForNodeInGraph(app.rootGraph, graph, node.id)
|
||||
: String(node.id)
|
||||
dropTrackerCacheEntry(execId)
|
||||
|
||||
if (node.isSubgraphNode()) {
|
||||
clearInteriorOutputs(node, execId)
|
||||
}
|
||||
} finally {
|
||||
originalOnNodeRemoved?.call(this, node)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
}
|
||||
}
|
||||
@@ -499,6 +499,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
revokeSubgraphPreviews,
|
||||
removeNodeOutputs,
|
||||
removeNodeOutputsForNode,
|
||||
removeOutputsByLocatorId,
|
||||
snapshotOutputs,
|
||||
restoreOutputs,
|
||||
resetAllOutputsAndPreviews,
|
||||
|
||||
Reference in New Issue
Block a user