Compare commits

...

18 Commits

Author SHA1 Message Date
Dante
160c3dfdc0 Merge branch 'main' into fix/subgraph-promoted-widget-inline-state 2026-04-29 14:48:52 +09:00
dante01yoon
6ada401bb3 fix: address promoted widget review blockers 2026-04-29 14:32:20 +09:00
dante01yoon
eea6cb5b69 fix: reject uncloneable promoted widget values 2026-04-29 13:30:00 +09:00
dante01yoon
2c048f5eca fix: render unedited promoted widget values 2026-04-28 12:18:48 +09:00
dante01yoon
e49b3407de Merge remote-tracking branch 'origin/fix/subgraph-promoted-widget-inline-state' into fix/subgraph-promoted-widget-inline-state 2026-04-28 11:41:29 +09:00
dante01yoon
92e03ea286 fix: migrate legacy promoted widget template values 2026-04-28 11:40:37 +09:00
Dante
76529666cf Merge branch 'main' into fix/subgraph-promoted-widget-inline-state 2026-04-28 11:19:28 +09:00
dante01yoon
839e9dcb03 chore: merge main into promoted widget branch 2026-04-28 10:26:17 +09:00
dante01yoon
156bae8654 fix: scope promoted widget state by subgraph instance 2026-04-28 10:14:34 +09:00
dante01yoon
60a90e6f53 refactor(subgraph): address DrJKL review feedback
- Extract cloneWidgetValue to shared module; remove safeDeepClone duplicate
- Unify _serializeEntriesWithState/_serializeEntriesWithPendingValues into
  _serializeEntries with a value-resolver callback
- Slim restorePerInstanceValue: drop redundant interior comments and remove
  implementation-detail names from the interface JSDoc
- Reword promotionSchema ordering note (z.tuple enforces exact arity)
- Guard pendingValues against undefined state.value
- Share single clone in _seedInstanceState
- Rename safeDeepClone test to behavioral name; tighten widgets length
  assertion to .toHaveLength(1)
2026-04-25 12:33:26 +09:00
dante01yoon
ec2a486adb fix(subgraph): extend disambiguator preservation to legacy -1 fallback paths
The initial disambiguator fix (commit 968a2fd) only guarded PATH 2 of
_resolveLegacyEntry — the case where input._widget is a PromotedWidgetView
when resolution runs.

In production configure, input._widget is always undefined at the time
_resolveLegacyEntry executes because configure() rebuilds inputs fresh
and _resolveInputWidget (which assigns _widget) doesn't run until after
entry resolution. So PATH 1 (fallback via resolveSubgraphInputTarget)
is the path that actually runs, and it was still dropping the
disambiguator carried in resolvedTarget.sourceNodeId.

Extract _fromResolvedTarget and share it between both fallback paths
(L761-769 and L782-789) so legacy -1 entries whose deeper resolution
terminates at a nested PromotedWidgetView preserve their disambiguator
into the promotion-store key — which must match view.instanceKey for
the per-instance value hydration to find its target.

Addresses jaeone94's follow-up review on #11559. Also adds a
safeDeepClone catch-branch regression test so a future refactor can't
silently reintroduce the pre-review JSON.parse(JSON.stringify) crash
on pathological widget values.
2026-04-25 06:20:05 +09:00
dante01yoon
8f78d09d99 fix(schema): make ProxyWidgetsProperty/ProxyWidgetEntry internal (knip)
Knip flagged the newly exported types as unused. They're consumed only
by getProxyWidgetInlineState in the same file, so keep them internal.
2026-04-24 18:49:55 +09:00
dante01yoon
e2ca5e8d3f fix(subgraph): harden proxyWidgets round-trip (review follow-up)
Addresses jaeone94's review on #11559:

- write-back during configure now uses `_serializeEntriesWithPendingValues`
  so legacy-entry normalization doesn't demote the freshly-loaded 4-tuple
  into an identity-only 2/3-tuple, which would drop per-instance values
  from `properties.proxyWidgets` until the next full `serialize()`.
- `cloneWidgetValue` now uses `structuredClone` with a raw-reference
  fallback, so a malformed `{value}` blob (circular ref, throwing
  `toJSON`, etc.) in saved JSON can't crash the whole subgraph load.
- `_serializeEntriesWithState` skips the `{value}` wrapper when the
  resolved value is `undefined`, preventing ghost `null` entries from
  poisoning the per-instance map and sibling-fallback capture.
- `restorePerInstanceValue` early-returns on `undefined` for the same
  reason.
- `set value` now shares `_seedInstanceState` with `restorePerInstanceValue`
  to prevent drift between the setter and load paths.
