fix: preserve app builder inputs through graph reconfiguration (#11422)

*PR Created by the Glary-Bot Agent*

---

## Summary

- Fixes app mode bug where custom combo inputs (and other widget inputs)
unselect and show "Widget not visible" after refreshing or reopening a
saved app workflow
- Root cause: `resetSelectedToWorkflow()` loads from
`changeTracker.activeState` which may not have linearData yet after
refresh, and `pruneLinearData()` prunes valid entries during graph
loading when nodes aren't yet in the graph
- Two defensive guards: fallback to `initialState` for authoritative
data, and skip pruning during graph loading

## Changes

- `appModeStore.ts`: `resetSelectedToWorkflow()` now falls back to
`initialState.extra.linearData` when `activeState` has none
- `appModeStore.ts`: `pruneLinearData()` skips node-existence checks
when `ChangeTracker.isLoadingGraph` is true
- Unit tests: 4 new tests covering both fix paths (pruning during
loading, fallback to initialState)
- E2E test: Save-as → close → reopen → verify all inputs persist with no
"Widget not visible"

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11422-fix-preserve-app-builder-inputs-through-graph-reconfiguration-3476d73d36508166a563f7df3967665c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Christian Byrne
2026-05-14 12:28:21 -07:00
committed by GitHub
parent daab936d15
commit f090ea3d28
5 changed files with 325 additions and 48 deletions

View File

@@ -122,3 +122,19 @@ export async function saveAndReopenInAppMode(
await comfyPage.appMode.toggleAppMode()
}
export async function saveCloseAndReopenInBuilder(
comfyPage: ComfyPage,
appMode: AppModeHelper,
workflowName: string
) {
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await expect(appMode.saveAs.successDialog).toBeHidden()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)
await appMode.enterBuilder()
await appMode.steps.goToInputs()
}

View File

@@ -0,0 +1,44 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import {
saveCloseAndReopenInBuilder,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']
test.describe(
'App builder input persistence after reload',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('persists selected inputs after save and reopen without visibility errors', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupBuilder(comfyPage, undefined, WIDGETS)
await appMode.steps.goToInputs()
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
const workflowName = `${Date.now()} input-persistence`
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
for (const widget of WIDGETS) {
await expect(
appMode.select.getInputItemSubtitle(widget)
).not.toContainText('Widget not visible')
}
})
}
)

View File

