Compare commits

...

2 Commits

Author SHA1 Message Date
Nathaniel Parson Koroso
afe5568d07 test: cover widget value leak on node id reuse after clear
Fails on the old guard (a re-added node with a reused id reads the deleted node's width) and passes with the zero-UUID purge.
2026-07-14 20:02:27 -07:00
Nathaniel Parson Koroso
a5dfaf4462 fix(litegraph): purge widget state when a zero-UUID graph is cleared
LGraph.clear() only purged widgetValueStore/previewExposureStore when the graph id was non-zero, so a root graph running at the zero UUID never purged. The API-format load path clears then re-adds nodes without assigning an id, and a freshly constructed graph also stays at the zero UUID, so a node reusing an id after clear inherited the previous node's same-named widget value. That stale value serializes into prompts and fails backend validation ('Value not in list').

Gate the zero-UUID case on the graph having nodes, so real teardowns purge while the constructor's clear() and throwaway graphs (e.g. insertWorkflow's clipboard graph) don't wipe the active graph's shared zero-UUID namespace.

Adds LGraph.test.ts coverage for zero-UUID node-id reuse and the throwaway-construction guard, plus a widgetValueStore.test.ts case for re-registering a reused widget id after clearGraph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:32:11 -07:00
4 changed files with 109 additions and 1 deletions

View File

@@ -424,3 +424,35 @@ test.describe('Unserialized widgets', { tag: '@widget' }, () => {
.toBe(false)
})
})
test.describe('Widget value isolation', { tag: '@widget' }, () => {
test('re-added node does not inherit a cleared node widget value', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
const firstNode = await comfyPage.nodeOps.addNode('EmptyLatentImage')
const firstWidth = await firstNode.getWidgetByName('width')
const defaultWidth = await firstWidth.getValue()
await comfyPage.page.evaluate(
({ id, name, value }) => {
const node = window.app!.graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found`)
const widget = node.widgets?.find((w) => w.name === name)
if (!widget) throw new Error(`Widget ${name} not found`)
widget.value = value
},
{ id: firstNode.id, name: 'width', value: 128 }
)
expect(await firstWidth.getValue()).toBe(128)
await comfyPage.nodeOps.clearGraph()
const secondNode = await comfyPage.nodeOps.addNode('EmptyLatentImage')
expect(secondNode.id).toBe(firstNode.id)
const secondWidth = await secondNode.getWidgetByName('width')
expect(await secondWidth.getValue()).toBe(defaultWidth)
})
})

View File

@@ -344,6 +344,66 @@ describe('Graph Clearing and Callbacks', () => {
[]
)
})
test('clear() purges widget state on a zero-UUID graph that holds nodes (node-id reuse leak)', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
expect(graph.id).toBe(zeroUuid)
const node = new LGraphNode('ImageAnalyze')
node.id = toNodeId(1)
graph.add(node)
const widgetValueStore = useWidgetValueStore()
const modeWidgetId = widgetId(graph.id, toNodeId(1), 'mode')
widgetValueStore.registerWidget(modeWidgetId, {
type: 'combo',
value: 'Black White Levels',
options: {},
label: undefined,
serialize: undefined,
disabled: undefined
})
const previewExposureStore = usePreviewExposureStore()
previewExposureStore.addExposure(graph.id, `${graph.id}:1`, {
sourceNodeId: '1',
sourcePreviewName: '$$canvas-image-preview'
})
graph.clear()
expect(widgetValueStore.getWidget(modeWidgetId)).toBeUndefined()
expect(
previewExposureStore.getExposures(graph.id, `${graph.id}:1`)
).toEqual([])
})
test('constructing a new empty graph does not purge existing zero-UUID widget state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const active = new LGraph()
const node = new LGraphNode('ImageAnalyze')
node.id = toNodeId(1)
active.add(node)
const widgetValueStore = useWidgetValueStore()
const modeWidgetId = widgetId(active.id, toNodeId(1), 'mode')
widgetValueStore.registerWidget(modeWidgetId, {
type: 'combo',
value: 'keep me',
options: {},
label: undefined,
serialize: undefined,
disabled: undefined
})
const throwaway = new LGraph()
expect(throwaway.id).toBe(zeroUuid)
expect(widgetValueStore.getWidget(modeWidgetId)?.value).toBe('keep me')
})
})
describe('node:before-removed event', () => {

View File

@@ -392,7 +392,9 @@ export class LGraph
this.status = LGraph.STATUS_STOPPED
const graphId = this.id
if (this.isRootGraph && graphId !== zeroUuid) {
const isEmptyUnconfiguredGraph =
graphId === zeroUuid && this._nodes.length === 0
if (this.isRootGraph && !isEmptyUnconfiguredGraph) {
usePreviewExposureStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
}

View File

@@ -225,5 +225,19 @@ describe('useWidgetValueStore', () => {
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getWidget(seedB)?.value).toBe(2)
})
it('re-registering a reused widget id after clearGraph returns fresh state', () => {
const store = useWidgetValueStore()
const modeA = widgetId(graphA, toNodeId('node-1'), 'mode')
store.registerWidget(modeA, state('combo', 'Black White Levels'))
expect(store.getWidget(modeA)?.value).toBe('Black White Levels')
store.clearGraph(graphA)
const reused = store.registerWidget(modeA, state('combo', 'normal'))
expect(reused.value).toBe('normal')
expect(store.getWidget(modeA)?.value).toBe('normal')
})
})
})