From cb75f4e92da76030def927ec4092979b4fb8e2ec Mon Sep 17 00:00:00 2001 From: Glary-Bot Date: Tue, 12 May 2026 04:45:00 +0000 Subject: [PATCH] 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. --- .../graph/useNodeOutputClearingHooks.test.ts | 24 +++++++++++++++++++ .../graph/useNodeOutputClearingHooks.ts | 23 +++++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/composables/graph/useNodeOutputClearingHooks.test.ts b/src/composables/graph/useNodeOutputClearingHooks.test.ts index f66d5933f1..be010cd5e8 100644 --- a/src/composables/graph/useNodeOutputClearingHooks.test.ts +++ b/src/composables/graph/useNodeOutputClearingHooks.test.ts @@ -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 = { + [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) diff --git a/src/composables/graph/useNodeOutputClearingHooks.ts b/src/composables/graph/useNodeOutputClearingHooks.ts index 2ad6abbb3b..0042b08c85 100644 --- a/src/composables/graph/useNodeOutputClearingHooks.ts +++ b/src/composables/graph/useNodeOutputClearingHooks.ts @@ -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)