mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-09 15:10:17 +00:00
fix: stabilize nested subgraph promoted widget resolution (#9282)
## Summary Fix multiple issues with promoted widget resolution in nested subgraphs, ensuring correct value propagation, slot matching, and rendering for deeply nested promoted widgets. ## Changes - **What**: Stabilize nested subgraph promoted widget resolution chain - Use deep source keys for promoted widget values in Vue rendering mode - Resolve effective widget options from the source widget instead of the promoted view - Stabilize slot resolution for nested promoted widgets - Preserve combo value rendering for promoted subgraph widgets - Prevent subgraph definition deletion while other nodes still reference the same type - Clean up unused exported resolution types ## Review Focus - `resolveConcretePromotedWidget.ts` — new recursive resolution logic for deeply nested promoted widgets - `useGraphNodeManager.ts` — option extraction now uses `effectiveWidget` for promoted widgets - `SubgraphNode.ts` — unpack no longer force-deletes definitions referenced by other nodes ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9282-fix-stabilize-nested-subgraph-promoted-widget-resolution-3146d73d365081208a4fe931bb7569cf) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export type ResolvedPromotedWidget = {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
export interface PromotedWidgetView extends IBaseWidget {
|
||||
readonly node: SubgraphNode
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
@@ -121,11 +122,19 @@ describe(createPromotedWidgetView, () => {
|
||||
expect(view.serialize).toBe(false)
|
||||
})
|
||||
|
||||
test('computedDisabled is false and setter is a no-op', () => {
|
||||
test('computedDisabled defaults to false and accepts boolean values', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
||||
expect(view.computedDisabled).toBe(false)
|
||||
view.computedDisabled = true
|
||||
expect(view.computedDisabled).toBe(true)
|
||||
})
|
||||
|
||||
test('computedDisabled treats undefined as false', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
||||
view.computedDisabled = true
|
||||
view.computedDisabled = undefined
|
||||
expect(view.computedDisabled).toBe(false)
|
||||
})
|
||||
|
||||
@@ -382,11 +391,173 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('defers promotions while subgraph node id is -1 and flushes on add', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'picker_input', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: -1 })
|
||||
|
||||
const innerNode = new LGraphNode('InnerNode')
|
||||
const innerInput = innerNode.addInput('picker_input', '*')
|
||||
innerNode.addWidget('combo', 'picker', 'a', () => {}, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
innerInput.widget = { name: 'picker' }
|
||||
subgraph.add(innerNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(innerInput, innerNode)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
|
||||
const store = usePromotionStore()
|
||||
expect(store.getPromotions(subgraphNode.rootGraph.id, -1)).toStrictEqual([])
|
||||
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
expect(subgraphNode.id).not.toBe(-1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'picker'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('rebinds one input to latest source without stale disconnected views', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'picker_input', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 41 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('picker_input', '*')
|
||||
firstNode.addWidget('combo', 'picker', 'a', () => {}, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
firstInput.widget = { name: 'picker' }
|
||||
subgraph.add(firstNode)
|
||||
const subgraphInputSlot = subgraph.inputNode.slots[0]
|
||||
subgraphInputSlot.connect(firstInput, firstNode)
|
||||
|
||||
// Mirror user-driven rebind behavior: move the slot connection from first
|
||||
// source to second source, rather than keeping both links connected.
|
||||
subgraphInputSlot.disconnect()
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('picker_input', '*')
|
||||
secondNode.addWidget('combo', 'picker', 'b', () => {}, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
secondInput.widget = { name: 'picker' }
|
||||
subgraph.add(secondNode)
|
||||
subgraphInputSlot.connect(secondInput, secondNode)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
|
||||
expect(promotions).toHaveLength(1)
|
||||
expect(promotions[0]).toStrictEqual({
|
||||
interiorNodeId: String(secondNode.id),
|
||||
widgetName: 'picker'
|
||||
})
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('b')
|
||||
})
|
||||
|
||||
test('preserves distinct promoted display names when two inputs share one concrete widget name', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'strength_model', type: '*' },
|
||||
{ name: 'strength_model_1', type: '*' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 90 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const innerNode = new LGraphNode('InnerNumberNode')
|
||||
const firstInput = innerNode.addInput('strength_model', '*')
|
||||
const secondInput = innerNode.addInput('strength_model_1', '*')
|
||||
innerNode.addWidget('number', 'strength_model', 1, () => {})
|
||||
firstInput.widget = { name: 'strength_model' }
|
||||
secondInput.widget = { name: 'strength_model' }
|
||||
subgraph.add(innerNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, innerNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, innerNode)
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([
|
||||
'strength_model',
|
||||
'strength_model_1'
|
||||
])
|
||||
})
|
||||
|
||||
test('returns empty array when no proxyWidgets', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
expect(subgraphNode.widgets).toEqual([])
|
||||
})
|
||||
|
||||
test('widgets getter prefers live linked entries over stale store entries', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'widgetA', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 91 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const liveNode = new LGraphNode('LiveNode')
|
||||
const liveInput = liveNode.addInput('widgetA', '*')
|
||||
liveNode.addWidget('text', 'widgetA', 'a', () => {})
|
||||
liveInput.widget = { name: 'widgetA' }
|
||||
subgraph.add(liveNode)
|
||||
subgraph.inputNode.slots[0].connect(liveInput, liveNode)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(liveNode.id), 'widgetA'],
|
||||
['9999', 'missingWidget']
|
||||
])
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetA')
|
||||
})
|
||||
|
||||
test('partial linked coverage does not destructively prune unresolved store promotions', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'widgetA', type: '*' },
|
||||
{ name: 'widgetB', type: '*' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 92 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const liveNode = new LGraphNode('LiveNode')
|
||||
const liveInput = liveNode.addInput('widgetA', '*')
|
||||
liveNode.addWidget('text', 'widgetA', 'a', () => {})
|
||||
liveInput.widget = { name: 'widgetA' }
|
||||
subgraph.add(liveNode)
|
||||
subgraph.inputNode.slots[0].connect(liveInput, liveNode)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(liveNode.id), 'widgetA'],
|
||||
['9999', 'widgetB']
|
||||
])
|
||||
|
||||
// Trigger widgets getter reconciliation in partial-linked state.
|
||||
void subgraphNode.widgets
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '9999', widgetName: 'widgetB' }
|
||||
])
|
||||
})
|
||||
|
||||
test('caches view objects across getter calls (stable references)', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
@@ -741,7 +912,7 @@ describe('disconnected state', () => {
|
||||
expect(subgraphNode.widgets[0].type).toBe('number')
|
||||
})
|
||||
|
||||
test('view falls back to button type when interior node is removed', () => {
|
||||
test('keeps promoted entry as disconnected when interior node is removed', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'myWidget']])
|
||||
@@ -750,6 +921,7 @@ describe('disconnected state', () => {
|
||||
|
||||
// Remove the interior node from the subgraph
|
||||
subgraphNode.subgraph.remove(innerNodes[0])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].type).toBe('button')
|
||||
})
|
||||
|
||||
@@ -767,16 +939,11 @@ describe('disconnected state', () => {
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
})
|
||||
|
||||
test('options returns empty object when disconnected', () => {
|
||||
test('keeps missing source-node promotions as disconnected views', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
setPromotions(subgraphNode, [['999', 'ghost']])
|
||||
expect(subgraphNode.widgets[0].options).toEqual({})
|
||||
})
|
||||
|
||||
test('tooltip returns undefined when disconnected', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
setPromotions(subgraphNode, [['999', 'ghost']])
|
||||
expect(subgraphNode.widgets[0].tooltip).toBeUndefined()
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].type).toBe('button')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -786,6 +953,381 @@ function createFakeCanvasContext() {
|
||||
})
|
||||
}
|
||||
|
||||
function createInspectableCanvasContext(fillText = vi.fn()) {
|
||||
const fallback = vi.fn()
|
||||
return new Proxy(
|
||||
{
|
||||
fillText,
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
measureText: (text: string) => ({ width: text.length * 8 }),
|
||||
fillStyle: '#fff',
|
||||
strokeStyle: '#fff',
|
||||
textAlign: 'left',
|
||||
globalAlpha: 1,
|
||||
lineWidth: 1
|
||||
} as Record<string, unknown>,
|
||||
{
|
||||
get(target, key) {
|
||||
if (typeof key === 'string' && key in target)
|
||||
return target[key as keyof typeof target]
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
) as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
function createTwoLevelNestedSubgraph() {
|
||||
const subgraphA = createTestSubgraph({
|
||||
inputs: [{ name: 'a_input', type: '*' }]
|
||||
})
|
||||
const innerNode = new LGraphNode('InnerComboNode')
|
||||
const innerInput = innerNode.addInput('picker_input', '*')
|
||||
const comboWidget = innerNode.addWidget('combo', 'picker', 'a', () => {}, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
innerInput.widget = { name: 'picker' }
|
||||
subgraphA.add(innerNode)
|
||||
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
|
||||
|
||||
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 })
|
||||
|
||||
const subgraphB = createTestSubgraph({
|
||||
inputs: [{ name: 'b_input', type: '*' }]
|
||||
})
|
||||
subgraphB.add(subgraphNodeA)
|
||||
subgraphNodeA._internalConfigureAfterSlots()
|
||||
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
||||
|
||||
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 })
|
||||
return { innerNode, comboWidget, subgraphNodeB }
|
||||
}
|
||||
|
||||
describe('promoted combo rendering', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('draw shows value even when interior combo is computedDisabled', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const comboWidget = innerNode.addWidget('combo', 'picker', 'a', () => {}, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
|
||||
// Simulates source widgets connected to subgraph inputs.
|
||||
comboWidget.computedDisabled = true
|
||||
setPromotions(subgraphNode, [[String(innerNode.id), 'picker']])
|
||||
|
||||
const fillText = vi.fn()
|
||||
const ctx = createInspectableCanvasContext(fillText)
|
||||
subgraphNode.widgets[0].draw?.(
|
||||
ctx,
|
||||
subgraphNode,
|
||||
260,
|
||||
0,
|
||||
LiteGraph.NODE_WIDGET_HEIGHT,
|
||||
false
|
||||
)
|
||||
|
||||
const renderedText = fillText.mock.calls.map((call) => call[0])
|
||||
expect(renderedText).toContain('a')
|
||||
})
|
||||
|
||||
test('draw shows value through two input-based promotion layers', () => {
|
||||
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
|
||||
comboWidget.computedDisabled = true
|
||||
const fillText = vi.fn()
|
||||
const ctx = createInspectableCanvasContext(fillText)
|
||||
|
||||
subgraphNodeB.widgets[0].draw?.(
|
||||
ctx,
|
||||
subgraphNodeB,
|
||||
260,
|
||||
0,
|
||||
LiteGraph.NODE_WIDGET_HEIGHT,
|
||||
false
|
||||
)
|
||||
|
||||
const renderedText = fillText.mock.calls.map((call) => call[0])
|
||||
expect(renderedText).toContain('a')
|
||||
})
|
||||
|
||||
test('value updates propagate through two promoted input layers', () => {
|
||||
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
|
||||
comboWidget.computedDisabled = true
|
||||
const promotedWidget = subgraphNodeB.widgets[0]
|
||||
|
||||
expect(promotedWidget.value).toBe('a')
|
||||
promotedWidget.value = 'b'
|
||||
expect(comboWidget.value).toBe('b')
|
||||
|
||||
const fillText = vi.fn()
|
||||
const ctx = createInspectableCanvasContext(fillText)
|
||||
promotedWidget.draw?.(
|
||||
ctx,
|
||||
subgraphNodeB,
|
||||
260,
|
||||
0,
|
||||
LiteGraph.NODE_WIDGET_HEIGHT,
|
||||
false
|
||||
)
|
||||
|
||||
const renderedText = fillText.mock.calls.map((call) => call[0])
|
||||
expect(renderedText).toContain('b')
|
||||
})
|
||||
|
||||
test('draw projection recovers after transient button fallback in nested promotion', () => {
|
||||
const { innerNode, subgraphNodeB } = createTwoLevelNestedSubgraph()
|
||||
const promotedWidget = subgraphNodeB.widgets[0]
|
||||
|
||||
// Force a transient disconnect to project a fallback widget once.
|
||||
innerNode.widgets = []
|
||||
promotedWidget.draw?.(
|
||||
createInspectableCanvasContext(),
|
||||
subgraphNodeB,
|
||||
260,
|
||||
0,
|
||||
LiteGraph.NODE_WIDGET_HEIGHT,
|
||||
false
|
||||
)
|
||||
|
||||
// Restore the concrete widget and verify draw reflects recovery.
|
||||
innerNode.addWidget('combo', 'picker', 'a', () => {}, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
const fillText = vi.fn()
|
||||
promotedWidget.draw?.(
|
||||
createInspectableCanvasContext(fillText),
|
||||
subgraphNodeB,
|
||||
260,
|
||||
0,
|
||||
LiteGraph.NODE_WIDGET_HEIGHT,
|
||||
false
|
||||
)
|
||||
|
||||
const renderedText = fillText.mock.calls.map((call) => call[0])
|
||||
expect(renderedText).toContain('a')
|
||||
})
|
||||
|
||||
test('state lookup behavior resolves to deepest promoted widget source', () => {
|
||||
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
|
||||
|
||||
const promotedWidget = subgraphNodeB.widgets[0]
|
||||
expect(promotedWidget.value).toBe('a')
|
||||
|
||||
comboWidget.value = 'b'
|
||||
expect(promotedWidget.value).toBe('b')
|
||||
})
|
||||
|
||||
test('state lookup does not use promotion store fallback when intermediate view is unavailable', () => {
|
||||
const subgraphA = createTestSubgraph({
|
||||
inputs: [{ name: 'strength_model', type: '*' }]
|
||||
})
|
||||
const innerNode = new LGraphNode('InnerNumberNode')
|
||||
const innerInput = innerNode.addInput('strength_model', '*')
|
||||
innerNode.addWidget('number', 'strength_model', 1, () => {})
|
||||
innerInput.widget = { name: 'strength_model' }
|
||||
subgraphA.add(innerNode)
|
||||
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
|
||||
|
||||
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 })
|
||||
|
||||
const subgraphB = createTestSubgraph({
|
||||
inputs: [{ name: 'strength_model', type: '*' }]
|
||||
})
|
||||
subgraphB.add(subgraphNodeA)
|
||||
subgraphNodeA._internalConfigureAfterSlots()
|
||||
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
||||
|
||||
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 })
|
||||
|
||||
// Simulate transient stale intermediate view state by forcing host 47
|
||||
// to report no promoted widgets while promotionStore still has entries.
|
||||
Object.defineProperty(subgraphNodeA, 'widgets', {
|
||||
get: () => [],
|
||||
configurable: true
|
||||
})
|
||||
|
||||
expect(subgraphNodeB.widgets[0].type).toBe('button')
|
||||
})
|
||||
|
||||
test('state lookup does not use input-widget fallback when intermediate promotions are absent', () => {
|
||||
const subgraphA = createTestSubgraph({
|
||||
inputs: [{ name: 'strength_model', type: '*' }]
|
||||
})
|
||||
const innerNode = new LGraphNode('InnerNumberNode')
|
||||
const innerInput = innerNode.addInput('strength_model', '*')
|
||||
innerNode.addWidget('number', 'strength_model', 1, () => {})
|
||||
innerInput.widget = { name: 'strength_model' }
|
||||
subgraphA.add(innerNode)
|
||||
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
|
||||
|
||||
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 })
|
||||
|
||||
const subgraphB = createTestSubgraph({
|
||||
inputs: [{ name: 'strength_model', type: '*' }]
|
||||
})
|
||||
subgraphB.add(subgraphNodeA)
|
||||
subgraphNodeA._internalConfigureAfterSlots()
|
||||
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
||||
|
||||
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 })
|
||||
|
||||
// Simulate a transient where intermediate promotions are unavailable but
|
||||
// input _widget binding is already updated.
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNodeA.rootGraph.id,
|
||||
subgraphNodeA.id,
|
||||
[]
|
||||
)
|
||||
Object.defineProperty(subgraphNodeA, 'widgets', {
|
||||
get: () => [],
|
||||
configurable: true
|
||||
})
|
||||
|
||||
expect(subgraphNodeB.widgets[0].type).toBe('button')
|
||||
})
|
||||
|
||||
test('state lookup does not use subgraph-link fallback when intermediate bindings are unavailable', () => {
|
||||
const subgraphA = createTestSubgraph({
|
||||
inputs: [{ name: 'strength_model', type: '*' }]
|
||||
})
|
||||
const innerNode = new LGraphNode('InnerNumberNode')
|
||||
const innerInput = innerNode.addInput('strength_model', '*')
|
||||
innerNode.addWidget('number', 'strength_model', 1, () => {})
|
||||
innerInput.widget = { name: 'strength_model' }
|
||||
subgraphA.add(innerNode)
|
||||
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
|
||||
|
||||
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 })
|
||||
|
||||
const subgraphB = createTestSubgraph({
|
||||
inputs: [{ name: 'strength_model', type: '*' }]
|
||||
})
|
||||
subgraphB.add(subgraphNodeA)
|
||||
subgraphNodeA._internalConfigureAfterSlots()
|
||||
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
||||
|
||||
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 })
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNodeA.rootGraph.id,
|
||||
subgraphNodeA.id,
|
||||
[]
|
||||
)
|
||||
Object.defineProperty(subgraphNodeA, 'widgets', {
|
||||
get: () => [],
|
||||
configurable: true
|
||||
})
|
||||
subgraphNodeA.inputs[0]._widget = undefined
|
||||
|
||||
expect(subgraphNodeB.widgets[0].type).toBe('button')
|
||||
})
|
||||
|
||||
test('nested promotion keeps concrete widget types at top level', () => {
|
||||
const subgraphA = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'lora_name', type: '*' },
|
||||
{ name: 'strength_model', type: '*' }
|
||||
]
|
||||
})
|
||||
const innerNode = new LGraphNode('InnerLoraNode')
|
||||
const comboInput = innerNode.addInput('lora_name', '*')
|
||||
const numberInput = innerNode.addInput('strength_model', '*')
|
||||
innerNode.addWidget('combo', 'lora_name', 'a', () => {}, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
innerNode.addWidget('number', 'strength_model', 1, () => {})
|
||||
comboInput.widget = { name: 'lora_name' }
|
||||
numberInput.widget = { name: 'strength_model' }
|
||||
subgraphA.add(innerNode)
|
||||
subgraphA.inputNode.slots[0].connect(comboInput, innerNode)
|
||||
subgraphA.inputNode.slots[1].connect(numberInput, innerNode)
|
||||
|
||||
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 60 })
|
||||
|
||||
const subgraphB = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'lora_name', type: '*' },
|
||||
{ name: 'strength_model', type: '*' }
|
||||
]
|
||||
})
|
||||
subgraphB.add(subgraphNodeA)
|
||||
subgraphNodeA._internalConfigureAfterSlots()
|
||||
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
||||
subgraphB.inputNode.slots[1].connect(subgraphNodeA.inputs[1], subgraphNodeA)
|
||||
|
||||
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 61 })
|
||||
|
||||
expect(subgraphNodeB.widgets[0].type).toBe('combo')
|
||||
expect(subgraphNodeB.widgets[1].type).toBe('number')
|
||||
})
|
||||
|
||||
test('input promotion from promoted view stores immediate source node id', () => {
|
||||
const subgraphA = createTestSubgraph({
|
||||
inputs: [{ name: 'lora_name', type: '*' }]
|
||||
})
|
||||
const innerNode = new LGraphNode('InnerNode')
|
||||
const innerInput = innerNode.addInput('lora_name', '*')
|
||||
innerNode.addWidget('combo', 'lora_name', 'a', () => {}, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
innerInput.widget = { name: 'lora_name' }
|
||||
subgraphA.add(innerNode)
|
||||
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
|
||||
|
||||
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 70 })
|
||||
|
||||
const subgraphB = createTestSubgraph({
|
||||
inputs: [{ name: 'lora_name', type: '*' }]
|
||||
})
|
||||
subgraphB.add(subgraphNodeA)
|
||||
subgraphNodeA._internalConfigureAfterSlots()
|
||||
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
||||
|
||||
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 71 })
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNodeB.rootGraph.id,
|
||||
subgraphNodeB.id
|
||||
)
|
||||
|
||||
expect(promotions).toContainEqual({
|
||||
interiorNodeId: String(subgraphNodeA.id),
|
||||
widgetName: 'lora_name'
|
||||
})
|
||||
expect(promotions).not.toContainEqual({
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'lora_name'
|
||||
})
|
||||
})
|
||||
|
||||
test('resolvePromotedWidgetSource is safe for detached subgraph hosts', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
const promotedView = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'999',
|
||||
'missingWidget'
|
||||
)
|
||||
|
||||
subgraphNode.graph = null
|
||||
|
||||
expect(() =>
|
||||
resolvePromotedWidgetSource(subgraphNode, promotedView)
|
||||
).not.toThrow()
|
||||
expect(
|
||||
resolvePromotedWidgetSource(subgraphNode, promotedView)
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOM widget promotion', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
@@ -13,23 +13,16 @@ import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
export { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
function resolve(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): { node: LGraphNode; widget: IBaseWidget } | undefined {
|
||||
const node = subgraphNode.subgraph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
const widget = node.widgets?.find((w: IBaseWidget) => w.name === widgetName)
|
||||
return widget ? { node, widget } : undefined
|
||||
}
|
||||
|
||||
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
|
||||
if (value === undefined) return true
|
||||
if (typeof value === 'string') return true
|
||||
@@ -46,6 +39,8 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
|
||||
return 'mouse' in widget && typeof widget.mouse === 'function'
|
||||
}
|
||||
|
||||
const designTokenCache = new Map<string, string>()
|
||||
|
||||
export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
@@ -67,12 +62,15 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
computedHeight?: number
|
||||
|
||||
private readonly graphId: string
|
||||
private readonly bareNodeId: NodeId
|
||||
private yValue = 0
|
||||
private _computedDisabled = false
|
||||
|
||||
private projectedSourceNode?: LGraphNode
|
||||
private projectedSourceWidget?: IBaseWidget
|
||||
private projectedSourceWidgetType?: IBaseWidget['type']
|
||||
private projectedWidget?: BaseWidget
|
||||
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
|
||||
private cachedDeepestFrame = -1
|
||||
|
||||
constructor(
|
||||
private readonly subgraphNode: SubgraphNode,
|
||||
@@ -83,7 +81,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
this.sourceNodeId = nodeId
|
||||
this.sourceWidgetName = widgetName
|
||||
this.graphId = subgraphNode.rootGraph.id
|
||||
this.bareNodeId = stripGraphPrefix(nodeId)
|
||||
}
|
||||
|
||||
get node(): SubgraphNode {
|
||||
@@ -103,32 +100,34 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
this.syncDomOverride()
|
||||
}
|
||||
|
||||
get computedDisabled(): false {
|
||||
return false
|
||||
get computedDisabled(): boolean {
|
||||
return this._computedDisabled
|
||||
}
|
||||
|
||||
set computedDisabled(_value: boolean | undefined) {}
|
||||
set computedDisabled(value: boolean | undefined) {
|
||||
this._computedDisabled = value ?? false
|
||||
}
|
||||
|
||||
get type(): IBaseWidget['type'] {
|
||||
return this.resolve()?.widget.type ?? 'button'
|
||||
return this.resolveDeepest()?.widget.type ?? 'button'
|
||||
}
|
||||
|
||||
get options(): IBaseWidget['options'] {
|
||||
return this.resolve()?.widget.options ?? {}
|
||||
return this.resolveDeepest()?.widget.options ?? {}
|
||||
}
|
||||
|
||||
get tooltip(): string | undefined {
|
||||
return this.resolve()?.widget.tooltip
|
||||
return this.resolveDeepest()?.widget.tooltip
|
||||
}
|
||||
|
||||
get linkedWidgets(): IBaseWidget[] | undefined {
|
||||
return this.resolve()?.widget.linkedWidgets
|
||||
return this.resolveDeepest()?.widget.linkedWidgets
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolve()?.widget.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
@@ -138,7 +137,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = this.resolve()
|
||||
const resolved = this.resolveAtHost()
|
||||
if (resolved && isWidgetValue(value)) {
|
||||
resolved.widget.value = value
|
||||
}
|
||||
@@ -155,18 +154,18 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this.resolve()?.widget.hidden ?? false
|
||||
return this.resolveDeepest()?.widget.hidden ?? false
|
||||
}
|
||||
|
||||
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
|
||||
const resolved = this.resolve()
|
||||
const resolved = this.resolveDeepest()
|
||||
const computeLayoutSize = resolved?.widget.computeLayoutSize
|
||||
if (!computeLayoutSize) return undefined
|
||||
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
|
||||
}
|
||||
|
||||
get computeSize(): IBaseWidget['computeSize'] {
|
||||
const resolved = this.resolve()
|
||||
const resolved = this.resolveDeepest()
|
||||
const computeSize = resolved?.widget.computeSize
|
||||
if (!computeSize) return undefined
|
||||
return (width?: number) => computeSize.call(resolved.widget, width)
|
||||
@@ -180,7 +179,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
H: number,
|
||||
lowQuality?: boolean
|
||||
): void {
|
||||
const resolved = this.resolve()
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) {
|
||||
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
|
||||
return
|
||||
@@ -193,9 +192,11 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
|
||||
const originalY = projected.y
|
||||
const originalComputedHeight = projected.computedHeight
|
||||
const originalComputedDisabled = projected.computedDisabled
|
||||
|
||||
projected.y = this.y
|
||||
projected.computedHeight = this.computedHeight
|
||||
projected.computedDisabled = this.computedDisabled
|
||||
projected.value = this.value
|
||||
|
||||
projected.drawWidget(ctx, {
|
||||
@@ -207,6 +208,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
|
||||
projected.y = originalY
|
||||
projected.computedHeight = originalComputedHeight
|
||||
projected.computedDisabled = originalComputedDisabled
|
||||
}
|
||||
|
||||
onPointerDown(
|
||||
@@ -214,7 +216,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
_node: LGraphNode,
|
||||
canvas: LGraphCanvas
|
||||
): boolean {
|
||||
const resolved = this.resolve()
|
||||
const resolved = this.resolveAtHost()
|
||||
if (!resolved) return false
|
||||
|
||||
const interior = resolved.widget
|
||||
@@ -240,18 +242,48 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
pos?: Point,
|
||||
e?: CanvasPointerEvent
|
||||
) {
|
||||
this.resolve()?.widget.callback?.(value, canvas, node, pos, e)
|
||||
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
|
||||
}
|
||||
|
||||
private resolve(): { node: LGraphNode; widget: IBaseWidget } | undefined {
|
||||
return resolve(this.subgraphNode, this.sourceNodeId, this.sourceWidgetName)
|
||||
private resolveAtHost():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
return resolvePromotedWidgetAtHost(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
private resolveDeepest():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
const frame = this.subgraphNode.rootGraph.primaryCanvas?.frame
|
||||
if (frame !== undefined && this.cachedDeepestFrame === frame)
|
||||
return this.cachedDeepestByFrame
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
)
|
||||
const resolved = result.status === 'resolved' ? result.resolved : undefined
|
||||
|
||||
if (frame !== undefined) {
|
||||
this.cachedDeepestFrame = frame
|
||||
this.cachedDeepestByFrame = resolved
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) return undefined
|
||||
return useWidgetValueStore().getWidget(
|
||||
this.graphId,
|
||||
this.bareNodeId,
|
||||
this.sourceWidgetName
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
}
|
||||
|
||||
@@ -262,7 +294,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
const shouldRebuild =
|
||||
!this.projectedWidget ||
|
||||
this.projectedSourceNode !== resolved.node ||
|
||||
this.projectedSourceWidget !== resolved.widget
|
||||
this.projectedSourceWidget !== resolved.widget ||
|
||||
this.projectedSourceWidgetType !== resolved.widget.type
|
||||
|
||||
if (!shouldRebuild) return this.projectedWidget
|
||||
|
||||
@@ -271,12 +304,14 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
this.projectedWidget = undefined
|
||||
this.projectedSourceNode = undefined
|
||||
this.projectedSourceWidget = undefined
|
||||
this.projectedSourceWidgetType = undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
|
||||
this.projectedSourceNode = resolved.node
|
||||
this.projectedSourceWidget = resolved.widget
|
||||
this.projectedSourceWidgetType = resolved.widget.type
|
||||
return this.projectedWidget
|
||||
}
|
||||
|
||||
@@ -333,7 +368,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
private syncDomOverride(
|
||||
resolved:
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined = this.resolve()
|
||||
| undefined = this.resolveAtHost()
|
||||
) {
|
||||
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
|
||||
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
|
||||
@@ -356,13 +391,35 @@ function drawDisconnectedPlaceholder(
|
||||
y: number,
|
||||
H: number
|
||||
) {
|
||||
const backgroundColor = readDesignToken(
|
||||
'--color-secondary-background',
|
||||
'#333'
|
||||
)
|
||||
const textColor = readDesignToken('--color-text-secondary', '#999')
|
||||
const fontSize = readDesignToken('--text-xxs', '11px')
|
||||
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
|
||||
|
||||
ctx.save()
|
||||
ctx.fillStyle = '#333'
|
||||
ctx.fillStyle = backgroundColor
|
||||
ctx.fillRect(15, y, width - 30, H)
|
||||
ctx.fillStyle = '#999'
|
||||
ctx.font = '11px monospace'
|
||||
ctx.fillStyle = textColor
|
||||
ctx.font = `${fontSize} ${fontFamily}`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function readDesignToken(token: string, fallback: string): string {
|
||||
if (typeof document === 'undefined') return fallback
|
||||
|
||||
const cachedValue = designTokenCache.get(token)
|
||||
if (cachedValue) return cachedValue
|
||||
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
const resolvedValue = value || fallback
|
||||
designTokenCache.set(token, resolvedValue)
|
||||
return resolvedValue
|
||||
}
|
||||
|
||||
257
src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts
Normal file
257
src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({ widgetStates: new Map() })
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
type PromotedWidgetStub = Pick<
|
||||
IBaseWidget,
|
||||
'name' | 'type' | 'options' | 'value' | 'y'
|
||||
> & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
node?: SubgraphNode
|
||||
}
|
||||
|
||||
function createHostNode(id: number): SubgraphNode {
|
||||
return createTestSubgraphNode(createTestSubgraph(), { id })
|
||||
}
|
||||
|
||||
function addNodeToHost(host: SubgraphNode, title: string): LGraphNode {
|
||||
const node = new LGraphNode(title)
|
||||
host.subgraph.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
function addConcreteWidget(node: LGraphNode, name: string): IBaseWidget {
|
||||
return node.addWidget('text', name, `${name}-value`, () => undefined)
|
||||
}
|
||||
|
||||
function createPromotedWidget(
|
||||
name: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
node?: SubgraphNode
|
||||
): IBaseWidget {
|
||||
const promotedWidget: PromotedWidgetStub = {
|
||||
name,
|
||||
type: 'button',
|
||||
options: {},
|
||||
y: 0,
|
||||
value: undefined,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
node
|
||||
}
|
||||
return promotedWidget as IBaseWidget
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('resolvePromotedWidgetAtHost', () => {
|
||||
test('resolves a direct concrete widget on the host subgraph node', () => {
|
||||
const host = createHostNode(100)
|
||||
const concreteNode = addNodeToHost(host, 'leaf')
|
||||
addConcreteWidget(concreteNode, 'seed')
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(
|
||||
host,
|
||||
String(concreteNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node.id).toBe(concreteNode.id)
|
||||
expect(resolved?.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('returns undefined when host does not contain the target node', () => {
|
||||
const host = createHostNode(100)
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(host, 'missing', 'seed')
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveConcretePromotedWidget', () => {
|
||||
test('resolves a direct concrete source widget', () => {
|
||||
const host = createHostNode(100)
|
||||
const concreteNode = addNodeToHost(host, 'leaf')
|
||||
addConcreteWidget(concreteNode, 'seed')
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
host,
|
||||
String(concreteNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
expect(result.resolved.node.id).toBe(concreteNode.id)
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('descends through nested promoted widgets to resolve concrete source', () => {
|
||||
const rootHost = createHostNode(100)
|
||||
const nestedHost = createHostNode(101)
|
||||
const leafNode = addNodeToHost(nestedHost, 'leaf')
|
||||
addConcreteWidget(leafNode, 'seed')
|
||||
const sourceNode = addNodeToHost(rootHost, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('outer', String(leafNode.id), 'seed', nestedHost)
|
||||
]
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
rootHost,
|
||||
String(sourceNode.id),
|
||||
'outer'
|
||||
)
|
||||
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
expect(result.resolved.node.id).toBe(leafNode.id)
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('returns cycle failure when promoted widgets form a loop', () => {
|
||||
const hostA = createHostNode(200)
|
||||
const hostB = createHostNode(201)
|
||||
const relayA = addNodeToHost(hostA, 'relayA')
|
||||
const relayB = addNodeToHost(hostB, 'relayB')
|
||||
|
||||
relayA.widgets = [
|
||||
createPromotedWidget('wA', String(relayB.id), 'wB', hostB)
|
||||
]
|
||||
relayB.widgets = [
|
||||
createPromotedWidget('wB', String(relayA.id), 'wA', hostA)
|
||||
]
|
||||
|
||||
const result = resolveConcretePromotedWidget(hostA, String(relayA.id), 'wA')
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'cycle'
|
||||
})
|
||||
})
|
||||
|
||||
test('does not report a cycle when different host objects share an id', () => {
|
||||
const rootHost = createHostNode(41)
|
||||
const nestedHost = createHostNode(41)
|
||||
const leafNode = addNodeToHost(nestedHost, 'leaf')
|
||||
addConcreteWidget(leafNode, 'w')
|
||||
const sourceNode = addNodeToHost(rootHost, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('w', String(leafNode.id), 'w', nestedHost)
|
||||
]
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
rootHost,
|
||||
String(sourceNode.id),
|
||||
'w'
|
||||
)
|
||||
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
|
||||
expect(result.resolved.node.id).toBe(leafNode.id)
|
||||
expect(result.resolved.widget.name).toBe('w')
|
||||
})
|
||||
|
||||
test('returns max-depth-exceeded for very deep non-cyclic promoted chains', () => {
|
||||
const hosts = Array.from({ length: 102 }, (_, index) =>
|
||||
createHostNode(index + 1)
|
||||
)
|
||||
const relayNodes = hosts.map((host, index) =>
|
||||
addNodeToHost(host, `relay-${index}`)
|
||||
)
|
||||
|
||||
for (let index = 0; index < relayNodes.length - 1; index += 1) {
|
||||
relayNodes[index].widgets = [
|
||||
createPromotedWidget(
|
||||
`w-${index}`,
|
||||
String(relayNodes[index + 1].id),
|
||||
`w-${index + 1}`,
|
||||
hosts[index + 1]
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
addConcreteWidget(
|
||||
relayNodes[relayNodes.length - 1],
|
||||
`w-${relayNodes.length - 1}`
|
||||
)
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hosts[0],
|
||||
String(relayNodes[0].id),
|
||||
'w-0'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'max-depth-exceeded'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns invalid-host for non-subgraph host node', () => {
|
||||
const host = new LGraphNode('plain-host')
|
||||
|
||||
const result = resolveConcretePromotedWidget(host, 'x', 'y')
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'invalid-host'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns missing-node when source node does not exist in host subgraph', () => {
|
||||
const host = createHostNode(100)
|
||||
|
||||
const result = resolveConcretePromotedWidget(host, 'missing-node', 'seed')
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'missing-node'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns missing-widget when source node exists but widget cannot be resolved', () => {
|
||||
const host = createHostNode(100)
|
||||
const sourceNode = addNodeToHost(host, 'source')
|
||||
sourceNode.widgets = []
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
host,
|
||||
String(sourceNode.id),
|
||||
'missing-widget'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'missing-widget'
|
||||
})
|
||||
})
|
||||
})
|
||||
102
src/core/graph/subgraph/resolveConcretePromotedWidget.ts
Normal file
102
src/core/graph/subgraph/resolveConcretePromotedWidget.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type PromotedWidgetResolutionFailure =
|
||||
| 'invalid-host'
|
||||
| 'cycle'
|
||||
| 'missing-node'
|
||||
| 'missing-widget'
|
||||
| 'max-depth-exceeded'
|
||||
|
||||
type PromotedWidgetResolutionResult =
|
||||
| { status: 'resolved'; resolved: ResolvedPromotedWidget }
|
||||
| { status: 'failure'; failure: PromotedWidgetResolutionFailure }
|
||||
|
||||
const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
|
||||
|
||||
function traversePromotedWidgetChain(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): PromotedWidgetResolutionResult {
|
||||
const visited = new Set<string>()
|
||||
const hostUidByObject = new WeakMap<SubgraphNode, number>()
|
||||
let nextHostUid = 0
|
||||
let currentHost = hostNode
|
||||
let currentNodeId = nodeId
|
||||
let currentWidgetName = widgetName
|
||||
|
||||
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
|
||||
let hostUid = hostUidByObject.get(currentHost)
|
||||
if (hostUid === undefined) {
|
||||
hostUid = nextHostUid
|
||||
nextHostUid += 1
|
||||
hostUidByObject.set(currentHost, hostUid)
|
||||
}
|
||||
|
||||
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}`
|
||||
if (visited.has(key)) {
|
||||
return { status: 'failure', failure: 'cycle' }
|
||||
}
|
||||
visited.add(key)
|
||||
|
||||
const sourceNode = currentHost.subgraph.getNodeById(currentNodeId)
|
||||
if (!sourceNode) {
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
const sourceWidget = sourceNode.widgets?.find(
|
||||
(entry) => entry.name === currentWidgetName
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
}
|
||||
|
||||
if (!isPromotedWidgetView(sourceWidget)) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
resolved: { node: sourceNode, widget: sourceWidget }
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceWidget.node?.isSubgraphNode()) {
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
currentHost = sourceWidget.node
|
||||
currentNodeId = sourceWidget.sourceNodeId
|
||||
currentWidgetName = sourceWidget.sourceWidgetName
|
||||
}
|
||||
|
||||
return { status: 'failure', failure: 'max-depth-exceeded' }
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetAtHost(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
const node = hostNode.subgraph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
|
||||
const widget = node.widgets?.find(
|
||||
(entry: IBaseWidget) => entry.name === widgetName
|
||||
)
|
||||
if (!widget) return undefined
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
export function resolveConcretePromotedWidget(
|
||||
hostNode: LGraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): PromotedWidgetResolutionResult {
|
||||
if (!hostNode.isSubgraphNode()) {
|
||||
return { status: 'failure', failure: 'invalid-host' }
|
||||
}
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
|
||||
}
|
||||
@@ -1,29 +1,22 @@
|
||||
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
interface ResolvedPromotedWidgetSource {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetSource(
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): ResolvedPromotedWidgetSource | undefined {
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
if (!isPromotedWidgetView(widget)) return undefined
|
||||
if (!hostNode.isSubgraphNode()) return undefined
|
||||
|
||||
const sourceNode = hostNode.subgraph.getNodeById(widget.sourceNodeId)
|
||||
if (!sourceNode) return undefined
|
||||
|
||||
const sourceWidget = sourceNode.widgets?.find(
|
||||
(entry) => entry.name === widget.sourceWidgetName
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
if (!sourceWidget) return undefined
|
||||
if (result.status === 'resolved') return result.resolved
|
||||
|
||||
return {
|
||||
node: sourceNode,
|
||||
widget: sourceWidget
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
147
src/core/graph/subgraph/resolveSubgraphInputLink.test.ts
Normal file
147
src/core/graph/subgraph/resolveSubgraphInputLink.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { resolveSubgraphInputLink } from '@/core/graph/subgraph/resolveSubgraphInputLink'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({ widgetStates: new Map() })
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function createSubgraphSetup(inputName: string): {
|
||||
subgraph: Subgraph
|
||||
subgraphNode: SubgraphNode
|
||||
} {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: inputName, type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 1 })
|
||||
return { subgraph, subgraphNode }
|
||||
}
|
||||
|
||||
function addLinkedInteriorInput(
|
||||
subgraph: Subgraph,
|
||||
inputName: string,
|
||||
linkedInputName: string,
|
||||
widgetName: string
|
||||
): {
|
||||
node: LGraphNode
|
||||
linkId: number
|
||||
} {
|
||||
const inputSlot = subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === inputName
|
||||
)
|
||||
if (!inputSlot) throw new Error(`Missing subgraph input slot: ${inputName}`)
|
||||
|
||||
const node = new LGraphNode(`Interior-${linkedInputName}`)
|
||||
const input = node.addInput(linkedInputName, '*')
|
||||
node.addWidget('text', widgetName, '', () => undefined)
|
||||
input.widget = { name: widgetName }
|
||||
subgraph.add(node)
|
||||
inputSlot.connect(input, node)
|
||||
|
||||
if (input.link == null)
|
||||
throw new Error(`Expected link to be created for input ${linkedInputName}`)
|
||||
|
||||
return { node, linkId: input.link }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('resolveSubgraphInputLink', () => {
|
||||
test('returns undefined for non-subgraph nodes', () => {
|
||||
const node = new LGraphNode('plain-node')
|
||||
|
||||
const result = resolveSubgraphInputLink(node, 'missing', () => 'resolved')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns undefined when input slot is missing', () => {
|
||||
const { subgraphNode } = createSubgraphSetup('existing')
|
||||
|
||||
const result = resolveSubgraphInputLink(
|
||||
subgraphNode,
|
||||
'missing',
|
||||
() => 'resolved'
|
||||
)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('skips stale links where inputNode.inputs is unavailable', () => {
|
||||
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'seed_input', 'seed')
|
||||
const stale = addLinkedInteriorInput(
|
||||
subgraph,
|
||||
'prompt',
|
||||
'stale_input',
|
||||
'stale'
|
||||
)
|
||||
|
||||
const originalGetLink = subgraph.getLink.bind(subgraph)
|
||||
vi.spyOn(subgraph, 'getLink').mockImplementation((linkId) => {
|
||||
if (typeof linkId !== 'number') return originalGetLink(linkId)
|
||||
if (linkId === stale.linkId) {
|
||||
return {
|
||||
resolve: () => ({
|
||||
inputNode: {
|
||||
inputs: undefined,
|
||||
getWidgetFromSlot: () => ({ name: 'ignored' })
|
||||
}
|
||||
})
|
||||
} as unknown as ReturnType<typeof subgraph.getLink>
|
||||
}
|
||||
|
||||
return originalGetLink(linkId)
|
||||
})
|
||||
|
||||
const result = resolveSubgraphInputLink(
|
||||
subgraphNode,
|
||||
'prompt',
|
||||
({ targetInput }) => targetInput.name
|
||||
)
|
||||
|
||||
expect(result).toBe('seed_input')
|
||||
})
|
||||
|
||||
test('caches getTargetWidget result within the same callback evaluation', () => {
|
||||
const { subgraph, subgraphNode } = createSubgraphSetup('model')
|
||||
const linked = addLinkedInteriorInput(
|
||||
subgraph,
|
||||
'model',
|
||||
'model_input',
|
||||
'modelWidget'
|
||||
)
|
||||
const getWidgetFromSlot = vi.spyOn(linked.node, 'getWidgetFromSlot')
|
||||
|
||||
const result = resolveSubgraphInputLink(
|
||||
subgraphNode,
|
||||
'model',
|
||||
({ getTargetWidget }) => {
|
||||
expect(getTargetWidget()?.name).toBe('modelWidget')
|
||||
expect(getTargetWidget()?.name).toBe('modelWidget')
|
||||
return 'ok'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toBe('ok')
|
||||
expect(getWidgetFromSlot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
55
src/core/graph/subgraph/resolveSubgraphInputLink.ts
Normal file
55
src/core/graph/subgraph/resolveSubgraphInputLink.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
type SubgraphInputLinkContext = {
|
||||
inputNode: LGraphNode
|
||||
targetInput: INodeInputSlot
|
||||
getTargetWidget: () => ReturnType<LGraphNode['getWidgetFromSlot']>
|
||||
}
|
||||
|
||||
export function resolveSubgraphInputLink<TResult>(
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
resolve: (context: SubgraphInputLinkContext) => TResult | undefined
|
||||
): TResult | undefined {
|
||||
if (!node.isSubgraphNode()) return undefined
|
||||
|
||||
const inputSlot = node.subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === inputName
|
||||
)
|
||||
if (!inputSlot) return undefined
|
||||
|
||||
// Iterate from newest to oldest so the latest connection wins.
|
||||
for (let index = inputSlot.linkIds.length - 1; index >= 0; index -= 1) {
|
||||
const linkId = inputSlot.linkIds[index]
|
||||
const link = node.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
const { inputNode } = link.resolve(node.subgraph)
|
||||
if (!inputNode) continue
|
||||
if (!Array.isArray(inputNode.inputs)) continue
|
||||
|
||||
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
|
||||
if (!targetInput) continue
|
||||
|
||||
let cachedTargetWidget:
|
||||
| ReturnType<LGraphNode['getWidgetFromSlot']>
|
||||
| undefined
|
||||
let hasCachedTargetWidget = false
|
||||
|
||||
const resolved = resolve({
|
||||
inputNode,
|
||||
targetInput,
|
||||
getTargetWidget: () => {
|
||||
if (!hasCachedTargetWidget) {
|
||||
cachedTargetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
hasCachedTargetWidget = true
|
||||
}
|
||||
return cachedTargetWidget
|
||||
}
|
||||
})
|
||||
if (resolved !== undefined) return resolved
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
34
src/core/graph/subgraph/resolveSubgraphInputTarget.ts
Normal file
34
src/core/graph/subgraph/resolveSubgraphInputTarget.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
|
||||
|
||||
type ResolvedSubgraphInputTarget = {
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
export function resolveSubgraphInputTarget(
|
||||
node: LGraphNode,
|
||||
inputName: string
|
||||
): ResolvedSubgraphInputTarget | undefined {
|
||||
return resolveSubgraphInputLink(
|
||||
node,
|
||||
inputName,
|
||||
({ inputNode, targetInput, getTargetWidget }) => {
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetWidget.name
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user