Compare commits

...

12 Commits

Author SHA1 Message Date
dante01yoon
3a83180770 fix: skip non-serializable source widgets in promoted view serialization
Add sourceSerialize getter to PromotedWidgetView that checks the
resolved source widget's serialize flag. SubgraphNode.serialize() and
configure now filter out views whose source widget has serialize=false,
preventing preview/audio/video widget values from being persisted.
2026-04-09 13:19:50 +09:00
dante01yoon
c191a88d05 fix: serialize subgraph instance widget values 2026-04-09 13:18:15 +09:00
dante
d4beb73b1c test: cover multi-instance subgraph widget values 2026-04-09 13:18:15 +09:00
dante
04f792452b fix: restore per-instance promoted widget reads 2026-04-09 13:17:32 +09:00
dante01yoon
5de9ee7bea fix: use serializeValue for per-instance execution instead of overriding value getter
The value getter must use the shared widget state store for UI/DOM widget
persistence (e.g. textarea values surviving navigation).  Instead of
overriding the getter, add serializeValue() on PromotedWidgetView — the
execution path (graphToPrompt) already prefers serializeValue over .value.
Also wire serializeValue into ExecutableNodeDTO for subgraph input
resolution.  This cleanly separates the UI read path (shared store) from
the execution read path (per-instance map).
2026-04-09 13:17:32 +09:00
dante01yoon
7accc99402 fix: wire per-instance widget values into value getter and remove dead getInstanceValue
Integrate _instanceWidgetValues lookup into the PromotedWidgetView.value
getter (before the shared widget state store fallback) so multiple
SubgraphNode instances actually return their own configured values.
Remove the unused getInstanceValue() method. Rewrite the test to assert
through the public .value getter instead of inspecting private internals.
2026-04-09 13:17:32 +09:00
dante01yoon
81af70cc93 fix: preserve getter/setter compatibility, use getInstanceValue for execution
Revert getter to original behavior so inner node sync and E2E
navigation tests are unaffected. Per-instance values are stored
via setter and during configure, accessible through
getInstanceValue() for execution contexts.
2026-04-09 13:17:32 +09:00
dante01yoon
073f57c907 fix: use view setter during configure to sync inner node values
Use view.value setter (not direct Map write) when restoring
widgets_values during configure, so the value is stored in the
per-instance map AND synced to the inner node widget for E2E
compatibility.
2026-04-09 13:17:32 +09:00
dante01yoon
b5138cb800 fix: include disambiguatingSourceNodeId in instance widget key
The per-instance key must include disambiguatingSourceNodeId to
avoid collisions when multiple promoted widgets share the same
sourceNodeId and sourceWidgetName (e.g. nested subgraph promotions).
2026-04-09 13:17:32 +09:00
dante01yoon
731967c79d fix: store promoted widget values per SubgraphNode instance
Multiple SubgraphNode instances sharing the same blueprint wrote
promoted widget values to the shared inner node, causing the last
configure to overwrite all previous instances' values.