@@ -5,26 +5,18 @@ import {
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
builderSaveAs,
openWorkflowFromSidebar,
saveCloseAndReopenInBuilder,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']
/** Save as app, close it by loading default, reopen from sidebar, enter app mode. */
async function saveCloseAndReopenAsApp(
comfyPage: ComfyPage,
appMode: AppModeHelper,
workflowName: string
) {
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await expect(appMode.saveAs.successDialog).toBeHidden()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
await appMode.toggleAppMode()
}

View File

@@ -5,11 +5,16 @@ import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
InputWidgetConfig,
LinearInput,
LoadedComfyWorkflow
} from '@/platform/workflow/management/stores/comfyWorkflow'
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 { ChangeTracker } from '@/scripts/changeTracker'
import { ChangeTracker } from '@/scripts/changeTracker'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
const mockEmptyWorkflowDialog = vi.hoisted(() => {
@@ -100,6 +105,29 @@ function createBuilderWorkflowWithOutputs(
return workflow
}
function createWorkflowWithLinearData(
activeMode: string,
inputs: LinearInput[],
outputs: NodeId[]
): LoadedComfyWorkflow {
const workflow = createBuilderWorkflow(activeMode)
workflow.changeTracker = createMockChangeTracker(
fromPartial<Partial<ChangeTracker>>({
activeState: {
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: [],
groups: [],
config: {},
version: 0.4,
extra: { linearData: fromAny({ inputs, outputs }) }
}
})
)
return workflow
}
describe('appModeStore', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let store: ReturnType<typeof useAppModeStore>
@@ -107,6 +135,7 @@ describe('appModeStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.mocked(app.rootGraph).extra = {}
ChangeTracker.isLoadingGraph = false
mockResolveNode.mockReturnValue(undefined)
mockSettings.reset()
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
@@ -163,6 +192,28 @@ describe('appModeStore', () => {
)
expect(workflowStore.activeWorkflow!.activeMode).toBe('graph')
})
it('prunes selections from workflow state on entry', () => {
const node1 = { id: 1 }
mockResolveNode.mockImplementation((id) =>
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
)
workflowStore.activeWorkflow = createWorkflowWithLinearData(
'graph',
[
[1, 'seed'],
[99, 'steps']
],
[1, 99]
)
store.selectedInputs = [[42, 'prompt']]
store.selectedOutputs = [42]
store.enterBuilder()
expect(store.selectedInputs).toEqual([[1, 'seed']])
expect(store.selectedOutputs).toEqual([1])
})
})
describe('empty workflow dialog callbacks', () => {
@@ -202,33 +253,36 @@ describe('appModeStore', () => {
})
})
describe('exitBuilder', () => {
it('prunes selections from workflow state on exit', () => {
const node1 = { id: 1 }
mockResolveNode.mockImplementation((id) =>
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
)
workflowStore.activeWorkflow = createWorkflowWithLinearData(
'builder:inputs',
[
[1, 'seed'],
[99, 'steps']
],
[1, 99]
)
store.selectedInputs = [[42, 'prompt']]
store.selectedOutputs = [42]
store.exitBuilder()
expect(store.selectedInputs).toEqual([[1, 'seed']])
expect(store.selectedOutputs).toEqual([1])
expect(workflowStore.activeWorkflow!.activeMode).toBe('graph')
})
})
describe('loadSelections pruning', () => {
function mockNode(id: number) {
return { id }
}
function workflowWithLinearData(
inputs: [number, string][],
outputs: number[]
) {
const workflow = createBuilderWorkflow('app')
workflow.changeTracker = createMockChangeTracker(
fromPartial<Partial<ChangeTracker>>({
activeState: {
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: [],
groups: [],
config: {},
version: 0.4,
extra: { linearData: { inputs, outputs } }
}
})
)
return workflow
}
it('removes inputs referencing deleted nodes on load', async () => {
const node1 = mockNode(1)
mockResolveNode.mockImplementation((id) =>
@@ -294,7 +348,11 @@ describe('appModeStore', () => {
// Initially nodes are not resolvable — pruning removes them
mockResolveNode.mockReturnValue(undefined)
const inputs: [number, string][] = [[1, 'seed']]
workflowStore.activeWorkflow = workflowWithLinearData(inputs, [1])
workflowStore.activeWorkflow = createWorkflowWithLinearData(
'app',
inputs,
[1]
)
store.loadSelections({ inputs })
await nextTick()
@@ -324,6 +382,141 @@ describe('appModeStore', () => {
})
})
describe('loadSelections edge cases', () => {
it('clears existing selections on undefined or empty data', () => {
store.selectedInputs = [[1, 'seed']]
store.selectedOutputs = [1]
store.loadSelections(undefined)
expect(store.selectedInputs).toEqual([])
expect(store.selectedOutputs).toEqual([])
store.selectedInputs = [[1, 'seed']]
store.selectedOutputs = [1]
store.loadSelections({})
expect(store.selectedInputs).toEqual([])
expect(store.selectedOutputs).toEqual([])
})
})
describe('pruneLinearData', () => {
it('returns empty selections for undefined data', () => {
expect(store.pruneLinearData(undefined)).toEqual({
inputs: [],
outputs: []
})
})
it('does not prune when rootGraph is empty', () => {
const originalRootGraph = app.rootGraph
Object.defineProperty(app, 'rootGraph', { value: null, writable: true })
try {
expect(
store.pruneLinearData({
inputs: [[1, 'seed']],
outputs: [1]
})
).toEqual({
inputs: [[1, 'seed']],
outputs: [1]
})
} finally {
Object.defineProperty(app, 'rootGraph', {
value: originalRootGraph,
writable: true
})
}
})
})
describe('pruneLinearData during graph loading', () => {
it('preserves all entries when ChangeTracker.isLoadingGraph is true', () => {
ChangeTracker.isLoadingGraph = true
store.loadSelections({
inputs: [
[1, 'seed'],
[999, 'steps']
],
outputs: [1, 999]
})
expect(store.selectedInputs).toEqual([
[1, 'seed'],
[999, 'steps']
])
expect(store.selectedOutputs).toEqual([1, 999])
})
it('prunes entries for deleted nodes when not loading', () => {
const node1 = { id: 1 }
mockResolveNode.mockImplementation((id) =>
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
)
store.loadSelections({
inputs: [
[1, 'seed'],
[999, 'steps']
],
outputs: [1, 999]
})
expect(store.selectedInputs).toEqual([[1, 'seed']])
expect(store.selectedOutputs).toEqual([1])
})
})
describe('resetSelectedToWorkflow fallback', () => {
it('falls back to initialState when activeState has no linearData', () => {
const node1 = { id: 1 }
mockResolveNode.mockImplementation((id) =>
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
)
const workflow = createBuilderWorkflow('app')
workflow.changeTracker.activeState.extra = {}
workflow.changeTracker.initialState = fromAny({
...workflow.changeTracker.activeState,
extra: {
linearData: { inputs: [[1, 'seed']], outputs: [1] }
}
})
workflowStore.activeWorkflow = workflow
store.resetSelectedToWorkflow()
expect(store.selectedInputs).toEqual([[1, 'seed']])
expect(store.selectedOutputs).toEqual([1])
})
it('prefers activeState linearData when available', () => {
const node1 = { id: 1 }
mockResolveNode.mockImplementation((id) =>
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
)
const workflow = createBuilderWorkflow('app')
workflow.changeTracker.activeState.extra = {
linearData: { inputs: [[1, 'steps']], outputs: [1] }
}
workflow.changeTracker.initialState = fromAny({
...workflow.changeTracker.activeState,
extra: {
linearData: { inputs: [[1, 'seed']], outputs: [1] }
}
})
workflowStore.activeWorkflow = workflow
store.resetSelectedToWorkflow()
expect(store.selectedInputs).toEqual([[1, 'steps']])
expect(store.selectedOutputs).toEqual([1])
})
})
describe('linearData sync watcher', () => {
it('writes linearData to rootGraph.extra when in builder mode', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
@@ -353,15 +546,22 @@ describe('appModeStore', () => {
await nextTick()
const originalRootGraph = app.rootGraph
const dataBefore = JSON.parse(
JSON.stringify(originalRootGraph.extra.linearData)
)
Object.defineProperty(app, 'rootGraph', { value: null, writable: true })
store.selectedOutputs.push(1)
await nextTick()
try {
store.selectedOutputs.push(1)
await nextTick()
} finally {
Object.defineProperty(app, 'rootGraph', {
value: originalRootGraph,
writable: true
})
}
Object.defineProperty(app, 'rootGraph', {
value: originalRootGraph,
writable: true
})
expect(originalRootGraph.extra.linearData).toEqual(dataBefore)
})
it('calls captureCanvasState when input is selected', async () => {
@@ -428,6 +628,18 @@ describe('appModeStore', () => {
expect(store.selectedInputs[0][2]).toEqual({ height: 200 })
})
it('merges existing config with new values', () => {
const existingConfig: InputWidgetConfig & { width: number } = {
height: 120,
width: 240
}
store.selectedInputs.push([1, 'prompt', existingConfig])
store.updateInputConfig(1 as NodeId, 'prompt', { height: 300 })
expect(store.selectedInputs[0][2]).toEqual({ height: 300, width: 240 })
})
it('triggers linearData sync watcher', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.selectedInputs.push([42, 'prompt'])
@@ -443,6 +655,17 @@ describe('appModeStore', () => {
})
})
describe('removeSelectedInput', () => {
it('removes the matching input entry only', () => {
store.selectedInputs.push([1, 'prompt'])
store.selectedInputs.push([2, 'steps'])
store.removeSelectedInput({ name: 'steps' } as IBaseWidget, { id: 2 })
expect(store.selectedInputs).toEqual([[1, 'prompt']])
})
})
describe('autoEnableVueNodes', () => {
it('enables Vue nodes when entering select mode with them disabled', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false

View File

@@ -49,14 +49,13 @@ export const useAppModeStore = defineStore('appMode', () => {
function pruneLinearData(data: Partial<LinearData> | undefined): LinearData {
const rawInputs = data?.inputs ?? []
const rawOutputs = data?.outputs ?? []
if (!app.rootGraph || ChangeTracker.isLoadingGraph) {
return { inputs: rawInputs, outputs: rawOutputs }
}
return {
inputs: app.rootGraph
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
: rawInputs,
outputs: app.rootGraph
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
: rawOutputs
inputs: rawInputs.filter(([nodeId]) => resolveNode(nodeId)),
outputs: rawOutputs.filter((nodeId) => resolveNode(nodeId))
}
}
@@ -70,7 +69,10 @@ export const useAppModeStore = defineStore('appMode', () => {
const { activeWorkflow } = workflowStore
if (!activeWorkflow) return
loadSelections(activeWorkflow.changeTracker?.activeState?.extra?.linearData)
const source =
activeWorkflow.changeTracker?.activeState?.extra?.linearData ??
activeWorkflow.initialState?.extra?.linearData
loadSelections(source)
}
useEventListener(