mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 09:48:09 +00:00
Compare commits
3 Commits
fix/subgra
...
glary/subg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9242ce7f3a | ||
|
|
16cbd8f4ff | ||
|
|
570da3b453 |
@@ -20,9 +20,8 @@
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["10", "text", null, { "value": "Alpha\n" }]]
|
||||
}
|
||||
"properties": {},
|
||||
"widgets_values": ["Alpha\n"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
@@ -40,9 +39,8 @@
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["10", "text", null, { "value": "Beta\n" }]]
|
||||
}
|
||||
"properties": {},
|
||||
"widgets_values": ["Beta\n"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
@@ -60,9 +58,8 @@
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["10", "text", null, { "value": "Gamma\n" }]]
|
||||
}
|
||||
"properties": {},
|
||||
"widgets_values": ["Gamma\n"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -77,15 +76,8 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
|
||||
const storeInstanceId =
|
||||
node.isSubgraphNode() && isPromotedWidgetView(widget) ? node.id : undefined
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(
|
||||
graphId,
|
||||
bareNodeId,
|
||||
sourceWidget.name,
|
||||
storeInstanceId
|
||||
)
|
||||
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
|
||||
@@ -229,7 +229,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.slotName).toBe('value')
|
||||
expect(widgetData?.value).toBe('hello')
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
|
||||
// Disconnect
|
||||
|
||||
@@ -50,8 +50,7 @@ export interface WidgetSlotMetadata {
|
||||
|
||||
/**
|
||||
* Minimal render-specific widget data extracted from LiteGraph widgets.
|
||||
* widgetValueStore is preferred for value and metadata. `value` provides the
|
||||
* LiteGraph fallback when no scoped store entry exists yet.
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
nodeId?: NodeId
|
||||
@@ -60,7 +59,6 @@ export interface SafeWidgetData {
|
||||
name: string
|
||||
storeName?: string
|
||||
type: string
|
||||
value?: WidgetValue
|
||||
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
/** Control widget for seed randomization/increment/decrement */
|
||||
@@ -100,6 +98,12 @@ export interface SafeWidgetData {
|
||||
tooltip?: string
|
||||
/** For promoted widgets, the display label from the subgraph input slot. */
|
||||
promotedLabel?: string
|
||||
/**
|
||||
* Read-only fallback value sourced from the underlying widget when no
|
||||
* scoped store entry exists yet. Render paths use this for first-paint of
|
||||
* promoted widgets that have never been edited.
|
||||
*/
|
||||
defaultValue?: unknown
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -345,7 +349,6 @@ function safeWidgetMapper(
|
||||
name,
|
||||
storeName,
|
||||
type: effectiveWidget.type,
|
||||
value: normalizeWidgetValue(widget.value),
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
|
||||
@@ -365,7 +368,10 @@ function safeWidgetMapper(
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
tooltip: widget.tooltip,
|
||||
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
|
||||
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined,
|
||||
defaultValue: isPromotedWidgetView(widget)
|
||||
? (sourceWidget?.value ?? widget.value)
|
||||
: undefined
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* Deep-clone a widget value for inline serialization or per-instance
|
||||
* isolation. Uses `structuredClone` so uncloneable mutable values fail loudly
|
||||
* instead of sharing a reference across SubgraphNode instances.
|
||||
*/
|
||||
export function cloneWidgetValue<TValue>(value: TValue): TValue {
|
||||
if (value == null) return value
|
||||
if (typeof value !== 'object' && typeof value !== 'function') return value
|
||||
return structuredClone(value)
|
||||
}
|
||||
@@ -1730,8 +1730,9 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
|
||||
const clonedSerialized = clonedNode.serialize()
|
||||
expect(clonedSerialized.properties?.proxyWidgets).toStrictEqual([
|
||||
[String(innerNode.id), 'widgetA', null, { value: 'edited' }]
|
||||
[String(innerNode.id), 'widgetA']
|
||||
])
|
||||
expect(clonedSerialized.widgets_values).toStrictEqual(['edited'])
|
||||
|
||||
const hydratedClone = createTestSubgraphNode(subgraphNode.subgraph, {
|
||||
id: 100
|
||||
@@ -2006,6 +2007,40 @@ describe('promote/demote cycle', () => {
|
||||
(subgraphNode.widgets[0] as PromotedWidgetView).sourceWidgetName
|
||||
).toBe('widgetA')
|
||||
})
|
||||
|
||||
test('re-promote does not reuse stale scoped value from a demoted view', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'source-default', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
const firstView = subgraphNode.widgets[0] as PromotedWidgetView
|
||||
firstView.value = 'edited-scoped-value'
|
||||
|
||||
expect(firstView.value).toBe('edited-scoped-value')
|
||||
expect(
|
||||
useWidgetValueStore().getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
innerIds[0],
|
||||
'widgetA',
|
||||
subgraphNode.id
|
||||
)?.value
|
||||
).toBe('edited-scoped-value')
|
||||
|
||||
subgraphNode.removeWidget(firstView)
|
||||
expect(
|
||||
useWidgetValueStore().getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
innerIds[0],
|
||||
'widgetA',
|
||||
subgraphNode.id
|
||||
)
|
||||
).toBeUndefined()
|
||||
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
const secondView = subgraphNode.widgets[0] as PromotedWidgetView
|
||||
|
||||
expect(secondView.value).toBe('source-default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('disconnected state', () => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
|
||||
import { cloneWidgetValue } from './cloneWidgetValue'
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
@@ -48,6 +47,12 @@ function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
function cloneWidgetValue(value: IBaseWidget['value']): IBaseWidget['value'] {
|
||||
return value != null && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: value
|
||||
}
|
||||
|
||||
type LegacyMouseWidget = IBaseWidget & {
|
||||
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('serialize inlines promoted widget values into proxyWidgets entries', () => {
|
||||
test('serialize writes positional widgets_values for edited promoted widgets', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
@@ -276,9 +276,9 @@ describe('Subgraph proxyWidgets', () => {
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
expect(serialized.widgets_values).toStrictEqual(['edited'])
|
||||
expect(serialized.properties?.proxyWidgets).toStrictEqual([
|
||||
[innerIds[0], 'stringWidget', null, { value: 'edited' }]
|
||||
[innerIds[0], 'stringWidget']
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseProxyWidgets } from './promotionSchema'
|
||||
import { getProxyWidgetInlineState, parseProxyWidgets } from './promotionSchema'
|
||||
|
||||
describe(parseProxyWidgets, () => {
|
||||
it('parses 2-tuple arrays', () => {
|
||||
@@ -36,11 +36,12 @@ describe(parseProxyWidgets, () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('parses 4-tuple arrays with inline state', () => {
|
||||
const input = [['3', 'text', null, { value: 'hello' }]]
|
||||
expect(parseProxyWidgets(input)).toEqual([
|
||||
['3', 'text', null, { value: 'hello' }]
|
||||
])
|
||||
it('parses legacy 4-tuple arrays', () => {
|
||||
const input = [
|
||||
['3', 'text', '1', { value: 42 }],
|
||||
['9', 'seed', null, { value: 'abc' }]
|
||||
]
|
||||
expect(parseProxyWidgets(input)).toEqual(input)
|
||||
})
|
||||
|
||||
it('returns empty array for non-array input', () => {
|
||||
@@ -51,8 +52,28 @@ describe(parseProxyWidgets, () => {
|
||||
it('returns empty array for invalid tuples', () => {
|
||||
expect(parseProxyWidgets([['only-one']])).toEqual([])
|
||||
expect(parseProxyWidgets([['a', 'b', 'c', 'd']])).toEqual([])
|
||||
expect(parseProxyWidgets([['a', 'b', null, { value: undefined }]])).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects legacy 4-tuple entries with undefined inline value', () => {
|
||||
expect(
|
||||
parseProxyWidgets([
|
||||
['3', 'text', null, { value: undefined }] as unknown as [
|
||||
string,
|
||||
string,
|
||||
null,
|
||||
{ value: undefined }
|
||||
]
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe(getProxyWidgetInlineState, () => {
|
||||
it('returns inline value for 4-tuples only', () => {
|
||||
expect(getProxyWidgetInlineState(['1', 'seed'])).toBeUndefined()
|
||||
expect(getProxyWidgetInlineState(['1', 'seed', '2'])).toBeUndefined()
|
||||
expect(
|
||||
getProxyWidgetInlineState(['1', 'seed', null, { value: 10 }])
|
||||
).toEqual({ value: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,8 @@ const definedValueSchema = z.custom<DefinedProxyWidgetValue>(
|
||||
)
|
||||
const proxyWidgetStateSchema = z.object({ value: definedValueSchema })
|
||||
const proxyWidgetTupleSchema = z.union([
|
||||
// 4-tuple is read-only migration shim (legacy PR #11559 workflows).
|
||||
// Writer never emits this shape.
|
||||
z.tuple([
|
||||
z.string(),
|
||||
z.string(),
|
||||
@@ -50,10 +52,7 @@ export function parseProxyWidgets(
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed accessor for the optional trailing `{ value }` state on a proxyWidgets
|
||||
* entry. Returns undefined for 2- and 3-tuple (identity-only) entries.
|
||||
*/
|
||||
/** Returns the optional inline {value} state from a legacy PR #11559 4-tuple entry. */
|
||||
export function getProxyWidgetInlineState(
|
||||
entry: ProxyWidgetEntry
|
||||
): ProxyWidgetInlineState | undefined {
|
||||
|
||||
@@ -968,8 +968,19 @@ export class LGraphNode
|
||||
|
||||
if (this.properties) o.properties = LiteGraph.cloneObject(this.properties)
|
||||
|
||||
const widgetsValues = this.getSerializableWidgetsValues()
|
||||
if (widgetsValues) o.widgets_values = widgetsValues
|
||||
const { widgets } = this
|
||||
if (widgets && this.serialize_widgets) {
|
||||
o.widgets_values = []
|
||||
for (const [i, widget] of widgets.entries()) {
|
||||
if (widget.serialize === false) continue
|
||||
const val = widget?.value
|
||||
// Ensure object values are plain (not reactive proxies) for structuredClone compatibility.
|
||||
o.widgets_values[i] =
|
||||
val != null && typeof val === 'object'
|
||||
? JSON.parse(JSON.stringify(val))
|
||||
: (val ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!o.type && this.constructor.type) o.type = this.constructor.type
|
||||
|
||||
@@ -986,26 +997,6 @@ export class LGraphNode
|
||||
return o
|
||||
}
|
||||
|
||||
protected getSerializableWidgetsValues():
|
||||
| ISerialisedNode['widgets_values']
|
||||
| undefined {
|
||||
const { widgets } = this
|
||||
if (!widgets || !this.serialize_widgets) return undefined
|
||||
|
||||
const widgetsValues: NonNullable<ISerialisedNode['widgets_values']> = []
|
||||
for (const [i, widget] of widgets.entries()) {
|
||||
if (widget.serialize === false) continue
|
||||
const val = widget?.value
|
||||
// Ensure object values are plain (not reactive proxies) for structuredClone compatibility.
|
||||
widgetsValues[i] =
|
||||
val != null && typeof val === 'object'
|
||||
? JSON.parse(JSON.stringify(val))
|
||||
: (val ?? null)
|
||||
}
|
||||
|
||||
return widgetsValues
|
||||
}
|
||||
|
||||
/* Creates a clone of this node */
|
||||
clone(): LGraphNode | null {
|
||||
if (this.type == null) return null
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
@@ -53,8 +54,6 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
|
||||
const innerNodeId = String(node.id)
|
||||
|
||||
// Per-instance values are inlined as the optional {value} state on
|
||||
// each proxyWidgets entry so identity and value cannot desync.
|
||||
instance1.configure({
|
||||
id: 201,
|
||||
type: subgraph.id,
|
||||
@@ -66,8 +65,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [[innerNodeId, 'widget', null, { value: 10 }]]
|
||||
}
|
||||
proxyWidgets: [[innerNodeId, 'widget']]
|
||||
},
|
||||
widgets_values: [10]
|
||||
})
|
||||
|
||||
instance2.configure({
|
||||
@@ -81,8 +81,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
order: 1,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [[innerNodeId, 'widget', null, { value: 20 }]]
|
||||
}
|
||||
proxyWidgets: [[innerNodeId, 'widget']]
|
||||
},
|
||||
widgets_values: [20]
|
||||
})
|
||||
|
||||
const widgets1 = instance1.widgets!
|
||||
@@ -97,13 +98,13 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
|
||||
const serialized1 = instance1.serialize()
|
||||
const serialized2 = instance2.serialize()
|
||||
expect(serialized1.widgets_values).toBeUndefined()
|
||||
expect(serialized2.widgets_values).toBeUndefined()
|
||||
expect(serialized1.widgets_values).toEqual([10])
|
||||
expect(serialized2.widgets_values).toEqual([20])
|
||||
expect(serialized1.properties?.proxyWidgets).toEqual([
|
||||
[innerNodeId, 'widget', null, { value: 10 }]
|
||||
[innerNodeId, 'widget']
|
||||
])
|
||||
expect(serialized2.properties?.proxyWidgets).toEqual([
|
||||
[innerNodeId, 'widget', null, { value: 20 }]
|
||||
[innerNodeId, 'widget']
|
||||
])
|
||||
})
|
||||
|
||||
@@ -153,6 +154,66 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
expect(instance2.widgets?.[0].serializeValue?.(instance2, 0)).toBe(20)
|
||||
})
|
||||
|
||||
it('clears stale scoped entries keyed by info.id during configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 700 })
|
||||
const staleInstanceId = '701'
|
||||
const innerNodeId = String(node.id)
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(
|
||||
instance.rootGraph.id,
|
||||
{
|
||||
nodeId: innerNodeId,
|
||||
name: 'widget',
|
||||
type: 'number',
|
||||
value: 999,
|
||||
options: {}
|
||||
},
|
||||
staleInstanceId
|
||||
)
|
||||
|
||||
expect(
|
||||
widgetValueStore.getWidget(
|
||||
instance.rootGraph.id,
|
||||
innerNodeId,
|
||||
'widget',
|
||||
staleInstanceId
|
||||
)?.value
|
||||
).toBe(999)
|
||||
|
||||
instance.configure({
|
||||
id: Number(staleInstanceId),
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [[innerNodeId, 'widget']]
|
||||
},
|
||||
widgets_values: [10]
|
||||
})
|
||||
|
||||
expect(
|
||||
widgetValueStore.getWidget(
|
||||
instance.rootGraph.id,
|
||||
innerNodeId,
|
||||
'widget',
|
||||
staleInstanceId
|
||||
)?.value
|
||||
).toBe(10)
|
||||
})
|
||||
|
||||
it('round-trips per-instance widget values through serialize and configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
@@ -175,8 +236,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [[innerNodeId, 'widget', null, { value: 33 }]]
|
||||
}
|
||||
proxyWidgets: [[innerNodeId, 'widget']]
|
||||
},
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
@@ -193,6 +255,72 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
|
||||
})
|
||||
|
||||
it('preserves source defaults for unedited promoted widgets when serializing mixed edits', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'value', type: 'number' },
|
||||
{ name: 'value_2', type: 'number' }
|
||||
]
|
||||
})
|
||||
|
||||
const SOURCE_DEFAULT = 42
|
||||
const EDITED_VALUE = 99
|
||||
|
||||
const { node: firstNode } = createNodeWithWidget(
|
||||
'FirstNode',
|
||||
SOURCE_DEFAULT
|
||||
)
|
||||
const { node: secondNode } = createNodeWithWidget(
|
||||
'SecondNode',
|
||||
SOURCE_DEFAULT
|
||||
)
|
||||
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
subgraph.inputNode.slots[0].connect(firstNode.inputs[0], firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondNode.inputs[0], secondNode)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 303 })
|
||||
const firstNodeId = String(firstNode.id)
|
||||
const secondNodeId = String(secondNode.id)
|
||||
|
||||
originalInstance.configure({
|
||||
id: 303,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [
|
||||
[firstNodeId, 'widget'],
|
||||
[secondNodeId, 'widget']
|
||||
]
|
||||
},
|
||||
widgets_values: [EDITED_VALUE, SOURCE_DEFAULT]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
expect(serialized.widgets_values).toEqual([EDITED_VALUE, SOURCE_DEFAULT])
|
||||
expect(serialized.widgets_values).not.toContain(null)
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 304 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 304,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
const restoredWidgets = restoredInstance.widgets
|
||||
expect(restoredWidgets).toHaveLength(2)
|
||||
expect(restoredWidgets?.[0].value).toBe(EDITED_VALUE)
|
||||
expect(restoredWidgets?.[1].value).toBe(SOURCE_DEFAULT)
|
||||
expect(restoredWidgets?.[1].value).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps fresh sibling instances isolated before save or reload', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
@@ -243,8 +371,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [[innerNodeId, 'widget', null, { value: 33 }]]
|
||||
}
|
||||
proxyWidgets: [[innerNodeId, 'widget']]
|
||||
},
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
@@ -378,9 +507,9 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
expect(widget?.serializeValue?.(instance, 0)).toBe(LEGACY_VALUE)
|
||||
|
||||
const serialized = instance.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
expect(serialized.widgets_values).toEqual([LEGACY_VALUE])
|
||||
expect(serialized.properties?.proxyWidgets).toEqual([
|
||||
[String(node.id), 'widget', null, { value: LEGACY_VALUE }]
|
||||
[String(node.id), 'widget']
|
||||
])
|
||||
})
|
||||
|
||||
@@ -417,11 +546,47 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
expect(widget?.value).toBe(SOURCE_DEFAULT)
|
||||
expect(widget?.value).not.toBe(LEGACY_NOISE_A)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[SubgraphNode] Legacy widgets_values length (2) does not match proxyWidgets length (1); dropping legacy values for instance 803.'
|
||||
'[SubgraphNode] Dropping stale widgets_values for 803: widgets_values length (2) does not match proxyWidgets length (1).'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects uncloneable promoted widget values', () => {
|
||||
it('migrates legacy 4-tuple inline value into positional widgets_values', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 850 })
|
||||
const innerNodeId = String(node.id)
|
||||
|
||||
instance.configure({
|
||||
id: 850,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [[innerNodeId, 'widget', null, { value: 50 }]]
|
||||
}
|
||||
})
|
||||
|
||||
expect(instance.widgets?.[0].value).toBe(50)
|
||||
|
||||
const serialized = instance.serialize()
|
||||
expect(serialized.properties?.proxyWidgets).toEqual([
|
||||
[innerNodeId, 'widget']
|
||||
])
|
||||
expect(serialized.widgets_values).toEqual([50])
|
||||
})
|
||||
|
||||
it('drops function fields from promoted widget values during cloning', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
@@ -432,11 +597,12 @@ describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 901 })
|
||||
instance.graph!.add(instance)
|
||||
|
||||
const uncloneable = { fn: () => 'nope' }
|
||||
const valueWithFunction = { fn: () => 'nope' }
|
||||
const promotedWidget = instance.widgets![0]
|
||||
|
||||
expect(() => {
|
||||
promotedWidget.value = uncloneable as unknown as typeof widget.value
|
||||
}).toThrow()
|
||||
promotedWidget.value = valueWithFunction as unknown as typeof widget.value
|
||||
|
||||
expect(promotedWidget.value).toEqual({})
|
||||
expect(instance.serialize().widgets_values).toEqual([{}])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
createPromotedWidgetView,
|
||||
isPromotedWidgetView
|
||||
} from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { cloneWidgetValue } from '@/core/graph/subgraph/cloneWidgetValue'
|
||||
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
@@ -120,7 +119,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
hasMissingBoundSourceWidget: boolean
|
||||
views: PromotedWidgetView[]
|
||||
}
|
||||
private _pendingLegacyWidgetsValues?: unknown[]
|
||||
|
||||
// Declared as accessor via Object.defineProperty in constructor.
|
||||
// TypeScript doesn't allow overriding a property with get/set syntax,
|
||||
@@ -650,93 +648,39 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize promotion entries with optional inline `{value}` state.
|
||||
*
|
||||
* Binding identity and value into the same record lets every downstream
|
||||
* transformation — promotion-store reordering, null-entry filtering,
|
||||
* clipboard-paste / subgraph-dedup / nested-pack ID remaps — carry them
|
||||
* together without any extra bookkeeping.
|
||||
*
|
||||
* `resolveValue` returns `undefined` when the entry should be written as
|
||||
* an identity-only 2- or 3-tuple (no view, non-serializable source, or
|
||||
* resolved value is undefined). Returning `undefined` keeps
|
||||
* `widgets_values` on the SubgraphNode a dead field, matching the
|
||||
* pre-#10849 invariant.
|
||||
*/
|
||||
private _serializeEntries(
|
||||
entries: PromotedWidgetSource[],
|
||||
resolveValue: (
|
||||
key: string,
|
||||
entry: PromotedWidgetSource
|
||||
) => unknown | undefined
|
||||
): (
|
||||
| [string, string]
|
||||
| [string, string, string]
|
||||
| [string, string, string | null, { value: unknown }]
|
||||
)[] {
|
||||
return entries.map((e) => {
|
||||
const key = makePromotionEntryKey(e)
|
||||
const disambiguator = e.disambiguatingSourceNodeId ?? null
|
||||
const identityOnly: [string, string] | [string, string, string] =
|
||||
disambiguator
|
||||
? [e.sourceNodeId, e.sourceWidgetName, disambiguator]
|
||||
: [e.sourceNodeId, e.sourceWidgetName]
|
||||
|
||||
const resolved = resolveValue(key, e)
|
||||
if (resolved === undefined) return identityOnly
|
||||
|
||||
return [
|
||||
e.sourceNodeId,
|
||||
e.sourceWidgetName,
|
||||
disambiguator,
|
||||
{ value: resolved }
|
||||
]
|
||||
})
|
||||
entries: PromotedWidgetSource[]
|
||||
): (string[] | [string, string, string])[] {
|
||||
return entries.map((e) =>
|
||||
e.disambiguatingSourceNodeId
|
||||
? [e.sourceNodeId, e.sourceWidgetName, e.disambiguatingSourceNodeId]
|
||||
: [e.sourceNodeId, e.sourceWidgetName]
|
||||
)
|
||||
}
|
||||
|
||||
private _resolveLegacyEntry(
|
||||
widgetName: string
|
||||
): PromotedWidgetSource | undefined {
|
||||
): [string, string] | undefined {
|
||||
// Legacy -1 entries use the slot name as the widget name.
|
||||
// Find the input with that name, then trace to the connected interior widget.
|
||||
const input = this.inputs.find((i) => i.name === widgetName)
|
||||
if (!input?._widget) {
|
||||
// Fallback: find via subgraph input slot connection. During normal
|
||||
// configure this is the path that actually runs — inputs are freshly
|
||||
// rebuilt with no `_widget`, and `_resolveInputWidget` doesn't run
|
||||
// until after entry resolution. `resolvedTarget.sourceNodeId` carries
|
||||
// the disambiguator when the deeper resolution chain terminates at a
|
||||
// nested `PromotedWidgetView`.
|
||||
return this._fromResolvedTarget(widgetName)
|
||||
// Fallback: find via subgraph input slot connection
|
||||
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
|
||||
if (!resolvedTarget) return undefined
|
||||
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
|
||||
}
|
||||
|
||||
const widget = input._widget
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
return {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
...(widget.disambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
return [widget.sourceNodeId, widget.sourceWidgetName]
|
||||
}
|
||||
|
||||
return this._fromResolvedTarget(widgetName)
|
||||
}
|
||||
|
||||
private _fromResolvedTarget(
|
||||
widgetName: string
|
||||
): PromotedWidgetSource | undefined {
|
||||
// Fallback: find via subgraph input slot connection
|
||||
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
|
||||
if (!resolvedTarget) return undefined
|
||||
return {
|
||||
sourceNodeId: resolvedTarget.nodeId,
|
||||
sourceWidgetName: resolvedTarget.widgetName,
|
||||
...(resolvedTarget.sourceNodeId && {
|
||||
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
|
||||
})
|
||||
}
|
||||
|
||||
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
|
||||
}
|
||||
|
||||
/** Manages lifecycle of all subgraph event listeners */
|
||||
@@ -1052,9 +996,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary configure-time handoff for legacy positional widgets_values.
|
||||
* LGraphNode.configure invokes _internalConfigureAfterSlots internally, so
|
||||
* this value is staged before super.configure and cleared in a finally block.
|
||||
*/
|
||||
private _pendingWidgetsValues?: unknown[]
|
||||
|
||||
override configure(info: ExportedSubgraphInstance): void {
|
||||
useWidgetValueStore().clearInstanceWidgets(this.rootGraph.id, this.id)
|
||||
this._pendingLegacyWidgetsValues = Array.isArray(info.widgets_values)
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.clearInstanceWidgets(this.rootGraph.id, this.id)
|
||||
if (info.id != null && info.id !== this.id) {
|
||||
widgetValueStore.clearInstanceWidgets(this.rootGraph.id, info.id)
|
||||
}
|
||||
this._pendingWidgetsValues = Array.isArray(info.widgets_values)
|
||||
? info.widgets_values
|
||||
: undefined
|
||||
|
||||
@@ -1109,7 +1064,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
try {
|
||||
super.configure(info)
|
||||
} finally {
|
||||
this._pendingLegacyWidgetsValues = undefined
|
||||
this._pendingWidgetsValues = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,27 +1084,19 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
this._promotedViewManager.clear()
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
/**
|
||||
* Hydrate the promotion store from serialized `properties.proxyWidgets`.
|
||||
* Inline `{value}` state on each entry is paired with its resolved
|
||||
* identity in a single pass so legacy `-1` entries and ancestor-
|
||||
* normalized entries stay aligned with their per-instance value. Older
|
||||
* templates may still carry the same positional values in
|
||||
* `widgets_values`; import those only when they line up with every
|
||||
* proxyWidgets entry, then re-save through inline state below.
|
||||
*/
|
||||
// Hydrate the store from serialized properties.proxyWidgets
|
||||
const raw = parseProxyWidgets(this.properties.proxyWidgets)
|
||||
const store = usePromotionStore()
|
||||
const pendingValues = new Map<string, unknown>()
|
||||
|
||||
const canHydrateLegacyWidgetsValues =
|
||||
this._pendingLegacyWidgetsValues?.length === raw.length
|
||||
this._pendingWidgetsValues?.length === raw.length
|
||||
|
||||
if (this._pendingLegacyWidgetsValues && !canHydrateLegacyWidgetsValues) {
|
||||
if (this._pendingWidgetsValues && !canHydrateLegacyWidgetsValues) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
`[SubgraphNode] Legacy widgets_values length (${this._pendingLegacyWidgetsValues.length}) ` +
|
||||
`does not match proxyWidgets length (${raw.length}); dropping legacy values for instance ${this.id}.`
|
||||
`[SubgraphNode] Dropping stale widgets_values for ${this.id}: ` +
|
||||
`widgets_values length (${this._pendingWidgetsValues.length}) ` +
|
||||
`does not match proxyWidgets length (${raw.length}).`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1166,7 +1113,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (nodeId === '-1') {
|
||||
const legacy = this._resolveLegacyEntry(widgetName)
|
||||
if (legacy) {
|
||||
resolved = legacy
|
||||
resolved = {
|
||||
sourceNodeId: legacy[0],
|
||||
sourceWidgetName: legacy[1]
|
||||
}
|
||||
} else {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
@@ -1187,13 +1137,16 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
if (resolved) {
|
||||
const state = getProxyWidgetInlineState(rawEntry)
|
||||
if (state) {
|
||||
pendingValues.set(makePromotionEntryKey(resolved), state.value)
|
||||
const inlineState = getProxyWidgetInlineState(rawEntry)
|
||||
if (inlineState) {
|
||||
pendingValues.set(
|
||||
makePromotionEntryKey(resolved),
|
||||
inlineState.value
|
||||
)
|
||||
} else if (canHydrateLegacyWidgetsValues) {
|
||||
const value = this._pendingLegacyWidgetsValues?.[index]
|
||||
if (value != null) {
|
||||
pendingValues.set(makePromotionEntryKey(resolved), value)
|
||||
const legacyValue = this._pendingWidgetsValues?.[index]
|
||||
if (legacyValue !== null && legacyValue !== undefined) {
|
||||
pendingValues.set(makePromotionEntryKey(resolved), legacyValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1204,18 +1157,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
store.setPromotions(this.rootGraph.id, this.id, entries)
|
||||
|
||||
/**
|
||||
* Write back resolved entries so legacy or stale identities don't
|
||||
* persist — but preserve the inline `{value}` state from `pendingValues`
|
||||
* alongside the normalized identity, otherwise this runs on every load
|
||||
* and demotes 4-tuple entries (which always differ byte-for-byte from
|
||||
* the pre-normalization `_serializeEntries` output) into 2/3-tuple,
|
||||
* stripping saved per-instance values from `properties.proxyWidgets`
|
||||
* until the next `serialize()` rebuilds them.
|
||||
*/
|
||||
const serialized = this._serializeEntries(entries, (key) =>
|
||||
pendingValues.get(key)
|
||||
)
|
||||
// Write back resolved entries so legacy or stale entries don't persist
|
||||
const serialized = this._serializeEntries(entries)
|
||||
if (JSON.stringify(serialized) !== JSON.stringify(raw)) {
|
||||
this.properties.proxyWidgets = serialized
|
||||
}
|
||||
@@ -1248,21 +1191,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
store.promote(this.rootGraph.id, this.id, source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate per-instance values by resolved identity key. The pending
|
||||
* map is built alongside entry resolution so legacy `-1` and ancestor
|
||||
* normalization paths are handled with the same logic as the promotion
|
||||
* store, and reorder / filter / ID-remap can't desync identity from
|
||||
* value.
|
||||
*/
|
||||
if (pendingValues.size > 0) {
|
||||
for (const view of this._getPromotedViews()) {
|
||||
if (!view.sourceSerialize) continue
|
||||
const key = view.instanceKey
|
||||
if (!pendingValues.has(key)) continue
|
||||
view.value = cloneWidgetValue(
|
||||
pendingValues.get(key)
|
||||
) as typeof view.value
|
||||
if (!pendingValues.has(view.instanceKey)) continue
|
||||
view.value = pendingValues.get(view.instanceKey) as typeof view.value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1361,6 +1293,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
previousView.sourceWidgetName !== widgetName)
|
||||
) {
|
||||
usePromotionStore().demote(this.rootGraph.id, this.id, previousView)
|
||||
this._clearPromotedViewScopedWidgets(previousView)
|
||||
this._removePromotedView(previousView)
|
||||
}
|
||||
|
||||
@@ -1632,6 +1565,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
private _clearPromotedViewScopedWidgets(view: PromotedWidgetView): void {
|
||||
useWidgetValueStore().clearScopedWidget(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
`${view.sourceNodeId}:${view.sourceWidgetName}`
|
||||
)
|
||||
}
|
||||
|
||||
override removeWidget(widget: IBaseWidget): void {
|
||||
this.ensureWidgetRemoved(widget)
|
||||
}
|
||||
@@ -1640,6 +1581,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
this._clearDomOverrideForView(widget)
|
||||
usePromotionStore().demote(this.rootGraph.id, this.id, widget)
|
||||
this._clearPromotedViewScopedWidgets(widget)
|
||||
this._removePromotedView(widget)
|
||||
}
|
||||
for (const input of this.inputs) {
|
||||
@@ -1716,33 +1658,37 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
override serialize(): ISerialisedNode {
|
||||
// Write promotion store state back to properties for serialization
|
||||
const entries = usePromotionStore().getPromotions(
|
||||
this.rootGraph.id,
|
||||
this.id
|
||||
)
|
||||
const viewByInstanceKey = new Map<string, PromotedWidgetView>()
|
||||
for (const view of this._getPromotedViews()) {
|
||||
viewByInstanceKey.set(view.instanceKey, view)
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
|
||||
const serialized = super.serialize()
|
||||
const views = this._getPromotedViews()
|
||||
|
||||
const serializableViews = views.filter((view) => view.sourceSerialize)
|
||||
const hasAnyScopedValue = serializableViews.some(
|
||||
(view) => view.getScopedStoreValue() !== undefined
|
||||
)
|
||||
|
||||
if (hasAnyScopedValue) {
|
||||
// For un-edited promoted views (no scoped store value), fall back to the
|
||||
// source widget's effective value via the view getter. This keeps the
|
||||
// round-trip stable: replaying the same source-default into the store
|
||||
// on configure is a no-op, whereas writing `null` would blank out the
|
||||
// widget on next load.
|
||||
serialized.widgets_values = serializableViews.map((view) => {
|
||||
const value = view.getScopedStoreValue() ?? view.value ?? null
|
||||
return value != null && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: value
|
||||
})
|
||||
}
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries, (key) => {
|
||||
const view = viewByInstanceKey.get(key)
|
||||
if (!view?.sourceSerialize) return undefined
|
||||
const raw = view.getScopedStoreValue()
|
||||
if (raw === undefined) return undefined
|
||||
return cloneWidgetValue(raw)
|
||||
})
|
||||
|
||||
return super.serialize()
|
||||
return serialized
|
||||
}
|
||||
|
||||
protected override getSerializableWidgetsValues(): undefined {
|
||||
/**
|
||||
* `SubgraphNode.widgets_values` is a dead field — per-instance values
|
||||
* live inline on `proxyWidgets` entries.
|
||||
*/
|
||||
return undefined
|
||||
}
|
||||
|
||||
override clone() {
|
||||
const clone = super.clone()
|
||||
|
||||
|
||||
@@ -452,8 +452,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'seed',
|
||||
nodeId: NODE_ID,
|
||||
storeInstanceId: 'subgraph-1',
|
||||
value: 1024
|
||||
storeInstanceId: 'subgraph-1'
|
||||
})
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(GRAPH_ID, {
|
||||
@@ -466,15 +465,14 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
|
||||
const [processed] = processWidgets([widget])
|
||||
|
||||
expect(processed.simplified.value).toBe(1024)
|
||||
expect(processed.simplified.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses storeInstanceId to resolve and update scoped widget state', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'seed',
|
||||
nodeId: NODE_ID,
|
||||
storeInstanceId: 'subgraph-1',
|
||||
value: 1024
|
||||
storeInstanceId: 'subgraph-1'
|
||||
})
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(GRAPH_ID, {
|
||||
|
||||
@@ -268,9 +268,7 @@ export function computeProcessedWidgets({
|
||||
|
||||
const { slotMetadata } = widget
|
||||
|
||||
const value = (
|
||||
widgetState ? widgetState.value : widget.value
|
||||
) as WidgetValue
|
||||
const value = (widgetState?.value ?? widget.defaultValue) as WidgetValue
|
||||
|
||||
const isDisabled = slotMetadata?.linked || widgetState?.disabled
|
||||
const widgetOptions = isDisabled
|
||||
|
||||
@@ -195,6 +195,102 @@ describe('useWidgetValueStore', () => {
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-b')?.value
|
||||
).toBe('instance-b')
|
||||
})
|
||||
|
||||
it('clearScopedWidget with node prefix clears all widgets for that source node only', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'prompt', 'text', 'shared'))
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-2', 'prompt', 'text', 'shared-2')
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-a-prompt'),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'steps', 'number', 20),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-2', 'prompt', 'text', 'instance-a-node-2'),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-b-prompt'),
|
||||
'subgraph-b'
|
||||
)
|
||||
|
||||
store.clearScopedWidget(graphA, 'subgraph-a', 'node-1')
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'prompt')?.value).toBe('shared')
|
||||
expect(store.getWidget(graphA, 'node-2', 'prompt')?.value).toBe(
|
||||
'shared-2'
|
||||
)
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'steps', 'subgraph-a')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-2', 'prompt', 'subgraph-a')?.value
|
||||
).toBe('instance-a-node-2')
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-b')?.value
|
||||
).toBe('instance-b-prompt')
|
||||
})
|
||||
|
||||
it('clearScopedWidget with node:widget prefix clears only the targeted widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-a-prompt'),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'steps', 'number', 20),
|
||||
'subgraph-a'
|
||||
)
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-b-prompt'),
|
||||
'subgraph-b'
|
||||
)
|
||||
|
||||
store.clearScopedWidget(graphA, 'subgraph-a', 'node-1:prompt')
|
||||
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'steps', 'subgraph-a')?.value
|
||||
).toBe(20)
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-b')?.value
|
||||
).toBe('instance-b-prompt')
|
||||
})
|
||||
|
||||
it('clearScopedWidget does not affect legacy unscoped keys', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'prompt', 'text', 'shared'))
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'text', 'instance-a-prompt'),
|
||||
'subgraph-a'
|
||||
)
|
||||
|
||||
store.clearScopedWidget(graphA, 'subgraph-a', 'node-1:prompt')
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'prompt')?.value).toBe('shared')
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1', 'prompt', 'subgraph-a')
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('direct property mutation', () => {
|
||||
|
||||
@@ -100,6 +100,26 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears one promoted-widget scope under a subgraph instance.
|
||||
*
|
||||
* `scopePrefix` starts after the `${instanceId}@` boundary from
|
||||
* `makeKey(nodeId, widgetName, instanceId)`:
|
||||
* - `${nodeId}` clears all scoped widgets on that source node.
|
||||
* - `${nodeId}:${widgetName}` clears exactly one scoped widget.
|
||||
*/
|
||||
function clearScopedWidget(
|
||||
graphId: UUID,
|
||||
instanceId: NodeId,
|
||||
scopePrefix: string
|
||||
): void {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const prefix = `${instanceId}@${scopePrefix}`
|
||||
for (const key of widgetStates.keys()) {
|
||||
if (key.startsWith(prefix)) widgetStates.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID): void {
|
||||
graphWidgetStates.value.delete(graphId)
|
||||
}
|
||||
@@ -109,6 +129,7 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
getWidget,
|
||||
getNodeWidgets,
|
||||
clearInstanceWidgets,
|
||||
clearScopedWidget,
|
||||
clearGraph
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user