Compare commits

...

3 Commits

Author SHA1 Message Date
Glary-Bot
9242ce7f3a fix: address CodeRabbit review findings
- SubgraphNode.configure now clears scoped entries under info.id when
it differs from this.id, preventing bleed-through on reconfigure.
- widgetValueStore exports clearScopedWidget for per-widget targeted
deletion. Used by SubgraphNode ensureWidgetRemoved and _setWidget demotion
paths to clear stale scoped promoted-widget values.
2026-04-29 18:10:03 +00:00
Glary-Bot
16cbd8f4ff fix: preserve source defaults in serialize and Vue render
Address Oracle review of mixed-edit and first-paint cases:

- SubgraphNode.serialize falls back to view.value (source default) when
  no scoped store value exists, instead of writing null. Avoids null
  replay corrupting un-edited widgets on reload.
- _internalConfigureAfterSlots skips null/undefined entries from legacy
  widgets_values defensively so older saves cannot poison the store.
- SafeWidgetData gains a read-only defaultValue field sourced from the
  underlying widget at construction. useProcessedWidgets falls back to
  it when no scoped store entry exists yet, so first paint of un-edited
  promoted widgets shows the source default.
2026-04-29 17:46:51 +00:00
Glary-Bot
570da3b453 fix: scope widgetValueStore by SubgraphNode instance (#10849 regression, #10146)
Adds an instanceId dimension to widgetValueStore so promoted widgets on
sibling SubgraphNode instances no longer collide on a shared key. The
store becomes the single source of truth for per-instance promoted
widget values, replacing the _instanceWidgetValues Map bolted onto
SubgraphNode by #10849.

Persistence keeps the existing positional widgets_values format that
the rest of the ComfyUI ecosystem speaks. SubgraphNode.serialize emits
widgets_values only when at least one promoted view has a scoped store
value — pre-#10849 templates without per-instance edits do not write
the field, restoring the dead-field invariant for unedited workflows.

The 4-tuple [nid, name, disambig, {value}] schema variant added by
PR #11559 is accepted by parseProxyWidgets as a one-release migration
shim; the writer never emits it. Workflows saved on the #11559 branch
hydrate their inline {value} into the store on load and re-save in the
positional widgets_values format.

DEV-only console.warn fires when legacy widgets_values length does not
match proxyWidgets length, dropping the stale array.

Renderer paths in useGraphNodeManager and useProcessedWidgets thread
the storeInstanceId through SafeWidgetData so Vue reads pick the right
instance scope without a parallel snapshot field.

Tests: regression coverage from PR #11559 ported and adjusted for
positional widgets_values; new Cohort C migration test pins the
4-tuple→positional rewrite on first save after upgrade.
2026-04-29 07:43:13 +00:00
14 changed files with 1135 additions and 342 deletions

View File

@@ -475,6 +475,7 @@ describe('Nested promoted widget mapping', () => {
expect(mappedWidget?.storeNodeId).toBe(
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
)
expect(mappedWidget?.storeInstanceId).toBe(String(subgraphNodeB.id))
})
it('keeps linked and independent same-name promotions as distinct sources', () => {
@@ -587,6 +588,9 @@ describe('Nested promoted widget mapping', () => {
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
])
)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeInstanceId))
).toEqual(new Set([String(outerSubgraphNode.id)]))
})
})

View File