- `promotionSchema.ts` exposes `getProxyWidgetInlineState` + typed tuple
  names so inline state is read without `as` casts. Union order is
  load-bearing and now documented.
- Regression test now asserts the LOAD invariant (inner widget value
  survives stale `widgets_values` in the payload), not just the SAVE
  invariant.
2026-04-24 18:42:51 +09:00
dante01yoon
968a2fd9a5 fix(subgraph): preserve disambiguator on legacy -1 entry resolution
_resolveLegacyEntry now returns a full PromotedWidgetSource rather than a
bare [nodeId, widgetName] tuple, carrying disambiguatingSourceNodeId when
the resolved input widget is a nested PromotedWidgetView.

Without this, legacy -1 entries in nested subgraphs resolved to a key
'${source}:${widget}' while the promoted view registered under
'${source}:${widget}:${disambig}', causing the per-instance value
hydration lookup to miss silently. Values then fell back to inner-widget
delegation instead of the saved per-instance state.

Addresses CodeRabbit Major finding on PR #11559.
2026-04-24 18:26:10 +09:00
dante01yoon
c9138e3894 test: migrate multi-instance subgraph fixture to inline proxyWidgets state
The fixture was authored in v1.44.5-7 format where per-instance values
lived on widgets_values. With widgets_values restored to a dead field,
values now live inline on properties.proxyWidgets entries.
2026-04-23 13:05:27 +09:00
dante01yoon
d7193a8576 docs(subgraph): reformat inline comments as JSDoc 2026-04-23 12:47:54 +09:00
dante01yoon
fe070633b0 fix: inline promoted widget values in proxyWidgets entries (#10849)
Restore the pre-#10849 invariant that SubgraphNode.widgets_values is a
dead field. Per-instance promoted widget values are now carried inline
on their proxyWidgets entry, so identity and value cannot be separated
under reorder, null-entry filter, clipboard paste / subgraph dedup /
nested-pack ID remap.

Add PromotedWidgetView.restorePerInstanceValue to seed
_instanceWidgetValues and promotedSourceWriteMeta without triggering
sibling-fallback capture during configure. Align the shared value so
the getTrackedValue self-heal does not discard the restored state.

Legacy workflows with stale widgets_values on SubgraphNode are ignored;
values fall back to inner-widget delegation as pre-#10849.
2026-04-23 12:31:37 +09:00
dante01yoon
6a982675ef test: add failing tests for SubgraphNode widgets_values corruption (#10849)
Reproduces the Z-Image-Turbo regression: SubgraphNode.serialize writes
widgets_values and SubgraphNode.configure applies it positionally to
promoted widgets, corrupting pre-existing templates whose widgets_values
was never produced by the promoted-widget save path.

Both tests assert the pre-#10849 invariant that widgets_values on a
SubgraphNode must remain a dead field.
2026-04-23 12:16:07 +09:00
18 changed files with 931 additions and 398 deletions

View File

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

View File

@@ -4,6 +4,7 @@ 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'
@@ -76,8 +77,15 @@ 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)
? widgetValueStore.getWidget(
graphId,
bareNodeId,
sourceWidget.name,
storeInstanceId
)
: undefined
return {

View File

@@ -229,6 +229,7 @@ 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
@@ -475,6 +476,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 +589,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

@@ -50,14 +50,17 @@ export interface WidgetSlotMetadata {
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
* widgetValueStore is preferred for value and metadata. `value` provides the
* LiteGraph fallback when no scoped store entry exists yet.
*/
export interface SafeWidgetData {
nodeId?: NodeId
storeNodeId?: NodeId
storeInstanceId?: NodeId
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 */
@@ -336,9 +339,13 @@ function safeWidgetMapper(
return {
nodeId,
storeNodeId: nodeId,
storeInstanceId: isPromotedWidgetView(widget)
? String(node.id)
: undefined,
name,
storeName,
type: effectiveWidget.type,
value: normalizeWidgetValue(widget.value),
...sharedEnhancements,
callback,
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',

View File

@@ -0,0 +1,10 @@
/**
* 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

@@ -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')
@@ -1654,7 +1730,7 @@ describe('SubgraphNode.widgets getter', () => {
const clonedSerialized = clonedNode.serialize()
expect(clonedSerialized.properties?.proxyWidgets).toStrictEqual([
[String(innerNode.id), 'widgetA']
[String(innerNode.id), 'widgetA', null, { value: 'edited' }]
])
const hydratedClone = createTestSubgraphNode(subgraphNode.subgraph, {
@@ -2022,14 +2098,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 +2194,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 +2241,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 +2378,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'
@@ -24,6 +21,7 @@ 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'
@@ -36,6 +34,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
@@ -53,43 +57,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 +165,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 +377,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 +531,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 inlines promoted widget values into proxyWidgets entries', () => {
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).toBeUndefined()
expect(serialized.properties?.proxyWidgets).toStrictEqual([
[innerIds[0], 'stringWidget', null, { value: 'edited' }]
])
})
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

@@ -36,6 +36,13 @@ 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('returns empty array for non-array input', () => {
expect(parseProxyWidgets(undefined)).toEqual([])
expect(parseProxyWidgets('not-json{')).toEqual([])
@@ -44,5 +51,8 @@ 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(
[]
)
})
})

View File

@@ -3,12 +3,34 @@ 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([
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 +49,13 @@ 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.
*/
export function getProxyWidgetInlineState(
entry: ProxyWidgetEntry
): ProxyWidgetInlineState | undefined {
return entry.length === 4 ? entry[3] : undefined
}

View File

@@ -968,19 +968,8 @@ export class LGraphNode
if (this.properties) o.properties = LiteGraph.cloneObject(this.properties)
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)
}
}
const widgetsValues = this.getSerializableWidgetsValues()
if (widgetsValues) o.widgets_values = widgetsValues
if (!o.type && this.constructor.type) o.type = this.constructor.type
@@ -997,6 +986,26 @@ 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

@@ -1,6 +1,6 @@
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'
@@ -35,6 +35,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 +51,10 @@ 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
// 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,
@@ -59,8 +65,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [10]
properties: {
proxyWidgets: [[innerNodeId, 'widget', null, { value: 10 }]]
}
})
instance2.configure({
@@ -73,21 +80,77 @@ describe('SubgraphNode multi-instance widget isolation', () => {
mode: 0,
order: 1,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [20]
properties: {
proxyWidgets: [[innerNodeId, 'widget', null, { value: 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).toBeUndefined()
expect(serialized2.widgets_values).toBeUndefined()
expect(serialized1.properties?.proxyWidgets).toEqual([
[innerNodeId, 'widget', null, { value: 10 }]
])
expect(serialized2.properties?.proxyWidgets).toEqual([
[innerNodeId, 'widget', null, { value: 20 }]
])
})
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('round-trips per-instance widget values through serialize and configure', () => {
@@ -100,6 +163,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,8 +174,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
properties: {
proxyWidgets: [[innerNodeId, 'widget', null, { value: 33 }]]
}
})
const serialized = originalInstance.serialize()
@@ -156,7 +221,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 +231,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,8 +242,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
properties: {
proxyWidgets: [[innerNodeId, 'widget', null, { value: 33 }]]
}
})
const serialized = originalInstance.serialize()
@@ -193,13 +260,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 +277,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 +285,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 +323,7 @@ describe('SubgraphNode multi-instance widget isolation', () => {
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
properties: { proxyWidgets: [['-1', 'value']] },
widgets_values: []
})
@@ -259,48 +331,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 +369,74 @@ 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).toBeUndefined()
expect(serialized.properties?.proxyWidgets).toEqual([
[String(node.id), 'widget', null, { value: LEGACY_VALUE }]
])
})
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] Legacy widgets_values length (2) does not match proxyWidgets length (1); dropping legacy values for instance 803.'
)
})
it('rejects uncloneable promoted widget values', () => {
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 uncloneable = { fn: () => 'nope' }
const promotedWidget = instance.widgets![0]
expect(() => {
promotedWidget.value = uncloneable as unknown as typeof widget.value
}).toThrow()
})
})

