fix: Prune invalid builder mappings on load (#9376)

## Summary

- extract resolveNode to reusable util
- remove mid builder pruning
- handle missing widgets with label

## Review Focus

`resolveNode` was simplified for subgraphs by calling getNodeById on
each of the subgraphs instead of searching their inner nodes manually.

## Screenshots (if applicable)

"Widget not visible"
<img width="657" height="822" alt="image"
src="https://github.com/user-attachments/assets/ab7d1e87-3210-4e54-876a-07881974b5c7"
/>
<img width="674" height="375" alt="image"
src="https://github.com/user-attachments/assets/c50ec871-d423-43d6-8e1e-7b1a362f621c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9376-fix-Prune-invalid-builder-mappings-on-load-3196d73d3650811280c2d459ed0271af)
by [Unito](https://www.unito.io)
This commit is contained in:
pythongosssss
2026-03-04 17:52:14 +00:00
committed by GitHub
parent 3e59f8e932
commit 194218a9d6
7 changed files with 223 additions and 36 deletions

View File

@@ -7,6 +7,8 @@ import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { ChangeTracker } from '@/scripts/changeTracker'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/scripts/app', () => ({
@@ -15,6 +17,14 @@ vi.mock('@/scripts/app', () => ({
}
}))
const mockResolveNode = vi.hoisted(() =>
vi.fn<(id: NodeId) => LGraphNode | undefined>(() => undefined)
)
vi.mock('@/utils/litegraphUtil', async (importOriginal) => ({
...(await importOriginal()),
resolveNode: mockResolveNode
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => ({ read_only: false })
@@ -43,6 +53,7 @@ describe('appModeStore', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
vi.mocked(app.rootGraph).extra = {}
mockResolveNode.mockReturnValue(undefined)
})
describe('enterBuilder', () => {
@@ -93,6 +104,105 @@ describe('appModeStore', () => {
})
})
describe('loadSelections pruning', () => {
function mockNode(id: number) {
return { id }
}
function workflowWithLinearData(
inputs: [number, string][],
outputs: number[]
) {
const workflow = createBuilderWorkflow('app')
workflow.changeTracker = createMockChangeTracker({
activeState: {
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: [],
groups: [],
config: {},
version: 0.4,
extra: { linearData: { inputs, outputs } }
}
} as unknown as Partial<ChangeTracker>)
return workflow
}
it('removes inputs referencing deleted nodes on load', async () => {
const node1 = mockNode(1)
mockResolveNode.mockImplementation((id) =>
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = workflowWithLinearData(
[
[1, 'prompt'],
[99, 'width']
],
[]
)
await nextTick()
expect(store.selectedInputs).toEqual([[1, 'prompt']])
})
it('keeps inputs for existing nodes even if widget is missing', async () => {
const node1 = mockNode(1)
mockResolveNode.mockImplementation((id) =>
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = workflowWithLinearData(
[
[1, 'prompt'],
[1, 'deleted_widget']
],
[]
)
await nextTick()
expect(store.selectedInputs).toEqual([
[1, 'prompt'],
[1, 'deleted_widget']
])
})
it('removes outputs referencing deleted nodes on load', async () => {
const node1 = mockNode(1)
mockResolveNode.mockImplementation((id) =>
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = workflowWithLinearData([], [1, 99])
await nextTick()
expect(store.selectedOutputs).toEqual([1])
})
it('hasOutputs is false when all output nodes are deleted', async () => {
mockResolveNode.mockReturnValue(undefined)
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = workflowWithLinearData([], [10, 20])
await nextTick()
expect(store.selectedOutputs).toEqual([])
expect(store.hasOutputs).toBe(false)
})
})
describe('linearData sync watcher', () => {
it('writes linearData to rootGraph.extra when in builder mode', async () => {
const workflowStore = useWorkflowStore()

View File

@@ -7,6 +7,7 @@ import type { LinearData } from '@/platform/workflow/management/stores/comfyWork
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { resolveNode } from '@/utils/litegraphUtil'
export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
@@ -18,8 +19,21 @@ export const useAppModeStore = defineStore('appMode', () => {
const hasOutputs = computed(() => !!selectedOutputs.length)
function loadSelections(data: Partial<LinearData> | undefined) {
selectedInputs.splice(0, selectedInputs.length, ...(data?.inputs ?? []))
selectedOutputs.splice(0, selectedOutputs.length, ...(data?.outputs ?? []))
const rawInputs = data?.inputs ?? []
const rawOutputs = data?.outputs ?? []
// Prune entries referencing nodes deleted in workflow mode.
// Only check node existence, not widgets — dynamic widgets can
// hide/show other widgets so a missing widget does not mean stale data.
const inputs = app.rootGraph
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
: rawInputs
const outputs = app.rootGraph
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
: rawOutputs
selectedInputs.splice(0, selectedInputs.length, ...inputs)
selectedOutputs.splice(0, selectedOutputs.length, ...outputs)
}
function resetSelectedToWorkflow() {