Compare commits

...

5 Commits

Author SHA1 Message Date
dante01yoon
435be884d9 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-05 09:36:16 +09:00
dante01yoon
bf4241a849 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-04 23:37:40 +09:00
dante01yoon
0628c09233 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-04 23:33:43 +09:00
dante01yoon
6cbb1f845d 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-04 22:53:33 +09:00
dante01yoon
5b3bc0a8d8 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-04 22:47:35 +09:00
3 changed files with 139 additions and 0 deletions

View File

@@ -149,13 +149,32 @@ 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 state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
/**
* Read the per-instance value stored during configure.
* Used by graphToPrompt to get the correct execution value when
* multiple SubgraphNode instances share the same blueprint.
*/
getInstanceValue(): IBaseWidget['value'] | undefined {
const v = this.subgraphNode._instanceWidgetValues.get(this._instanceKey)
return v as IBaseWidget['value'] | undefined
}
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

@@ -0,0 +1,93 @@
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 promoted widget values after configure with different widgets_values', () => {
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]
})
// After configuring both, each instance's per-instance value
// should be preserved in _instanceWidgetValues, even though the
// shared inner widget holds the last-configured value.
expect(instance1._instanceWidgetValues.size).toBeGreaterThan(0)
expect(instance2._instanceWidgetValues.size).toBeGreaterThan(0)
expect([...instance1._instanceWidgetValues.values()]).toContain(10)
expect([...instance2._instanceWidgetValues.values()]).toContain(20)
})
})

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,20 @@ 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)
}
// Restore per-instance promoted widget values from serialized widgets_values.
// LGraphNode.configure skips promoted widgets (serialize === false), so they
// must be applied here after the promoted views are created.
if (this._pendingWidgetsValues) {
const views = this._getPromotedViews()
let i = 0
for (const view of views) {
if (i >= this._pendingWidgetsValues.length) break
// Use the setter which stores in instance Map AND syncs to inner node
view.value = this._pendingWidgetsValues[i++] as typeof view.value
}
this._pendingWidgetsValues = undefined
}
}
/**