@@ -55,6 +55,7 @@ export interface WidgetSlotMetadata {
export interface SafeWidgetData {
nodeId?: NodeId
storeNodeId?: NodeId
storeInstanceId?: NodeId
name: string
storeName?: string
type: string
@@ -97,6 +98,12 @@ export interface SafeWidgetData {
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
/**
* Read-only fallback value sourced from the underlying widget when no
* scoped store entry exists yet. Render paths use this for first-paint of
* promoted widgets that have never been edited.
*/
defaultValue?: unknown
}
export interface VueNodeData {
@@ -336,6 +343,9 @@ function safeWidgetMapper(
return {
nodeId,
storeNodeId: nodeId,
storeInstanceId: isPromotedWidgetView(widget)
? String(node.id)
: undefined,
name,
storeName,
type: effectiveWidget.type,
@@ -358,7 +368,10 @@ function safeWidgetMapper(
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined,
defaultValue: isPromotedWidgetView(widget)
? (sourceWidget?.value ?? widget.value)
: undefined
}
} catch (error) {
console.warn(

View File

@@ -26,6 +26,10 @@ export interface PromotedWidgetView extends IBaseWidget {
readonly disambiguatingSourceNodeId?: string
/** Whether the resolved source widget is workflow-persistent. */
readonly sourceSerialize: boolean
/** Stable identity key for this promotion on its host instance. */
readonly instanceKey: string
/** Return the instance-scoped store value, if this view has one. */
getScopedStoreValue(): IBaseWidget['value'] | undefined
}
export function isPromotedWidgetView(

View File

@@ -280,8 +280,17 @@ describe(createPromotedWidgetView, () => {
view.value = 'updated'
expect(view.value).toBe('updated')
// The interior widget reads from the same store
expect(innerNode.widgets![0].value).toBe('updated')
// The promoted view owns its runtime value in an instance-scoped store
// entry; the shared interior widget value stays untouched.
expect(innerNode.widgets![0].value).toBe('initial')
expect(
useWidgetValueStore().getWidget(
subgraphNode.rootGraph.id,
String(innerNode.id),
'myWidget',
subgraphNode.id
)?.value
).toBe('updated')
})
test('value falls back to interior widget when store entry is missing', () => {
@@ -296,9 +305,6 @@ describe(createPromotedWidgetView, () => {
const fallbackWidget = fromPartial<IBaseWidget>(fallbackWidgetShape)
innerNode.widgets = [fallbackWidget]
const widgetValueStore = useWidgetValueStore()
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
@@ -307,10 +313,39 @@ describe(createPromotedWidgetView, () => {
expect(view.value).toBe('initial')
view.value = 'updated'
expect(fallbackWidget.value).toBe('updated')
expect(view.value).toBe('updated')
expect(fallbackWidget.value).toBe('initial')
})
test('value setter falls back to host widget when linked states are unavailable', () => {
test('value prefers source widget over stale unscoped store fallback', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
const fallbackWidget = fromPartial<IBaseWidget>({
name: 'myWidget',
type: 'text',
value: 'source-value',
options: {}
})
innerNode.widgets = [fallbackWidget]
useWidgetValueStore().registerWidget(subgraphNode.rootGraph.id, {
nodeId: String(innerNode.id),
name: 'myWidget',
type: 'text',
value: 'stale-shared-value',
options: {}
})
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'myWidget'
)
expect(view.value).toBe('source-value')
})
test('value setter creates scoped state when linked states are unavailable', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
@@ -327,12 +362,18 @@ describe(createPromotedWidgetView, () => {
const linkedView = promotedWidgets(subgraphNode)[0]
if (!linkedView) throw new Error('Expected a linked promoted widget')
const widgetValueStore = useWidgetValueStore()
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
linkedView.value = 'updated'
expect(linkedNode.widgets?.[0].value).toBe('updated')
expect(linkedView.value).toBe('updated')
expect(linkedNode.widgets?.[0].value).toBe('initial')
expect(
useWidgetValueStore().getWidget(
subgraphNode.rootGraph.id,
String(linkedNode.id),
'string_a',
subgraphNode.id
)?.value
).toBe('updated')
})
test('label falls back to displayName then widgetName', () => {
@@ -523,9 +564,6 @@ describe(createPromotedWidgetView, () => {
} as unknown as IBaseWidget
innerNode.widgets = [fallbackWidget]
const widgetValueStore = useWidgetValueStore()
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
@@ -534,7 +572,8 @@ describe(createPromotedWidgetView, () => {
const objValue = { key: 'data' }
view.value = objValue
expect(fallbackWidget.value).toBe(objValue)
expect(view.value).toEqual(objValue)
expect(fallbackWidget.value).toBe('old')
})
test('onPointerDown returns true when interior widget onPointerDown handles it', () => {
@@ -876,17 +915,34 @@ describe('SubgraphNode.widgets getter', () => {
linkedView.value = 'shared-value'
// Both linked nodes share the same SubgraphInput slot, so the value
// propagates to all connected widgets via getLinkedInputWidgets().
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
// Both linked nodes share the same SubgraphInput slot, so the scoped store
// records the same per-instance value for every connected source widget.
const widgetStore = useWidgetValueStore()
expect(
widgetStore.getWidget(
subgraphNode.rootGraph.id,
String(linkedNodeA.id),
'string_a',
subgraphNode.id
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
subgraphNode.rootGraph.id,
String(linkedNodeB.id),
'string_a',
subgraphNode.id
)?.value
).toBe('shared-value')
expect(linkedNodeA.widgets?.[0]?.value).toBe('a')
expect(linkedNodeB.widgets?.[0]?.value).toBe('b')
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
promotedView.value = 'independent-updated'
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
expect(promotedNode.widgets?.[0]?.value).toBe('independent-updated')
expect(linkedView.value).toBe('shared-value')
expect(promotedView.value).toBe('independent-updated')
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
})
test('duplicate-name promoted views map slot linkage by view identity', () => {
@@ -1223,13 +1279,28 @@ describe('SubgraphNode.widgets getter', () => {
firstView.value = 'first-updated'
secondView.value = 'second-updated'
expect(firstNode.widgets?.[0].value).toBe('first-updated')
expect(secondNode.widgets?.[0].value).toBe('second-updated')
const widgetStore = useWidgetValueStore()
expect(
widgetStore.getWidget(
subgraphNode.rootGraph.id,
String(firstNode.id),
'seed',
subgraphNode.id
)?.value
).toBe('first-updated')
expect(
widgetStore.getWidget(
subgraphNode.rootGraph.id,
String(secondNode.id),
'seed',
subgraphNode.id
)?.value
).toBe('second-updated')
subgraphNode.serialize()
expect(firstNode.widgets?.[0].value).toBe('first-updated')
expect(secondNode.widgets?.[0].value).toBe('second-updated')
expect(firstView.value).toBe('first-updated')
expect(secondView.value).toBe('second-updated')
})
test('renaming an input updates linked promoted view display names', () => {
@@ -1550,8 +1621,12 @@ describe('SubgraphNode.widgets getter', () => {
const widgetStore = useWidgetValueStore()
const getValue = (nodeId: string) =>
widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a')
?.value
widgetStore.getWidget(
graph.id,
stripGraphPrefix(nodeId),
'string_a',
hostNode.id
)?.value
expect(getValue('20')).toBe('shared-linked')
expect(getValue('18')).toBe('shared-linked')
@@ -1640,6 +1715,7 @@ describe('SubgraphNode.widgets getter', () => {
innerNode.addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [[String(innerNode.id), 'widgetA']])
subgraphNode.widgets[0].value = 'edited'
const createNodeSpy = vi
.spyOn(LiteGraph, 'createNode')
@@ -1656,6 +1732,7 @@ describe('SubgraphNode.widgets getter', () => {
expect(clonedSerialized.properties?.proxyWidgets).toStrictEqual([
[String(innerNode.id), 'widgetA']
])
expect(clonedSerialized.widgets_values).toStrictEqual(['edited'])
const hydratedClone = createTestSubgraphNode(subgraphNode.subgraph, {
id: 100
@@ -1930,6 +2007,40 @@ describe('promote/demote cycle', () => {
(subgraphNode.widgets[0] as PromotedWidgetView).sourceWidgetName
).toBe('widgetA')
})
test('re-promote does not reuse stale scoped value from a demoted view', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'source-default', () => {})
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const firstView = subgraphNode.widgets[0] as PromotedWidgetView
firstView.value = 'edited-scoped-value'
expect(firstView.value).toBe('edited-scoped-value')
expect(
useWidgetValueStore().getWidget(
subgraphNode.rootGraph.id,
innerIds[0],
'widgetA',
subgraphNode.id
)?.value
).toBe('edited-scoped-value')
subgraphNode.removeWidget(firstView)
expect(
useWidgetValueStore().getWidget(
subgraphNode.rootGraph.id,
innerIds[0],
'widgetA',
subgraphNode.id
)
).toBeUndefined()
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const secondView = subgraphNode.widgets[0] as PromotedWidgetView
expect(secondView.value).toBe('source-default')
})
})
describe('disconnected state', () => {
@@ -2022,14 +2133,23 @@ describe('three-level nested value propagation', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('value set at outermost level propagates to concrete widget', () => {
test('value set at outermost level writes scoped store state', () => {
const { concreteNode, subgraphNodeA } = createThreeLevelNestedSubgraph()
expect(subgraphNodeA.widgets).toHaveLength(1)
expect(subgraphNodeA.widgets[0].value).toBe(100)
subgraphNodeA.widgets[0].value = 200
expect(concreteNode.widgets![0].value).toBe(200)
expect(subgraphNodeA.widgets[0].value).toBe(200)
expect(concreteNode.widgets![0].value).toBe(100)
expect(
useWidgetValueStore().getWidget(
subgraphNodeA.rootGraph.id,
String(concreteNode.id),
'c_input',
subgraphNodeA.id
)?.value
).toBe(200)
})
test('type resolves correctly through all three layers', () => {
@@ -2109,7 +2229,7 @@ describe('three-level nested value propagation', () => {
widgets[1].value = 'updated-second'
expect(firstTextNode.widgets?.[0]?.value).toBe('11111111111')
expect(secondTextNode.widgets?.[0]?.value).toBe('updated-second')
expect(secondTextNode.widgets?.[0]?.value).toBe('22222222222')
expect(widgets[0].value).toBe('11111111111')
expect(widgets[1].value).toBe('updated-second')
})
@@ -2156,11 +2276,20 @@ describe('multi-link representative determinism for input-based promotion', () =
// Read returns the first link's value
expect(widgets[0].value).toBe('first-val')
// Write propagates to all linked nodes
// Write scopes the same runtime value to every linked source node.
widgets[0].value = 'updated'
expect(firstNode.widgets![0].value).toBe('updated')
expect(secondNode.widgets![0].value).toBe('updated')
expect(thirdNode.widgets![0].value).toBe('updated')
const widgetStore = useWidgetValueStore()
for (const node of [firstNode, secondNode, thirdNode]) {
expect(
widgetStore.getWidget(
subgraphNode.rootGraph.id,
String(node.id),
'shared',
subgraphNode.id
)?.value
).toBe('updated')
expect(node.widgets![0].value).not.toBe('updated')
}
// Repeated reads are still deterministic
expect(widgets[0].value).toBe('updated')
@@ -2284,7 +2413,8 @@ describe('promoted combo rendering', () => {
expect(promotedWidget.value).toBe('a')
promotedWidget.value = 'b'
expect(comboWidget.value).toBe('b')
expect(comboWidget.value).toBe('a')
expect(promotedWidget.value).toBe('b')
const fillText = vi.fn()
const ctx = createInspectableCanvasContext(fillText)

View File

@@ -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'
@@ -36,6 +33,12 @@ interface SubgraphSlotRef {
displayName?: string
}
interface WidgetStoreRef {
nodeId: NodeId
widgetName: string
widget: IBaseWidget
}
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
@@ -44,6 +47,12 @@ function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
return value !== null && typeof value === 'object'
}
function cloneWidgetValue(value: IBaseWidget['value']): IBaseWidget['value'] {
return value != null && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: value
}
type LegacyMouseWidget = IBaseWidget & {
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
}
@@ -53,43 +62,6 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
}
const designTokenCache = new Map<string, string>()
const promotedSourceWriteMetaByGraph = new WeakMap<
LGraph,
Map<string, PromotedSourceWriteMeta>
>()
interface PromotedSourceWriteMeta {
value: IBaseWidget['value']
writerInstanceId: string
}
function cloneWidgetValue<TValue extends IBaseWidget['value']>(
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<string, PromotedSourceWriteMeta>()
promotedSourceWriteMetaByGraph.set(graph, metaBySource)
}
metaBySource.set(sourceKey, meta)
}
export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
@@ -198,94 +170,31 @@ class PromotedWidgetView implements IPromotedWidgetView {
return this.resolveDeepest()?.widget.linkedWidgets
}
private get _instanceKey(): string {
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()
return this.getStoreBackedValue()
}
/**
* 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.
* Execution-time serialization follows the runtime getter: scoped promoted
* state first, then source/legacy fallbacks for unedited workflows.
*/
serializeValue(): IBaseWidget['value'] {
return this.getTrackedValue()
return this.getStoreBackedValue()
}
/**
* Writes only the scoped per-instance promoted-widget value. This no longer
* mutates the source widget's `.value`; extensions that need instance state
* should read the promoted widget or `widgetValueStore`.
*/
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()
let didUpdateState = false
for (const linkedWidget of linkedWidgets) {
const state = widgetStore.getWidget(
this.graphId,
linkedWidget.nodeId,
linkedWidget.widgetName
)
if (state) {
state.value = value
didUpdateState = true
}
}
const resolved = this.resolveDeepest()
if (resolved) {
const resolvedState = widgetStore.getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
if (resolvedState) {
resolvedState.value = value
didUpdateState = true
}
}
if (didUpdateState) return
}
const state = this.getWidgetState()
if (state) {
state.value = value
return
}
const resolved = this.resolveAtHost()
if (resolved && isWidgetValue(value)) {
resolved.widget.value = value
}
this.writeScopedValue(value)
}
get label(): string | undefined {
@@ -473,57 +382,122 @@ 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']
getScopedStoreValue(): IBaseWidget['value'] | undefined {
const state = this.getScopedWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return undefined
}
private getSharedValue(): IBaseWidget['value'] {
const state = this.getWidgetState()
private getStoreBackedValue(): IBaseWidget['value'] {
const scopedState = this.getScopedWidgetState()
if (scopedState) {
return isWidgetValue(scopedState.value) ? scopedState.value : undefined
}
const resolved = this.resolveAtHost()
if (resolved) {
return isWidgetValue(resolved.widget.value)
? resolved.widget.value
: undefined
}
const state = this.getLegacyWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
return undefined
}
private getWidgetState() {
const linkedState = this.getLinkedInputWidgetStates()[0]
if (linkedState) return linkedState
const resolved = this.resolveDeepest()
if (!resolved) return undefined
return useWidgetValueStore().getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
return this.getScopedWidgetState() ?? this.getLegacyWidgetState()
}
private getLinkedInputWidgets(): Array<{
nodeId: NodeId
widgetName: string
widget: IBaseWidget
}> {
private getScopedWidgetState(): WidgetState | undefined {
return this.getWidgetStateForRefs(this.getStoreRefs(), this.subgraphNode.id)
}
private getLegacyWidgetState(): WidgetState | undefined {
return this.getWidgetStateForRefs(this.getStoreRefs())
}
private getWidgetStateForRefs(
refs: WidgetStoreRef[],
instanceId?: NodeId
): WidgetState | undefined {
const widgetStore = useWidgetValueStore()
for (const { nodeId, widgetName } of refs) {
const state = widgetStore.getWidget(
this.graphId,
nodeId,
widgetName,
instanceId
)
if (state) return state
}
return undefined
}
private getStoreRefs(): WidgetStoreRef[] {
const refs = this.getLinkedInputWidgets()
const resolved = this.resolveDeepest()
if (resolved) {
refs.push({
nodeId: stripGraphPrefix(String(resolved.node.id)),
widgetName: resolved.widget.name,
widget: resolved.widget
})
}
const seen = new Set<string>()
return refs.filter(({ nodeId, widgetName }) => {
const key = `${nodeId}:${widgetName}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
private writeScopedValue(value: IBaseWidget['value']): void {
const refs = this.getStoreRefs()
if (refs.length === 0) return
const widgetStore = useWidgetValueStore()
for (const ref of refs) {
const state = widgetStore.getWidget(
this.graphId,
ref.nodeId,
ref.widgetName,
this.subgraphNode.id
)
const clonedValue = cloneWidgetValue(value)
if (state) {
state.value = clonedValue
continue
}
const legacyState = widgetStore.getWidget(
this.graphId,
ref.nodeId,
ref.widgetName
)
widgetStore.registerWidget(
this.graphId,
{
nodeId: ref.nodeId,
name: ref.widgetName,
type: legacyState?.type ?? ref.widget.type,
value: clonedValue,
options: legacyState?.options ?? ref.widget.options,
label: legacyState?.label ?? ref.widget.label,
serialize: legacyState?.serialize ?? ref.widget.serialize,
disabled: legacyState?.disabled ?? ref.widget.disabled
},
this.subgraphNode.id
)
}
}
private getLinkedInputWidgets(): WidgetStoreRef[] {
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
if (!input._subgraphSlot) return false
if (matchPromotedInput([input], this) !== input) return false
@@ -562,40 +536,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
}))
}
private getLinkedInputWidgetStates(): WidgetState[] {
const widgetStore = useWidgetValueStore()
return this.getLinkedInputWidgets()
.map(({ nodeId, widgetName }) =>
widgetStore.getWidget(this.graphId, nodeId, widgetName)
)
.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

View File

@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
createTestSubgraph,
@@ -103,7 +104,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(subgraphNode.widgets[1].name).toBe('widgetA')
})
test('Will mirror changes to value', () => {
test('reads source value until promoted view writes scoped state', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
@@ -116,7 +117,16 @@ describe('Subgraph proxyWidgets', () => {
innerNodes[0].widgets![0].value = 'test'
expect(subgraphNode.widgets[0].value).toBe('test')
subgraphNode.widgets[0].value = 'test2'
expect(innerNodes[0].widgets![0].value).toBe('test2')
expect(subgraphNode.widgets[0].value).toBe('test2')
expect(innerNodes[0].widgets![0].value).toBe('test')
expect(
useWidgetValueStore().getWidget(
subgraphNode.rootGraph.id,
innerIds[0],
'stringWidget',
subgraphNode.id
)?.value
).toBe('test2')
})
test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
@@ -253,7 +263,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets).toHaveLength(0)
})
test('serialize stores widgets_values for promoted views', () => {
test('serialize writes positional widgets_values for edited promoted widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
@@ -262,13 +272,17 @@ describe('Subgraph proxyWidgets', () => {
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets).toHaveLength(1)
subgraphNode.widgets[0].value = 'edited'
const serialized = subgraphNode.serialize()
expect(serialized.widgets_values).toEqual(['value'])
expect(serialized.widgets_values).toStrictEqual(['edited'])
expect(serialized.properties?.proxyWidgets).toStrictEqual([
[innerIds[0], 'stringWidget']
])
})
test('serialize preserves proxyWidgets in properties', () => {
test('serialize preserves identity-only proxyWidgets in properties', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
@@ -369,9 +383,18 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNodeA.widgets[0].type).toBe('number')
expect(subgraphNodeA.widgets[0].value).toBe(42)
// Setting value at outermost level propagates to concrete widget
// Setting value at outermost level writes the instance-scoped store slot.
subgraphNodeA.widgets[0].value = 99
expect(concreteNode.widgets![0].value).toBe(99)
expect(subgraphNodeA.widgets[0].value).toBe(99)
expect(concreteNode.widgets![0].value).toBe(42)
expect(
useWidgetValueStore().getWidget(
subgraphNodeA.rootGraph.id,
String(concreteNode.id),
'deep_input',
subgraphNodeA.id
)?.value
).toBe(99)
})
test('removeWidget cleans up promotion and input, then re-promote works', () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { parseProxyWidgets } from './promotionSchema'
import { getProxyWidgetInlineState, parseProxyWidgets } from './promotionSchema'
describe(parseProxyWidgets, () => {
it('parses 2-tuple arrays', () => {
@@ -36,6 +36,14 @@ describe(parseProxyWidgets, () => {
])
})
it('parses legacy 4-tuple arrays', () => {
const input = [
['3', 'text', '1', { value: 42 }],
['9', 'seed', null, { value: 'abc' }]
]
expect(parseProxyWidgets(input)).toEqual(input)
})
it('returns empty array for non-array input', () => {
expect(parseProxyWidgets(undefined)).toEqual([])
expect(parseProxyWidgets('not-json{')).toEqual([])
@@ -45,4 +53,27 @@ describe(parseProxyWidgets, () => {
expect(parseProxyWidgets([['only-one']])).toEqual([])
expect(parseProxyWidgets([['a', 'b', 'c', 'd']])).toEqual([])
})
it('rejects legacy 4-tuple entries with undefined inline value', () => {
expect(
parseProxyWidgets([
['3', 'text', null, { value: undefined }] as unknown as [
string,
string,
null,
{ value: undefined }
]
])
).toEqual([])
})
})
describe(getProxyWidgetInlineState, () => {
it('returns inline value for 4-tuples only', () => {
expect(getProxyWidgetInlineState(['1', 'seed'])).toBeUndefined()
expect(getProxyWidgetInlineState(['1', 'seed', '2'])).toBeUndefined()
expect(
getProxyWidgetInlineState(['1', 'seed', null, { value: 10 }])
).toEqual({ value: 10 })
})
})

View File

@@ -3,12 +3,36 @@ import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
type DefinedProxyWidgetValue =
| null
| boolean
| number
| string
| bigint
| symbol
| object
const definedValueSchema = z.custom<DefinedProxyWidgetValue>(
(value) => value !== undefined,
'Inline proxy widget value cannot be undefined'
)
const proxyWidgetStateSchema = z.object({ value: definedValueSchema })
const proxyWidgetTupleSchema = z.union([
// 4-tuple is read-only migration shim (legacy PR #11559 workflows).
// Writer never emits this shape.
z.tuple([
z.string(),
z.string(),
z.union([z.string(), z.null()]),
proxyWidgetStateSchema
]),
z.tuple([z.string(), z.string(), z.string()]),
z.tuple([z.string(), z.string()])
])
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
type ProxyWidgetEntry = ProxyWidgetsProperty[number]
type ProxyWidgetInlineState = z.infer<typeof proxyWidgetStateSchema>
export function parseProxyWidgets(
property: NodeProperty | undefined
@@ -27,3 +51,10 @@ export function parseProxyWidgets(
}
return []
}
/** Returns the optional inline {value} state from a legacy PR #11559 4-tuple entry. */
export function getProxyWidgetInlineState(
entry: ProxyWidgetEntry
): ProxyWidgetInlineState | undefined {
return entry.length === 4 ? entry[3] : undefined
}

View File

@@ -1,9 +1,10 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
createTestSubgraph,
@@ -35,6 +36,10 @@ beforeEach(() => {
resetSubgraphFixtureState()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('SubgraphNode multi-instance widget isolation', () => {
it('preserves per-instance widget values after configure', () => {
const subgraph = createTestSubgraph({
@@ -47,8 +52,8 @@ describe('SubgraphNode multi-instance widget isolation', () => {
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
const innerNodeId = String(node.id)
// Simulate what LGraph.configure does: call configure with different widgets_values
instance1.configure({
id: 201,
type: subgraph.id,
@@ -59,7 +64,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
properties: {
proxyWidgets: [[innerNodeId, 'widget']]
},
widgets_values: [10]
})
@@ -73,21 +80,138 @@ describe('SubgraphNode multi-instance widget isolation', () => {
mode: 0,
order: 1,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
properties: {
proxyWidgets: [[innerNodeId, 'widget']]
},
widgets_values: [20]
})
const widgets1 = instance1.widgets!
const widgets2 = instance2.widgets!
expect(widgets1.length).toBeGreaterThan(0)
expect(widgets2.length).toBeGreaterThan(0)
expect(widgets1).toHaveLength(1)
expect(widgets2).toHaveLength(1)
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])
const serialized1 = instance1.serialize()
const serialized2 = instance2.serialize()
expect(serialized1.widgets_values).toEqual([10])
expect(serialized2.widgets_values).toEqual([20])
expect(serialized1.properties?.proxyWidgets).toEqual([
[innerNodeId, 'widget']
])
expect(serialized2.properties?.proxyWidgets).toEqual([
[innerNodeId, 'widget']
])
})
it('migrates legacy widgets_values per instance without sharing sibling state', () => {
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: 203 })
const instance2 = createTestSubgraphNode(subgraph, { id: 204 })
instance1.configure({
id: 203,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'value']] },
widgets_values: [10]
})
instance2.configure({
id: 204,
type: subgraph.id,
pos: [400, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 1,
flags: {},
properties: { proxyWidgets: [['-1', 'value']] },
widgets_values: [20]
})
expect(instance1.widgets?.[0].value).toBe(10)
expect(instance2.widgets?.[0].value).toBe(20)
expect(instance1.widgets?.[0].serializeValue?.(instance1, 0)).toBe(10)
expect(instance2.widgets?.[0].serializeValue?.(instance2, 0)).toBe(20)
})
it('clears stale scoped entries keyed by info.id during 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 instance = createTestSubgraphNode(subgraph, { id: 700 })
const staleInstanceId = '701'
const innerNodeId = String(node.id)
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(
instance.rootGraph.id,
{
nodeId: innerNodeId,
name: 'widget',
type: 'number',
value: 999,
options: {}
},
staleInstanceId
)
expect(
widgetValueStore.getWidget(
instance.rootGraph.id,
innerNodeId,
'widget',
staleInstanceId
)?.value
).toBe(999)
instance.configure({
id: Number(staleInstanceId),
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: {
proxyWidgets: [[innerNodeId, 'widget']]
},
widgets_values: [10]
})
expect(
widgetValueStore.getWidget(
instance.rootGraph.id,
innerNodeId,
'widget',
staleInstanceId
)?.value
).toBe(10)
})
it('round-trips per-instance widget values through serialize and configure', () => {
@@ -100,6 +224,7 @@ describe('SubgraphNode multi-instance widget isolation', () => {
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
const innerNodeId = String(node.id)
originalInstance.configure({
id: 301,
type: subgraph.id,
@@ -110,7 +235,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
properties: {
proxyWidgets: [[innerNodeId, 'widget']]
},
widgets_values: [33]
})
@@ -128,6 +255,72 @@ describe('SubgraphNode multi-instance widget isolation', () => {
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
})
it('preserves source defaults for unedited promoted widgets when serializing mixed edits', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'value', type: 'number' },
{ name: 'value_2', type: 'number' }
]
})
const SOURCE_DEFAULT = 42
const EDITED_VALUE = 99
const { node: firstNode } = createNodeWithWidget(
'FirstNode',
SOURCE_DEFAULT
)
const { node: secondNode } = createNodeWithWidget(
'SecondNode',
SOURCE_DEFAULT
)
subgraph.add(firstNode)
subgraph.add(secondNode)
subgraph.inputNode.slots[0].connect(firstNode.inputs[0], firstNode)
subgraph.inputNode.slots[1].connect(secondNode.inputs[0], secondNode)
const originalInstance = createTestSubgraphNode(subgraph, { id: 303 })
const firstNodeId = String(firstNode.id)
const secondNodeId = String(secondNode.id)
originalInstance.configure({
id: 303,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: {
proxyWidgets: [
[firstNodeId, 'widget'],
[secondNodeId, 'widget']
]
},
widgets_values: [EDITED_VALUE, SOURCE_DEFAULT]
})
const serialized = originalInstance.serialize()
expect(serialized.widgets_values).toEqual([EDITED_VALUE, SOURCE_DEFAULT])
expect(serialized.widgets_values).not.toContain(null)
const restoredInstance = createTestSubgraphNode(subgraph, { id: 304 })
restoredInstance.configure({
...serialized,
id: 304,
type: subgraph.id
})
const restoredWidgets = restoredInstance.widgets
expect(restoredWidgets).toHaveLength(2)
expect(restoredWidgets?.[0].value).toBe(EDITED_VALUE)
expect(restoredWidgets?.[1].value).toBe(SOURCE_DEFAULT)
expect(restoredWidgets?.[1].value).not.toBeNull()
})
it('keeps fresh sibling instances isolated before save or reload', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
@@ -156,7 +349,7 @@ describe('SubgraphNode multi-instance widget isolation', () => {
expect(widget2?.serializeValue?.(instance2, 0)).toBe(7)
})
it('syncs restored promoted widgets when the inner source widget changes directly', () => {
it('keeps restored scoped value when the inner source widget changes directly', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
@@ -166,6 +359,7 @@ describe('SubgraphNode multi-instance widget isolation', () => {
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const originalInstance = createTestSubgraphNode(subgraph, { id: 601 })
const innerNodeId = String(node.id)
originalInstance.configure({
id: 601,
type: subgraph.id,
@@ -176,7 +370,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
properties: {
proxyWidgets: [[innerNodeId, 'widget']]
},
widgets_values: [33]
})
@@ -193,13 +389,13 @@ describe('SubgraphNode multi-instance widget isolation', () => {
widget.value = 45
expect(restoredInstance.widgets?.[0].value).toBe(45)
expect(restoredInstance.widgets?.[0].value).toBe(33)
expect(
restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0)
).toBe(45)
).toBe(33)
})
it('clears stale per-instance values when reconfigured without widgets_values', () => {
it('clears stale scoped values when reconfigured without inline value state', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
@@ -210,6 +406,7 @@ describe('SubgraphNode multi-instance widget isolation', () => {
const instance = createTestSubgraphNode(subgraph, { id: 701 })
instance.graph!.add(instance)
const innerNodeId = String(node.id)
const promotedWidget = instance.widgets?.[0]
promotedWidget!.value = 11
@@ -217,6 +414,10 @@ describe('SubgraphNode multi-instance widget isolation', () => {
const serialized = instance.serialize()
delete serialized.widgets_values
serialized.properties = {
...serialized.properties,
proxyWidgets: [[innerNodeId, 'widget']]
}
instance.configure({
...serialized,
@@ -251,7 +452,7 @@ describe('SubgraphNode multi-instance widget isolation', () => {
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
properties: { proxyWidgets: [['-1', 'value']] },
widgets_values: []
})
@@ -259,48 +460,28 @@ describe('SubgraphNode multi-instance widget isolation', () => {
expect(serialized.widgets_values).toBeUndefined()
})
// it.fails pins the open #10849 SubgraphNode.configure regression on Main;
// drop the marker once the inline-proxyWidgets-state fix lands.
it.fails('falls back to source widget value when proxyWidgets is in legacy 2-tuple shape (regression for #10849)', () => {
it('does not write widgets_values on SubgraphNode (fix for #10849 template corruption regression)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const SOURCE_DEFAULT = 42
const LEGACY_NOISE = 999
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
const { node } = createNodeWithWidget('TestNode', 42)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 801 })
instance.configure({
id: 801,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [LEGACY_NOISE]
})
instance.graph!.add(instance)
const widget = instance.widgets?.[0]
expect(widget?.value).toBe(SOURCE_DEFAULT)
expect(widget?.serializeValue?.(instance, 0)).toBe(SOURCE_DEFAULT)
expect(instance.serialize().widgets_values).toBeUndefined()
})
it.fails('does not corrupt unbound promoted widgets when widgets_values length mismatches view count (regression for #10849)', () => {
it('migrates aligned legacy widgets_values into scoped promoted state on load', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const SOURCE_DEFAULT = 42
const LEGACY_NOISE_A = 111
const LEGACY_NOISE_B = 222
const LEGACY_VALUE = 999
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
subgraph.add(node)
@@ -317,12 +498,111 @@ describe('SubgraphNode multi-instance widget isolation', () => {
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
properties: { proxyWidgets: [['-1', 'value']] },
widgets_values: [LEGACY_VALUE]
})
const widget = instance.widgets?.[0]
expect(widget?.value).toBe(LEGACY_VALUE)
expect(widget?.serializeValue?.(instance, 0)).toBe(LEGACY_VALUE)
const serialized = instance.serialize()
expect(serialized.widgets_values).toEqual([LEGACY_VALUE])
expect(serialized.properties?.proxyWidgets).toEqual([
[String(node.id), 'widget']
])
})
it('does not corrupt unbound promoted widgets when widgets_values length mismatches view count (regression for #10849)', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const SOURCE_DEFAULT = 42
const LEGACY_NOISE_A = 111
const LEGACY_NOISE_B = 222
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 803 })
instance.configure({
id: 803,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'value']] },
widgets_values: [LEGACY_NOISE_A, LEGACY_NOISE_B]
})
const widget = instance.widgets?.[0]
expect(widget?.value).toBe(SOURCE_DEFAULT)
expect(widget?.value).not.toBe(LEGACY_NOISE_A)
expect(warn).toHaveBeenCalledWith(
'[SubgraphNode] Dropping stale widgets_values for 803: widgets_values length (2) does not match proxyWidgets length (1).'
)
})
it('migrates legacy 4-tuple inline value into positional 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 instance = createTestSubgraphNode(subgraph, { id: 850 })
const innerNodeId = String(node.id)
instance.configure({
id: 850,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: {
proxyWidgets: [[innerNodeId, 'widget', null, { value: 50 }]]
}
})
expect(instance.widgets?.[0].value).toBe(50)
const serialized = instance.serialize()
expect(serialized.properties?.proxyWidgets).toEqual([
[innerNodeId, 'widget']
])
expect(serialized.widgets_values).toEqual([50])
})
it('drops function fields from promoted widget values during cloning', () => {
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 instance = createTestSubgraphNode(subgraph, { id: 901 })
instance.graph!.add(instance)
const valueWithFunction = { fn: () => 'nope' }
const promotedWidget = instance.widgets![0]
promotedWidget.value = valueWithFunction as unknown as typeof widget.value
expect(promotedWidget.value).toEqual({})
expect(instance.serialize().widgets_values).toEqual([{}])
})
})

View File

@@ -43,12 +43,16 @@ import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import {
getProxyWidgetInlineState,
parseProxyWidgets
} from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
makePromotionEntryKey,
usePromotionStore
} from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
@@ -992,20 +996,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/** Temporarily stored during configure for use by _internalConfigureAfterSlots */
/**
* Temporary configure-time handoff for legacy positional widgets_values.
* LGraphNode.configure invokes _internalConfigureAfterSlots internally, so
* this value is staged before super.configure and cleared in a finally block.
*/
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._instanceWidgetValues.clear()
this._pendingWidgetsValues = info.widgets_values
const widgetValueStore = useWidgetValueStore()
widgetValueStore.clearInstanceWidgets(this.rootGraph.id, this.id)
if (info.id != null && info.id !== this.id) {
widgetValueStore.clearInstanceWidgets(this.rootGraph.id, info.id)
}
this._pendingWidgetsValues = Array.isArray(info.widgets_values)
? info.widgets_values
: undefined
for (const input of this.inputs) {
if (
@@ -1055,7 +1061,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
)
super.configure(info)
try {
super.configure(info)
} finally {
this._pendingWidgetsValues = undefined
}
}
override _internalConfigureAfterSlots() {
@@ -1077,28 +1087,71 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Hydrate the store from serialized properties.proxyWidgets
const raw = parseProxyWidgets(this.properties.proxyWidgets)
const store = usePromotionStore()
const pendingValues = new Map<string, unknown>()
const canHydrateLegacyWidgetsValues =
this._pendingWidgetsValues?.length === raw.length
if (this._pendingWidgetsValues && !canHydrateLegacyWidgetsValues) {
if (import.meta.env.DEV) {
console.warn(
`[SubgraphNode] Dropping stale widgets_values for ${this.id}: ` +
`widgets_values length (${this._pendingWidgetsValues.length}) ` +
`does not match proxyWidgets length (${raw.length}).`
)
}
}
const entries = raw
.map(([nodeId, widgetName, sourceNodeId]) => {
if (nodeId === '-1') {
const resolved = this._resolveLegacyEntry(widgetName)
if (resolved)
return { sourceNodeId: resolved[0], sourceWidgetName: resolved[1] }
if (import.meta.env.DEV) {
console.warn(
`[SubgraphNode] Failed to resolve legacy -1 entry for widget "${widgetName}"`
)
}
return null
}
if (!this.subgraph.getNodeById(nodeId)) return null
.map((rawEntry, index) => {
const nodeId = rawEntry[0]
const widgetName = rawEntry[1]
const thirdElement = rawEntry[2]
const sourceNodeId =
typeof thirdElement === 'string' ? thirdElement : undefined
return normalizeLegacyProxyWidgetEntry(
this,
nodeId,
widgetName,
sourceNodeId
)
let resolved: PromotedWidgetSource | null
if (nodeId === '-1') {
const legacy = this._resolveLegacyEntry(widgetName)
if (legacy) {
resolved = {
sourceNodeId: legacy[0],
sourceWidgetName: legacy[1]
}
} else {
if (import.meta.env.DEV) {
console.warn(
`[SubgraphNode] Failed to resolve legacy -1 entry for widget "${widgetName}"`
)
}
resolved = null
}
} else if (!this.subgraph.getNodeById(nodeId)) {
resolved = null
} else {
resolved = normalizeLegacyProxyWidgetEntry(
this,
nodeId,
widgetName,
sourceNodeId
)
}
if (resolved) {
const inlineState = getProxyWidgetInlineState(rawEntry)
if (inlineState) {
pendingValues.set(
makePromotionEntryKey(resolved),
inlineState.value
)
} else if (canHydrateLegacyWidgetsValues) {
const legacyValue = this._pendingWidgetsValues?.[index]
if (legacyValue !== null && legacyValue !== undefined) {
pendingValues.set(makePromotionEntryKey(resolved), legacyValue)
}
}
}
return resolved
})
.filter((e): e is NonNullable<typeof e> => e !== null)
@@ -1138,19 +1191,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
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
if (pendingValues.size > 0) {
for (const view of this._getPromotedViews()) {
if (!pendingValues.has(view.instanceKey)) continue
view.value = pendingValues.get(view.instanceKey) as typeof view.value
}
this._pendingWidgetsValues = undefined
}
}
@@ -1248,6 +1293,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
previousView.sourceWidgetName !== widgetName)
) {
usePromotionStore().demote(this.rootGraph.id, this.id, previousView)
this._clearPromotedViewScopedWidgets(previousView)
this._removePromotedView(previousView)
}
@@ -1519,6 +1565,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
private _clearPromotedViewScopedWidgets(view: PromotedWidgetView): void {
useWidgetValueStore().clearScopedWidget(
this.rootGraph.id,
this.id,
`${view.sourceNodeId}:${view.sourceWidgetName}`
)
}
override removeWidget(widget: IBaseWidget): void {
this.ensureWidgetRemoved(widget)
}
@@ -1527,6 +1581,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (isPromotedWidgetView(widget)) {
this._clearDomOverrideForView(widget)
usePromotionStore().demote(this.rootGraph.id, this.id, widget)
this._clearPromotedViewScopedWidgets(widget)
this._removePromotedView(widget)
}
for (const input of this.inputs) {
@@ -1546,7 +1601,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override onRemoved(): void {
this._eventAbortController.abort()
this._invalidatePromotedViewsCache()
this._instanceWidgetValues.clear()
useWidgetValueStore().clearInstanceWidgets(this.rootGraph.id, this.id)
for (const widget of this.widgets) {
if (isPromotedWidgetView(widget)) {
@@ -1614,14 +1669,21 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const views = this._getPromotedViews()
const serializableViews = views.filter((view) => view.sourceSerialize)
if (serializableViews.length > 0) {
const hasAnyScopedValue = serializableViews.some(
(view) => view.getScopedStoreValue() !== undefined
)
if (hasAnyScopedValue) {
// For un-edited promoted views (no scoped store value), fall back to the
// source widget's effective value via the view getter. This keeps the
// round-trip stable: replaying the same source-default into the store
// on configure is a no-op, whereas writing `null` would blank out the
// widget on next load.
serialized.widgets_values = serializableViews.map((view) => {
const value = view.serializeValue
? view.serializeValue(this, -1)
: view.value
const value = view.getScopedStoreValue() ?? view.value ?? null
return value != null && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: (value ?? null)
: value
})
}

View File

@@ -448,6 +448,63 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
expect(state?.value).toBe(99)
})
it('uses widget value fallback when scoped store entry is missing', () => {
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID,
storeInstanceId: 'subgraph-1'
})
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(GRAPH_ID, {
nodeId: NODE_ID,
name: 'seed',
type: 'combo',
value: 0,
options: {}
})
const [processed] = processWidgets([widget])
expect(processed.simplified.value).toBeUndefined()
})
it('uses storeInstanceId to resolve and update scoped widget state', () => {
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID,
storeInstanceId: 'subgraph-1'
})
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(GRAPH_ID, {
nodeId: NODE_ID,
name: 'seed',
type: 'combo',
value: 0,
options: {}
})
widgetValueStore.registerWidget(
GRAPH_ID,
{
nodeId: NODE_ID,
name: 'seed',
type: 'combo',
value: 7,
options: {}
},
'subgraph-1'
)
const [processed] = processWidgets([widget])
expect(processed.simplified.value).toBe(7)
processed.updateHandler(8)
expect(widgetValueStore.getWidget(GRAPH_ID, NODE_ID, 'seed')?.value).toBe(0)
expect(
widgetValueStore.getWidget(GRAPH_ID, NODE_ID, 'seed', 'subgraph-1')?.value
).toBe(8)
})
it('clears execution errors on update', () => {
const widget = createMockWidget({
name: 'seed',

View File

@@ -200,7 +200,12 @@ export function computeProcessedWidgets({
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
? widgetValueStore.getWidget(
graphId,
bareWidgetId,
storeWidgetName,
widget.storeInstanceId
)
: undefined
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
@@ -263,7 +268,7 @@ export function computeProcessedWidgets({
const { slotMetadata } = widget
const value = widgetState?.value as WidgetValue
const value = (widgetState?.value ?? widget.defaultValue) as WidgetValue
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled

View File

@@ -124,6 +124,173 @@ describe('useWidgetValueStore', () => {
expect(widgets).toHaveLength(2)
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
})
it('keeps instance-scoped widgets isolated from the legacy shared key', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'prompt', 'text', 'shared'))
store.registerWidget(
graphA,
widget('node-1', 'prompt', 'text', 'instance-a'),
'subgraph-a'
)
store.registerWidget(
graphA,
widget('node-1', 'prompt', 'text', 'instance-b'),
'subgraph-b'
)
expect(store.getWidget(graphA, 'node-1', 'prompt')?.value).toBe('shared')
expect(
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')?.value
).toBe('instance-a')
expect(
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-b')?.value
).toBe('instance-b')
})
it('getNodeWidgets can read either shared or instance-scoped widgets', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 2),
'subgraph-a'
)
store.registerWidget(
graphA,
widget('node-1', 'steps', 'number', 20),
'subgraph-a'
)
expect(store.getNodeWidgets(graphA, 'node-1')).toHaveLength(1)
expect(
store
.getNodeWidgets(graphA, 'node-1', 'subgraph-a')
.map((w) => w.name)
.sort()
).toEqual(['seed', 'steps'])
})
it('clearInstanceWidgets removes only one instance scope', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'prompt', 'text', 'shared'))
store.registerWidget(
graphA,
widget('node-1', 'prompt', 'text', 'instance-a'),
'subgraph-a'
)
store.registerWidget(
graphA,
widget('node-1', 'prompt', 'text', 'instance-b'),
'subgraph-b'
)
store.clearInstanceWidgets(graphA, 'subgraph-a')
expect(store.getWidget(graphA, 'node-1', 'prompt')?.value).toBe('shared')
expect(
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')
).toBeUndefined()
expect(
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-b')?.value
).toBe('instance-b')
})
it('clearScopedWidget with node prefix clears all widgets for that source node only', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'prompt', 'text', 'shared'))
store.registerWidget(
graphA,
widget('node-2', 'prompt', 'text', 'shared-2')
)
store.registerWidget(
graphA,
widget('node-1', 'prompt', 'text', 'instance-a-prompt'),
'subgraph-a'
)
store.registerWidget(
graphA,
widget('node-1', 'steps', 'number', 20),
'subgraph-a'
)
store.registerWidget(
graphA,
widget('node-2', 'prompt', 'text', 'instance-a-node-2'),
'subgraph-a'
)
store.registerWidget(
graphA,
widget('node-1', 'prompt', 'text', 'instance-b-prompt'),
'subgraph-b'
)
store.clearScopedWidget(graphA, 'subgraph-a', 'node-1')
expect(store.getWidget(graphA, 'node-1', 'prompt')?.value).toBe('shared')
expect(store.getWidget(graphA, 'node-2', 'prompt')?.value).toBe(
'shared-2'
)
expect(
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')
).toBeUndefined()
expect(
store.getWidget(graphA, 'node-1', 'steps', 'subgraph-a')
).toBeUndefined()
expect(
store.getWidget(graphA, 'node-2', 'prompt', 'subgraph-a')?.value
).toBe('instance-a-node-2')
expect(
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-b')?.value
).toBe('instance-b-prompt')
})
it('clearScopedWidget with node:widget prefix clears only the targeted widget', () => {
const store = useWidgetValueStore()
store.registerWidget(
graphA,
widget('node-1', 'prompt', 'text', 'instance-a-prompt'),
'subgraph-a'
)
store.registerWidget(
graphA,
widget('node-1', 'steps', 'number', 20),
'subgraph-a'
)
store.registerWidget(
graphA,
widget('node-1', 'prompt', 'text', 'instance-b-prompt'),
'subgraph-b'
)
store.clearScopedWidget(graphA, 'subgraph-a', 'node-1:prompt')
expect(
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')
).toBeUndefined()
expect(
store.getWidget(graphA, 'node-1', 'steps', 'subgraph-a')?.value
).toBe(20)
expect(
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-b')?.value
).toBe('instance-b-prompt')
})
it('clearScopedWidget does not affect legacy unscoped keys', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'prompt', 'text', 'shared'))
store.registerWidget(
graphA,
widget('node-1', 'prompt', 'text', 'instance-a-prompt'),
'subgraph-a'
)
store.clearScopedWidget(graphA, 'subgraph-a', 'node-1:prompt')
expect(store.getWidget(graphA, 'node-1', 'prompt')?.value).toBe('shared')
expect(
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')
).toBeUndefined()
})
})
describe('direct property mutation', () => {

View File

@@ -9,11 +9,12 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
/**
* Widget state is keyed by `nodeId:widgetName` without graph context.
* This is intentional: nodes viewed at different subgraph depths share
* the same widget state, enabling synchronized values across the hierarchy.
* Widget state is keyed by `nodeId:widgetName` without graph context by
* default. Promoted subgraph widgets can add an instance coordinate so sibling
* SubgraphNode instances do not collide while regular depth views keep sharing
* the legacy slot.
*/
type WidgetKey = `${NodeId}:${string}`
type WidgetKey = `${NodeId}:${string}` | `${NodeId}@${NodeId}:${string}`
/**
* Strips graph/subgraph prefixes from a scoped node ID to get the bare node ID.
@@ -46,23 +47,35 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
return nextWidgetStates
}
function makeKey(nodeId: NodeId, widgetName: string): WidgetKey {
return `${nodeId}:${widgetName}`
function makeKey(
nodeId: NodeId,
widgetName: string,
instanceId?: NodeId
): WidgetKey {
return instanceId === undefined
? `${nodeId}:${widgetName}`
: `${instanceId}@${nodeId}:${widgetName}`
}
function registerWidget<TValue = unknown>(
graphId: UUID,
state: WidgetState<TValue>
state: WidgetState<TValue>,
instanceId?: NodeId
): WidgetState<TValue> {
const widgetStates = getWidgetStateMap(graphId)
const key = makeKey(state.nodeId, state.name)
const key = makeKey(state.nodeId, state.name, instanceId)
widgetStates.set(key, state)
return widgetStates.get(key) as WidgetState<TValue>
}
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
function getNodeWidgets(
graphId: UUID,
nodeId: NodeId,
instanceId?: NodeId
): WidgetState[] {
const widgetStates = getWidgetStateMap(graphId)
const prefix = `${nodeId}:`
const prefix =
instanceId === undefined ? `${nodeId}:` : `${instanceId}@${nodeId}:`
return [...widgetStates]
.filter(([key]) => key.startsWith(prefix))
.map(([, state]) => state)
@@ -71,9 +84,40 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
function getWidget(
graphId: UUID,
nodeId: NodeId,
widgetName: string
widgetName: string,
instanceId?: NodeId
): WidgetState | undefined {
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
return getWidgetStateMap(graphId).get(
makeKey(nodeId, widgetName, instanceId)
)
}
function clearInstanceWidgets(graphId: UUID, instanceId: NodeId): void {
const widgetStates = getWidgetStateMap(graphId)
const prefix = `${instanceId}@`
for (const key of widgetStates.keys()) {
if (key.startsWith(prefix)) widgetStates.delete(key)
}
}
/**
* Clears one promoted-widget scope under a subgraph instance.
*
* `scopePrefix` starts after the `${instanceId}@` boundary from
* `makeKey(nodeId, widgetName, instanceId)`:
* - `${nodeId}` clears all scoped widgets on that source node.
* - `${nodeId}:${widgetName}` clears exactly one scoped widget.
*/
function clearScopedWidget(
graphId: UUID,
instanceId: NodeId,
scopePrefix: string
): void {
const widgetStates = getWidgetStateMap(graphId)
const prefix = `${instanceId}@${scopePrefix}`
for (const key of widgetStates.keys()) {
if (key.startsWith(prefix)) widgetStates.delete(key)
}
}
function clearGraph(graphId: UUID): void {
@@ -84,6 +128,8 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
registerWidget,
getWidget,
getNodeWidgets,
clearInstanceWidgets,
clearScopedWidget,
clearGraph
}
})