Compare commits

...

5 Commits

Author SHA1 Message Date
Glary-Bot
93959cb865 refactor: run base onNodeRemoved in finally so cleanup errors cannot starve downstream hooks 2026-05-13 20:00:28 +00:00
Glary-Bot
9e954a910e fix: skip tracker cache prune during workflow tab-switch teardown
The previous version pruned the per-workflow ChangeTracker output
cache on every onNodeRemoved fire. That broke workflowPersistence
specs because switching workflow tabs runs loadGraphData(clean=true)
on the outgoing workflow, which fires onNodeRemoved for each node
while activeWorkflow still points at the workflow being deactivated.
The hook was deleting the snapshot used to restore previews when the
user switched back.

Gate tracker pruning on a tab-switch signal:
ChangeTracker.isLoadingGraph is true AND the active tracker is not
_restoringState. The undo/redo path keeps pruning (the tracker is
restoring), the direct-delete path keeps pruning (no graph load in
flight), and the tab-switch teardown is now a no-op for the tracker
cache.
2026-05-13 19:51:39 +00:00
Glary-Bot
cb75f4e92d fix: prune ChangeTracker output cache so undo cannot resurrect stale entries
The ChangeTracker keeps its own per-workflow nodeOutputs cache that is
populated by the 'executed' event and re-applied via restoreOutputs()
during workflow load. Without pruning it on node removal, undo would
clear app.nodeOutputs through the new onNodeRemoved hook only to have
the same entry written back from the tracker's stale cache.

Hook now also derives the execution id for the removed node and
deletes it from the active workflow's tracker cache, recursing through
subgraph contents.
2026-05-12 04:45:50 +00:00
Glary-Bot
7a54e27397 fix: clear interior outputs when removing a subgraph container
Address review feedback: onNodeRemoved fires per interior node on the
subgraph object (not the parent graph), so removing a SubgraphNode
from the root left interior nodeOutputStore entries behind. Recurse
into subgraph.nodes (including nested subgraphs) and prune their
locator ids alongside the container's own entry.
2026-05-12 04:37:09 +00:00
Glary-Bot
5e8defb166 fix: clear nodeOutputStore entries on node removal
Pressing undo to remove a node left preview images on screen because
nodeOutputStore had no onNodeRemoved listener. Outputs keyed by the
removed node's locator id stayed in the store, and any future node
that reused the same id inherited the stale preview.

Adds installNodeOutputClearingHooks composable, modeled on
useErrorClearingHooks, that prunes the store at onNodeRemoved time
using a locator id derived from the graph the hook is installed on
(node.graph is nulled before the callback fires).
2026-05-12 00:00:17 +00:00
5 changed files with 392 additions and 0 deletions

View 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)
})
})

View File

@@ -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) {

View 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()
})
})

View 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
}
}

View File

@@ -499,6 +499,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
revokeSubgraphPreviews,
removeNodeOutputs,
removeNodeOutputsForNode,
removeOutputsByLocatorId,
snapshotOutputs,
restoreOutputs,
resetAllOutputsAndPreviews,