Compare commits

...

2 Commits

Author SHA1 Message Date
DrJKL
fc79c90a2e test(subgraph): align proxyWidgets tests with forward-only migration
Update or delete tests that pinned the removed legacy hydration paths:

- promotedWidgetView.test.ts: delete 6 tests that asserted store
  hydration from properties.proxyWidgets, transient store-only
  fallbacks, clone-side property preservation, or the deleted
  _resolveLegacyEntry path. Trim a 7th to keep only its still-valid
  value-isolation half. Delete 1340 (tautological under new contract).
- SubgraphWidgetPromotion.test.ts: route legacy -1 and clone assertions
  through serialize() (the new contract: load deletes the property,
  only serialize() re-materializes it). Delete the nested-prefix
  normalization test (path explicitly out of scope per plan).
- migrateLegacyProxyWidgets.test.ts: add 7 nested-promotion cases.
  Two-level chained PromotedWidgetView source pins current drop
  behavior with an it.todo for the latent disambiguator-aware viewKey
  identity bug in SubgraphNode.

Amp-Thread-ID: https://ampcode.com/threads/T-019e13b4-d9f6-7770-839f-86fa3553f763
Co-authored-by: Amp <amp@ampcode.com>
2026-05-10 16:56:35 -07:00
DrJKL
943d90aae8 refactor(subgraph): link-only migration of legacy proxyWidgets on load
Convert serialized properties.proxyWidgets entries into real SubgraphInput
links during _internalConfigureAfterSlots. Unresolvable entries are
silently dropped; the property is deleted after running so the next save
is clean.

Removes _resolveLegacyEntry and the PromotionStore hydration block. Real
links become the canonical state for promoted-widget identity. Runtime
promotion (PromotionStore, _serializeEntries writeback in serialize(),
canvas-image-preview auto-promotion) is unchanged.

Amp-Thread-ID: https://ampcode.com/threads/T-019e13b4-d9f6-7770-839f-86fa3553f763
Co-authored-by: Amp <amp@ampcode.com>
2026-05-10 16:07:57 -07:00
5 changed files with 546 additions and 472 deletions

View File