View File

@@ -33,6 +33,7 @@ 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'
@@ -43,12 +44,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'
@@ -115,6 +120,7 @@ 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,
@@ -644,39 +650,93 @@ 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[]
): (string[] | [string, string, string])[] {
return entries.map((e) =>
e.disambiguatingSourceNodeId
? [e.sourceNodeId, e.sourceWidgetName, e.disambiguatingSourceNodeId]
: [e.sourceNodeId, e.sourceWidgetName]
)
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 }
]
})
}
private _resolveLegacyEntry(
widgetName: string
): [string, string] | undefined {
): PromotedWidgetSource | 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
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
// 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)
}
const widget = input._widget
if (isPromotedWidgetView(widget)) {
return [widget.sourceNodeId, widget.sourceWidgetName]
return {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName,
...(widget.disambiguatingSourceNodeId && {
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
})
}
}
// Fallback: find via subgraph input slot connection
return this._fromResolvedTarget(widgetName)
}
private _fromResolvedTarget(
widgetName: string
): PromotedWidgetSource | undefined {
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName,
...(resolvedTarget.sourceNodeId && {
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
})
}
}
/** Manages lifecycle of all subgraph event listeners */
@@ -992,20 +1052,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/** Temporarily stored during configure for use by _internalConfigureAfterSlots */
private _pendingWidgetsValues?: unknown[]
/**
* Per-instance promoted widget values.
* Multiple SubgraphNode instances share the same inner nodes, so
* promoted widget values must be stored per-instance to avoid collisions.
* Key: `${sourceNodeId}:${sourceWidgetName}`
*/
readonly _instanceWidgetValues = new Map<string, unknown>()
override configure(info: ExportedSubgraphInstance): void {
this._instanceWidgetValues.clear()
this._pendingWidgetsValues = info.widgets_values
useWidgetValueStore().clearInstanceWidgets(this.rootGraph.id, this.id)
this._pendingLegacyWidgetsValues = Array.isArray(info.widgets_values)
? info.widgets_values
: undefined
for (const input of this.inputs) {
if (
@@ -1055,7 +1106,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
)
super.configure(info)
try {
super.configure(info)
} finally {
this._pendingLegacyWidgetsValues = undefined
}
}
override _internalConfigureAfterSlots() {
@@ -1074,38 +1129,93 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this._promotedViewManager.clear()
this._invalidatePromotedViewsCache()
// Hydrate the store from serialized properties.proxyWidgets
/**
* 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.
*/
const raw = parseProxyWidgets(this.properties.proxyWidgets)
const store = usePromotionStore()
const pendingValues = new Map<string, unknown>()
const canHydrateLegacyWidgetsValues =
this._pendingLegacyWidgetsValues?.length === raw.length
if (this._pendingLegacyWidgetsValues && !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}.`
)
}
}
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 = legacy
} 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 state = getProxyWidgetInlineState(rawEntry)
if (state) {
pendingValues.set(makePromotionEntryKey(resolved), state.value)
} else if (canHydrateLegacyWidgetsValues) {
const value = this._pendingLegacyWidgetsValues?.[index]
if (value != null) {
pendingValues.set(makePromotionEntryKey(resolved), value)
}
}
}
return resolved
})
.filter((e): e is NonNullable<typeof e> => e !== null)
store.setPromotions(this.rootGraph.id, this.id, entries)
// Write back resolved entries so legacy or stale entries don't persist
const serialized = this._serializeEntries(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)
)
if (JSON.stringify(serialized) !== JSON.stringify(raw)) {
this.properties.proxyWidgets = serialized
}
@@ -1138,19 +1248,22 @@ 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) {
/**
* 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
if (i >= this._pendingWidgetsValues.length) break
view.value = this._pendingWidgetsValues[i++] as typeof view.value
const key = view.instanceKey
if (!pendingValues.has(key)) continue
view.value = cloneWidgetValue(
pendingValues.get(key)
) as typeof view.value
}
this._pendingWidgetsValues = undefined
}
}
@@ -1546,7 +1659,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)) {
@@ -1603,30 +1716,33 @@ 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
)
this.properties.proxyWidgets = this._serializeEntries(entries)
const serialized = super.serialize()
const views = this._getPromotedViews()
const serializableViews = views.filter((view) => view.sourceSerialize)
if (serializableViews.length > 0) {
serialized.widgets_values = serializableViews.map((view) => {
const value = view.serializeValue
? view.serializeValue(this, -1)
: view.value
return value != null && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: (value ?? null)
})
const viewByInstanceKey = new Map<string, PromotedWidgetView>()
for (const view of this._getPromotedViews()) {
viewByInstanceKey.set(view.instanceKey, view)
}
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 serialized
return super.serialize()
}
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

@@ -448,6 +448,65 @@ 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',
value: 1024
})
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).toBe(1024)
})
it('uses storeInstanceId to resolve and update scoped widget state', () => {
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID,
storeInstanceId: 'subgraph-1',
value: 1024
})
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,9 @@ export function computeProcessedWidgets({
const { slotMetadata } = widget
const value = widgetState?.value as WidgetValue
const value = (
widgetState ? widgetState.value : widget.value
) as WidgetValue
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled

View File

@@ -124,6 +124,77 @@ 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')
})
})
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,20 @@ 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)
}
}
function clearGraph(graphId: UUID): void {
@@ -84,6 +108,7 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
registerWidget,
getWidget,
getNodeWidgets,
clearInstanceWidgets,
clearGraph
}
})