Compare commits

...

3 Commits

Author SHA1 Message Date
Glary-Bot
9ff0374026 test(e2e): assert promoted widget values survive subgraph save/reload
Mirrors the existing 'Multi-link input representative stays stable
through save/reload' case in the same describe, but additionally writes
unique per-widget values before serialize and asserts the values after
loadGraphData (skipping serialize:false promoted views). Without the
compact-write fix in this PR, the round-tripped values shift down by
one slot per preceding serialize:false widget.
2026-05-13 19:55:12 +00:00
Glary-Bot
50f2c9bf77 fix(litegraph): read legacy sparse widgets_values by widget index
Workflows saved by the pre-compact serialize() emit a JSON null at every
preceding 'serialize: false' slot, so configure() still reads them
shifted. Detect this layout via length > serializableCount and walk the
payload by widget index in that case; otherwise consume the compact
array sequentially as before. Adds a configure-only test that pins the
behaviour against the buggy on-disk format.
2026-05-12 04:54:24 +00:00
Glary-Bot
0ad830eb6a fix(litegraph): emit compacted widgets_values in serialize()
LGraphNode.serialize() wrote widgets_values using the full widget-array
index (o.widgets_values[i]), leaving JSON null holes for every widget
with serialize: false. LGraphNode.configure() reads with a compacting
counter that only advances for serializable widgets. After a round-trip,
every value to the right of a non-serializable widget shifted down by
one position.

The asymmetry has existed since the IBaseWidget.serialize flag landed,
but only surfaces when a non-serializable widget precedes a serializable
one. SubgraphNode's PromotedWidgetView is declared serialize: false, so
recent subgraph churn that places preview/promoted views ahead of
combo/text widgets in node.widgets[] (e.g. virtual canvas auto-promotion
in supportsVirtualCanvasImagePreview) makes templates trigger this on
load — model/vae/clip widgets read each other's values.

Fix: write widgets_values via push() so the array is compacted on the
write side too, matching what configure() and every other consumer
already expect (PrimitiveNode.onAfterGraphConfigured, errorNodeWidgets,
groupNode offset math, useNodeReplacement, useMaskEditorSaver,
migrateWidgetsValues).
2026-05-09 01:18:28 +00:00
3 changed files with 124 additions and 8 deletions

View File