@@ -0,0 +1,491 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { migrateLegacyProxyWidgets } from '@/core/graph/subgraph/migrateLegacyProxyWidgets'
import type {
LGraph,
Subgraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
interface Setup {
rootGraph: LGraph
subgraph: Subgraph
host: SubgraphNode
innerNode: LGraphNode
innerWidgetName: string
}
function setup(widgetName = 'seed', slotType = 'INT'): Setup {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const innerNode = new LGraphNode('Inner')
const input = innerNode.addInput(widgetName, slotType)
innerNode.addWidget('number', widgetName, 0, () => {})
input.widget = { name: widgetName }
subgraph.add(innerNode)
const host = createTestSubgraphNode(subgraph, { parentGraph: rootGraph })
return { rootGraph, subgraph, host, innerNode, innerWidgetName: widgetName }
}
describe('migrateLegacyProxyWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
it('migrates a two-tuple resolvable entry into a SubgraphInput link', () => {
const { subgraph, host, innerNode, innerWidgetName } = setup()
host.properties.proxyWidgets = [[String(innerNode.id), innerWidgetName]]
const inputsBefore = subgraph.inputs.length
migrateLegacyProxyWidgets(host)
expect(subgraph.inputs.length).toBe(inputsBefore + 1)
const newInput = subgraph.inputs.at(-1)!
expect(newInput.linkIds.length).toBeGreaterThan(0)
expect(innerNode.inputs[0].link).not.toBeNull()
expect(innerNode.inputs[0].link).toBeDefined()
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('ignores the third tuple element (disambiguator) and migrates identically', () => {
const { subgraph, host, innerNode, innerWidgetName } = setup()
host.properties.proxyWidgets = [
[String(innerNode.id), innerWidgetName, 'disambiguator-ignored']
]
migrateLegacyProxyWidgets(host)
expect(subgraph.inputs.length).toBe(1)
expect(subgraph.inputs[0].linkIds.length).toBeGreaterThan(0)
expect(innerNode.inputs[0].link).not.toBeNull()
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('drops entries whose source node does not exist', () => {
const { subgraph, host } = setup()
host.properties.proxyWidgets = [['99999', 'whatever']]
migrateLegacyProxyWidgets(host)
expect(subgraph.inputs.length).toBe(0)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('drops entries whose source widget name is not found on the node', () => {
const { subgraph, host, innerNode } = setup()
host.properties.proxyWidgets = [[String(innerNode.id), 'nonexistent']]
migrateLegacyProxyWidgets(host)
expect(subgraph.inputs.length).toBe(0)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('drops entries for widgets without a backing input slot (preview-style pseudo-widget)', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const innerNode = new LGraphNode('Preview')
innerNode.addWidget('text', 'preview', '', () => {})
subgraph.add(innerNode)
const host = createTestSubgraphNode(subgraph, { parentGraph: rootGraph })
host.properties.proxyWidgets = [[String(innerNode.id), 'preview']]
migrateLegacyProxyWidgets(host)
expect(subgraph.inputs.length).toBe(0)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('migrates only the resolvable entries when mixed with unresolvable ones', () => {
const { subgraph, host, innerNode, innerWidgetName } = setup()
host.properties.proxyWidgets = [
[String(innerNode.id), innerWidgetName],
['99999', 'missing-node'],
[String(innerNode.id), 'missing-widget']
]
migrateLegacyProxyWidgets(host)
expect(subgraph.inputs.length).toBe(1)
expect(subgraph.inputs[0].linkIds.length).toBeGreaterThan(0)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('is idempotent when invoked multiple times', () => {
const { subgraph, host, innerNode, innerWidgetName } = setup()
host.properties.proxyWidgets = [[String(innerNode.id), innerWidgetName]]
migrateLegacyProxyWidgets(host)
const inputsAfterFirst = subgraph.inputs.length
expect(() => migrateLegacyProxyWidgets(host)).not.toThrow()
expect(subgraph.inputs.length).toBe(inputsAfterFirst)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it("drops legacy '-1' source-node-id entries", () => {
const { subgraph, host } = setup()
host.properties.proxyWidgets = [['-1', 'someName']]
migrateLegacyProxyWidgets(host)
expect(subgraph.inputs.length).toBe(0)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('handles an empty proxyWidgets array as a no-op and deletes the property', () => {
const { subgraph, host } = setup()
host.properties.proxyWidgets = []
expect(() => migrateLegacyProxyWidgets(host)).not.toThrow()
expect(subgraph.inputs.length).toBe(0)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('handles invalid (non-JSON string) proxyWidgets without throwing', () => {
const { subgraph, host } = setup()
host.properties.proxyWidgets = 'not-json'
expect(() => migrateLegacyProxyWidgets(host)).not.toThrow()
expect(subgraph.inputs.length).toBe(0)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('handles undefined proxyWidgets as a no-op', () => {
const { subgraph, host } = setup()
host.properties.proxyWidgets = undefined
expect(() => migrateLegacyProxyWidgets(host)).not.toThrow()
expect(subgraph.inputs.length).toBe(0)
})
describe('nested promotions', () => {
interface NestedSetup {
rootGraph: LGraph
hostSubgraph: Subgraph
innerSubgraph: Subgraph
innerLeaf: LGraphNode
nestedNode: SubgraphNode
host: SubgraphNode
promotedWidgetName: string
}
/**
* Builds: rootGraph -> host(SubgraphNode for hostSubgraph)
* └── hostSubgraph contains nestedNode(SubgraphNode for innerSubgraph)
* └── innerSubgraph contains innerLeaf with `seed` widget
* connected through innerSubgraph's input.
* The nestedNode surfaces a promoted widget named after the inner subgraph
* input (`seed` here).
*/
function nestedSetup(promotedName = 'seed'): NestedSetup {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: promotedName, type: 'INT' }]
})
const innerLeaf = new LGraphNode('Leaf')
const leafInput = innerLeaf.addInput(promotedName, 'INT')
innerLeaf.addWidget('number', promotedName, 0, () => {})
leafInput.widget = { name: promotedName }
innerSubgraph.add(innerLeaf)
innerSubgraph.inputNode.slots[0].connect(innerLeaf.inputs[0], innerLeaf)
const hostSubgraph = createTestSubgraph({ rootGraph })
const nestedNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: hostSubgraph
})
hostSubgraph.add(nestedNode)
const host = createTestSubgraphNode(hostSubgraph, {
parentGraph: rootGraph
})
return {
rootGraph,
hostSubgraph,
innerSubgraph,
innerLeaf,
nestedNode,
host,
promotedWidgetName: promotedName
}
}
it('migrates an entry whose source widget is on a nested SubgraphNode', () => {
const {
host,
hostSubgraph,
innerSubgraph,
nestedNode,
promotedWidgetName
} = nestedSetup()
const innerInputsBefore = innerSubgraph.inputs.length
host.properties.proxyWidgets = [
[String(nestedNode.id), promotedWidgetName]
]
migrateLegacyProxyWidgets(host)
expect(hostSubgraph.inputs.length).toBe(1)
const newInput = hostSubgraph.inputs[0]
expect(newInput.name).toBe(promotedWidgetName)
expect(newInput.linkIds.length).toBeGreaterThan(0)
const nestedSlot = nestedNode.inputs.find(
(slot) => slot._subgraphSlot?.name === promotedWidgetName
)
expect(nestedSlot).toBeDefined()
expect(nestedSlot?.link).not.toBeNull()
expect(nestedSlot?.link).toBeDefined()
// The nested subgraph's own promotion structure is unaffected.
expect(innerSubgraph.inputs.length).toBe(innerInputsBefore)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('skips entries whose source slot already has a non-null link', () => {
const { subgraph, host, innerNode, innerWidgetName } = setup()
// Pre-wire the inner node's slot through a SubgraphInput so its
// `link` is non-null before migration runs.
const preExistingInput = subgraph.addInput(innerWidgetName, 'INT')
const preLink = preExistingInput.connect(innerNode.inputs[0], innerNode)
expect(preLink).not.toBeNull()
const inputsBefore = subgraph.inputs.length
const preExistingLinkId = innerNode.inputs[0].link
expect(preExistingLinkId).not.toBeNull()
host.properties.proxyWidgets = [[String(innerNode.id), innerWidgetName]]
migrateLegacyProxyWidgets(host)
// No new SubgraphInput; pre-existing link untouched.
expect(subgraph.inputs.length).toBe(inputsBefore)
expect(innerNode.inputs[0].link).toBe(preExistingLinkId)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('migrates multiple entries pointing at widgets on the same nested SubgraphNode', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
rootGraph,
inputs: [
{ name: 'seed', type: 'INT' },
{ name: 'steps', type: 'INT' }
]
})
const innerLeaf = new LGraphNode('Leaf')
const seedInput = innerLeaf.addInput('seed', 'INT')
const stepsInput = innerLeaf.addInput('steps', 'INT')
innerLeaf.addWidget('number', 'seed', 0, () => {})
innerLeaf.addWidget('number', 'steps', 20, () => {})
seedInput.widget = { name: 'seed' }
stepsInput.widget = { name: 'steps' }
innerSubgraph.add(innerLeaf)
innerSubgraph.inputNode.slots[0].connect(innerLeaf.inputs[0], innerLeaf)
innerSubgraph.inputNode.slots[1].connect(innerLeaf.inputs[1], innerLeaf)
const hostSubgraph = createTestSubgraph({ rootGraph })
const nestedNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: hostSubgraph
})
hostSubgraph.add(nestedNode)
const host = createTestSubgraphNode(hostSubgraph, {
parentGraph: rootGraph
})
host.properties.proxyWidgets = [
[String(nestedNode.id), 'seed'],
[String(nestedNode.id), 'steps']
]
migrateLegacyProxyWidgets(host)
expect(hostSubgraph.inputs.length).toBe(2)
const names = hostSubgraph.inputs.map((i) => i.name)
expect(new Set(names).size).toBe(names.length)
expect(names).toEqual(expect.arrayContaining(['seed', 'steps']))
for (const input of hostSubgraph.inputs) {
expect(input.linkIds.length).toBeGreaterThan(0)
}
const seedSlot = nestedNode.inputs.find(
(slot) => slot._subgraphSlot?.name === 'seed'
)
const stepsSlot = nestedNode.inputs.find(
(slot) => slot._subgraphSlot?.name === 'steps'
)
expect(seedSlot?.link).not.toBeNull()
expect(stepsSlot?.link).not.toBeNull()
expect(host.properties.proxyWidgets).toBeUndefined()
})
it("does not touch the inner SubgraphNode's own legacy proxyWidgets", () => {
const { host, hostSubgraph, nestedNode, promotedWidgetName } =
nestedSetup()
const innerLegacy: [string, string][] = [['12345', 'untouched']]
nestedNode.properties.proxyWidgets = innerLegacy.map(
(entry) => [...entry] as [string, string]
)
host.properties.proxyWidgets = [
[String(nestedNode.id), promotedWidgetName]
]
migrateLegacyProxyWidgets(host)
expect(host.properties.proxyWidgets).toBeUndefined()
expect(hostSubgraph.inputs.length).toBe(1)
expect(nestedNode.properties.proxyWidgets).toStrictEqual(innerLegacy)
})
it('drops two-level nested entries whose source widget is itself a chained PromotedWidgetView', () => {
// Pins the current contract: when the source widget on the immediate
// child SubgraphNode is itself a chained promotion (PromotedWidgetView),
// SubgraphNode.getSlotFromWidget returns undefined due to a view-cache
// identity mismatch and the migration drops the entry silently. This is
// consistent with case 5 (no backing input slot → drop) and with the
// plan's "unresolvable entries are dropped silently" contract. Lifting
// this restriction requires fixing the disambiguator-aware viewKey
// identity in SubgraphNode and is tracked as a follow-up.
const rootGraph = createTestRootGraph()
// Innermost (B): contains the leaf widget.
const subgraphB = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: 'INT' }]
})
const leafB = new LGraphNode('LeafB')
const leafInputB = leafB.addInput('seed', 'INT')
leafB.addWidget('number', 'seed', 7, () => {})
leafInputB.widget = { name: 'seed' }
subgraphB.add(leafB)
subgraphB.inputNode.slots[0].connect(leafB.inputs[0], leafB)
// Middle (A): contains a SubgraphNode for B; surfaces seed via its own input.
const subgraphA = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: 'INT' }]
})
const nodeForB = createTestSubgraphNode(subgraphB, {
parentGraph: subgraphA
})
subgraphA.add(nodeForB)
const nodeForBSeedSlot = nodeForB.inputs.find(
(slot) => slot._subgraphSlot?.name === 'seed'
)
expect(nodeForBSeedSlot).toBeDefined()
subgraphA.inputNode.slots[0].connect(nodeForBSeedSlot!, nodeForB)
// Host: contains a SubgraphNode for A.
const hostSubgraph = createTestSubgraph({ rootGraph })
const nodeForA = createTestSubgraphNode(subgraphA, {
parentGraph: hostSubgraph
})
hostSubgraph.add(nodeForA)
const host = createTestSubgraphNode(hostSubgraph, {
parentGraph: rootGraph
})
host.properties.proxyWidgets = [[String(nodeForA.id), 'seed']]
migrateLegacyProxyWidgets(host)
expect(hostSubgraph.inputs.length).toBe(0)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it.todo(
'should resolve two-level nested chained promotions once the disambiguator-aware viewKey identity in SubgraphNode is fixed'
)
it('uses nextUniqueName when the desired name collides with an existing input', () => {
const { subgraph, host, innerNode } = setup('width', 'INT')
// Pre-existing input with the same name (and type irrelevant for the test).
const existingInput = subgraph.addInput('width', 'INT')
const otherNode = new LGraphNode('Other')
otherNode.addOutput('width', 'INT')
subgraph.add(otherNode)
const preExistingLink = existingInput.connect(
otherNode.inputs[0] ?? otherNode.outputs[0],
otherNode
)
// The pre-existing input may or may not connect depending on slot
// direction; we only care that it remains in the inputs list with its
// identity preserved.
void preExistingLink
const inputsBefore = subgraph.inputs.length
host.properties.proxyWidgets = [[String(innerNode.id), 'width']]
migrateLegacyProxyWidgets(host)
expect(subgraph.inputs.length).toBe(inputsBefore + 1)
expect(subgraph.inputs[0]).toBe(existingInput)
expect(subgraph.inputs[0].name).toBe('width')
const newInput = subgraph.inputs.at(-1)!
expect(newInput).not.toBe(existingInput)
expect(newInput.name).not.toBe('width')
expect(newInput.name.startsWith('width')).toBe(true)
expect(newInput.linkIds.length).toBeGreaterThan(0)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('drops entries when the nested SubgraphNode has not materialized the widget', () => {
const rootGraph = createTestRootGraph()
// Inner subgraph with no inputs and no leaf widgets — a SubgraphNode
// built from this surfaces zero promoted widgets.
const innerSubgraph = createTestSubgraph({ rootGraph })
const hostSubgraph = createTestSubgraph({ rootGraph })
const nestedNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: hostSubgraph
})
hostSubgraph.add(nestedNode)
// Sanity: the nested SubgraphNode has no synthetic widgets to promote.
expect(nestedNode.widgets.length).toBe(0)
const host = createTestSubgraphNode(hostSubgraph, {
parentGraph: rootGraph
})
host.properties.proxyWidgets = [[String(nestedNode.id), 'never_promoted']]
expect(() => migrateLegacyProxyWidgets(host)).not.toThrow()
expect(hostSubgraph.inputs.length).toBe(0)
expect(host.properties.proxyWidgets).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,44 @@
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
/**
* Forward-ratchet legacy `properties.proxyWidgets` into real
* SubgraphInput links. Unresolvable entries are dropped silently.
* Idempotent: removes the legacy property after running.
*/
export function migrateLegacyProxyWidgets(hostNode: SubgraphNode): void {
const tuples = parseProxyWidgets(hostNode.properties.proxyWidgets)
if (tuples.length === 0) {
delete hostNode.properties.proxyWidgets
return
}
const { subgraph } = hostNode
for (const [sourceNodeId, sourceWidgetName] of tuples) {
const sourceNode = subgraph.getNodeById(sourceNodeId)
if (!sourceNode) continue
const sourceWidget = sourceNode.widgets?.find(
(w) => w.name === sourceWidgetName
)
if (!sourceWidget) continue
const sourceSlot = sourceNode.getSlotFromWidget(sourceWidget)
if (!sourceSlot) continue
if (sourceSlot.link != null) continue
const desiredName = nextUniqueName(
sourceWidgetName,
subgraph.inputs.map((i) => i.name)
)
const slotType = String(sourceSlot.type ?? sourceWidget.type ?? '*')
const subgraphInput = subgraph.addInput(desiredName, slotType)
const link = subgraphInput.connect(sourceSlot, sourceNode)
if (!link) subgraph.removeInput(subgraphInput)
}
delete hostNode.properties.proxyWidgets
}

View File

@@ -1128,67 +1128,6 @@ describe('SubgraphNode.widgets getter', () => {
])
})
test('configure prunes stale disconnected host aliases that resolve to the active linked concrete widget', () => {
const nestedSubgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('string_a', '*')
concreteNode.addWidget('text', 'string_a', 'value', () => {})
concreteInput.widget = { name: 'string_a' }
nestedSubgraph.add(concreteNode)
nestedSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
const hostSubgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const activeAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 118 })
const staleAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 119 })
hostSubgraph.add(activeAliasNode)
hostSubgraph.add(staleAliasNode)
activeAliasNode._internalConfigureAfterSlots()
staleAliasNode._internalConfigureAfterSlots()
hostSubgraph.inputNode.slots[0].connect(
activeAliasNode.inputs[0],
activeAliasNode
)
const hostSubgraphNode = createTestSubgraphNode(hostSubgraph, { id: 120 })
hostSubgraphNode.graph?.add(hostSubgraphNode)
setPromotions(hostSubgraphNode, [
[String(activeAliasNode.id), 'string_a'],
[String(staleAliasNode.id), 'string_a']
])
const serialized = hostSubgraphNode.serialize()
const restoredNode = createTestSubgraphNode(hostSubgraph, { id: 121 })
restoredNode.configure({
...serialized,
id: restoredNode.id,
type: hostSubgraph.id,
inputs: []
})
const restoredPromotions = usePromotionStore().getPromotions(
restoredNode.rootGraph.id,
restoredNode.id
)
expect(restoredPromotions).toStrictEqual([
{
sourceNodeId: String(activeAliasNode.id),
sourceWidgetName: 'string_a'
}
])
const restoredWidgets = promotedWidgets(restoredNode)
expect(restoredWidgets).toHaveLength(1)
expect(restoredWidgets[0].sourceNodeId).toBe(String(activeAliasNode.id))
})
test('serialize syncs duplicate-name linked inputs by subgraph slot identity', () => {
const subgraph = createTestSubgraph({
inputs: [
@@ -1337,193 +1276,6 @@ describe('SubgraphNode.widgets getter', () => {
expect(subgraphNode.widgets).toHaveLength(1)
})
test('migrates legacy -1 entries via _resolveLegacyEntry', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
// Simulate a slot-connected widget so legacy resolution works
const subgraph = subgraphNode.subgraph
subgraph.addInput('stringWidget', '*')
subgraphNode._internalConfigureAfterSlots()
// The _internalConfigureAfterSlots would have set up the slot-connected
// widget via _setWidget if there's a link. For unit testing legacy
// migration, we need to set up the input._widget manually.
const input = subgraphNode.inputs.find((i) => i.name === 'stringWidget')
if (input) {
input._widget = createPromotedWidgetView(
subgraphNode,
String(innerNodes[0].id),
'stringWidget'
)
}
// Set legacy -1 format via properties and re-run hydration
subgraphNode.properties.proxyWidgets = [['-1', 'stringWidget']]
subgraphNode._internalConfigureAfterSlots()
// Migration should have rewritten the store with resolved IDs
const entries = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(entries).toStrictEqual([
{
sourceNodeId: String(innerNodes[0].id),
sourceWidgetName: 'stringWidget'
}
])
})
test('hydrate promotions from serialize/configure round-trip', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [[String(innerNode.id), 'widgetA']])
const serialized = subgraphNode.serialize()
const restoredNode = createTestSubgraphNode(subgraphNode.subgraph, {
id: 99
})
restoredNode.configure({
...serialized,
id: restoredNode.id,
type: subgraphNode.subgraph.id
})
const restoredEntries = usePromotionStore().getPromotions(
restoredNode.rootGraph.id,
restoredNode.id
)
expect(restoredEntries).toStrictEqual([
{
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'widgetA'
}
])
})
test('configure with empty serialized inputs keeps linked filtering active', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
subgraphNode.graph?.add(subgraphNode)
const linkedNodeA = new LGraphNode('LinkedNodeA')
const linkedInputA = linkedNodeA.addInput('string_a', '*')
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
linkedInputA.widget = { name: 'string_a' }
subgraph.add(linkedNodeA)
const linkedNodeB = new LGraphNode('LinkedNodeB')
const linkedInputB = linkedNodeB.addInput('string_a', '*')
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
linkedInputB.widget = { name: 'string_a' }
subgraph.add(linkedNodeB)
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
subgraph.add(storeOnlyNode)
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
setPromotions(subgraphNode, [
[String(linkedNodeA.id), 'string_a'],
[String(linkedNodeB.id), 'string_a'],
[String(storeOnlyNode.id), 'string_a']
])
const serialized = subgraphNode.serialize()
const restoredNode = createTestSubgraphNode(subgraph, { id: 98 })
restoredNode.configure({
...serialized,
id: restoredNode.id,
type: subgraph.id,
inputs: []
})
const restoredWidgets = promotedWidgets(restoredNode)
expect(restoredWidgets).toHaveLength(2)
const linkedViewCount = restoredWidgets.filter((widget) =>
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
widget.sourceNodeId
)
).length
expect(linkedViewCount).toBe(1)
expect(
restoredWidgets.some(
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
)
).toBe(true)
})
test('configure with serialized inputs rebinds subgraph slots for linked filtering', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 107 })
subgraphNode.graph?.add(subgraphNode)
const linkedNodeA = new LGraphNode('LinkedNodeA')
const linkedInputA = linkedNodeA.addInput('string_a', '*')
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
linkedInputA.widget = { name: 'string_a' }
subgraph.add(linkedNodeA)
const linkedNodeB = new LGraphNode('LinkedNodeB')
const linkedInputB = linkedNodeB.addInput('string_a', '*')
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
linkedInputB.widget = { name: 'string_a' }
subgraph.add(linkedNodeB)
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
subgraph.add(storeOnlyNode)
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
setPromotions(subgraphNode, [
[String(linkedNodeA.id), 'string_a'],
[String(linkedNodeB.id), 'string_a'],
[String(storeOnlyNode.id), 'string_a']
])
const serialized = subgraphNode.serialize()
const restoredNode = createTestSubgraphNode(subgraph, { id: 108 })
restoredNode.configure({
...serialized,
id: restoredNode.id,
type: subgraph.id,
inputs: [
{
name: 'string_a',
type: '*',
link: null
}
]
})
const restoredWidgets = promotedWidgets(restoredNode)
expect(restoredWidgets).toHaveLength(2)
const linkedViewCount = restoredWidgets.filter((widget) =>
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
widget.sourceNodeId
)
).length
expect(linkedViewCount).toBe(1)
expect(
restoredWidgets.some(
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
)
).toBe(true)
})
test('fixture keeps earliest linked representative and independent promotion only', () => {
const { graph, hostNode } = setupComplexPromotionFixture()
@@ -1558,34 +1310,8 @@ describe('SubgraphNode.widgets getter', () => {
expect(getValue('19')).toBe('independent-value')
})
test('fixture refreshes duplicate fallback after linked representative recovers', () => {
const { subgraph, hostNode } = setupComplexPromotionFixture()
const earliestLinkedNode = subgraph.getNodeById(20)
if (!earliestLinkedNode?.widgets)
throw new Error('Expected fixture to contain node 20 with widgets')
const originalWidgets = earliestLinkedNode.widgets
earliestLinkedNode.widgets = originalWidgets.filter(
(widget) => widget.name !== 'string_a'
)
const unresolvedWidgets = promotedWidgets(hostNode)
expect(
unresolvedWidgets.map((widget) => widget.sourceNodeId)
).toStrictEqual(['18', '20', '19'])
earliestLinkedNode.widgets = originalWidgets
const restoredWidgets = promotedWidgets(hostNode)
expect(restoredWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
'20',
'19'
])
})
test('fixture converges external widgets and keeps rendered value isolation after transient linked fallback churn', () => {
const { subgraph, hostNode } = setupComplexPromotionFixture()
test('fixture keeps rendered value isolation between linked representative and second promotion', () => {
const { hostNode } = setupComplexPromotionFixture()
const initialWidgets = promotedWidgets(hostNode)
expect(initialWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
@@ -1593,33 +1319,10 @@ describe('SubgraphNode.widgets getter', () => {
'19'
])
const earliestLinkedNode = subgraph.getNodeById(20)
if (!earliestLinkedNode?.widgets)
throw new Error('Expected fixture to contain node 20 with widgets')
const originalWidgets = earliestLinkedNode.widgets
earliestLinkedNode.widgets = originalWidgets.filter(
(widget) => widget.name !== 'string_a'
)
const transientWidgets = promotedWidgets(hostNode)
expect(transientWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual(
['18', '20', '19']
)
earliestLinkedNode.widgets = originalWidgets
const finalWidgets = promotedWidgets(hostNode)
expect(finalWidgets).toHaveLength(2)
expect(finalWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
'20',
'19'
])
const finalLinkedView = finalWidgets.find(
const finalLinkedView = initialWidgets.find(
(widget) => widget.sourceNodeId === '20'
)
const finalIndependentView = finalWidgets.find(
const finalIndependentView = initialWidgets.find(
(widget) => widget.sourceNodeId === '19'
)
if (!finalLinkedView || !finalIndependentView)
@@ -1633,50 +1336,6 @@ describe('SubgraphNode.widgets getter', () => {
expect(finalLinkedView.value).toBe('linked-final')
expect(finalIndependentView.value).toBe('independent-final')
})
test('clone output preserves proxyWidgets for promotion hydration', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [[String(innerNode.id), 'widgetA']])
const createNodeSpy = vi
.spyOn(LiteGraph, 'createNode')
.mockImplementation(() =>
createTestSubgraphNode(subgraphNode.subgraph, { id: 999 })
)
const clonedNode = subgraphNode.clone()
expect(clonedNode).toBeTruthy()
createNodeSpy.mockRestore()
if (!clonedNode) throw new Error('Expected clone to return a node')
const clonedSerialized = clonedNode.serialize()
expect(clonedSerialized.properties?.proxyWidgets).toStrictEqual([
[String(innerNode.id), 'widgetA']
])
const hydratedClone = createTestSubgraphNode(subgraphNode.subgraph, {
id: 100
})
hydratedClone.configure({
...clonedSerialized,
id: hydratedClone.id,
type: subgraphNode.subgraph.id
})
const hydratedEntries = usePromotionStore().getPromotions(
hydratedClone.rootGraph.id,
hydratedClone.id
)
expect(hydratedEntries).toStrictEqual([
{
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'widgetA'
}
])
})
})
describe('widgets getter caching', () => {

View File

@@ -33,17 +33,15 @@ import {
createPromotedWidgetView,
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
import { migrateLegacyProxyWidgets } from '@/core/graph/subgraph/migrateLegacyProxyWidgets'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
makePromotionEntryKey,
@@ -655,31 +653,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
}
private _resolveLegacyEntry(
widgetName: string
): [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
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
}
const widget = input._widget
if (isPromotedWidgetView(widget)) {
return [widget.sourceNodeId, widget.sourceWidgetName]
}
// Fallback: find via subgraph input slot connection
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
}
/** Manages lifecycle of all subgraph event listeners */
private _eventAbortController = new AbortController()
@@ -1052,51 +1025,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// This prevents stale/duplicate serialized inputs from persisting (#9977).
this.inputs = this.inputs.filter((input) => input._subgraphSlot)
// Ensure proxyWidgets is initialized so it serializes
this.properties.proxyWidgets ??= []
// Clear view cache — forces re-creation on next getter access.
// Do NOT clear properties.proxyWidgets — it was already populated
// from serialized data by super.configure(info) before this runs.
this._promotedViewManager.clear()
this._invalidatePromotedViewsCache()
// Hydrate the store from serialized properties.proxyWidgets
const raw = parseProxyWidgets(this.properties.proxyWidgets)
migrateLegacyProxyWidgets(this)
this._rebindInputSubgraphSlots()
const store = usePromotionStore()
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
return normalizeLegacyProxyWidgetEntry(
this,
nodeId,
widgetName,
sourceNodeId
)
})
.filter((e): e is NonNullable<typeof e> => e !== null)
store.setPromotions(this.rootGraph.id, this.id, entries)
// Write back resolved entries so legacy or stale entries don't persist
const serialized = this._serializeEntries(entries)
if (JSON.stringify(serialized) !== JSON.stringify(raw)) {
this.properties.proxyWidgets = serialized
}
// Check all inputs for connected widgets
for (const input of this.inputs) {
const subgraphInput = input._subgraphSlot

View File

@@ -12,7 +12,6 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
createEventCapture,
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
@@ -291,7 +290,7 @@ describe('SubgraphWidgetPromotion', () => {
hostNode.configure(serializedHostNode)
expect(hostNode.properties.proxyWidgets).toStrictEqual([
expect(hostNode.serialize().properties?.proxyWidgets).toStrictEqual([
[String(interiorNode.id), 'batch_size']
])
expect(hostNode.widgets).toHaveLength(1)
@@ -356,63 +355,6 @@ describe('SubgraphWidgetPromotion', () => {
expect(widgetSourceIds).toContain(keptSamplerNodeId)
})
it('should normalize legacy prefixed proxyWidgets on configure', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: 'number' }]
})
const samplerNode = new LGraphNode('Sampler')
const samplerInput = samplerNode.addInput('seed', 'number')
samplerNode.addWidget('number', 'noise_seed', 123, () => {})
samplerInput.widget = { name: 'noise_seed' }
innerSubgraph.add(samplerNode)
innerSubgraph.inputNode.slots[0].connect(
samplerNode.inputs[0],
samplerNode
)
const outerSubgraph = createTestSubgraph({ rootGraph })
const nestedNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: outerSubgraph
})
outerSubgraph.add(nestedNode)
const hostNode = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph
})
const serializedHostNode = hostNode.serialize()
serializedHostNode.properties = {
...serializedHostNode.properties,
proxyWidgets: [
[
String(nestedNode.id),
`${nestedNode.id}: ${samplerNode.id}: noise_seed`
]
]
}
hostNode.configure(serializedHostNode)
const promotedWidgets = hostNode.widgets
.filter(isPromotedWidgetView)
.filter((widget) => !widget.name.startsWith('$$'))
expect(promotedWidgets).toHaveLength(1)
expect(promotedWidgets[0].type).toBe('number')
expect(promotedWidgets[0].value).toBe(123)
expect(promotedWidgets[0].sourceWidgetName).toBe('noise_seed')
expect(promotedWidgets[0].disambiguatingSourceNodeId).toBe(
String(samplerNode.id)
)
expect(hostNode.properties.proxyWidgets).toStrictEqual([
[String(nestedNode.id), 'noise_seed', String(samplerNode.id)]
])
})
it('should preserve promoted widget entries after cloning', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
@@ -441,7 +383,8 @@ describe('SubgraphWidgetPromotion', () => {
// Simulate clone: create a second SubgraphNode configured from serialized data
const cloneNode = createTestSubgraphNode(subgraph)
cloneNode.configure(serialized)
const cloneProxyWidgets = cloneNode.properties.proxyWidgets as string[][]
const cloneProxyWidgets = cloneNode.serialize().properties!
.proxyWidgets as string[][]
expect(cloneProxyWidgets.length).toBeGreaterThan(0)
expect(