Add a per-instance Map (_instanceWidgetValues) on SubgraphNode that
stores promoted widget values independently. PromotedWidgetView reads
from this map first, falling back to the widget store and inner node.
During configure, widgets_values are restored into this map after
promoted views are created.
2026-04-09 13:17:32 +09:00
dante01yoon
6974bf626b test: add failing tests for multi-instance subgraph widget value collision
Multiple SubgraphNode instances sharing the same blueprint overwrite
each other's promoted widget values because PromotedWidgetView writes
directly to shared inner node widgets.
2026-04-09 13:17:31 +09:00
Dante
65d1313443 fix: preserve CustomCombo options through clone and paste (#10853)
## Summary

- Fix `CustomCombo` copy/paste so the combo keeps its option list and
selected value
- Scope the fix to `src/extensions/core/customWidgets.ts` instead of
changing LiteGraph core deserialization
- Replace the previous round-trip test with a regression test that
exercises the actual clone/paste lifecycle

- Fixes #9927

## Root Cause

`CustomCombo` option widgets override `value` to read from
`widgetValueStore`.
During `node.clone()` and clipboard paste, `configure()` restores widget
values before the new node is added to the graph and before those
widgets are registered in the store.
That meant the option widgets read back as empty while `updateCombo()`
was rebuilding the combo state, so `comboWidget.options.values` became
blank on the pasted node.

## Fix

Keep a local fallback value for each generated `option*` widget in
`customWidgets.ts`.
The getter now returns the store-backed value when available and falls
back to the locally restored value before store registration.
This preserves the option list during `clone().serialize()` and paste
without hard-coding `CustomCombo` behavior into
`LGraphNode.configure()`.

## Why No E2E Test

This regression happens in the internal LiteGraph clipboard lifecycle:
`clone() -> serialize() -> createNode() -> configure() -> graph.add()`.
The failing state is the transient pre-add relationship between
`CustomCombo`'s store-backed option widgets and
`comboWidget.options.values`, which is not directly exposed through a
stable DOM assertion in the current Playwright suite.
A focused unit regression test is the most direct way to cover that
lifecycle without depending on brittle canvas interaction timing.

## Test Plan

- [x] Regression test covers `clone().serialize() -> createNode() ->
configure() -> graph.add()` for `CustomCombo`
- [ ] CI on the latest two commits (`81ac6d2ce`, `94147caf1`)
- [ ] Manual: create `CustomCombo` -> add `alpha`, `beta`, `gamma` ->
select `beta` -> copy/paste -> verify the pasted combo still shows all
three options and keeps `beta` selected
2026-04-09 12:35:20 +09:00
10 changed files with 712 additions and 33 deletions

View File

@@ -0,0 +1,284 @@
{
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
"revision": 0,
"last_node_id": 13,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [120, 180],
"size": [210, 168],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Alpha\n"]
},
{
"id": 12,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [420, 180],
"size": [210, 168],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Beta\n"]
},
{
"id": 13,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [720, 180],
"size": [210, 168],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Gamma\n"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
},
"outputNode": {
"id": -20,
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [10],
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
},
{
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
"name": "clip",
"type": "CLIP",
"linkIds": [11],
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
},
{
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
"name": "model",
"type": "MODEL",
"linkIds": [12],
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
},
{
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [13],
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
},
{
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [14],
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
},
{
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
"name": "latent_image",
"type": "LATENT",
"linkIds": [15],
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [661.59912109375, 314.13336181640625],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 11
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": { "name": "text" },
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
},
{
"id": 11,
"type": "KSampler",
"pos": [674.1234741210938, 570.5839233398438],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 13
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 12,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 4,
"target_id": 11,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 5,
"target_id": 11,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -1,5 +1,6 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
@@ -8,6 +9,31 @@ const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const getPromotedHostWidgetValues = async (
comfyPage: ComfyPage,
nodeIds: string[]
) => {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
})
}, nodeIds)
}
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
}) => {
@@ -83,5 +109,35 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await expect(textarea).toBeVisible()
await expect(textarea).toBeDisabled()
})
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const workflowName =
'subgraphs/subgraph-multi-instance-promoted-text-values'
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(workflowName)
await comfyPage.nextFrame()
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(initialValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
})
})
})

View File

