Compare commits

...

1 Commits

Author SHA1 Message Date
Nathaniel Parson Koroso
0732a5fbbc 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 10:41:46 -07:00
3 changed files with 89 additions and 1 deletions

View File

@@ -344,6 +344,74 @@ 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 }))
// A root graph that never configured a workflow keeps the zero UUID: the
// API-format load path clears then re-adds nodes without assigning an id.
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()
// A new node reusing id 1 after the clear must not inherit the old value,
// and the graph's preview exposures are purged too (both share the gate).
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
})
// A throwaway graph (e.g. insertWorkflow's clipboard graph) is constructed
// while the active graph is still at the zero UUID. Its constructor-time
// clear() runs with no nodes, so it must not wipe the active graph's shared
// zero-UUID namespace.
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,12 @@ export class LGraph
this.status = LGraph.STATUS_STOPPED
const graphId = this.id
if (this.isRootGraph && graphId !== zeroUuid) {
// A zero-UUID root graph can still hold nodes and graph-scoped state (the
// API-format load path clears then re-adds without an id). Purge those too,
// or a reused node id inherits the prior node's widget value. Gate on node
// presence so the constructor's empty clear() and throwaway graphs don't
// wipe the active graph's shared zero-UUID namespace.
if (this.isRootGraph && (graphId !== zeroUuid || this._nodes.length > 0)) {
usePreviewExposureStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
}

View File

@@ -225,5 +225,20 @@ 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)
// A node reusing the same id + widget name must not inherit the old value.
const reused = store.registerWidget(modeA, state('combo', 'normal'))
expect(reused.value).toBe('normal')
expect(store.getWidget(modeA)?.value).toBe('normal')
})
})
})