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.
This commit is contained in:
Glary-Bot
2026-05-12 04:45:00 +00:00
parent 7a54e27397
commit cb75f4e92d
2 changed files with 44 additions and 3 deletions

View File

@@ -10,6 +10,7 @@ import {
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
function seedOutputForLocator(locatorId: string) {
app.nodeOutputs[locatorId] = {
@@ -139,6 +140,29 @@ describe('installNodeOutputClearingHooks', () => {
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('does not throw when the removal hook fires for an already-cleared node', () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)

View File

@@ -2,18 +2,30 @@ 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 { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { getExecutionIdForNodeInGraph } from '@/utils/graphTraversalUtil'
import { isSubgraph } from '@/utils/typeGuardUtil'
function clearInteriorOutputs(subgraphNode: SubgraphNode) {
function dropTrackerCacheEntry(execId: string) {
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)
clearInteriorOutputs(interior, interiorExecId)
}
}
}
@@ -29,8 +41,13 @@ export function installNodeOutputClearingHooks(graph: LGraph): () => void {
: 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)
clearInteriorOutputs(node, execId)
}
originalOnNodeRemoved?.call(this, node)