mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 06:01:58 +00:00
Compare commits
3 Commits
glary/subs
...
glary/subg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9242ce7f3a | ||
|
|
16cbd8f4ff | ||
|
|
570da3b453 |
@@ -475,6 +475,7 @@ describe('Nested promoted widget mapping', () => {
|
||||
expect(mappedWidget?.storeNodeId).toBe(
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
)
|
||||
expect(mappedWidget?.storeInstanceId).toBe(String(subgraphNodeB.id))
|
||||
})
|
||||
|
||||
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
||||
@@ -587,6 +588,9 @@ describe('Nested promoted widget mapping', () => {
|
||||
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
|
||||
])
|
||||
)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeInstanceId))
|
||||
).toEqual(new Set([String(outerSubgraphNode.id)]))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface WidgetSlotMetadata {
|
||||
export interface SafeWidgetData {
|
||||
nodeId?: NodeId
|
||||
storeNodeId?: NodeId
|
||||
storeInstanceId?: NodeId
|
||||
name: string
|
||||
storeName?: string
|
||||
type: string
|
||||
@@ -97,6 +98,12 @@ export interface SafeWidgetData {
|
||||
tooltip?: string
|
||||
/** For promoted widgets, the display label from the subgraph input slot. */
|
||||
promotedLabel?: string
|
||||
/**
|
||||
* Read-only fallback value sourced from the underlying widget when no
|
||||
* scoped store entry exists yet. Render paths use this for first-paint of
|
||||
* promoted widgets that have never been edited.
|
||||
*/
|
||||
defaultValue?: unknown
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -336,6 +343,9 @@ function safeWidgetMapper(
|
||||
return {
|
||||
nodeId,
|
||||
storeNodeId: nodeId,
|
||||
storeInstanceId: isPromotedWidgetView(widget)
|
||||
? String(node.id)
|
||||
: undefined,
|
||||
name,
|
||||
storeName,
|
||||
type: effectiveWidget.type,
|
||||
@@ -358,7 +368,10 @@ function safeWidgetMapper(
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
tooltip: widget.tooltip,
|
||||
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
|
||||
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined,
|
||||
defaultValue: isPromotedWidgetView(widget)
|
||||
? (sourceWidget?.value ?? widget.value)
|
||||
: undefined
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -280,8 +280,17 @@ describe(createPromotedWidgetView, () => {
|
||||
view.value = 'updated'
|
||||
expect(view.value).toBe('updated')
|
||||
|
||||
// The interior widget reads from the same store
|
||||
expect(innerNode.widgets![0].value).toBe('updated')
|
||||
// The promoted view owns its runtime value in an instance-scoped store
|
||||
// entry; the shared interior widget value stays untouched.
|
||||
expect(innerNode.widgets![0].value).toBe('initial')
|
||||
expect(
|
||||
useWidgetValueStore().getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(innerNode.id),
|
||||
'myWidget',
|
||||
subgraphNode.id
|
||||
)?.value
|
||||
).toBe('updated')
|
||||
})
|
||||
|
||||
test('value falls back to interior widget when store entry is missing', () => {
|
||||
@@ -296,9 +305,6 @@ describe(createPromotedWidgetView, () => {
|
||||
const fallbackWidget = fromPartial<IBaseWidget>(fallbackWidgetShape)
|
||||
innerNode.widgets = [fallbackWidget]
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
@@ -307,10 +313,39 @@ describe(createPromotedWidgetView, () => {
|
||||
|
||||
expect(view.value).toBe('initial')
|
||||
view.value = 'updated'
|
||||
expect(fallbackWidget.value).toBe('updated')
|
||||
expect(view.value).toBe('updated')
|
||||
expect(fallbackWidget.value).toBe('initial')
|
||||
})
|
||||
|
||||
test('value setter falls back to host widget when linked states are unavailable', () => {
|
||||
test('value prefers source widget over stale unscoped store fallback', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const fallbackWidget = fromPartial<IBaseWidget>({
|
||||
name: 'myWidget',
|
||||
type: 'text',
|
||||
value: 'source-value',
|
||||
options: {}
|
||||
})
|
||||
innerNode.widgets = [fallbackWidget]
|
||||
|
||||
useWidgetValueStore().registerWidget(subgraphNode.rootGraph.id, {
|
||||
nodeId: String(innerNode.id),
|
||||
name: 'myWidget',
|
||||
type: 'text',
|
||||
value: 'stale-shared-value',
|
||||
options: {}
|
||||
})
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'myWidget'
|
||||
)
|
||||
|
||||
expect(view.value).toBe('source-value')
|
||||
})
|
||||
|
||||
test('value setter creates scoped state when linked states are unavailable', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
@@ -327,12 +362,18 @@ describe(createPromotedWidgetView, () => {
|
||||
const linkedView = promotedWidgets(subgraphNode)[0]
|
||||
if (!linkedView) throw new Error('Expected a linked promoted widget')
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
||||
|
||||
linkedView.value = 'updated'
|
||||
|
||||
expect(linkedNode.widgets?.[0].value).toBe('updated')
|
||||
expect(linkedView.value).toBe('updated')
|
||||
expect(linkedNode.widgets?.[0].value).toBe('initial')
|
||||
expect(
|
||||
useWidgetValueStore().getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(linkedNode.id),
|
||||
'string_a',
|
||||
subgraphNode.id
|
||||
)?.value
|
||||
).toBe('updated')
|
||||
})
|
||||
|
||||
test('label falls back to displayName then widgetName', () => {
|
||||
@@ -523,9 +564,6 @@ describe(createPromotedWidgetView, () => {
|
||||
} as unknown as IBaseWidget
|
||||
innerNode.widgets = [fallbackWidget]
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
@@ -534,7 +572,8 @@ describe(createPromotedWidgetView, () => {
|
||||
|
||||
const objValue = { key: 'data' }
|
||||
view.value = objValue
|
||||
expect(fallbackWidget.value).toBe(objValue)
|
||||
expect(view.value).toEqual(objValue)
|
||||
expect(fallbackWidget.value).toBe('old')
|
||||
})
|
||||
|
||||
test('onPointerDown returns true when interior widget onPointerDown handles it', () => {
|
||||
@@ -876,17 +915,34 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
|
||||
linkedView.value = 'shared-value'
|
||||
|
||||
// Both linked nodes share the same SubgraphInput slot, so the value
|
||||
// propagates to all connected widgets via getLinkedInputWidgets().
|
||||
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
|
||||
// Both linked nodes share the same SubgraphInput slot, so the scoped store
|
||||
// records the same per-instance value for every connected source widget.
|
||||
const widgetStore = useWidgetValueStore()
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(linkedNodeA.id),
|
||||
'string_a',
|
||||
subgraphNode.id
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(linkedNodeB.id),
|
||||
'string_a',
|
||||
subgraphNode.id
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(linkedNodeA.widgets?.[0]?.value).toBe('a')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('b')
|
||||
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
|
||||
|
||||
promotedView.value = 'independent-updated'
|
||||
|
||||
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(promotedNode.widgets?.[0]?.value).toBe('independent-updated')
|
||||
expect(linkedView.value).toBe('shared-value')
|
||||
expect(promotedView.value).toBe('independent-updated')
|
||||
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
|
||||
})
|
||||
|
||||
test('duplicate-name promoted views map slot linkage by view identity', () => {
|
||||
@@ -1223,13 +1279,28 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
firstView.value = 'first-updated'
|
||||
secondView.value = 'second-updated'
|
||||
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
const widgetStore = useWidgetValueStore()
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(firstNode.id),
|
||||
'seed',
|
||||
subgraphNode.id
|
||||
)?.value
|
||||
).toBe('first-updated')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(secondNode.id),
|
||||
'seed',
|
||||
subgraphNode.id
|
||||
)?.value
|
||||
).toBe('second-updated')
|
||||
|
||||
subgraphNode.serialize()
|
||||
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
expect(firstView.value).toBe('first-updated')
|
||||
expect(secondView.value).toBe('second-updated')
|
||||
})
|
||||
|
||||
test('renaming an input updates linked promoted view display names', () => {
|
||||
@@ -1550,8 +1621,12 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const getValue = (nodeId: string) =>
|
||||
widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a')
|
||||
?.value
|
||||
widgetStore.getWidget(
|
||||
graph.id,
|
||||
stripGraphPrefix(nodeId),
|
||||
'string_a',
|
||||
hostNode.id
|
||||
)?.value
|
||||
|
||||
expect(getValue('20')).toBe('shared-linked')
|
||||
expect(getValue('18')).toBe('shared-linked')
|
||||
@@ -1640,6 +1715,7 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
innerNode.addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [[String(innerNode.id), 'widgetA']])
|
||||
subgraphNode.widgets[0].value = 'edited'
|
||||
|
||||
const createNodeSpy = vi
|
||||
.spyOn(LiteGraph, 'createNode')
|
||||
@@ -1656,6 +1732,7 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
expect(clonedSerialized.properties?.proxyWidgets).toStrictEqual([
|
||||
[String(innerNode.id), 'widgetA']
|
||||
])
|
||||
expect(clonedSerialized.widgets_values).toStrictEqual(['edited'])
|
||||
|
||||
const hydratedClone = createTestSubgraphNode(subgraphNode.subgraph, {
|
||||
id: 100
|
||||
@@ -1930,6 +2007,40 @@ describe('promote/demote cycle', () => {
|
||||
(subgraphNode.widgets[0] as PromotedWidgetView).sourceWidgetName
|
||||
).toBe('widgetA')
|
||||
})
|
||||
|
||||
test('re-promote does not reuse stale scoped value from a demoted view', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'source-default', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
const firstView = subgraphNode.widgets[0] as PromotedWidgetView
|
||||
firstView.value = 'edited-scoped-value'
|
||||
|
||||
expect(firstView.value).toBe('edited-scoped-value')
|
||||
expect(
|
||||
useWidgetValueStore().getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
innerIds[0],
|
||||
'widgetA',
|
||||
subgraphNode.id
|
||||
)?.value
|
||||
).toBe('edited-scoped-value')
|
||||
|
||||
subgraphNode.removeWidget(firstView)
|
||||
expect(
|
||||
useWidgetValueStore().getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
innerIds[0],
|
||||
'widgetA',
|
||||
subgraphNode.id
|
||||
)
|
||||
).toBeUndefined()
|
||||
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
const secondView = subgraphNode.widgets[0] as PromotedWidgetView
|
||||
|
||||
expect(secondView.value).toBe('source-default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('disconnected state', () => {
|
||||
@@ -2022,14 +2133,23 @@ describe('three-level nested value propagation', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('value set at outermost level propagates to concrete widget', () => {
|
||||
test('value set at outermost level writes scoped store state', () => {
|
||||
const { concreteNode, subgraphNodeA } = createThreeLevelNestedSubgraph()
|
||||
|
||||
expect(subgraphNodeA.widgets).toHaveLength(1)
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(100)
|
||||
|
||||
subgraphNodeA.widgets[0].value = 200
|
||||
expect(concreteNode.widgets![0].value).toBe(200)
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(200)
|
||||
expect(concreteNode.widgets![0].value).toBe(100)
|
||||
expect(
|
||||
useWidgetValueStore().getWidget(
|
||||
subgraphNodeA.rootGraph.id,
|
||||
String(concreteNode.id),
|
||||
'c_input',
|
||||
subgraphNodeA.id
|
||||
)?.value
|
||||
).toBe(200)
|
||||
})
|
||||
|
||||
test('type resolves correctly through all three layers', () => {
|
||||
@@ -2109,7 +2229,7 @@ describe('three-level nested value propagation', () => {
|
||||
widgets[1].value = 'updated-second'
|
||||
|
||||
expect(firstTextNode.widgets?.[0]?.value).toBe('11111111111')
|
||||
expect(secondTextNode.widgets?.[0]?.value).toBe('updated-second')
|
||||
expect(secondTextNode.widgets?.[0]?.value).toBe('22222222222')
|
||||
expect(widgets[0].value).toBe('11111111111')
|
||||
expect(widgets[1].value).toBe('updated-second')
|
||||
})
|
||||
@@ -2156,11 +2276,20 @@ describe('multi-link representative determinism for input-based promotion', () =
|
||||
// Read returns the first link's value
|
||||
expect(widgets[0].value).toBe('first-val')
|
||||
|
||||
// Write propagates to all linked nodes
|
||||
// Write scopes the same runtime value to every linked source node.
|
||||
widgets[0].value = 'updated'
|
||||
expect(firstNode.widgets![0].value).toBe('updated')
|
||||
expect(secondNode.widgets![0].value).toBe('updated')
|
||||
expect(thirdNode.widgets![0].value).toBe('updated')
|
||||
const widgetStore = useWidgetValueStore()
|
||||
for (const node of [firstNode, secondNode, thirdNode]) {
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(node.id),
|
||||
'shared',
|
||||
subgraphNode.id
|
||||
)?.value
|
||||
).toBe('updated')
|
||||
expect(node.widgets![0].value).not.toBe('updated')
|
||||
}
|
||||
|
||||
// Repeated reads are still deterministic
|
||||
expect(widgets[0].value).toBe('updated')
|
||||
@@ -2284,7 +2413,8 @@ describe('promoted combo rendering', () => {
|
||||
|
||||
expect(promotedWidget.value).toBe('a')
|
||||
promotedWidget.value = 'b'
|
||||
expect(comboWidget.value).toBe('b')
|
||||
expect(comboWidget.value).toBe('a')
|
||||
expect(promotedWidget.value).toBe('b')
|
||||
|
||||
const fillText = vi.fn()
|
||||
const ctx = createInspectableCanvasContext(fillText)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { isEqual } from 'es-toolkit'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
@@ -36,6 +33,12 @@ interface SubgraphSlotRef {
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
interface WidgetStoreRef {
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
|
||||
if (value === undefined) return true
|
||||
if (typeof value === 'string') return true
|
||||
@@ -44,6 +47,12 @@ function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
function cloneWidgetValue(value: IBaseWidget['value']): IBaseWidget['value'] {
|
||||
return value != null && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: value
|
||||
}
|
||||
|
||||
type LegacyMouseWidget = IBaseWidget & {
|
||||
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
|
||||
}
|
||||
@@ -53,43 +62,6 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
|
||||
}
|
||||
|
||||
const designTokenCache = new Map<string, string>()
|
||||
const promotedSourceWriteMetaByGraph = new WeakMap<
|
||||
LGraph,
|
||||
Map<string, PromotedSourceWriteMeta>
|
||||
>()
|
||||
|
||||
interface PromotedSourceWriteMeta {
|
||||
value: IBaseWidget['value']
|
||||
writerInstanceId: string
|
||||
}
|
||||
|
||||
function cloneWidgetValue<TValue extends IBaseWidget['value']>(
|
||||
value: TValue
|
||||
): TValue {
|
||||
return value != null && typeof value === 'object'
|
||||
? (JSON.parse(JSON.stringify(value)) as TValue)
|
||||
: value
|
||||
}
|
||||
|
||||
function getPromotedSourceWriteMeta(
|
||||
graph: LGraph,
|
||||
sourceKey: string
|
||||
): PromotedSourceWriteMeta | undefined {
|
||||
return promotedSourceWriteMetaByGraph.get(graph)?.get(sourceKey)
|
||||
}
|
||||
|
||||
function setPromotedSourceWriteMeta(
|
||||
graph: LGraph,
|
||||
sourceKey: string,
|
||||
meta: PromotedSourceWriteMeta
|
||||
): void {
|
||||
let metaBySource = promotedSourceWriteMetaByGraph.get(graph)
|
||||
if (!metaBySource) {
|
||||
metaBySource = new Map<string, PromotedSourceWriteMeta>()
|
||||
promotedSourceWriteMetaByGraph.set(graph, metaBySource)
|
||||
}
|
||||
metaBySource.set(sourceKey, meta)
|
||||
}
|
||||
|
||||
export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
@@ -198,94 +170,31 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return this.resolveDeepest()?.widget.linkedWidgets
|
||||
}
|
||||
|
||||
private get _instanceKey(): string {
|
||||
get instanceKey(): string {
|
||||
return this.disambiguatingSourceNodeId
|
||||
? `${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
|
||||
: `${this.sourceNodeId}:${this.sourceWidgetName}`
|
||||
}
|
||||
|
||||
private get _sharedSourceKey(): string {
|
||||
return this.disambiguatingSourceNodeId
|
||||
? `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
|
||||
: `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}`
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
return this.getTrackedValue()
|
||||
return this.getStoreBackedValue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution-time serialization — returns the per-instance value stored
|
||||
* during configure, falling back to the regular value getter.
|
||||
*
|
||||
* The widget state store is shared across instances (keyed by inner node
|
||||
* ID), so the regular getter returns the last-configured value for all
|
||||
* instances. graphToPrompt already prefers serializeValue over .value,
|
||||
* so this is the hook that makes multi-instance execution correct.
|
||||
* Execution-time serialization follows the runtime getter: scoped promoted
|
||||
* state first, then source/legacy fallbacks for unedited workflows.
|
||||
*/
|
||||
serializeValue(): IBaseWidget['value'] {
|
||||
return this.getTrackedValue()
|
||||
return this.getStoreBackedValue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes only the scoped per-instance promoted-widget value. This no longer
|
||||
* mutates the source widget's `.value`; extensions that need instance state
|
||||
* should read the promoted widget or `widgetValueStore`.
|
||||
*/
|
||||
set value(value: IBaseWidget['value']) {
|
||||
this.captureSiblingFallbackValues()
|
||||
|
||||
// Keep per-instance map in sync for execution (graphToPrompt)
|
||||
this.subgraphNode._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(value)
|
||||
)
|
||||
setPromotedSourceWriteMeta(
|
||||
this.subgraphNode.rootGraph,
|
||||
this._sharedSourceKey,
|
||||
{
|
||||
value: cloneWidgetValue(value),
|
||||
writerInstanceId: String(this.subgraphNode.id)
|
||||
}
|
||||
)
|
||||
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
let didUpdateState = false
|
||||
for (const linkedWidget of linkedWidgets) {
|
||||
const state = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
linkedWidget.nodeId,
|
||||
linkedWidget.widgetName
|
||||
)
|
||||
if (state) {
|
||||
state.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (resolved) {
|
||||
const resolvedState = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
if (resolvedState) {
|
||||
resolvedState.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
|
||||
if (didUpdateState) return
|
||||
}
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = this.resolveAtHost()
|
||||
if (resolved && isWidgetValue(value)) {
|
||||
resolved.widget.value = value
|
||||
}
|
||||
this.writeScopedValue(value)
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
@@ -473,57 +382,122 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return resolved
|
||||
}
|
||||
|
||||
private getTrackedValue(): IBaseWidget['value'] {
|
||||
const instanceValue = this.subgraphNode._instanceWidgetValues.get(
|
||||
this._instanceKey
|
||||
)
|
||||
const sharedValue = this.getSharedValue()
|
||||
|
||||
if (instanceValue === undefined) return sharedValue
|
||||
|
||||
const sourceWriteMeta = getPromotedSourceWriteMeta(
|
||||
this.subgraphNode.rootGraph,
|
||||
this._sharedSourceKey
|
||||
)
|
||||
if (
|
||||
sharedValue !== undefined &&
|
||||
sourceWriteMeta &&
|
||||
!isEqual(sharedValue, sourceWriteMeta.value)
|
||||
) {
|
||||
this.subgraphNode._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(sharedValue)
|
||||
)
|
||||
return sharedValue
|
||||
}
|
||||
|
||||
return instanceValue as IBaseWidget['value']
|
||||
getScopedStoreValue(): IBaseWidget['value'] | undefined {
|
||||
const state = this.getScopedWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return undefined
|
||||
}
|
||||
|
||||
private getSharedValue(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
private getStoreBackedValue(): IBaseWidget['value'] {
|
||||
const scopedState = this.getScopedWidgetState()
|
||||
if (scopedState) {
|
||||
return isWidgetValue(scopedState.value) ? scopedState.value : undefined
|
||||
}
|
||||
|
||||
const resolved = this.resolveAtHost()
|
||||
if (resolved) {
|
||||
return isWidgetValue(resolved.widget.value)
|
||||
? resolved.widget.value
|
||||
: undefined
|
||||
}
|
||||
|
||||
const state = this.getLegacyWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
return undefined
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) return undefined
|
||||
return useWidgetValueStore().getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
return this.getScopedWidgetState() ?? this.getLegacyWidgetState()
|
||||
}
|
||||
|
||||
private getLinkedInputWidgets(): Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
widget: IBaseWidget
|
||||
}> {
|
||||
private getScopedWidgetState(): WidgetState | undefined {
|
||||
return this.getWidgetStateForRefs(this.getStoreRefs(), this.subgraphNode.id)
|
||||
}
|
||||
|
||||
private getLegacyWidgetState(): WidgetState | undefined {
|
||||
return this.getWidgetStateForRefs(this.getStoreRefs())
|
||||
}
|
||||
|
||||
private getWidgetStateForRefs(
|
||||
refs: WidgetStoreRef[],
|
||||
instanceId?: NodeId
|
||||
): WidgetState | undefined {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
for (const { nodeId, widgetName } of refs) {
|
||||
const state = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
nodeId,
|
||||
widgetName,
|
||||
instanceId
|
||||
)
|
||||
if (state) return state
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private getStoreRefs(): WidgetStoreRef[] {
|
||||
const refs = this.getLinkedInputWidgets()
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (resolved) {
|
||||
refs.push({
|
||||
nodeId: stripGraphPrefix(String(resolved.node.id)),
|
||||
widgetName: resolved.widget.name,
|
||||
widget: resolved.widget
|
||||
})
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
return refs.filter(({ nodeId, widgetName }) => {
|
||||
const key = `${nodeId}:${widgetName}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
private writeScopedValue(value: IBaseWidget['value']): void {
|
||||
const refs = this.getStoreRefs()
|
||||
if (refs.length === 0) return
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
for (const ref of refs) {
|
||||
const state = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
ref.nodeId,
|
||||
ref.widgetName,
|
||||
this.subgraphNode.id
|
||||
)
|
||||
const clonedValue = cloneWidgetValue(value)
|
||||
if (state) {
|
||||
state.value = clonedValue
|
||||
continue
|
||||
}
|
||||
|
||||
const legacyState = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
ref.nodeId,
|
||||
ref.widgetName
|
||||
)
|
||||
widgetStore.registerWidget(
|
||||
this.graphId,
|
||||
{
|
||||
nodeId: ref.nodeId,
|
||||
name: ref.widgetName,
|
||||
type: legacyState?.type ?? ref.widget.type,
|
||||
value: clonedValue,
|
||||
options: legacyState?.options ?? ref.widget.options,
|
||||
label: legacyState?.label ?? ref.widget.label,
|
||||
serialize: legacyState?.serialize ?? ref.widget.serialize,
|
||||
disabled: legacyState?.disabled ?? ref.widget.disabled
|
||||
},
|
||||
this.subgraphNode.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private getLinkedInputWidgets(): WidgetStoreRef[] {
|
||||
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
|
||||
if (!input._subgraphSlot) return false
|
||||
if (matchPromotedInput([input], this) !== input) return false
|
||||
@@ -562,40 +536,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}))
|
||||
}
|
||||
|
||||
private getLinkedInputWidgetStates(): WidgetState[] {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
return this.getLinkedInputWidgets()
|
||||
.map(({ nodeId, widgetName }) =>
|
||||
widgetStore.getWidget(this.graphId, nodeId, widgetName)
|
||||
)
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private captureSiblingFallbackValues(): void {
|
||||
const { rootGraph } = this.subgraphNode
|
||||
|
||||
for (const node of rootGraph.nodes) {
|
||||
if (node === this.subgraphNode || !node.isSubgraphNode()) continue
|
||||
if (node.subgraph.id !== this.subgraphNode.subgraph.id) continue
|
||||
if (node._instanceWidgetValues.has(this._instanceKey)) continue
|
||||
|
||||
const siblingView = node.widgets.find(
|
||||
(widget): widget is IPromotedWidgetView =>
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId === this.sourceNodeId &&
|
||||
widget.sourceWidgetName === this.sourceWidgetName &&
|
||||
widget.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId
|
||||
)
|
||||
if (!siblingView) continue
|
||||
|
||||
node._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(siblingView.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
@@ -103,7 +104,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(subgraphNode.widgets[1].name).toBe('widgetA')
|
||||
})
|
||||
test('Will mirror changes to value', () => {
|
||||
test('reads source value until promoted view writes scoped state', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
@@ -116,7 +117,16 @@ describe('Subgraph proxyWidgets', () => {
|
||||
innerNodes[0].widgets![0].value = 'test'
|
||||
expect(subgraphNode.widgets[0].value).toBe('test')
|
||||
subgraphNode.widgets[0].value = 'test2'
|
||||
expect(innerNodes[0].widgets![0].value).toBe('test2')
|
||||
expect(subgraphNode.widgets[0].value).toBe('test2')
|
||||
expect(innerNodes[0].widgets![0].value).toBe('test')
|
||||
expect(
|
||||
useWidgetValueStore().getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
innerIds[0],
|
||||
'stringWidget',
|
||||
subgraphNode.id
|
||||
)?.value
|
||||
).toBe('test2')
|
||||
})
|
||||
test('Will not modify position or sizing of existing widgets', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
@@ -253,7 +263,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('serialize stores widgets_values for promoted views', () => {
|
||||
test('serialize writes positional widgets_values for edited promoted widgets', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
@@ -262,13 +272,17 @@ describe('Subgraph proxyWidgets', () => {
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
subgraphNode.widgets[0].value = 'edited'
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
expect(serialized.widgets_values).toEqual(['value'])
|
||||
expect(serialized.widgets_values).toStrictEqual(['edited'])
|
||||
expect(serialized.properties?.proxyWidgets).toStrictEqual([
|
||||
[innerIds[0], 'stringWidget']
|
||||
])
|
||||
})
|
||||
|
||||
test('serialize preserves proxyWidgets in properties', () => {
|
||||
test('serialize preserves identity-only proxyWidgets in properties', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
@@ -369,9 +383,18 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNodeA.widgets[0].type).toBe('number')
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(42)
|
||||
|
||||
// Setting value at outermost level propagates to concrete widget
|
||||
// Setting value at outermost level writes the instance-scoped store slot.
|
||||
subgraphNodeA.widgets[0].value = 99
|
||||
expect(concreteNode.widgets![0].value).toBe(99)
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(99)
|
||||
expect(concreteNode.widgets![0].value).toBe(42)
|
||||
expect(
|
||||
useWidgetValueStore().getWidget(
|
||||
subgraphNodeA.rootGraph.id,
|
||||
String(concreteNode.id),
|
||||
'deep_input',
|
||||
subgraphNodeA.id
|
||||
)?.value
|
||||
).toBe(99)
|
||||
})
|
||||
|
||||
test('removeWidget cleans up promotion and input, then re-promote works', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseProxyWidgets } from './promotionSchema'
|
||||
import { getProxyWidgetInlineState, parseProxyWidgets } from './promotionSchema'
|
||||
|
||||
describe(parseProxyWidgets, () => {
|
||||
it('parses 2-tuple arrays', () => {
|
||||
@@ -36,6 +36,14 @@ describe(parseProxyWidgets, () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('parses legacy 4-tuple arrays', () => {
|
||||
const input = [
|
||||
['3', 'text', '1', { value: 42 }],
|
||||
['9', 'seed', null, { value: 'abc' }]
|
||||
]
|
||||
expect(parseProxyWidgets(input)).toEqual(input)
|
||||
})
|
||||
|
||||
it('returns empty array for non-array input', () => {
|
||||
expect(parseProxyWidgets(undefined)).toEqual([])
|
||||
expect(parseProxyWidgets('not-json{')).toEqual([])
|
||||
@@ -45,4 +53,27 @@ describe(parseProxyWidgets, () => {
|
||||
expect(parseProxyWidgets([['only-one']])).toEqual([])
|
||||
expect(parseProxyWidgets([['a', 'b', 'c', 'd']])).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects legacy 4-tuple entries with undefined inline value', () => {
|
||||
expect(
|
||||
parseProxyWidgets([
|
||||
['3', 'text', null, { value: undefined }] as unknown as [
|
||||
string,
|
||||
string,
|
||||
null,
|
||||
{ value: undefined }
|
||||
]
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe(getProxyWidgetInlineState, () => {
|
||||
it('returns inline value for 4-tuples only', () => {
|
||||
expect(getProxyWidgetInlineState(['1', 'seed'])).toBeUndefined()
|
||||
expect(getProxyWidgetInlineState(['1', 'seed', '2'])).toBeUndefined()
|
||||
expect(
|
||||
getProxyWidgetInlineState(['1', 'seed', null, { value: 10 }])
|
||||
).toEqual({ value: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,12 +3,36 @@ import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
type DefinedProxyWidgetValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| bigint
|
||||
| symbol
|
||||
| object
|
||||
|
||||
const definedValueSchema = z.custom<DefinedProxyWidgetValue>(
|
||||
(value) => value !== undefined,
|
||||
'Inline proxy widget value cannot be undefined'
|
||||
)
|
||||
const proxyWidgetStateSchema = z.object({ value: definedValueSchema })
|
||||
const proxyWidgetTupleSchema = z.union([
|
||||
// 4-tuple is read-only migration shim (legacy PR #11559 workflows).
|
||||
// Writer never emits this shape.
|
||||
z.tuple([
|
||||
z.string(),
|
||||
z.string(),
|
||||
z.union([z.string(), z.null()]),
|
||||
proxyWidgetStateSchema
|
||||
]),
|
||||
z.tuple([z.string(), z.string(), z.string()]),
|
||||
z.tuple([z.string(), z.string()])
|
||||
])
|
||||
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
|
||||
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
type ProxyWidgetEntry = ProxyWidgetsProperty[number]
|
||||
type ProxyWidgetInlineState = z.infer<typeof proxyWidgetStateSchema>
|
||||
|
||||
export function parseProxyWidgets(
|
||||
property: NodeProperty | undefined
|
||||
@@ -27,3 +51,10 @@ export function parseProxyWidgets(
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** Returns the optional inline {value} state from a legacy PR #11559 4-tuple entry. */
|
||||
export function getProxyWidgetInlineState(
|
||||
entry: ProxyWidgetEntry
|
||||
): ProxyWidgetInlineState | undefined {
|
||||
return entry.length === 4 ? entry[3] : undefined
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
@@ -35,6 +36,10 @@ beforeEach(() => {
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
it('preserves per-instance widget values after configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
@@ -47,8 +52,8 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
|
||||
const innerNodeId = String(node.id)
|
||||
|
||||
// Simulate what LGraph.configure does: call configure with different widgets_values
|
||||
instance1.configure({
|
||||
id: 201,
|
||||
type: subgraph.id,
|
||||
@@ -59,7 +64,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
properties: {
|
||||
proxyWidgets: [[innerNodeId, 'widget']]
|
||||
},
|
||||
widgets_values: [10]
|
||||
})
|
||||
|
||||
@@ -73,21 +80,138 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
mode: 0,
|
||||
order: 1,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
properties: {
|
||||
proxyWidgets: [[innerNodeId, 'widget']]
|
||||
},
|
||||
widgets_values: [20]
|
||||
})
|
||||
|
||||
const widgets1 = instance1.widgets!
|
||||
const widgets2 = instance2.widgets!
|
||||
|
||||
expect(widgets1.length).toBeGreaterThan(0)
|
||||
expect(widgets2.length).toBeGreaterThan(0)
|
||||
expect(widgets1).toHaveLength(1)
|
||||
expect(widgets2).toHaveLength(1)
|
||||
expect(widgets1[0].value).toBe(10)
|
||||
expect(widgets2[0].value).toBe(20)
|
||||
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
|
||||
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
|
||||
expect(instance1.serialize().widgets_values).toEqual([10])
|
||||
expect(instance2.serialize().widgets_values).toEqual([20])
|
||||
|
||||
const serialized1 = instance1.serialize()
|
||||
const serialized2 = instance2.serialize()
|
||||
expect(serialized1.widgets_values).toEqual([10])
|
||||
expect(serialized2.widgets_values).toEqual([20])
|
||||
expect(serialized1.properties?.proxyWidgets).toEqual([
|
||||
[innerNodeId, 'widget']
|
||||
])
|
||||
expect(serialized2.properties?.proxyWidgets).toEqual([
|
||||
[innerNodeId, 'widget']
|
||||
])
|
||||
})
|
||||
|
||||
it('migrates legacy widgets_values per instance without sharing sibling state', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 203 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 204 })
|
||||
|
||||
instance1.configure({
|
||||
id: 203,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'value']] },
|
||||
widgets_values: [10]
|
||||
})
|
||||
|
||||
instance2.configure({
|
||||
id: 204,
|
||||
type: subgraph.id,
|
||||
pos: [400, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 1,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'value']] },
|
||||
widgets_values: [20]
|
||||
})
|
||||
|
||||
expect(instance1.widgets?.[0].value).toBe(10)
|
||||
expect(instance2.widgets?.[0].value).toBe(20)
|
||||
expect(instance1.widgets?.[0].serializeValue?.(instance1, 0)).toBe(10)
|
||||
expect(instance2.widgets?.[0].serializeValue?.(instance2, 0)).toBe(20)
|
||||
})
|
||||
|
||||
it('clears stale scoped entries keyed by info.id during configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 700 })
|
||||
const staleInstanceId = '701'
|
||||
const innerNodeId = String(node.id)
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(
|
||||
instance.rootGraph.id,
|
||||
{
|
||||
nodeId: innerNodeId,
|
||||
name: 'widget',
|
||||
type: 'number',
|
||||
value: 999,
|
||||
options: {}
|
||||
},
|
||||
staleInstanceId
|
||||
)
|
||||
|
||||
expect(
|
||||
widgetValueStore.getWidget(
|
||||
instance.rootGraph.id,
|
||||
innerNodeId,
|
||||
'widget',
|
||||
staleInstanceId
|
||||
)?.value
|
||||
).toBe(999)
|
||||
|
||||
instance.configure({
|
||||
id: Number(staleInstanceId),
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [[innerNodeId, 'widget']]
|
||||
},
|
||||
widgets_values: [10]
|
||||
})
|
||||
|
||||
expect(
|
||||
widgetValueStore.getWidget(
|
||||
instance.rootGraph.id,
|
||||
innerNodeId,
|
||||
'widget',
|
||||
staleInstanceId
|
||||
)?.value
|
||||
).toBe(10)
|
||||
})
|
||||
|
||||
it('round-trips per-instance widget values through serialize and configure', () => {
|
||||
@@ -100,6 +224,7 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
|
||||
const innerNodeId = String(node.id)
|
||||
originalInstance.configure({
|
||||
id: 301,
|
||||
type: subgraph.id,
|
||||
@@ -110,7 +235,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
properties: {
|
||||
proxyWidgets: [[innerNodeId, 'widget']]
|
||||
},
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
@@ -128,6 +255,72 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
|
||||
})
|
||||
|
||||
it('preserves source defaults for unedited promoted widgets when serializing mixed edits', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'value', type: 'number' },
|
||||
{ name: 'value_2', type: 'number' }
|
||||
]
|
||||
})
|
||||
|
||||
const SOURCE_DEFAULT = 42
|
||||
const EDITED_VALUE = 99
|
||||
|
||||
const { node: firstNode } = createNodeWithWidget(
|
||||
'FirstNode',
|
||||
SOURCE_DEFAULT
|
||||
)
|
||||
const { node: secondNode } = createNodeWithWidget(
|
||||
'SecondNode',
|
||||
SOURCE_DEFAULT
|
||||
)
|
||||
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
subgraph.inputNode.slots[0].connect(firstNode.inputs[0], firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondNode.inputs[0], secondNode)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 303 })
|
||||
const firstNodeId = String(firstNode.id)
|
||||
const secondNodeId = String(secondNode.id)
|
||||
|
||||
originalInstance.configure({
|
||||
id: 303,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [
|
||||
[firstNodeId, 'widget'],
|
||||
[secondNodeId, 'widget']
|
||||
]
|
||||
},
|
||||
widgets_values: [EDITED_VALUE, SOURCE_DEFAULT]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
expect(serialized.widgets_values).toEqual([EDITED_VALUE, SOURCE_DEFAULT])
|
||||
expect(serialized.widgets_values).not.toContain(null)
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 304 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 304,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
const restoredWidgets = restoredInstance.widgets
|
||||
expect(restoredWidgets).toHaveLength(2)
|
||||
expect(restoredWidgets?.[0].value).toBe(EDITED_VALUE)
|
||||
expect(restoredWidgets?.[1].value).toBe(SOURCE_DEFAULT)
|
||||
expect(restoredWidgets?.[1].value).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps fresh sibling instances isolated before save or reload', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
@@ -156,7 +349,7 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
expect(widget2?.serializeValue?.(instance2, 0)).toBe(7)
|
||||
})
|
||||
|
||||
it('syncs restored promoted widgets when the inner source widget changes directly', () => {
|
||||
it('keeps restored scoped value when the inner source widget changes directly', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
@@ -166,6 +359,7 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 601 })
|
||||
const innerNodeId = String(node.id)
|
||||
originalInstance.configure({
|
||||
id: 601,
|
||||
type: subgraph.id,
|
||||
@@ -176,7 +370,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
properties: {
|
||||
proxyWidgets: [[innerNodeId, 'widget']]
|
||||
},
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
@@ -193,13 +389,13 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
|
||||
widget.value = 45
|
||||
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(45)
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(33)
|
||||
expect(
|
||||
restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0)
|
||||
).toBe(45)
|
||||
).toBe(33)
|
||||
})
|
||||
|
||||
it('clears stale per-instance values when reconfigured without widgets_values', () => {
|
||||
it('clears stale scoped values when reconfigured without inline value state', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
@@ -210,6 +406,7 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 701 })
|
||||
instance.graph!.add(instance)
|
||||
const innerNodeId = String(node.id)
|
||||
|
||||
const promotedWidget = instance.widgets?.[0]
|
||||
promotedWidget!.value = 11
|
||||
@@ -217,6 +414,10 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
|
||||
const serialized = instance.serialize()
|
||||
delete serialized.widgets_values
|
||||
serialized.properties = {
|
||||
...serialized.properties,
|
||||
proxyWidgets: [[innerNodeId, 'widget']]
|
||||
}
|
||||
|
||||
instance.configure({
|
||||
...serialized,
|
||||
@@ -251,7 +452,7 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
properties: { proxyWidgets: [['-1', 'value']] },
|
||||
widgets_values: []
|
||||
})
|
||||
|
||||
@@ -259,48 +460,28 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
// it.fails pins the open #10849 SubgraphNode.configure regression on Main;
|
||||
// drop the marker once the inline-proxyWidgets-state fix lands.
|
||||
it.fails('falls back to source widget value when proxyWidgets is in legacy 2-tuple shape (regression for #10849)', () => {
|
||||
it('does not write widgets_values on SubgraphNode (fix for #10849 template corruption regression)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const SOURCE_DEFAULT = 42
|
||||
const LEGACY_NOISE = 999
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
|
||||
const { node } = createNodeWithWidget('TestNode', 42)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 801 })
|
||||
instance.configure({
|
||||
id: 801,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [LEGACY_NOISE]
|
||||
})
|
||||
instance.graph!.add(instance)
|
||||
|
||||
const widget = instance.widgets?.[0]
|
||||
expect(widget?.value).toBe(SOURCE_DEFAULT)
|
||||
expect(widget?.serializeValue?.(instance, 0)).toBe(SOURCE_DEFAULT)
|
||||
expect(instance.serialize().widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
it.fails('does not corrupt unbound promoted widgets when widgets_values length mismatches view count (regression for #10849)', () => {
|
||||
it('migrates aligned legacy widgets_values into scoped promoted state on load', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const SOURCE_DEFAULT = 42
|
||||
const LEGACY_NOISE_A = 111
|
||||
const LEGACY_NOISE_B = 222
|
||||
const LEGACY_VALUE = 999
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
|
||||
subgraph.add(node)
|
||||
@@ -317,12 +498,111 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
properties: { proxyWidgets: [['-1', 'value']] },
|
||||
widgets_values: [LEGACY_VALUE]
|
||||
})
|
||||
|
||||
const widget = instance.widgets?.[0]
|
||||
expect(widget?.value).toBe(LEGACY_VALUE)
|
||||
expect(widget?.serializeValue?.(instance, 0)).toBe(LEGACY_VALUE)
|
||||
|
||||
const serialized = instance.serialize()
|
||||
expect(serialized.widgets_values).toEqual([LEGACY_VALUE])
|
||||
expect(serialized.properties?.proxyWidgets).toEqual([
|
||||
[String(node.id), 'widget']
|
||||
])
|
||||
})
|
||||
|
||||
it('does not corrupt unbound promoted widgets when widgets_values length mismatches view count (regression for #10849)', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const SOURCE_DEFAULT = 42
|
||||
const LEGACY_NOISE_A = 111
|
||||
const LEGACY_NOISE_B = 222
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 803 })
|
||||
instance.configure({
|
||||
id: 803,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'value']] },
|
||||
widgets_values: [LEGACY_NOISE_A, LEGACY_NOISE_B]
|
||||
})
|
||||
|
||||
const widget = instance.widgets?.[0]
|
||||
expect(widget?.value).toBe(SOURCE_DEFAULT)
|
||||
expect(widget?.value).not.toBe(LEGACY_NOISE_A)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[SubgraphNode] Dropping stale widgets_values for 803: widgets_values length (2) does not match proxyWidgets length (1).'
|
||||
)
|
||||
})
|
||||
|
||||
it('migrates legacy 4-tuple inline value into positional widgets_values', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 850 })
|
||||
const innerNodeId = String(node.id)
|
||||
|
||||
instance.configure({
|
||||
id: 850,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [[innerNodeId, 'widget', null, { value: 50 }]]
|
||||
}
|
||||
})
|
||||
|
||||
expect(instance.widgets?.[0].value).toBe(50)
|
||||
|
||||
const serialized = instance.serialize()
|
||||
expect(serialized.properties?.proxyWidgets).toEqual([
|
||||
[innerNodeId, 'widget']
|
||||
])
|
||||
expect(serialized.widgets_values).toEqual([50])
|
||||
})
|
||||
|
||||
it('drops function fields from promoted widget values during cloning', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 901 })
|
||||
instance.graph!.add(instance)
|
||||
|
||||
const valueWithFunction = { fn: () => 'nope' }
|
||||
const promotedWidget = instance.widgets![0]
|
||||
|
||||
promotedWidget.value = valueWithFunction as unknown as typeof widget.value
|
||||
|
||||
expect(promotedWidget.value).toEqual({})
|
||||
expect(instance.serialize().widgets_values).toEqual([{}])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,12 +43,16 @@ import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
supportsVirtualCanvasImagePreview
|
||||
} from '@/composables/node/canvasImagePreviewTypes'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import {
|
||||
getProxyWidgetInlineState,
|
||||
parseProxyWidgets
|
||||
} from '@/core/schemas/promotionSchema'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
makePromotionEntryKey,
|
||||
usePromotionStore
|
||||
} from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
|
||||
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
|
||||
@@ -992,20 +996,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/** Temporarily stored during configure for use by _internalConfigureAfterSlots */
|
||||
/**
|
||||
* Temporary configure-time handoff for legacy positional widgets_values.
|
||||
* LGraphNode.configure invokes _internalConfigureAfterSlots internally, so
|
||||
* this value is staged before super.configure and cleared in a finally block.
|
||||
*/
|
||||
private _pendingWidgetsValues?: unknown[]
|
||||
|
||||
/**
|
||||
* Per-instance promoted widget values.
|
||||
* Multiple SubgraphNode instances share the same inner nodes, so
|
||||
* promoted widget values must be stored per-instance to avoid collisions.
|
||||
* Key: `${sourceNodeId}:${sourceWidgetName}`
|
||||
*/
|
||||
readonly _instanceWidgetValues = new Map<string, unknown>()
|
||||
|
||||
override configure(info: ExportedSubgraphInstance): void {
|
||||
this._instanceWidgetValues.clear()
|
||||
this._pendingWidgetsValues = info.widgets_values
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.clearInstanceWidgets(this.rootGraph.id, this.id)
|
||||
if (info.id != null && info.id !== this.id) {
|
||||
widgetValueStore.clearInstanceWidgets(this.rootGraph.id, info.id)
|
||||
}
|
||||
this._pendingWidgetsValues = Array.isArray(info.widgets_values)
|
||||
? info.widgets_values
|
||||
: undefined
|
||||
|
||||
for (const input of this.inputs) {
|
||||
if (
|
||||
@@ -1055,7 +1061,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
)
|
||||
|
||||
super.configure(info)
|
||||
try {
|
||||
super.configure(info)
|
||||
} finally {
|
||||
this._pendingWidgetsValues = undefined
|
||||
}
|
||||
}
|
||||
|
||||
override _internalConfigureAfterSlots() {
|
||||
@@ -1077,28 +1087,71 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
// Hydrate the store from serialized properties.proxyWidgets
|
||||
const raw = parseProxyWidgets(this.properties.proxyWidgets)
|
||||
const store = usePromotionStore()
|
||||
const pendingValues = new Map<string, unknown>()
|
||||
const canHydrateLegacyWidgetsValues =
|
||||
this._pendingWidgetsValues?.length === raw.length
|
||||
|
||||
if (this._pendingWidgetsValues && !canHydrateLegacyWidgetsValues) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
`[SubgraphNode] Dropping stale widgets_values for ${this.id}: ` +
|
||||
`widgets_values length (${this._pendingWidgetsValues.length}) ` +
|
||||
`does not match proxyWidgets length (${raw.length}).`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const entries = raw
|
||||
.map(([nodeId, widgetName, sourceNodeId]) => {
|
||||
if (nodeId === '-1') {
|
||||
const resolved = this._resolveLegacyEntry(widgetName)
|
||||
if (resolved)
|
||||
return { sourceNodeId: resolved[0], sourceWidgetName: resolved[1] }
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
`[SubgraphNode] Failed to resolve legacy -1 entry for widget "${widgetName}"`
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!this.subgraph.getNodeById(nodeId)) return null
|
||||
.map((rawEntry, index) => {
|
||||
const nodeId = rawEntry[0]
|
||||
const widgetName = rawEntry[1]
|
||||
const thirdElement = rawEntry[2]
|
||||
const sourceNodeId =
|
||||
typeof thirdElement === 'string' ? thirdElement : undefined
|
||||
|
||||
return normalizeLegacyProxyWidgetEntry(
|
||||
this,
|
||||
nodeId,
|
||||
widgetName,
|
||||
sourceNodeId
|
||||
)
|
||||
let resolved: PromotedWidgetSource | null
|
||||
if (nodeId === '-1') {
|
||||
const legacy = this._resolveLegacyEntry(widgetName)
|
||||
if (legacy) {
|
||||
resolved = {
|
||||
sourceNodeId: legacy[0],
|
||||
sourceWidgetName: legacy[1]
|
||||
}
|
||||
} else {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
`[SubgraphNode] Failed to resolve legacy -1 entry for widget "${widgetName}"`
|
||||
)
|
||||
}
|
||||
resolved = null
|
||||
}
|
||||
} else if (!this.subgraph.getNodeById(nodeId)) {
|
||||
resolved = null
|
||||
} else {
|
||||
resolved = normalizeLegacyProxyWidgetEntry(
|
||||
this,
|
||||
nodeId,
|
||||
widgetName,
|
||||
sourceNodeId
|
||||
)
|
||||
}
|
||||
|
||||
if (resolved) {
|
||||
const inlineState = getProxyWidgetInlineState(rawEntry)
|
||||
if (inlineState) {
|
||||
pendingValues.set(
|
||||
makePromotionEntryKey(resolved),
|
||||
inlineState.value
|
||||
)
|
||||
} else if (canHydrateLegacyWidgetsValues) {
|
||||
const legacyValue = this._pendingWidgetsValues?.[index]
|
||||
if (legacyValue !== null && legacyValue !== undefined) {
|
||||
pendingValues.set(makePromotionEntryKey(resolved), legacyValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
})
|
||||
.filter((e): e is NonNullable<typeof e> => e !== null)
|
||||
|
||||
@@ -1138,19 +1191,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
store.promote(this.rootGraph.id, this.id, source)
|
||||
}
|
||||
|
||||
// Hydrate per-instance promoted widget values from serialized data.
|
||||
// LGraphNode.configure skips promoted widgets (serialize === false on
|
||||
// the view), so they must be applied here after promoted views exist.
|
||||
// Only iterate serializable views to match what serialize() wrote.
|
||||
if (this._pendingWidgetsValues) {
|
||||
const views = this._getPromotedViews()
|
||||
let i = 0
|
||||
for (const view of views) {
|
||||
if (!view.sourceSerialize) continue
|
||||
if (i >= this._pendingWidgetsValues.length) break
|
||||
view.value = this._pendingWidgetsValues[i++] as typeof view.value
|
||||
if (pendingValues.size > 0) {
|
||||
for (const view of this._getPromotedViews()) {
|
||||
if (!pendingValues.has(view.instanceKey)) continue
|
||||
view.value = pendingValues.get(view.instanceKey) as typeof view.value
|
||||
}
|
||||
this._pendingWidgetsValues = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1248,6 +1293,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
previousView.sourceWidgetName !== widgetName)
|
||||
) {
|
||||
usePromotionStore().demote(this.rootGraph.id, this.id, previousView)
|
||||
this._clearPromotedViewScopedWidgets(previousView)
|
||||
this._removePromotedView(previousView)
|
||||
}
|
||||
|
||||
@@ -1519,6 +1565,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
private _clearPromotedViewScopedWidgets(view: PromotedWidgetView): void {
|
||||
useWidgetValueStore().clearScopedWidget(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
`${view.sourceNodeId}:${view.sourceWidgetName}`
|
||||
)
|
||||
}
|
||||
|
||||
override removeWidget(widget: IBaseWidget): void {
|
||||
this.ensureWidgetRemoved(widget)
|
||||
}
|
||||
@@ -1527,6 +1581,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
this._clearDomOverrideForView(widget)
|
||||
usePromotionStore().demote(this.rootGraph.id, this.id, widget)
|
||||
this._clearPromotedViewScopedWidgets(widget)
|
||||
this._removePromotedView(widget)
|
||||
}
|
||||
for (const input of this.inputs) {
|
||||
@@ -1546,7 +1601,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
override onRemoved(): void {
|
||||
this._eventAbortController.abort()
|
||||
this._invalidatePromotedViewsCache()
|
||||
this._instanceWidgetValues.clear()
|
||||
useWidgetValueStore().clearInstanceWidgets(this.rootGraph.id, this.id)
|
||||
|
||||
for (const widget of this.widgets) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
@@ -1614,14 +1669,21 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const views = this._getPromotedViews()
|
||||
|
||||
const serializableViews = views.filter((view) => view.sourceSerialize)
|
||||
if (serializableViews.length > 0) {
|
||||
const hasAnyScopedValue = serializableViews.some(
|
||||
(view) => view.getScopedStoreValue() !== undefined
|
||||
)
|
||||
|
||||
if (hasAnyScopedValue) {
|
||||
// For un-edited promoted views (no scoped store value), fall back to the
|
||||
// source widget's effective value via the view getter. This keeps the
|
||||
// round-trip stable: replaying the same source-default into the store
|
||||
// on configure is a no-op, whereas writing `null` would blank out the
|
||||
// widget on next load.
|
||||
serialized.widgets_values = serializableViews.map((view) => {
|
||||
const value = view.serializeValue
|
||||
? view.serializeValue(this, -1)
|
||||
: view.value
|
||||
const value = view.getScopedStoreValue() ?? view.value ?? null
|
||||
return value != null && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: (value ?? null)
|
||||
: value
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -448,6 +448,63 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
expect(state?.value).toBe(99)
|
||||
})
|
||||
|
||||
it('uses widget value fallback when scoped store entry is missing', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'seed',
|
||||
nodeId: NODE_ID,
|
||||
storeInstanceId: 'subgraph-1'
|
||||
})
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(GRAPH_ID, {
|
||||
nodeId: NODE_ID,
|
||||
name: 'seed',
|
||||
type: 'combo',
|
||||
value: 0,
|
||||
options: {}
|
||||
})
|
||||
|
||||
const [processed] = processWidgets([widget])
|
||||
|
||||
expect(processed.simplified.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses storeInstanceId to resolve and update scoped widget state', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'seed',
|
||||
nodeId: NODE_ID,
|
||||
storeInstanceId: 'subgraph-1'
|
||||
})
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(GRAPH_ID, {
|
||||
nodeId: NODE_ID,
|
||||
name: 'seed',
|
||||
type: 'combo',
|
||||
value: 0,
|
||||
options: {}
|
||||
})
|
||||
widgetValueStore.registerWidget(
|
||||
GRAPH_ID,
|
||||
{
|
||||
nodeId: NODE_ID,
|
||||
name: 'seed',
|
||||
type: 'combo',
|
||||
value: 7,
|
||||
options: {}
|
||||
},
|
||||
'subgraph-1'
|
||||
)
|
||||
|
||||
const [processed] = processWidgets([widget])
|
||||
expect(processed.simplified.value).toBe(7)
|
||||
|
||||
processed.updateHandler(8)
|
||||
|
||||
expect(widgetValueStore.getWidget(GRAPH_ID, NODE_ID, 'seed')?.value).toBe(0)
|
||||
expect(
|
||||
widgetValueStore.getWidget(GRAPH_ID, NODE_ID, 'seed', 'subgraph-1')?.value
|
||||
).toBe(8)
|
||||
})
|
||||
|
||||
it('clears execution errors on update', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'seed',
|
||||
|
||||
@@ -200,7 +200,12 @@ export function computeProcessedWidgets({
|
||||
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
||||
)
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
|
||||
? widgetValueStore.getWidget(
|
||||
graphId,
|
||||
bareWidgetId,
|
||||
storeWidgetName,
|
||||
widget.storeInstanceId
|
||||
)
|
||||
: undefined
|
||||
const mergedOptions: IWidgetOptions = {
|
||||
...(widget.options ?? {}),
|
||||
@@ -263,7 +268,7 @@ export function computeProcessedWidgets({
|
||||
|
||||
const { slotMetadata } = widget
|
||||
|
||||
const value = widgetState?.value as WidgetValue
|
||||
const value = (widgetState?.value ?? widget.defaultValue) as WidgetValue
|
||||
|
||||
const isDisabled = slotMetadata?.linked || widgetState?.disabled
|
||||
const widgetOptions = isDisabled
|
||||
|
||||
@@ -124,6 +124,173 @@ describe('useWidgetValueStore', () => {
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
|
||||
})
|
||||
|
||||
it('keeps instance-scoped widgets isolated from the legacy shared key', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'prompt', 'text', 'shared'))
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-a'),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-b'),
|
||||
'subgraph-b'
|
||||
)
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'prompt')?.value).toBe('shared')
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')?.value
|
||||
).toBe('instance-a')
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-b')?.value
|
||||
).toBe('instance-b')
|
||||
})
|
||||
|
||||
it('getNodeWidgets can read either shared or instance-scoped widgets', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 2),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'steps', 'number', 20),
|
||||
'subgraph-a'
|
||||
)
|
||||
|
||||
expect(store.getNodeWidgets(graphA, 'node-1')).toHaveLength(1)
|
||||
expect(
|
||||
store
|
||||
.getNodeWidgets(graphA, 'node-1', 'subgraph-a')
|
||||
.map((w) => w.name)
|
||||
.sort()
|
||||
).toEqual(['seed', 'steps'])
|
||||
})
|
||||
|
||||
it('clearInstanceWidgets removes only one instance scope', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'prompt', 'text', 'shared'))
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-a'),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-b'),
|
||||
'subgraph-b'
|
||||
)
|
||||
|
||||
store.clearInstanceWidgets(graphA, 'subgraph-a')
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'prompt')?.value).toBe('shared')
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-b')?.value
|
||||
).toBe('instance-b')
|
||||
})
|
||||
|
||||
it('clearScopedWidget with node prefix clears all widgets for that source node only', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'prompt', 'text', 'shared'))
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-2', 'prompt', 'text', 'shared-2')
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-a-prompt'),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'steps', 'number', 20),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-2', 'prompt', 'text', 'instance-a-node-2'),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-b-prompt'),
|
||||
'subgraph-b'
|
||||
)
|
||||
|
||||
store.clearScopedWidget(graphA, 'subgraph-a', 'node-1')
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'prompt')?.value).toBe('shared')
|
||||
expect(store.getWidget(graphA, 'node-2', 'prompt')?.value).toBe(
|
||||
'shared-2'
|
||||
)
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'steps', 'subgraph-a')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-2', 'prompt', 'subgraph-a')?.value
|
||||
).toBe('instance-a-node-2')
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-b')?.value
|
||||
).toBe('instance-b-prompt')
|
||||
})
|
||||
|
||||
it('clearScopedWidget with node:widget prefix clears only the targeted widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-a-prompt'),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'steps', 'number', 20),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-b-prompt'),
|
||||
'subgraph-b'
|
||||
)
|
||||
|
||||
store.clearScopedWidget(graphA, 'subgraph-a', 'node-1:prompt')
|
||||
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'steps', 'subgraph-a')?.value
|
||||
).toBe(20)
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-b')?.value
|
||||
).toBe('instance-b-prompt')
|
||||
})
|
||||
|
||||
it('clearScopedWidget does not affect legacy unscoped keys', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'prompt', 'text', 'shared'))
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-a-prompt'),
|
||||
'subgraph-a'
|
||||
)
|
||||
|
||||
store.clearScopedWidget(graphA, 'subgraph-a', 'node-1:prompt')
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'prompt')?.value).toBe('shared')
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('direct property mutation', () => {
|
||||
|
||||
@@ -9,11 +9,12 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
* Widget state is keyed by `nodeId:widgetName` without graph context.
|
||||
* This is intentional: nodes viewed at different subgraph depths share
|
||||
* the same widget state, enabling synchronized values across the hierarchy.
|
||||
* Widget state is keyed by `nodeId:widgetName` without graph context by
|
||||
* default. Promoted subgraph widgets can add an instance coordinate so sibling
|
||||
* SubgraphNode instances do not collide while regular depth views keep sharing
|
||||
* the legacy slot.
|
||||
*/
|
||||
type WidgetKey = `${NodeId}:${string}`
|
||||
type WidgetKey = `${NodeId}:${string}` | `${NodeId}@${NodeId}:${string}`
|
||||
|
||||
/**
|
||||
* Strips graph/subgraph prefixes from a scoped node ID to get the bare node ID.
|
||||
@@ -46,23 +47,35 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
return nextWidgetStates
|
||||
}
|
||||
|
||||
function makeKey(nodeId: NodeId, widgetName: string): WidgetKey {
|
||||
return `${nodeId}:${widgetName}`
|
||||
function makeKey(
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
instanceId?: NodeId
|
||||
): WidgetKey {
|
||||
return instanceId === undefined
|
||||
? `${nodeId}:${widgetName}`
|
||||
: `${instanceId}@${nodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
function registerWidget<TValue = unknown>(
|
||||
graphId: UUID,
|
||||
state: WidgetState<TValue>
|
||||
state: WidgetState<TValue>,
|
||||
instanceId?: NodeId
|
||||
): WidgetState<TValue> {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const key = makeKey(state.nodeId, state.name)
|
||||
const key = makeKey(state.nodeId, state.name, instanceId)
|
||||
widgetStates.set(key, state)
|
||||
return widgetStates.get(key) as WidgetState<TValue>
|
||||
}
|
||||
|
||||
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
|
||||
function getNodeWidgets(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
instanceId?: NodeId
|
||||
): WidgetState[] {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const prefix = `${nodeId}:`
|
||||
const prefix =
|
||||
instanceId === undefined ? `${nodeId}:` : `${instanceId}@${nodeId}:`
|
||||
return [...widgetStates]
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([, state]) => state)
|
||||
@@ -71,9 +84,40 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
function getWidget(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
widgetName: string
|
||||
widgetName: string,
|
||||
instanceId?: NodeId
|
||||
): WidgetState | undefined {
|
||||
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
|
||||
return getWidgetStateMap(graphId).get(
|
||||
makeKey(nodeId, widgetName, instanceId)
|
||||
)
|
||||
}
|
||||
|
||||
function clearInstanceWidgets(graphId: UUID, instanceId: NodeId): void {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const prefix = `${instanceId}@`
|
||||
for (const key of widgetStates.keys()) {
|
||||
if (key.startsWith(prefix)) widgetStates.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears one promoted-widget scope under a subgraph instance.
|
||||
*
|
||||
* `scopePrefix` starts after the `${instanceId}@` boundary from
|
||||
* `makeKey(nodeId, widgetName, instanceId)`:
|
||||
* - `${nodeId}` clears all scoped widgets on that source node.
|
||||
* - `${nodeId}:${widgetName}` clears exactly one scoped widget.
|
||||
*/
|
||||
function clearScopedWidget(
|
||||
graphId: UUID,
|
||||
instanceId: NodeId,
|
||||
scopePrefix: string
|
||||
): void {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const prefix = `${instanceId}@${scopePrefix}`
|
||||
for (const key of widgetStates.keys()) {
|
||||
if (key.startsWith(prefix)) widgetStates.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID): void {
|
||||
@@ -84,6 +128,8 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
registerWidget,
|
||||
getWidget,
|
||||
getNodeWidgets,
|
||||
clearInstanceWidgets,
|
||||
clearScopedWidget,
|
||||
clearGraph
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user