@@ -246,6 +246,44 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
expect(afterSnapshot).toEqual(beforeSnapshot)
})
test('Promoted widget values survive serialize -> reload without shifting', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
const widgetNamesBefore = await getPromotedWidgetNames(comfyPage, '11')
expect(widgetNamesBefore.length).toBeGreaterThanOrEqual(2)
const expectedValues = widgetNamesBefore.map(
(_, index) => `value-${index}`
)
await comfyPage.page.evaluate(
([hostId, values]) => {
const node = window.app!.graph!.getNodeById(Number(hostId))
const widgets = node?.widgets ?? []
widgets.forEach((widget, index) => {
if (index < values.length) widget.value = values[index]
})
},
['11', expectedValues] as const
)
await comfyPage.subgraph.serializeAndReload()
await expect
.poll(async () =>
comfyPage.page.evaluate((hostId) => {
const node = window.app!.graph!.getNodeById(Number(hostId))
const widgets = node?.widgets ?? []
return widgets.map((widget) => widget.value)
}, '11')
)
.toEqual(expectedValues)
})
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
comfyPage
}) => {

View File

@@ -587,6 +587,61 @@ describe('LGraphNode', () => {
expect(node.widgets![0].value).toBe(1)
expect(node.widgets![1].value).toBe(100)
})
test('configure recovers legacy sparse widgets_values written by pre-compact serialize()', () => {
const node = new LGraphNode('TestNode')
node.serialize_widgets = true
node.addWidget('button', 'preview', '', undefined)
node.addWidget('text', 'model', '', undefined)
node.addWidget('text', 'vae', '', undefined)
node.addWidget('text', 'clip', '', undefined)
node.widgets![0].serialize = false
const legacySparsePayload = JSON.parse(
'{"id":1,"type":"TestNode","pos":[100,100],"size":[100,100],"properties":{},"flags":{},"order":0,"mode":0,"widgets_values":[null,"model.safetensors","vae.safetensors","clip.safetensors"]}'
)
node.configure(legacySparsePayload)
expect(node.widgets![1].value).toBe('model.safetensors')
expect(node.widgets![2].value).toBe('vae.safetensors')
expect(node.widgets![3].value).toBe('clip.safetensors')
})
test('serialize/configure round-trip preserves values when a non-serializable widget precedes serializable ones', () => {
const source = new LGraphNode('TestNode')
source.serialize_widgets = true
source.addWidget('button', 'preview', '', undefined)
source.addWidget('text', 'model', '', undefined)
source.addWidget('text', 'vae', '', undefined)
source.addWidget('text', 'clip', '', undefined)
source.widgets![0].serialize = false
source.widgets![1].value = 'model.safetensors'
source.widgets![2].value = 'vae.safetensors'
source.widgets![3].value = 'clip.safetensors'
const serialized = source.serialize()
expect(serialized.widgets_values).toEqual([
'model.safetensors',
'vae.safetensors',
'clip.safetensors'
])
const roundTripped = JSON.parse(JSON.stringify(serialized))
const target = new LGraphNode('TestNode')
target.serialize_widgets = true
target.addWidget('button', 'preview', '', undefined)
target.addWidget('text', 'model', '', undefined)
target.addWidget('text', 'vae', '', undefined)
target.addWidget('text', 'clip', '', undefined)
target.widgets![0].serialize = false
target.configure(roundTripped)
expect(target.widgets![1].value).toBe('model.safetensors')
expect(target.widgets![2].value).toBe('vae.safetensors')
expect(target.widgets![3].value).toBe('clip.safetensors')
})
})
describe('getInputSlotPos', () => {

View File

@@ -915,11 +915,32 @@ export class LGraphNode
}
if (info.widgets_values) {
let i = 0
for (const widget of this.widgets ?? []) {
if (widget.serialize === false) continue
if (i >= info.widgets_values.length) break
widget.value = info.widgets_values[i++]
const allWidgets = this.widgets ?? []
// Legacy payloads written by pre-compact serialize() emit a JSON
// null at every preceding `serialize: false` slot, so the array
// is longer than the serializable widget count. Read by widget
// index in that case to undo the shift; otherwise consume the
// compact array sequentially.
const serializableCount = allWidgets.reduce(
(count, widget) => (widget.serialize === false ? count : count + 1),
0
)
const isLegacyIndexedLayout =
info.widgets_values.length > serializableCount
if (isLegacyIndexedLayout) {
for (const [widgetIndex, widget] of allWidgets.entries()) {
if (widget.serialize === false) continue
if (widgetIndex >= info.widgets_values.length) break
widget.value = info.widgets_values[widgetIndex]
}
} else {
let i = 0
for (const widget of allWidgets) {
if (widget.serialize === false) continue
if (i >= info.widgets_values.length) break
widget.value = info.widgets_values[i++]
}
}
}
}
@@ -970,15 +991,17 @@ export class LGraphNode
const { widgets } = this
if (widgets && this.serialize_widgets) {
// Compact write to mirror configure()'s compacting read; indexed
// assignment would leave null holes that shift values on round-trip.
o.widgets_values = []
for (const [i, widget] of widgets.entries()) {
for (const widget of widgets) {
if (widget.serialize === false) continue
const val = widget?.value
// Ensure object values are plain (not reactive proxies) for structuredClone compatibility.
o.widgets_values[i] =
o.widgets_values.push(
val != null && typeof val === 'object'
? JSON.parse(JSON.stringify(val))
: (val ?? null)
)
}
}