@@ -24,6 +24,8 @@ export interface PromotedWidgetView extends IBaseWidget {
* origin.
*/
readonly disambiguatingSourceNodeId?: string
/** Whether the resolved source widget is workflow-persistent. */
readonly sourceSerialize: boolean
}
export function isPromotedWidgetView(

View File

@@ -77,6 +77,15 @@ class PromotedWidgetView implements IPromotedWidgetView {
readonly serialize = false
/**
* Whether the resolved source widget is workflow-persistent.
* Used by SubgraphNode.serialize to skip preview/audio/video widgets
* whose source sets serialize = false.
*/
get sourceSerialize(): boolean {
return this.resolveDeepest()?.widget.serialize !== false
}
last_y?: number
computedHeight?: number
@@ -149,13 +158,43 @@ class PromotedWidgetView implements IPromotedWidgetView {
return this.resolveDeepest()?.widget.linkedWidgets
}
private get _instanceKey(): string {
return this.disambiguatingSourceNodeId
? `${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
: `${this.sourceNodeId}:${this.sourceWidgetName}`
}
get value(): IBaseWidget['value'] {
const instanceValue = this.subgraphNode._instanceWidgetValues.get(
this._instanceKey
)
if (instanceValue !== undefined)
return instanceValue as IBaseWidget['value']
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
/**
* Execution-time serialization — returns the per-instance value stored
* during configure, falling back to the regular value getter.
*
* The widget state store is shared across instances (keyed by inner node
* ID), so the regular getter returns the last-configured value for all
* instances. graphToPrompt already prefers serializeValue over .value,
* so this is the hook that makes multi-instance execution correct.
*/
serializeValue(): IBaseWidget['value'] {
const v = this.subgraphNode._instanceWidgetValues.get(this._instanceKey)
if (v !== undefined) return v as IBaseWidget['value']
return this.value
}
set value(value: IBaseWidget['value']) {
// Keep per-instance map in sync for execution (graphToPrompt)
this.subgraphNode._instanceWidgetValues.set(this._instanceKey, value)
const linkedWidgets = this.getLinkedInputWidgets()
if (linkedWidgets.length > 0) {
const widgetStore = useWidgetValueStore()

View File

@@ -253,7 +253,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets).toHaveLength(0)
})
test('serialize does not produce widgets_values for promoted views', () => {
test('serialize stores widgets_values for promoted views', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
@@ -265,9 +265,7 @@ describe('Subgraph proxyWidgets', () => {
const serialized = subgraphNode.serialize()
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
// Even if it were set, views have serialize: false and would be skipped.
expect(serialized.widgets_values).toBeUndefined()
expect(serialized.widgets_values).toEqual(['value'])
})
test('serialize preserves proxyWidgets in properties', () => {

View File

@@ -0,0 +1,103 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
const TEST_CUSTOM_COMBO_TYPE = 'test/CustomComboCopyPaste'
class TestCustomComboNode extends LGraphNode {
static override title = 'CustomCombo'
constructor() {
super('CustomCombo')
this.serialize_widgets = true
this.addOutput('value', '*')
this.addWidget('combo', 'value', '', () => {}, {
values: [] as string[]
})
}
}
function findWidget(node: LGraphNode, name: string) {
return node.widgets?.find((widget) => widget.name === name)
}
function getCustomWidgetsExtension(): ComfyExtension {
const extension = useExtensionStore().extensions.find(
(candidate) => candidate.name === 'Comfy.CustomWidgets'
)
if (!extension) {
throw new Error('Comfy.CustomWidgets extension was not registered')
}
return extension
}
describe('CustomCombo copy/paste', () => {
beforeAll(async () => {
setActivePinia(createTestingPinia({ stubActions: false }))
await import('./customWidgets')
const extension = getCustomWidgetsExtension()
await extension.beforeRegisterNodeDef?.(
TestCustomComboNode,
{ name: 'CustomCombo' } as ComfyNodeDef,
app
)
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
}
LiteGraph.registerNodeType(TEST_CUSTOM_COMBO_TYPE, TestCustomComboNode)
})
afterAll(() => {
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
}
})
it('preserves combo options and selected value through clone and paste', () => {
const graph = new LGraph()
type AppWithRootGraph = { rootGraphInternal?: LGraph }
const appWithRootGraph = app as unknown as AppWithRootGraph
const previousRootGraph = appWithRootGraph.rootGraphInternal
appWithRootGraph.rootGraphInternal = graph
try {
const original = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
graph.add(original)
findWidget(original, 'option1')!.value = 'alpha'
findWidget(original, 'option2')!.value = 'beta'
findWidget(original, 'option3')!.value = 'gamma'
findWidget(original, 'value')!.value = 'beta'
const clonedSerialised = original.clone()?.serialize()
expect(clonedSerialised).toBeDefined()
const pasted = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
pasted.configure(clonedSerialised!)
graph.add(pasted)
expect(findWidget(pasted, 'value')!.value).toBe('beta')
expect(findWidget(pasted, 'option1')!.value).toBe('alpha')
expect(findWidget(pasted, 'option2')!.value).toBe('beta')
expect(findWidget(pasted, 'option3')!.value).toBe('gamma')
expect(findWidget(pasted, 'value')!.options.values).toEqual([
'alpha',
'beta',
'gamma'
])
} finally {
appWithRootGraph.rootGraphInternal = previousRootGraph
}
})
})

View File

@@ -63,7 +63,7 @@ function onCustomComboCreated(this: LGraphNode) {
(w) => w.name.startsWith('option') && w.value
).map((w) => `${w.value}`)
)
if (app.configuringGraph) return
if (app.configuringGraph || !this.graph) return
if (values.includes(`${comboWidget.value}`)) return
comboWidget.value = values[0] ?? ''
comboWidget.callback?.(comboWidget.value)
@@ -71,6 +71,9 @@ function onCustomComboCreated(this: LGraphNode) {
comboWidget.callback = useChainCallback(comboWidget.callback, () =>
this.applyToGraph!()
)
this.onAdded = useChainCallback(this.onAdded, function () {
updateCombo()
})
function addOption(node: LGraphNode) {
if (!node.widgets) return
@@ -78,16 +81,17 @@ function onCustomComboCreated(this: LGraphNode) {
const widgetName = `option${newCount}`
const widget = node.addWidget('string', widgetName, '', () => {})
if (!widget) return
let localValue = `${widget.value ?? ''}`
Object.defineProperty(widget, 'value', {
get() {
return useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)?.value
return (
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
?.value ?? localValue
)
},
set(v: string) {
localValue = v
const state = useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,

View File

@@ -186,11 +186,16 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
if (!widget) return
// Special case: SubgraphNode widget.
// Prefer serializeValue (per-instance) over the shared .value getter
// so multiple SubgraphNode instances return their own configured values.
const widgetValue = widget.serializeValue
? widget.serializeValue(subgraphNode, -1)
: widget.value
return {
node: this,
origin_id: this.id,
origin_slot: -1,
widgetInfo: { value: widget.value }
widgetInfo: { value: widgetValue }
}
}

View File

@@ -0,0 +1,166 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
function createNodeWithWidget(
title: string,
widgetValue: unknown = 42,
slotType: ISlotType = 'number'
) {
const node = new LGraphNode(title)
const input = node.addInput('value', slotType)
node.addOutput('out', slotType)
// @ts-expect-error Abstract class instantiation
const widget = new BaseWidget({
name: 'widget',
type: 'number',
value: widgetValue,
y: 0,
options: { min: 0, max: 100, step: 1 },
node
})
node.widgets = [widget]
input.widget = { name: widget.name }
return { node, widget, input }
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphNode multi-instance widget isolation', () => {
it('preserves per-instance widget values after configure', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
// Simulate what LGraph.configure does: call configure with different widgets_values
instance1.configure({
id: 201,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [10]
})
instance2.configure({
id: 202,
type: subgraph.id,
pos: [400, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 1,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [20]
})
const widgets1 = instance1.widgets!
const widgets2 = instance2.widgets!
expect(widgets1.length).toBeGreaterThan(0)
expect(widgets2.length).toBeGreaterThan(0)
expect(widgets1[0].value).toBe(10)
expect(widgets2[0].value).toBe(20)
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
expect(instance1.serialize().widgets_values).toEqual([10])
expect(instance2.serialize().widgets_values).toEqual([20])
})
it('round-trips per-instance widget values through serialize and configure', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
originalInstance.configure({
id: 301,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
const serialized = originalInstance.serialize()
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
restoredInstance.configure({
...serialized,
id: 302,
type: subgraph.id
})
const restoredWidget = restoredInstance.widgets?.[0]
expect(restoredWidget?.value).toBe(33)
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
})
it('skips non-serializable source widgets during serialize', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 10)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
// Mark the source widget as non-persistent (e.g. preview widget)
widget.serialize = false
const instance = createTestSubgraphNode(subgraph, { id: 501 })
instance.configure({
id: 501,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: []
})
const serialized = instance.serialize()
expect(serialized.widgets_values).toBeUndefined()
})
})

View File

@@ -993,7 +993,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/** Temporarily stored during configure for use by _internalConfigureAfterSlots */
private _pendingWidgetsValues?: unknown[]
/**
* Per-instance promoted widget values.
* Multiple SubgraphNode instances share the same inner nodes, so
* promoted widget values must be stored per-instance to avoid collisions.
* Key: `${sourceNodeId}:${sourceWidgetName}`
*/
readonly _instanceWidgetValues = new Map<string, unknown>()
override configure(info: ExportedSubgraphInstance): void {
this._pendingWidgetsValues = info.widgets_values
for (const input of this.inputs) {
if (
input._listenerController &&
@@ -1124,6 +1137,21 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
store.promote(this.rootGraph.id, this.id, source)
}
// Hydrate per-instance promoted widget values from serialized data.
// LGraphNode.configure skips promoted widgets (serialize === false on
// the view), so they must be applied here after promoted views exist.
// Only iterate serializable views to match what serialize() wrote.
if (this._pendingWidgetsValues) {
const views = this._getPromotedViews()
let i = 0
for (const view of views) {
if (!view.sourceSerialize) continue
if (i >= this._pendingWidgetsValues.length) break
view.value = this._pendingWidgetsValues[i++] as typeof view.value
}
this._pendingWidgetsValues = undefined
}
}
/**
@@ -1573,28 +1601,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
ctx.restore()
}
/**
* Synchronizes widget values from this SubgraphNode instance to the
* corresponding widgets in the subgraph definition before serialization.
* This ensures nested subgraph widget values are preserved when saving.
*/
override serialize(): ISerialisedNode {
// Sync widget values to subgraph definition before serialization.
// Only sync for inputs that are linked to a promoted widget via _widget.
for (const input of this.inputs) {
if (!input._widget) continue
const subgraphInput =
input._subgraphSlot ??
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
if (!subgraphInput) continue
const connectedWidgets = subgraphInput.getConnectedWidgets()
for (const connectedWidget of connectedWidgets) {
connectedWidget.value = input._widget.value
}
}
// Write promotion store state back to properties for serialization
const entries = usePromotionStore().getPromotions(
this.rootGraph.id,
@@ -1602,7 +1609,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
this.properties.proxyWidgets = this._serializeEntries(entries)
return super.serialize()
const serialized = super.serialize()
const views = this._getPromotedViews()
const serializableViews = views.filter((view) => view.sourceSerialize)
if (serializableViews.length > 0) {
serialized.widgets_values = serializableViews.map((view) => {
const value = view.serializeValue
? view.serializeValue(this, -1)
: view.value
return value != null && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: (value ?? null)
})
}
return serialized
}
override clone() {
const clone = super.clone()