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
17 changed files with 505 additions and 245 deletions

View File

@@ -20,9 +20,8 @@
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {
"proxyWidgets": [["10", "text", null, { "value": "Alpha\n" }]]
}
"properties": {},
"widgets_values": ["Alpha\n"]
},
{
"id": 12,
@@ -40,9 +39,8 @@
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {
"proxyWidgets": [["10", "text", null, { "value": "Beta\n" }]]
}
"properties": {},
"widgets_values": ["Beta\n"]
},
{
"id": 13,
@@ -60,9 +58,8 @@
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {
"proxyWidgets": [["10", "text", null, { "value": "Gamma\n" }]]
}
"properties": {},
"widgets_values": ["Gamma\n"]
}
],
"links": [],

View File

@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -77,15 +76,8 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
const storeInstanceId =
node.isSubgraphNode() && isPromotedWidgetView(widget) ? node.id : undefined
const widgetState = graphId
? widgetValueStore.getWidget(
graphId,
bareNodeId,
sourceWidget.name,
storeInstanceId
)
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
: undefined
return {

View File

@@ -229,7 +229,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
expect(widgetData?.slotName).toBe('value')
expect(widgetData?.value).toBe('hello')
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Disconnect

View File

@@ -50,8 +50,7 @@ export interface WidgetSlotMetadata {
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* widgetValueStore is preferred for value and metadata. `value` provides the
* LiteGraph fallback when no scoped store entry exists yet.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
nodeId?: NodeId
@@ -60,7 +59,6 @@ export interface SafeWidgetData {
name: string
storeName?: string
type: string
value?: WidgetValue
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
@@ -100,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 {
@@ -345,7 +349,6 @@ function safeWidgetMapper(
name,
storeName,
type: effectiveWidget.type,
value: normalizeWidgetValue(widget.value),
...sharedEnhancements,
callback,
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
@@ -365,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

@@ -1,10 +0,0 @@
/**
* Deep-clone a widget value for inline serialization or per-instance
* isolation. Uses `structuredClone` so uncloneable mutable values fail loudly
* instead of sharing a reference across SubgraphNode instances.
*/
export function cloneWidgetValue<TValue>(value: TValue): TValue {
if (value == null) return value
if (typeof value !== 'object' && typeof value !== 'function') return value
return structuredClone(value)
}

View File

@@ -1730,8 +1730,9 @@ describe('SubgraphNode.widgets getter', () => {
const clonedSerialized = clonedNode.serialize()
expect(clonedSerialized.properties?.proxyWidgets).toStrictEqual([
[String(innerNode.id), 'widgetA', null, { value: 'edited' }]
[String(innerNode.id), 'widgetA']
])
expect(clonedSerialized.widgets_values).toStrictEqual(['edited'])
const hydratedClone = createTestSubgraphNode(subgraphNode.subgraph, {
id: 100
@@ -2006,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', () => {

View File

@@ -21,7 +21,6 @@ import {
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import { cloneWidgetValue } from './cloneWidgetValue'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
@@ -48,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
}

View File

@@ -263,7 +263,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets).toHaveLength(0)
})
test('serialize inlines promoted widget values into proxyWidgets entries', () => {
test('serialize writes positional widgets_values for edited promoted widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
@@ -276,9 +276,9 @@ describe('Subgraph proxyWidgets', () => {
const serialized = subgraphNode.serialize()
expect(serialized.widgets_values).toBeUndefined()
expect(serialized.widgets_values).toStrictEqual(['edited'])
expect(serialized.properties?.proxyWidgets).toStrictEqual([
[innerIds[0], 'stringWidget', null, { value: 'edited' }]
[innerIds[0], 'stringWidget']
])
})

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,11 +36,12 @@ describe(parseProxyWidgets, () => {
])
})
it('parses 4-tuple arrays with inline state', () => {
const input = [['3', 'text', null, { value: 'hello' }]]
expect(parseProxyWidgets(input)).toEqual([
['3', 'text', null, { value: 'hello' }]
])
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', () => {
@@ -51,8 +52,28 @@ describe(parseProxyWidgets, () => {
it('returns empty array for invalid tuples', () => {
expect(parseProxyWidgets([['only-one']])).toEqual([])
expect(parseProxyWidgets([['a', 'b', 'c', 'd']])).toEqual([])
expect(parseProxyWidgets([['a', 'b', null, { value: undefined }]])).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

@@ -18,6 +18,8 @@ const definedValueSchema = z.custom<DefinedProxyWidgetValue>(
)
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(),
@@ -50,10 +52,7 @@ export function parseProxyWidgets(
return []
}
/**
* Typed accessor for the optional trailing `{ value }` state on a proxyWidgets
* entry. Returns undefined for 2- and 3-tuple (identity-only) entries.
*/
/** Returns the optional inline {value} state from a legacy PR #11559 4-tuple entry. */
export function getProxyWidgetInlineState(
entry: ProxyWidgetEntry
): ProxyWidgetInlineState | undefined {

View File

@@ -968,8 +968,19 @@ export class LGraphNode
if (this.properties) o.properties = LiteGraph.cloneObject(this.properties)
const widgetsValues = this.getSerializableWidgetsValues()
if (widgetsValues) o.widgets_values = widgetsValues
const { widgets } = this
if (widgets && this.serialize_widgets) {
o.widgets_values = []
for (const [i, widget] of widgets.entries()) {
if (widget.serialize === false) continue
const val = widget?.value
// Ensure object values are plain (not reactive proxies) for structuredClone compatibility.
o.widgets_values[i] =
val != null && typeof val === 'object'
? JSON.parse(JSON.stringify(val))
: (val ?? null)
}
}
if (!o.type && this.constructor.type) o.type = this.constructor.type
@@ -986,26 +997,6 @@ export class LGraphNode
return o
}
protected getSerializableWidgetsValues():
| ISerialisedNode['widgets_values']
| undefined {
const { widgets } = this
if (!widgets || !this.serialize_widgets) return undefined
const widgetsValues: NonNullable<ISerialisedNode['widgets_values']> = []
for (const [i, widget] of widgets.entries()) {
if (widget.serialize === false) continue
const val = widget?.value
// Ensure object values are plain (not reactive proxies) for structuredClone compatibility.
widgetsValues[i] =
val != null && typeof val === 'object'
? JSON.parse(JSON.stringify(val))
: (val ?? null)
}
return widgetsValues
}
/* Creates a clone of this node */
clone(): LGraphNode | null {
if (this.type == null) return null

View File

@@ -4,6 +4,7 @@ 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,
@@ -53,8 +54,6 @@ describe('SubgraphNode multi-instance widget isolation', () => {
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
const innerNodeId = String(node.id)
// Per-instance values are inlined as the optional {value} state on
// each proxyWidgets entry so identity and value cannot desync.
instance1.configure({
id: 201,
type: subgraph.id,
@@ -66,8 +65,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
order: 0,
flags: {},
properties: {
proxyWidgets: [[innerNodeId, 'widget', null, { value: 10 }]]
}
proxyWidgets: [[innerNodeId, 'widget']]
},
widgets_values: [10]
})
instance2.configure({
@@ -81,8 +81,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
order: 1,
flags: {},
properties: {
proxyWidgets: [[innerNodeId, 'widget', null, { value: 20 }]]
}
proxyWidgets: [[innerNodeId, 'widget']]
},
widgets_values: [20]
})
const widgets1 = instance1.widgets!
@@ -97,13 +98,13 @@ describe('SubgraphNode multi-instance widget isolation', () => {
const serialized1 = instance1.serialize()
const serialized2 = instance2.serialize()
expect(serialized1.widgets_values).toBeUndefined()
expect(serialized2.widgets_values).toBeUndefined()
expect(serialized1.widgets_values).toEqual([10])
expect(serialized2.widgets_values).toEqual([20])
expect(serialized1.properties?.proxyWidgets).toEqual([
[innerNodeId, 'widget', null, { value: 10 }]
[innerNodeId, 'widget']
])
expect(serialized2.properties?.proxyWidgets).toEqual([
[innerNodeId, 'widget', null, { value: 20 }]
[innerNodeId, 'widget']
])
})
@@ -153,6 +154,66 @@ describe('SubgraphNode multi-instance widget isolation', () => {
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', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
@@ -175,8 +236,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
order: 0,
flags: {},
properties: {
proxyWidgets: [[innerNodeId, 'widget', null, { value: 33 }]]
}
proxyWidgets: [[innerNodeId, 'widget']]
},
widgets_values: [33]
})
const serialized = originalInstance.serialize()
@@ -193,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' }]
@@ -243,8 +371,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
order: 0,
flags: {},
properties: {
proxyWidgets: [[innerNodeId, 'widget', null, { value: 33 }]]
}
proxyWidgets: [[innerNodeId, 'widget']]
},
widgets_values: [33]
})
const serialized = originalInstance.serialize()
@@ -378,9 +507,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
expect(widget?.serializeValue?.(instance, 0)).toBe(LEGACY_VALUE)
const serialized = instance.serialize()
expect(serialized.widgets_values).toBeUndefined()
expect(serialized.widgets_values).toEqual([LEGACY_VALUE])
expect(serialized.properties?.proxyWidgets).toEqual([
[String(node.id), 'widget', null, { value: LEGACY_VALUE }]
[String(node.id), 'widget']
])
})
@@ -417,11 +546,47 @@ describe('SubgraphNode multi-instance widget isolation', () => {
expect(widget?.value).toBe(SOURCE_DEFAULT)
expect(widget?.value).not.toBe(LEGACY_NOISE_A)
expect(warn).toHaveBeenCalledWith(
'[SubgraphNode] Legacy widgets_values length (2) does not match proxyWidgets length (1); dropping legacy values for instance 803.'
'[SubgraphNode] Dropping stale widgets_values for 803: widgets_values length (2) does not match proxyWidgets length (1).'
)
})
it('rejects uncloneable promoted widget values', () => {
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' }]
})
@@ -432,11 +597,12 @@ describe('SubgraphNode multi-instance widget isolation', () => {
const instance = createTestSubgraphNode(subgraph, { id: 901 })
instance.graph!.add(instance)
const uncloneable = { fn: () => 'nope' }
const valueWithFunction = { fn: () => 'nope' }
const promotedWidget = instance.widgets![0]
expect(() => {
promotedWidget.value = uncloneable as unknown as typeof widget.value
}).toThrow()
promotedWidget.value = valueWithFunction as unknown as typeof widget.value
expect(promotedWidget.value).toEqual({})
expect(instance.serialize().widgets_values).toEqual([{}])
})
})

View File

@@ -33,7 +33,6 @@ import {
createPromotedWidgetView,
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
import { cloneWidgetValue } from '@/core/graph/subgraph/cloneWidgetValue'
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
@@ -120,7 +119,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
hasMissingBoundSourceWidget: boolean
views: PromotedWidgetView[]
}
private _pendingLegacyWidgetsValues?: unknown[]
// Declared as accessor via Object.defineProperty in constructor.
// TypeScript doesn't allow overriding a property with get/set syntax,
@@ -650,93 +648,39 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
}
/**
* Serialize promotion entries with optional inline `{value}` state.
*
* Binding identity and value into the same record lets every downstream
* transformation — promotion-store reordering, null-entry filtering,
* clipboard-paste / subgraph-dedup / nested-pack ID remaps — carry them
* together without any extra bookkeeping.
*
* `resolveValue` returns `undefined` when the entry should be written as
* an identity-only 2- or 3-tuple (no view, non-serializable source, or
* resolved value is undefined). Returning `undefined` keeps
* `widgets_values` on the SubgraphNode a dead field, matching the
* pre-#10849 invariant.
*/
private _serializeEntries(
entries: PromotedWidgetSource[],
resolveValue: (
key: string,
entry: PromotedWidgetSource
) => unknown | undefined
): (
| [string, string]
| [string, string, string]
| [string, string, string | null, { value: unknown }]
)[] {
return entries.map((e) => {
const key = makePromotionEntryKey(e)
const disambiguator = e.disambiguatingSourceNodeId ?? null
const identityOnly: [string, string] | [string, string, string] =
disambiguator
? [e.sourceNodeId, e.sourceWidgetName, disambiguator]
: [e.sourceNodeId, e.sourceWidgetName]
const resolved = resolveValue(key, e)
if (resolved === undefined) return identityOnly
return [
e.sourceNodeId,
e.sourceWidgetName,
disambiguator,
{ value: resolved }
]
})
entries: PromotedWidgetSource[]
): (string[] | [string, string, string])[] {
return entries.map((e) =>
e.disambiguatingSourceNodeId
? [e.sourceNodeId, e.sourceWidgetName, e.disambiguatingSourceNodeId]
: [e.sourceNodeId, e.sourceWidgetName]
)
}
private _resolveLegacyEntry(
widgetName: string
): PromotedWidgetSource | undefined {
): [string, string] | undefined {
// Legacy -1 entries use the slot name as the widget name.
// Find the input with that name, then trace to the connected interior widget.
const input = this.inputs.find((i) => i.name === widgetName)
if (!input?._widget) {
// Fallback: find via subgraph input slot connection. During normal
// configure this is the path that actually runs — inputs are freshly
// rebuilt with no `_widget`, and `_resolveInputWidget` doesn't run
// until after entry resolution. `resolvedTarget.sourceNodeId` carries
// the disambiguator when the deeper resolution chain terminates at a
// nested `PromotedWidgetView`.
return this._fromResolvedTarget(widgetName)
// Fallback: find via subgraph input slot connection
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
}
const widget = input._widget
if (isPromotedWidgetView(widget)) {
return {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName,
...(widget.disambiguatingSourceNodeId && {
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
})
}
return [widget.sourceNodeId, widget.sourceWidgetName]
}
return this._fromResolvedTarget(widgetName)
}
private _fromResolvedTarget(
widgetName: string
): PromotedWidgetSource | undefined {
// Fallback: find via subgraph input slot connection
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName,
...(resolvedTarget.sourceNodeId && {
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
})
}
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
}
/** Manages lifecycle of all subgraph event listeners */
@@ -1052,9 +996,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/**
* 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[]
override configure(info: ExportedSubgraphInstance): void {
useWidgetValueStore().clearInstanceWidgets(this.rootGraph.id, this.id)
this._pendingLegacyWidgetsValues = Array.isArray(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
@@ -1109,7 +1064,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
try {
super.configure(info)
} finally {
this._pendingLegacyWidgetsValues = undefined
this._pendingWidgetsValues = undefined
}
}
@@ -1129,27 +1084,19 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this._promotedViewManager.clear()
this._invalidatePromotedViewsCache()
/**
* Hydrate the promotion store from serialized `properties.proxyWidgets`.
* Inline `{value}` state on each entry is paired with its resolved
* identity in a single pass so legacy `-1` entries and ancestor-
* normalized entries stay aligned with their per-instance value. Older
* templates may still carry the same positional values in
* `widgets_values`; import those only when they line up with every
* proxyWidgets entry, then re-save through inline state below.
*/
// 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._pendingLegacyWidgetsValues?.length === raw.length
this._pendingWidgetsValues?.length === raw.length
if (this._pendingLegacyWidgetsValues && !canHydrateLegacyWidgetsValues) {
if (this._pendingWidgetsValues && !canHydrateLegacyWidgetsValues) {
if (import.meta.env.DEV) {
console.warn(
`[SubgraphNode] Legacy widgets_values length (${this._pendingLegacyWidgetsValues.length}) ` +
`does not match proxyWidgets length (${raw.length}); dropping legacy values for instance ${this.id}.`
`[SubgraphNode] Dropping stale widgets_values for ${this.id}: ` +
`widgets_values length (${this._pendingWidgetsValues.length}) ` +
`does not match proxyWidgets length (${raw.length}).`
)
}
}
@@ -1166,7 +1113,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (nodeId === '-1') {
const legacy = this._resolveLegacyEntry(widgetName)
if (legacy) {
resolved = legacy
resolved = {
sourceNodeId: legacy[0],
sourceWidgetName: legacy[1]
}
} else {
if (import.meta.env.DEV) {
console.warn(
@@ -1187,13 +1137,16 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
if (resolved) {
const state = getProxyWidgetInlineState(rawEntry)
if (state) {
pendingValues.set(makePromotionEntryKey(resolved), state.value)
const inlineState = getProxyWidgetInlineState(rawEntry)
if (inlineState) {
pendingValues.set(
makePromotionEntryKey(resolved),
inlineState.value
)
} else if (canHydrateLegacyWidgetsValues) {
const value = this._pendingLegacyWidgetsValues?.[index]
if (value != null) {
pendingValues.set(makePromotionEntryKey(resolved), value)
const legacyValue = this._pendingWidgetsValues?.[index]
if (legacyValue !== null && legacyValue !== undefined) {
pendingValues.set(makePromotionEntryKey(resolved), legacyValue)
}
}
}
@@ -1204,18 +1157,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
store.setPromotions(this.rootGraph.id, this.id, entries)
/**
* Write back resolved entries so legacy or stale identities don't
* persist — but preserve the inline `{value}` state from `pendingValues`
* alongside the normalized identity, otherwise this runs on every load
* and demotes 4-tuple entries (which always differ byte-for-byte from
* the pre-normalization `_serializeEntries` output) into 2/3-tuple,
* stripping saved per-instance values from `properties.proxyWidgets`
* until the next `serialize()` rebuilds them.
*/
const serialized = this._serializeEntries(entries, (key) =>
pendingValues.get(key)
)
// Write back resolved entries so legacy or stale entries don't persist
const serialized = this._serializeEntries(entries)
if (JSON.stringify(serialized) !== JSON.stringify(raw)) {
this.properties.proxyWidgets = serialized
}
@@ -1248,21 +1191,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
store.promote(this.rootGraph.id, this.id, source)
}
/**
* Hydrate per-instance values by resolved identity key. The pending
* map is built alongside entry resolution so legacy `-1` and ancestor
* normalization paths are handled with the same logic as the promotion
* store, and reorder / filter / ID-remap can't desync identity from
* value.
*/
if (pendingValues.size > 0) {
for (const view of this._getPromotedViews()) {
if (!view.sourceSerialize) continue
const key = view.instanceKey
if (!pendingValues.has(key)) continue
view.value = cloneWidgetValue(
pendingValues.get(key)
) as typeof view.value
if (!pendingValues.has(view.instanceKey)) continue
view.value = pendingValues.get(view.instanceKey) as typeof view.value
}
}
}
@@ -1361,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)
}
@@ -1632,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)
}
@@ -1640,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) {
@@ -1716,33 +1658,37 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
override serialize(): ISerialisedNode {
// Write promotion store state back to properties for serialization
const entries = usePromotionStore().getPromotions(
this.rootGraph.id,
this.id
)
const viewByInstanceKey = new Map<string, PromotedWidgetView>()
for (const view of this._getPromotedViews()) {
viewByInstanceKey.set(view.instanceKey, view)
this.properties.proxyWidgets = this._serializeEntries(entries)
const serialized = super.serialize()
const views = this._getPromotedViews()
const serializableViews = views.filter((view) => view.sourceSerialize)
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.getScopedStoreValue() ?? view.value ?? null
return value != null && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: value
})
}
this.properties.proxyWidgets = this._serializeEntries(entries, (key) => {
const view = viewByInstanceKey.get(key)
if (!view?.sourceSerialize) return undefined
const raw = view.getScopedStoreValue()
if (raw === undefined) return undefined
return cloneWidgetValue(raw)
})
return super.serialize()
return serialized
}
protected override getSerializableWidgetsValues(): undefined {
/**
* `SubgraphNode.widgets_values` is a dead field — per-instance values
* live inline on `proxyWidgets` entries.
*/
return undefined
}
override clone() {
const clone = super.clone()

View File

@@ -452,8 +452,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID,
storeInstanceId: 'subgraph-1',
value: 1024
storeInstanceId: 'subgraph-1'
})
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(GRAPH_ID, {
@@ -466,15 +465,14 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
const [processed] = processWidgets([widget])
expect(processed.simplified.value).toBe(1024)
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',
value: 1024
storeInstanceId: 'subgraph-1'
})
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(GRAPH_ID, {

View File

@@ -268,9 +268,7 @@ export function computeProcessedWidgets({
const { slotMetadata } = widget
const value = (
widgetState ? widgetState.value : widget.value
) as WidgetValue
const value = (widgetState?.value ?? widget.defaultValue) as WidgetValue
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled

View File

@@ -195,6 +195,102 @@ describe('useWidgetValueStore', () => {
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

@@ -100,6 +100,26 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
}
}
/**
* 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 {
graphWidgetStates.value.delete(graphId)
}
@@ -109,6 +129,7 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
getWidget,
getNodeWidgets,
clearInstanceWidgets,
clearScopedWidget,
clearGraph
}
})