diff --git a/browser_tests/assets/subgraphs/subgraph-multi-instance-promoted-text-values.json b/browser_tests/assets/subgraphs/subgraph-multi-instance-promoted-text-values.json deleted file mode 100644 index 459cc73c32..0000000000 --- a/browser_tests/assets/subgraphs/subgraph-multi-instance-promoted-text-values.json +++ /dev/null @@ -1,284 +0,0 @@ -{ - "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 -} diff --git a/browser_tests/tests/subgraph/subgraphSerialization.spec.ts b/browser_tests/tests/subgraph/subgraphSerialization.spec.ts index c497d6ef5e..0c5d19c25f 100644 --- a/browser_tests/tests/subgraph/subgraphSerialization.spec.ts +++ b/browser_tests/tests/subgraph/subgraphSerialization.spec.ts @@ -1,6 +1,5 @@ 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' @@ -9,31 +8,6 @@ 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 }) => { @@ -109,35 +83,5 @@ 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 - ) - }) }) }) diff --git a/src/core/graph/subgraph/promotedWidgetTypes.ts b/src/core/graph/subgraph/promotedWidgetTypes.ts index a1e52f6a5e..69f28879d9 100644 --- a/src/core/graph/subgraph/promotedWidgetTypes.ts +++ b/src/core/graph/subgraph/promotedWidgetTypes.ts @@ -24,8 +24,6 @@ export interface PromotedWidgetView extends IBaseWidget { * origin. */ readonly disambiguatingSourceNodeId?: string - /** Whether the resolved source widget is workflow-persistent. */ - readonly sourceSerialize: boolean } export function isPromotedWidgetView( diff --git a/src/core/graph/subgraph/promotedWidgetView.ts b/src/core/graph/subgraph/promotedWidgetView.ts index f7e5ee1789..610ba4cbec 100644 --- a/src/core/graph/subgraph/promotedWidgetView.ts +++ b/src/core/graph/subgraph/promotedWidgetView.ts @@ -1,6 +1,3 @@ -import { isEqual } from 'es-toolkit' - -import type { LGraph } from '@/lib/litegraph/src/LGraph' import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer' @@ -53,43 +50,6 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget { } const designTokenCache = new Map() -const promotedSourceWriteMetaByGraph = new WeakMap< - LGraph, - Map ->() - -interface PromotedSourceWriteMeta { - value: IBaseWidget['value'] - writerInstanceId: string -} - -function cloneWidgetValue( - value: TValue -): TValue { - return value != null && typeof value === 'object' - ? (JSON.parse(JSON.stringify(value)) as TValue) - : value -} - -function getPromotedSourceWriteMeta( - graph: LGraph, - sourceKey: string -): PromotedSourceWriteMeta | undefined { - return promotedSourceWriteMetaByGraph.get(graph)?.get(sourceKey) -} - -function setPromotedSourceWriteMeta( - graph: LGraph, - sourceKey: string, - meta: PromotedSourceWriteMeta -): void { - let metaBySource = promotedSourceWriteMetaByGraph.get(graph) - if (!metaBySource) { - metaBySource = new Map() - promotedSourceWriteMetaByGraph.set(graph, metaBySource) - } - metaBySource.set(sourceKey, meta) -} export function createPromotedWidgetView( subgraphNode: SubgraphNode, @@ -117,15 +77,6 @@ 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 @@ -198,52 +149,13 @@ 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}` - } - - private get _sharedSourceKey(): string { - return this.disambiguatingSourceNodeId - ? `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}` - : `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}` - } - get value(): IBaseWidget['value'] { - return this.getTrackedValue() - } - - /** - * 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'] { - return this.getTrackedValue() + const state = this.getWidgetState() + if (state && isWidgetValue(state.value)) return state.value + return this.resolveAtHost()?.widget.value } set value(value: IBaseWidget['value']) { - this.captureSiblingFallbackValues() - - // Keep per-instance map in sync for execution (graphToPrompt) - this.subgraphNode._instanceWidgetValues.set( - this._instanceKey, - cloneWidgetValue(value) - ) - setPromotedSourceWriteMeta( - this.subgraphNode.rootGraph, - this._sharedSourceKey, - { - value: cloneWidgetValue(value), - writerInstanceId: String(this.subgraphNode.id) - } - ) - const linkedWidgets = this.getLinkedInputWidgets() if (linkedWidgets.length > 0) { const widgetStore = useWidgetValueStore() @@ -473,39 +385,6 @@ class PromotedWidgetView implements IPromotedWidgetView { return resolved } - private getTrackedValue(): IBaseWidget['value'] { - const instanceValue = this.subgraphNode._instanceWidgetValues.get( - this._instanceKey - ) - const sharedValue = this.getSharedValue() - - if (instanceValue === undefined) return sharedValue - - const sourceWriteMeta = getPromotedSourceWriteMeta( - this.subgraphNode.rootGraph, - this._sharedSourceKey - ) - if ( - sharedValue !== undefined && - sourceWriteMeta && - !isEqual(sharedValue, sourceWriteMeta.value) - ) { - this.subgraphNode._instanceWidgetValues.set( - this._instanceKey, - cloneWidgetValue(sharedValue) - ) - return sharedValue - } - - return instanceValue as IBaseWidget['value'] - } - - private getSharedValue(): IBaseWidget['value'] { - const state = this.getWidgetState() - if (state && isWidgetValue(state.value)) return state.value - return this.resolveAtHost()?.widget.value - } - private getWidgetState() { const linkedState = this.getLinkedInputWidgetStates()[0] if (linkedState) return linkedState @@ -572,30 +451,6 @@ class PromotedWidgetView implements IPromotedWidgetView { .filter((state): state is WidgetState => state !== undefined) } - private captureSiblingFallbackValues(): void { - const { rootGraph } = this.subgraphNode - - for (const node of rootGraph.nodes) { - if (node === this.subgraphNode || !node.isSubgraphNode()) continue - if (node.subgraph.id !== this.subgraphNode.subgraph.id) continue - if (node._instanceWidgetValues.has(this._instanceKey)) continue - - const siblingView = node.widgets.find( - (widget): widget is IPromotedWidgetView => - isPromotedWidgetView(widget) && - widget.sourceNodeId === this.sourceNodeId && - widget.sourceWidgetName === this.sourceWidgetName && - widget.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId - ) - if (!siblingView) continue - - node._instanceWidgetValues.set( - this._instanceKey, - cloneWidgetValue(siblingView.value) - ) - } - } - private getProjectedWidget(resolved: { node: LGraphNode widget: IBaseWidget diff --git a/src/core/graph/subgraph/subgraphNodePromotion.test.ts b/src/core/graph/subgraph/subgraphNodePromotion.test.ts index a6aaba7f58..b0203fe4b7 100644 --- a/src/core/graph/subgraph/subgraphNodePromotion.test.ts +++ b/src/core/graph/subgraph/subgraphNodePromotion.test.ts @@ -253,7 +253,7 @@ describe('Subgraph proxyWidgets', () => { expect(subgraphNode.widgets).toHaveLength(0) }) - test('serialize stores widgets_values for promoted views', () => { + test('serialize does not produce widgets_values for promoted views', () => { const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) usePromotionStore().setPromotions( @@ -265,7 +265,9 @@ describe('Subgraph proxyWidgets', () => { const serialized = subgraphNode.serialize() - expect(serialized.widgets_values).toEqual(['value']) + // 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() }) test('serialize preserves proxyWidgets in properties', () => { diff --git a/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts b/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts index bacfd609e2..1ea33b319d 100644 --- a/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts +++ b/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts @@ -186,16 +186,11 @@ 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: widgetValue } + widgetInfo: { value: widget.value } } } diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.multiInstance.test.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.multiInstance.test.ts deleted file mode 100644 index 66f2817cc3..0000000000 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.multiInstance.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -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 { LGraphNode } from '@/lib/litegraph/src/litegraph' - -import { - createTestSubgraph, - createTestSubgraphNode, - resetSubgraphFixtureState -} from './__fixtures__/subgraphHelpers' - -function createNodeWithWidget( - title: string, - widgetValue: number = 42, - slotType: ISlotType = 'number' -) { - const node = new LGraphNode(title) - const input = node.addInput('value', slotType) - node.addOutput('out', slotType) - - const widget = node.addWidget('number', 'widget', widgetValue, () => {}, { - min: 0, - max: 100, - step: 1 - }) - 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('keeps fresh sibling instances isolated before save or reload', () => { - const subgraph = createTestSubgraph({ - inputs: [{ name: 'value', type: 'number' }] - }) - - const { node } = createNodeWithWidget('TestNode', 7) - subgraph.add(node) - subgraph.inputNode.slots[0].connect(node.inputs[0], node) - - const instance1 = createTestSubgraphNode(subgraph, { id: 401 }) - const instance2 = createTestSubgraphNode(subgraph, { id: 402 }) - instance1.graph!.add(instance1) - instance2.graph!.add(instance2) - - const widget1 = instance1.widgets?.[0] - const widget2 = instance2.widgets?.[0] - - expect(widget1?.value).toBe(7) - expect(widget2?.value).toBe(7) - - widget1!.value = 10 - - expect(widget1?.value).toBe(10) - expect(widget2?.value).toBe(7) - expect(widget1?.serializeValue?.(instance1, 0)).toBe(10) - expect(widget2?.serializeValue?.(instance2, 0)).toBe(7) - }) - - it('syncs restored promoted widgets when the inner source widget changes directly', () => { - const subgraph = createTestSubgraph({ - inputs: [{ name: 'value', type: 'number' }] - }) - - const { node, widget } = createNodeWithWidget('TestNode', 0) - subgraph.add(node) - subgraph.inputNode.slots[0].connect(node.inputs[0], node) - - const originalInstance = createTestSubgraphNode(subgraph, { id: 601 }) - originalInstance.configure({ - id: 601, - 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: 602 }) - restoredInstance.configure({ - ...serialized, - id: 602, - type: subgraph.id - }) - - expect(restoredInstance.widgets?.[0].value).toBe(33) - - widget.value = 45 - - expect(restoredInstance.widgets?.[0].value).toBe(45) - expect( - restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0) - ).toBe(45) - }) - - it('clears stale per-instance values when reconfigured without widgets_values', () => { - const subgraph = createTestSubgraph({ - inputs: [{ name: 'value', type: 'number' }] - }) - - const { node, widget } = createNodeWithWidget('TestNode', 5) - subgraph.add(node) - subgraph.inputNode.slots[0].connect(node.inputs[0], node) - - const instance = createTestSubgraphNode(subgraph, { id: 701 }) - instance.graph!.add(instance) - - const promotedWidget = instance.widgets?.[0] - promotedWidget!.value = 11 - widget.value = 17 - - const serialized = instance.serialize() - delete serialized.widgets_values - - instance.configure({ - ...serialized, - id: instance.id, - type: subgraph.id - }) - - expect(instance.widgets?.[0].value).toBe(17) - expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(17) - }) - - 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() - }) -}) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index e07cf03942..3710e5bd9f 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -994,21 +994,7 @@ 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() - override configure(info: ExportedSubgraphInstance): void { - this._instanceWidgetValues.clear() - this._pendingWidgetsValues = info.widgets_values - for (const input of this.inputs) { if ( input._listenerController && @@ -1139,21 +1125,6 @@ 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 - } } /** @@ -1548,7 +1519,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { override onRemoved(): void { this._eventAbortController.abort() this._invalidatePromotedViewsCache() - this._instanceWidgetValues.clear() for (const widget of this.widgets) { if (isPromotedWidgetView(widget)) { @@ -1604,7 +1574,28 @@ 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, @@ -1612,22 +1603,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { ) this.properties.proxyWidgets = this._serializeEntries(entries) - 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 + return super.serialize() } override clone() { const clone = super.clone()