diff --git a/src/components/rightSidePanel/parameters/SectionWidgets.vue b/src/components/rightSidePanel/parameters/SectionWidgets.vue index 7a86a2f4cb2..e2768fcc194 100644 --- a/src/components/rightSidePanel/parameters/SectionWidgets.vue +++ b/src/components/rightSidePanel/parameters/SectionWidgets.vue @@ -23,6 +23,7 @@ import { HideLayoutFieldKey } from '@/types/widgetTypes' import { GetNodeParentGroupKey } from '../shared' import WidgetItem from './WidgetItem.vue' +import { getStableWidgetRenderKey } from '@/core/graph/subgraph/widgetRenderKey' const { label, @@ -272,7 +273,7 @@ defineExpose({ { expect(widgetData?.slotMetadata?.linked).toBe(false) }) + it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => { + const subgraph = createTestSubgraph({ + inputs: [ + { name: 'seed', type: '*' }, + { name: 'seed', type: '*' } + ] + }) + + const firstNode = new LGraphNode('FirstNode') + const firstInput = firstNode.addInput('seed', '*') + firstNode.addWidget('number', 'seed', 1, () => undefined, {}) + firstInput.widget = { name: 'seed' } + subgraph.add(firstNode) + + const secondNode = new LGraphNode('SecondNode') + const secondInput = secondNode.addInput('seed', '*') + secondNode.addWidget('number', 'seed', 2, () => undefined, {}) + secondInput.widget = { name: 'seed' } + subgraph.add(secondNode) + + subgraph.inputNode.slots[0].connect(firstInput, firstNode) + subgraph.inputNode.slots[1].connect(secondInput, secondNode) + + const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 }) + const graph = subgraphNode.graph + if (!graph) throw new Error('Expected subgraph node graph') + graph.add(subgraphNode) + + const promotedViews = subgraphNode.widgets + const secondPromotedView = promotedViews[1] + if (!secondPromotedView) throw new Error('Expected second promoted view') + + ;( + secondPromotedView as unknown as { + sourceNodeId: string + sourceWidgetName: string + } + ).sourceNodeId = '9999' + ;( + secondPromotedView as unknown as { + sourceNodeId: string + sourceWidgetName: string + } + ).sourceWidgetName = 'stale_widget' + + const { vueNodeData } = useGraphNodeManager(graph) + const nodeData = vueNodeData.get(String(subgraphNode.id)) + const secondMappedWidget = nodeData?.widgets?.find( + (widget) => widget.slotMetadata?.index === 1 + ) + if (!secondMappedWidget) + throw new Error('Expected mapped widget for slot 1') + + expect(secondMappedWidget.name).not.toBe('stale_widget') + }) + it('clears stale slotMetadata when input no longer matches widget', async () => { const { graph, node } = createWidgetInputGraph() const { vueNodeData } = useGraphNodeManager(graph) @@ -416,6 +472,56 @@ describe('Nested promoted widget mapping', () => { `${subgraphNodeB.subgraph.id}:${innerNode.id}` ) }) + + it('keeps linked and independent same-name promotions as distinct sources', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'string_a', type: '*' }] + }) + + const linkedNode = new LGraphNode('LinkedNode') + const linkedInput = linkedNode.addInput('string_a', '*') + linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {}) + linkedInput.widget = { name: 'string_a' } + subgraph.add(linkedNode) + subgraph.inputNode.slots[0].connect(linkedInput, linkedNode) + + const independentNode = new LGraphNode('IndependentNode') + independentNode.addWidget( + 'text', + 'string_a', + 'independent', + () => undefined, + {} + ) + subgraph.add(independentNode) + + const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 }) + const graph = subgraphNode.graph as LGraph + graph.add(subgraphNode) + + usePromotionStore().promote( + subgraphNode.rootGraph.id, + subgraphNode.id, + String(independentNode.id), + 'string_a' + ) + + const { vueNodeData } = useGraphNodeManager(graph) + const nodeData = vueNodeData.get(String(subgraphNode.id)) + const promotedWidgets = nodeData?.widgets?.filter( + (widget) => widget.name === 'string_a' + ) + + expect(promotedWidgets).toHaveLength(2) + expect( + new Set(promotedWidgets?.map((widget) => widget.storeNodeId)) + ).toEqual( + new Set([ + `${subgraph.id}:${linkedNode.id}`, + `${subgraph.id}:${independentNode.id}` + ]) + ) + }) }) describe('Promoted widget sourceExecutionId', () => { diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index a56812040f2..a708c77431e 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -7,6 +7,7 @@ import { reactive, shallowReactive } from 'vue' import { useChainCallback } from '@/composables/functional/useChainCallback' import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' +import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput' import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget' import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource' import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget' @@ -244,16 +245,17 @@ function safeWidgetMapper( } } - const promotedInputName = node.inputs?.find((input) => { - if (input.name === widget.name) return true - if (input._widget === widget) return true - return false - })?.name + const matchedInput = matchPromotedInput(node.inputs, widget) + const promotedInputName = matchedInput?.name const displayName = promotedInputName ?? widget.name - const promotedSource = resolvePromotedSourceByInputName(displayName) ?? { + const directSource = { sourceNodeId: widget.sourceNodeId, sourceWidgetName: widget.sourceWidgetName } + const promotedSource = + matchedInput?._widget === widget + ? (resolvePromotedSourceByInputName(displayName) ?? directSource) + : directSource return { displayName, diff --git a/src/core/graph/subgraph/matchPromotedInput.test.ts b/src/core/graph/subgraph/matchPromotedInput.test.ts new file mode 100644 index 00000000000..82787f4057a --- /dev/null +++ b/src/core/graph/subgraph/matchPromotedInput.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' + +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' + +import { matchPromotedInput } from './matchPromotedInput' + +type MockInput = { + name: string + _widget?: IBaseWidget +} + +function createWidget(name: string): IBaseWidget { + return { + name, + type: 'text' + } as IBaseWidget +} + +describe(matchPromotedInput, () => { + it('prefers exact _widget matches before same-name inputs', () => { + const targetWidget = createWidget('seed') + const aliasWidget = createWidget('seed') + + const aliasInput: MockInput = { + name: 'seed', + _widget: aliasWidget + } + const exactInput: MockInput = { + name: 'seed', + _widget: targetWidget + } + + const matched = matchPromotedInput( + [aliasInput, exactInput] as unknown as Array<{ + name: string + _widget?: IBaseWidget + }>, + targetWidget + ) + + expect(matched).toBe(exactInput) + }) + + it('falls back to same-name matching when no exact widget match exists', () => { + const targetWidget = createWidget('seed') + const aliasInput: MockInput = { + name: 'seed' + } + + const matched = matchPromotedInput( + [aliasInput] as unknown as Array<{ name: string; _widget?: IBaseWidget }>, + targetWidget + ) + + expect(matched).toBe(aliasInput) + }) + + it('does not guess when multiple same-name inputs exist without an exact match', () => { + const targetWidget = createWidget('seed') + const firstAliasInput: MockInput = { + name: 'seed' + } + const secondAliasInput: MockInput = { + name: 'seed' + } + + const matched = matchPromotedInput( + [firstAliasInput, secondAliasInput] as unknown as Array<{ + name: string + _widget?: IBaseWidget + }>, + targetWidget + ) + + expect(matched).toBeUndefined() + }) +}) diff --git a/src/core/graph/subgraph/matchPromotedInput.ts b/src/core/graph/subgraph/matchPromotedInput.ts new file mode 100644 index 00000000000..a426e4197c6 --- /dev/null +++ b/src/core/graph/subgraph/matchPromotedInput.ts @@ -0,0 +1,19 @@ +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' + +type PromotedInputLike = { + name: string + _widget?: IBaseWidget +} + +export function matchPromotedInput( + inputs: PromotedInputLike[] | undefined, + widget: IBaseWidget +): PromotedInputLike | undefined { + if (!inputs) return undefined + + const exactMatch = inputs.find((input) => input._widget === widget) + if (exactMatch) return exactMatch + + const sameNameMatches = inputs.filter((input) => input.name === widget.name) + return sameNameMatches.length === 1 ? sameNameMatches[0] : undefined +} diff --git a/src/core/graph/subgraph/promotedWidgetView.test.ts b/src/core/graph/subgraph/promotedWidgetView.test.ts index 463c7747f3c..0c2d5bf6205 100644 --- a/src/core/graph/subgraph/promotedWidgetView.test.ts +++ b/src/core/graph/subgraph/promotedWidgetView.test.ts @@ -1,6 +1,6 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' -import { beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' // Barrel import must come first to avoid circular dependency // (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel) @@ -11,19 +11,26 @@ import { } from '@/lib/litegraph/src/litegraph' import type { CanvasPointerEvent, + LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph' 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 { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget' import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource' import { usePromotionStore } from '@/stores/promotionStore' -import { useWidgetValueStore } from '@/stores/widgetValueStore' +import { + stripGraphPrefix, + useWidgetValueStore +} from '@/stores/widgetValueStore' import { + cleanupComplexPromotionFixtureNodeType, createTestSubgraph, - createTestSubgraphNode + createTestSubgraphNode, + setupComplexPromotionFixture } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' vi.mock('@/renderer/core/canvas/canvasStore', () => ({ @@ -78,6 +85,22 @@ function firstInnerNode(innerNodes: LGraphNode[]): LGraphNode { return innerNode } +function promotedWidgets(node: SubgraphNode): PromotedWidgetView[] { + return node.widgets as PromotedWidgetView[] +} + +function callSyncPromotions(node: SubgraphNode) { + ;( + node as unknown as { + _syncPromotions: () => void + } + )._syncPromotions() +} + +afterEach(() => { + cleanupComplexPromotionFixtureNodeType() +}) + describe(createPromotedWidgetView, () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) @@ -263,6 +286,31 @@ describe(createPromotedWidgetView, () => { expect(fallbackWidget.value).toBe('updated') }) + test('value setter falls back to host widget when linked states are unavailable', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'string_a', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 }) + subgraphNode.graph?.add(subgraphNode) + + const linkedNode = new LGraphNode('LinkedNode') + const linkedInput = linkedNode.addInput('string_a', '*') + linkedNode.addWidget('text', 'string_a', 'initial', () => {}) + linkedInput.widget = { name: 'string_a' } + subgraph.add(linkedNode) + subgraph.inputNode.slots[0].connect(linkedInput, linkedNode) + + const linkedView = promotedWidgets(subgraphNode)[0] + if (!linkedView) throw new Error('Expected a linked promoted widget') + + const widgetValueStore = useWidgetValueStore() + vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined) + + linkedView.value = 'updated' + + expect(linkedNode.widgets?.[0].value).toBe('updated') + }) + test('label falls back to displayName then widgetName', () => { const [subgraphNode, innerNodes] = setupSubgraph(1) const innerNode = firstInnerNode(innerNodes) @@ -495,6 +543,185 @@ describe('SubgraphNode.widgets getter', () => { ]) }) + test('renders all promoted widgets when duplicate input names are connected to different nodes', () => { + const subgraph = createTestSubgraph({ + inputs: [ + { name: 'seed', type: '*' }, + { name: 'seed', type: '*' } + ] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 94 }) + subgraphNode.graph?.add(subgraphNode) + + const firstNode = new LGraphNode('FirstSeedNode') + const firstInput = firstNode.addInput('seed', '*') + firstNode.addWidget('number', 'seed', 1, () => {}) + firstInput.widget = { name: 'seed' } + subgraph.add(firstNode) + + const secondNode = new LGraphNode('SecondSeedNode') + const secondInput = secondNode.addInput('seed', '*') + secondNode.addWidget('number', 'seed', 2, () => {}) + secondInput.widget = { name: 'seed' } + subgraph.add(secondNode) + + subgraph.inputNode.slots[0].connect(firstInput, firstNode) + subgraph.inputNode.slots[1].connect(secondInput, secondNode) + + const widgets = promotedWidgets(subgraphNode) + expect(widgets).toHaveLength(2) + expect(widgets.map((widget) => widget.sourceNodeId)).toStrictEqual([ + String(firstNode.id), + String(secondNode.id) + ]) + }) + + test('input-linked same-name widgets share value state while store-promoted peer stays independent', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'string_a', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 95 }) + 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 promotedNode = new LGraphNode('PromotedNode') + promotedNode.addWidget('text', 'string_a', 'independent', () => {}) + subgraph.add(promotedNode) + + subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA) + subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB) + + usePromotionStore().promote( + subgraphNode.rootGraph.id, + subgraphNode.id, + String(promotedNode.id), + 'string_a' + ) + usePromotionStore().promote( + subgraphNode.rootGraph.id, + subgraphNode.id, + String(linkedNodeA.id), + 'string_a' + ) + + const widgets = promotedWidgets(subgraphNode) + expect(widgets).toHaveLength(2) + + const linkedView = widgets.find( + (widget) => widget.sourceNodeId === String(linkedNodeA.id) + ) + const promotedView = widgets.find( + (widget) => widget.sourceNodeId === String(promotedNode.id) + ) + if (!linkedView || !promotedView) + throw new Error( + 'Expected linked and store-promoted widgets to be present' + ) + + linkedView.value = 'shared-value' + + const widgetStore = useWidgetValueStore() + const graphId = subgraphNode.rootGraph.id + expect( + widgetStore.getWidget( + graphId, + stripGraphPrefix(String(linkedNodeA.id)), + 'string_a' + )?.value + ).toBe('shared-value') + expect( + widgetStore.getWidget( + graphId, + stripGraphPrefix(String(linkedNodeB.id)), + 'string_a' + )?.value + ).toBe('shared-value') + expect( + widgetStore.getWidget( + graphId, + stripGraphPrefix(String(promotedNode.id)), + 'string_a' + )?.value + ).toBe('independent') + + promotedView.value = 'independent-updated' + + expect( + widgetStore.getWidget( + graphId, + stripGraphPrefix(String(linkedNodeA.id)), + 'string_a' + )?.value + ).toBe('shared-value') + expect( + widgetStore.getWidget( + graphId, + stripGraphPrefix(String(linkedNodeB.id)), + 'string_a' + )?.value + ).toBe('shared-value') + expect( + widgetStore.getWidget( + graphId, + stripGraphPrefix(String(promotedNode.id)), + 'string_a' + )?.value + ).toBe('independent-updated') + }) + + test('duplicate-name promoted views map slot linkage by view identity', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'string_a', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 }) + subgraphNode.graph?.add(subgraphNode) + + const linkedNode = new LGraphNode('LinkedNode') + const linkedInput = linkedNode.addInput('string_a', '*') + linkedNode.addWidget('text', 'string_a', 'linked', () => {}) + linkedInput.widget = { name: 'string_a' } + subgraph.add(linkedNode) + + const independentNode = new LGraphNode('IndependentNode') + independentNode.addWidget('text', 'string_a', 'independent', () => {}) + subgraph.add(independentNode) + + subgraph.inputNode.slots[0].connect(linkedInput, linkedNode) + usePromotionStore().promote( + subgraphNode.rootGraph.id, + subgraphNode.id, + String(independentNode.id), + 'string_a' + ) + + const widgets = promotedWidgets(subgraphNode) + const linkedView = widgets.find( + (widget) => widget.sourceNodeId === String(linkedNode.id) + ) + const independentView = widgets.find( + (widget) => widget.sourceNodeId === String(independentNode.id) + ) + if (!linkedView || !independentView) + throw new Error('Expected linked and independent promoted views') + + const linkedSlot = subgraphNode.getSlotFromWidget(linkedView) + const independentSlot = subgraphNode.getSlotFromWidget(independentView) + + expect(linkedSlot).toBeDefined() + expect(independentSlot).toBeUndefined() + }) + test('returns empty array when no proxyWidgets', () => { const [subgraphNode] = setupSubgraph() expect(subgraphNode.widgets).toEqual([]) @@ -558,6 +785,273 @@ describe('SubgraphNode.widgets getter', () => { ]) }) + test('full linked coverage does not prune unresolved independent fallback promotions', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'widgetA', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 125 }) + 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', 'widgetA'] + ]) + + callSyncPromotions(subgraphNode) + + const promotions = usePromotionStore().getPromotions( + subgraphNode.rootGraph.id, + subgraphNode.id + ) + expect(promotions).toStrictEqual([ + { interiorNodeId: String(liveNode.id), widgetName: 'widgetA' }, + { interiorNodeId: '9999', widgetName: 'widgetA' } + ]) + }) + + test('input-added existing-input path tolerates missing link metadata', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'widgetA', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 126 }) + subgraphNode.graph?.add(subgraphNode) + + const existingSlot = subgraph.inputNode.slots[0] + if (!existingSlot) throw new Error('Expected subgraph input slot') + + expect(() => { + subgraph.events.dispatch('input-added', { input: existingSlot }) + }).not.toThrow() + }) + + test('syncPromotions prunes stale connected entries but keeps independent promotions', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'string_a', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 96 }) + 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 independentNode = new LGraphNode('IndependentNode') + independentNode.addWidget('text', 'string_a', 'independent', () => {}) + subgraph.add(independentNode) + + subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA) + subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB) + + setPromotions(subgraphNode, [ + [String(independentNode.id), 'string_a'], + [String(linkedNodeA.id), 'string_a'], + [String(linkedNodeB.id), 'string_a'] + ]) + + callSyncPromotions(subgraphNode) + + const promotions = usePromotionStore().getPromotions( + subgraphNode.rootGraph.id, + subgraphNode.id + ) + expect(promotions).toStrictEqual([ + { interiorNodeId: String(linkedNodeA.id), widgetName: 'string_a' }, + { interiorNodeId: String(independentNode.id), widgetName: 'string_a' } + ]) + }) + + test('syncPromotions prunes stale deep-alias entries for nested linked promotions', () => { + const { subgraphNodeB } = createTwoLevelNestedSubgraph() + const linkedView = promotedWidgets(subgraphNodeB)[0] + if (!linkedView) + throw new Error( + 'Expected nested subgraph to expose a linked promoted view' + ) + + const concrete = resolveConcretePromotedWidget( + subgraphNodeB, + linkedView.sourceNodeId, + linkedView.sourceWidgetName + ) + if (concrete.status !== 'resolved') + throw new Error( + 'Expected nested promoted view to resolve to concrete widget' + ) + + const linkedEntry = [ + linkedView.sourceNodeId, + linkedView.sourceWidgetName + ] as [string, string] + const deepAliasEntry = [ + String(concrete.resolved.node.id), + concrete.resolved.widget.name + ] as [string, string] + + // Guardrail: this test specifically validates host/deep alias cleanup. + expect(deepAliasEntry).not.toStrictEqual(linkedEntry) + + setPromotions(subgraphNodeB, [linkedEntry, deepAliasEntry]) + + callSyncPromotions(subgraphNodeB) + + const promotions = usePromotionStore().getPromotions( + subgraphNodeB.rootGraph.id, + subgraphNodeB.id + ) + expect(promotions).toStrictEqual([ + { + interiorNodeId: linkedEntry[0], + widgetName: linkedEntry[1] + } + ]) + }) + + 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([ + { + interiorNodeId: String(activeAliasNode.id), + widgetName: '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: [ + { name: 'seed', type: '*' }, + { name: 'seed', type: '*' } + ] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 127 }) + subgraphNode.graph?.add(subgraphNode) + + const firstNode = new LGraphNode('FirstNode') + const firstInput = firstNode.addInput('seed', '*') + firstNode.addWidget('text', 'seed', 'first-initial', () => {}) + firstInput.widget = { name: 'seed' } + subgraph.add(firstNode) + + const secondNode = new LGraphNode('SecondNode') + const secondInput = secondNode.addInput('seed', '*') + secondNode.addWidget('text', 'seed', 'second-initial', () => {}) + secondInput.widget = { name: 'seed' } + subgraph.add(secondNode) + + subgraph.inputNode.slots[0].connect(firstInput, firstNode) + subgraph.inputNode.slots[1].connect(secondInput, secondNode) + + const widgets = promotedWidgets(subgraphNode) + const firstView = widgets[0] + const secondView = widgets[1] + if (!firstView || !secondView) + throw new Error('Expected two linked promoted views') + + firstView.value = 'first-updated' + secondView.value = 'second-updated' + + expect(firstNode.widgets?.[0].value).toBe('first-updated') + expect(secondNode.widgets?.[0].value).toBe('second-updated') + + subgraphNode.serialize() + + expect(firstNode.widgets?.[0].value).toBe('first-updated') + expect(secondNode.widgets?.[0].value).toBe('second-updated') + }) + + test('renaming an input updates linked promoted view display names', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'seed', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 128 }) + subgraphNode.graph?.add(subgraphNode) + + const linkedNode = new LGraphNode('LinkedNode') + const linkedInput = linkedNode.addInput('seed', '*') + linkedNode.addWidget('text', 'seed', 'value', () => {}) + linkedInput.widget = { name: 'seed' } + subgraph.add(linkedNode) + subgraph.inputNode.slots[0].connect(linkedInput, linkedNode) + + const beforeRename = promotedWidgets(subgraphNode)[0] + if (!beforeRename) throw new Error('Expected linked promoted view') + expect(beforeRename.name).toBe('seed') + + const inputToRename = subgraph.inputs[0] + if (!inputToRename) throw new Error('Expected input to rename') + subgraph.renameInput(inputToRename, 'seed_renamed') + + const afterRename = promotedWidgets(subgraphNode)[0] + if (!afterRename) throw new Error('Expected linked promoted view') + expect(afterRename.name).toBe('seed_renamed') + }) + test('caches view objects across getter calls (stable references)', () => { const [subgraphNode, innerNodes] = setupSubgraph(1) innerNodes[0].addWidget('text', 'widgetA', 'a', () => {}) @@ -701,6 +1195,236 @@ describe('SubgraphNode.widgets getter', () => { ]) }) + 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() + + const hostWidgets = promotedWidgets(hostNode) + expect(hostWidgets).toHaveLength(2) + expect(hostWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([ + '20', + '19' + ]) + + const promotions = usePromotionStore().getPromotions(graph.id, hostNode.id) + expect(promotions).toStrictEqual([ + { interiorNodeId: '20', widgetName: 'string_a' }, + { interiorNodeId: '19', widgetName: 'string_a' } + ]) + + const linkedView = hostWidgets[0] + const independentView = hostWidgets[1] + if (!linkedView || !independentView) + throw new Error('Expected linked and independent promoted widgets') + + independentView.value = 'independent-value' + linkedView.value = 'shared-linked' + + const widgetStore = useWidgetValueStore() + const getValue = (nodeId: string) => + widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a') + ?.value + + expect(getValue('20')).toBe('shared-linked') + expect(getValue('18')).toBe('shared-linked') + 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() + + const initialWidgets = promotedWidgets(hostNode) + expect(initialWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([ + '20', + '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( + (widget) => widget.sourceNodeId === '20' + ) + const finalIndependentView = finalWidgets.find( + (widget) => widget.sourceNodeId === '19' + ) + if (!finalLinkedView || !finalIndependentView) + throw new Error('Expected final rendered linked and independent views') + + finalIndependentView.value = 'independent-final' + expect(finalIndependentView.value).toBe('independent-final') + expect(finalLinkedView.value).not.toBe('independent-final') + + finalLinkedView.value = 'linked-final' + 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) @@ -751,6 +1475,103 @@ describe('widgets getter caching', () => { setActivePinia(createTestingPinia({ stubActions: false })) }) + test('reconciles at most once per canvas frame across repeated widgets reads', () => { + const [subgraphNode, innerNodes] = setupSubgraph(1) + innerNodes[0].addWidget('text', 'widgetA', 'a', () => {}) + setPromotions(subgraphNode, [['1', 'widgetA']]) + + const fakeCanvas = { frame: 12 } as Pick + subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas + + const reconcileSpy = vi.spyOn( + subgraphNode as unknown as { + _buildPromotionReconcileState: ( + entries: Array<{ interiorNodeId: string; widgetName: string }>, + linkedEntries: Array<{ + inputName: string + inputKey: string + interiorNodeId: string + widgetName: string + }> + ) => unknown + }, + '_buildPromotionReconcileState' + ) + + void subgraphNode.widgets + void subgraphNode.widgets + void subgraphNode.widgets + + expect(reconcileSpy).toHaveBeenCalledTimes(1) + }) + + test('does not re-run reconciliation when only canvas frame advances', () => { + const [subgraphNode, innerNodes] = setupSubgraph(1) + innerNodes[0].addWidget('text', 'widgetA', 'a', () => {}) + setPromotions(subgraphNode, [['1', 'widgetA']]) + + const fakeCanvas = { frame: 24 } as Pick + subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas + + const reconcileSpy = vi.spyOn( + subgraphNode as unknown as { + _buildPromotionReconcileState: ( + entries: Array<{ interiorNodeId: string; widgetName: string }>, + linkedEntries: Array<{ + inputName: string + inputKey: string + interiorNodeId: string + widgetName: string + }> + ) => unknown + }, + '_buildPromotionReconcileState' + ) + + void subgraphNode.widgets + fakeCanvas.frame += 1 + void subgraphNode.widgets + + expect(reconcileSpy).toHaveBeenCalledTimes(1) + }) + + test('does not re-resolve linked entries when linked input state is unchanged', () => { + 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) + + subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA) + subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB) + + const resolveSpy = vi.spyOn( + subgraphNode as unknown as { + _resolveLinkedPromotionBySubgraphInput: (...args: unknown[]) => unknown + }, + '_resolveLinkedPromotionBySubgraphInput' + ) + + void subgraphNode.widgets + const initialResolveCount = resolveSpy.mock.calls.length + expect(initialResolveCount).toBeLessThanOrEqual(1) + + void subgraphNode.widgets + expect(resolveSpy).toHaveBeenCalledTimes(initialResolveCount) + }) + test('preserves view identities when promotion order changes', () => { const [subgraphNode, innerNodes] = setupSubgraph(1) innerNodes[0].addWidget('text', 'widgetA', 'a', () => {}) diff --git a/src/core/graph/subgraph/promotedWidgetView.ts b/src/core/graph/subgraph/promotedWidgetView.ts index 4a404d55ba1..10cf2b065b7 100644 --- a/src/core/graph/subgraph/promotedWidgetView.ts +++ b/src/core/graph/subgraph/promotedWidgetView.ts @@ -1,4 +1,4 @@ -import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer' import type { Point } from '@/lib/litegraph/src/interfaces' @@ -13,11 +13,15 @@ import { stripGraphPrefix, useWidgetValueStore } from '@/stores/widgetValueStore' +import type { WidgetState } from '@/stores/widgetValueStore' import { resolveConcretePromotedWidget, resolvePromotedWidgetAtHost } from '@/core/graph/subgraph/resolveConcretePromotedWidget' +import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput' +import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard' +import { isPromotedWidgetView } from './promotedWidgetTypes' import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes' export type { PromotedWidgetView } from './promotedWidgetTypes' @@ -131,6 +135,38 @@ class PromotedWidgetView implements IPromotedWidgetView { } set value(value: IBaseWidget['value']) { + const linkedWidgets = this.getLinkedInputWidgets() + if (linkedWidgets.length > 0) { + const widgetStore = useWidgetValueStore() + let didUpdateState = false + for (const linkedWidget of linkedWidgets) { + const state = widgetStore.getWidget( + this.graphId, + linkedWidget.nodeId, + linkedWidget.widgetName + ) + if (state) { + state.value = value + didUpdateState = true + } + } + + const resolved = this.resolveDeepest() + if (resolved) { + const resolvedState = widgetStore.getWidget( + this.graphId, + stripGraphPrefix(String(resolved.node.id)), + resolved.widget.name + ) + if (resolvedState) { + resolvedState.value = value + didUpdateState = true + } + } + + if (didUpdateState) return + } + const state = this.getWidgetState() if (state) { state.value = value @@ -278,6 +314,9 @@ class PromotedWidgetView implements IPromotedWidgetView { } private getWidgetState() { + const linkedState = this.getLinkedInputWidgetStates()[0] + if (linkedState) return linkedState + const resolved = this.resolveDeepest() if (!resolved) return undefined return useWidgetValueStore().getWidget( @@ -287,6 +326,57 @@ class PromotedWidgetView implements IPromotedWidgetView { ) } + private getLinkedInputWidgets(): Array<{ + nodeId: NodeId + widgetName: string + widget: IBaseWidget + }> { + const linkedInputSlot = this.subgraphNode.inputs.find((input) => { + if (!input._subgraphSlot) return false + if (matchPromotedInput([input], this) !== input) return false + + const boundWidget = input._widget + if (boundWidget === this) return true + + if (boundWidget && isPromotedWidgetView(boundWidget)) { + return ( + boundWidget.sourceNodeId === this.sourceNodeId && + boundWidget.sourceWidgetName === this.sourceWidgetName + ) + } + + return input._subgraphSlot + .getConnectedWidgets() + .filter(hasWidgetNode) + .some( + (widget) => + String(widget.node.id) === this.sourceNodeId && + widget.name === this.sourceWidgetName + ) + }) + const linkedInput = linkedInputSlot?._subgraphSlot + if (!linkedInput) return [] + + return linkedInput + .getConnectedWidgets() + .filter(hasWidgetNode) + .map((widget) => ({ + nodeId: stripGraphPrefix(String(widget.node.id)), + widgetName: widget.name, + widget + })) + } + + private getLinkedInputWidgetStates(): WidgetState[] { + const widgetStore = useWidgetValueStore() + + return this.getLinkedInputWidgets() + .map(({ nodeId, widgetName }) => + widgetStore.getWidget(this.graphId, nodeId, widgetName) + ) + .filter((state): state is WidgetState => state !== undefined) + } + private getProjectedWidget(resolved: { node: LGraphNode widget: IBaseWidget diff --git a/src/core/graph/subgraph/widgetNodeTypeGuard.ts b/src/core/graph/subgraph/widgetNodeTypeGuard.ts new file mode 100644 index 00000000000..e637e3303a6 --- /dev/null +++ b/src/core/graph/subgraph/widgetNodeTypeGuard.ts @@ -0,0 +1,8 @@ +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' + +export function hasWidgetNode( + widget: IBaseWidget +): widget is IBaseWidget & { node: LGraphNode } { + return 'node' in widget && !!widget.node +} diff --git a/src/core/graph/subgraph/widgetRenderKey.test.ts b/src/core/graph/subgraph/widgetRenderKey.test.ts new file mode 100644 index 00000000000..4633cbf8902 --- /dev/null +++ b/src/core/graph/subgraph/widgetRenderKey.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' + +import { getStableWidgetRenderKey } from './widgetRenderKey' + +function createWidget(overrides: Partial = {}): IBaseWidget { + return { + name: 'seed', + type: 'number', + ...overrides + } as IBaseWidget +} + +describe(getStableWidgetRenderKey, () => { + it('returns a stable key for the same widget instance', () => { + const widget = createWidget() + + const first = getStableWidgetRenderKey(widget) + const second = getStableWidgetRenderKey(widget) + + expect(second).toBe(first) + }) + + it('returns distinct keys for distinct widget instances', () => { + const firstWidget = createWidget() + const secondWidget = createWidget() + + const firstKey = getStableWidgetRenderKey(firstWidget) + const secondKey = getStableWidgetRenderKey(secondWidget) + + expect(secondKey).not.toBe(firstKey) + }) +}) diff --git a/src/core/graph/subgraph/widgetRenderKey.ts b/src/core/graph/subgraph/widgetRenderKey.ts new file mode 100644 index 00000000000..a4184a7280b --- /dev/null +++ b/src/core/graph/subgraph/widgetRenderKey.ts @@ -0,0 +1,17 @@ +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' + +import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' + +const widgetRenderKeys = new WeakMap() +let nextWidgetRenderKeyId = 0 + +export function getStableWidgetRenderKey(widget: IBaseWidget): string { + const cachedKey = widgetRenderKeys.get(widget) + if (cachedKey) return cachedKey + + const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget' + const key = `${prefix}:${nextWidgetRenderKeyId++}` + + widgetRenderKeys.set(widget, key) + return key +} diff --git a/src/lib/litegraph/src/strings.ts b/src/lib/litegraph/src/strings.ts index 78106e8f853..2ce4c237894 100644 --- a/src/lib/litegraph/src/strings.ts +++ b/src/lib/litegraph/src/strings.ts @@ -12,6 +12,9 @@ export function parseSlotTypes(type: ISlotType): string[] { * @param name The name to make unique * @param existingNames The names that already exist. Default: an empty array * @returns The name, or a unique name if it already exists. + * @remark Used by SubgraphInputNode to deduplicate input names when promoting + * the same widget name from multiple node instances (e.g. `seed` → `seed_1`). + * Extensions matching by slot name should account for the `_N` suffix. */ export function nextUniqueName( name: string, diff --git a/src/lib/litegraph/src/subgraph/SubgraphIO.test.ts b/src/lib/litegraph/src/subgraph/SubgraphIO.test.ts index 04c6757b6af..d6d6064bcc8 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphIO.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphIO.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { LGraphNode } from '@/lib/litegraph/src/litegraph' import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/canvas/ToInputFromIoNodeLink' import { LinkDirection } from '@/lib/litegraph/src//types/globalEnums' +import { usePromotionStore } from '@/stores/promotionStore' import { subgraphTest } from './__fixtures__/subgraphFixtures' import { @@ -456,4 +457,50 @@ describe('SubgraphIO - Empty Slot Connection', () => { expect(link.origin_slot).toBe(1) // Should be the second slot } ) + + subgraphTest( + 'creates distinct named inputs when promoting same widget name from multiple node instances', + ({ subgraphWithNode }) => { + const { subgraph, subgraphNode } = subgraphWithNode + + const firstNode = new LGraphNode('First Seed Node') + const firstInput = firstNode.addInput('seed', 'number') + firstNode.addWidget('number', 'seed', 1, () => undefined) + firstInput.widget = { name: 'seed' } + subgraph.add(firstNode) + + const secondNode = new LGraphNode('Second Seed Node') + const secondInput = secondNode.addInput('seed', 'number') + secondNode.addWidget('number', 'seed', 2, () => undefined) + secondInput.widget = { name: 'seed' } + subgraph.add(secondNode) + + subgraph.inputNode.connectByType(-1, firstNode, 'number') + subgraph.inputNode.connectByType(-1, secondNode, 'number') + + expect(subgraph.inputs.map((input) => input.name)).toStrictEqual([ + 'input', + 'seed', + 'seed_1' + ]) + expect(subgraphNode.inputs.map((input) => input.name)).toStrictEqual([ + 'input', + 'seed', + 'seed_1' + ]) + expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([ + 'seed', + 'seed_1' + ]) + expect( + usePromotionStore().getPromotions( + subgraphNode.rootGraph.id, + subgraphNode.id + ) + ).toStrictEqual([ + { interiorNodeId: String(firstNode.id), widgetName: 'seed' }, + { interiorNodeId: String(secondNode.id), widgetName: 'seed' } + ]) + } + ) }) diff --git a/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts b/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts index 02febba10bf..fe0dc25c02d 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts @@ -15,6 +15,7 @@ import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike' import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums' import { findFreeSlotOfType } from '@/lib/litegraph/src/utils/collections' +import { nextUniqueName } from '@/lib/litegraph/src/strings' import { EmptySubgraphInput } from './EmptySubgraphInput' import { SubgraphIONodeBase } from './SubgraphIONodeBase' @@ -130,8 +131,10 @@ export class SubgraphInputNode if (slot === -1) { // This indicates a connection is being made from the "Empty" slot. // We need to create a new, concrete input on the subgraph that matches the target. + const existingNames = this.subgraph.inputs.map((input) => input.name) + const uniqueName = nextUniqueName(inputSlot.slot.name, existingNames) const newSubgraphInput = this.subgraph.addInput( - inputSlot.slot.name, + uniqueName, String(inputSlot.slot.type ?? '') ) const newSlotIndex = this.slots.indexOf(newSubgraphInput) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts index d37bf070f3c..24514e4ae3f 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts @@ -6,6 +6,8 @@ * IO synchronization, and edge cases. */ import { describe, expect, it, vi } from 'vitest' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' import type { SubgraphNode } from '@/lib/litegraph/src/litegraph' import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph' @@ -615,3 +617,35 @@ describe.skip('SubgraphNode Cleanup', () => { expect(abortSpy2).toHaveBeenCalledTimes(1) }) }) + +describe('SubgraphNode promotion view keys', () => { + it('distinguishes tuples that differ only by colon placement', () => { + setActivePinia(createTestingPinia({ stubActions: false })) + + const subgraph = createTestSubgraph() + const subgraphNode = createTestSubgraphNode(subgraph) + const nodeWithKeyBuilder = subgraphNode as unknown as { + _makePromotionViewKey: ( + inputKey: string, + interiorNodeId: string, + widgetName: string, + inputName?: string + ) => string + } + + const firstKey = nodeWithKeyBuilder._makePromotionViewKey( + '65', + '18', + 'a:b', + 'c' + ) + const secondKey = nodeWithKeyBuilder._makePromotionViewKey( + '65', + '18', + 'a', + 'b:c' + ) + + expect(firstKey).not.toBe(secondKey) + }) +}) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index e7b31bdb9c3..aa69e4625d9 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -35,7 +35,9 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView' import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView' +import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget' import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget' +import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard' import { parseProxyWidgets } from '@/core/schemas/promotionSchema' import { useDomWidgetStore } from '@/stores/domWidgetStore' import { usePromotionStore } from '@/stores/promotionStore' @@ -52,6 +54,12 @@ workflowSvg.src = type LinkedPromotionEntry = { inputName: string + inputKey: string + interiorNodeId: string + widgetName: string +} + +type PromotionEntry = { interiorNodeId: string widgetName: string } @@ -91,46 +99,113 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { * `onAdded()`, so construction-time promotions require normal add-to-graph * lifecycle to persist. */ - private _pendingPromotions: Array<{ - interiorNodeId: string - widgetName: string - }> = [] + private _pendingPromotions: PromotionEntry[] = [] + private _cacheVersion = 0 + private _linkedEntriesCache?: { + version: number + hasMissingBoundSourceWidget: boolean + entries: LinkedPromotionEntry[] + } + private _promotedViewsCache?: { + version: number + entriesRef: PromotionEntry[] + hasMissingBoundSourceWidget: boolean + views: PromotedWidgetView[] + } // Declared as accessor via Object.defineProperty in constructor. // TypeScript doesn't allow overriding a property with get/set syntax, // so we use declare + defineProperty instead. declare widgets: IBaseWidget[] - private _resolveLinkedPromotionByInputName( - inputName: string + private _resolveLinkedPromotionBySubgraphInput( + subgraphInput: SubgraphInput ): { interiorNodeId: string; widgetName: string } | undefined { - const resolvedTarget = resolveSubgraphInputTarget(this, inputName) - if (!resolvedTarget) return undefined + // Preserve deterministic representative selection for multi-linked inputs: + // the first connected source remains the promoted linked view. + for (const linkId of subgraphInput.linkIds) { + const link = this.subgraph.getLink(linkId) + if (!link) continue - return { - interiorNodeId: resolvedTarget.nodeId, - widgetName: resolvedTarget.widgetName + const { inputNode } = link.resolve(this.subgraph) + if (!inputNode || !Array.isArray(inputNode.inputs)) continue + + const targetInput = inputNode.inputs.find( + (entry) => entry.link === linkId + ) + if (!targetInput) continue + + const targetWidget = inputNode.getWidgetFromSlot(targetInput) + if (!targetWidget) continue + + if (inputNode.isSubgraphNode()) + return { + interiorNodeId: String(inputNode.id), + widgetName: targetInput.name + } + + return { + interiorNodeId: String(inputNode.id), + widgetName: targetWidget.name + } } } - private _getLinkedPromotionEntries(): LinkedPromotionEntry[] { + private _getLinkedPromotionEntries(cache = true): LinkedPromotionEntry[] { + const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget() + const cached = this._linkedEntriesCache + if ( + cache && + cached?.version === this._cacheVersion && + cached.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget + ) + return cached.entries + const linkedEntries: LinkedPromotionEntry[] = [] - // TODO(pr9282): Optimization target. This path runs on widgets getter reads - // and resolves each input link chain eagerly. for (const input of this.inputs) { - const resolved = this._resolveLinkedPromotionByInputName(input.name) + const subgraphInput = input._subgraphSlot + if (!subgraphInput) continue + + const boundWidget = + input._widget && isPromotedWidgetView(input._widget) + ? input._widget + : undefined + if (boundWidget) { + const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId) + const hasBoundSourceWidget = + boundNode?.widgets?.some( + (widget) => widget.name === boundWidget.sourceWidgetName + ) === true + if (hasBoundSourceWidget) { + linkedEntries.push({ + inputName: input.label ?? input.name, + inputKey: String(subgraphInput.id), + interiorNodeId: boundWidget.sourceNodeId, + widgetName: boundWidget.sourceWidgetName + }) + continue + } + } + + const resolved = + this._resolveLinkedPromotionBySubgraphInput(subgraphInput) if (!resolved) continue - linkedEntries.push({ inputName: input.name, ...resolved }) + linkedEntries.push({ + inputName: input.label ?? input.name, + inputKey: String(subgraphInput.id), + ...resolved + }) } const seenEntryKeys = new Set() const deduplicatedEntries = linkedEntries.filter((entry) => { const entryKey = this._makePromotionViewKey( - entry.inputName, + entry.inputKey, entry.interiorNodeId, - entry.widgetName + entry.widgetName, + entry.inputName ) if (seenEntryKeys.has(entryKey)) return false @@ -138,24 +213,73 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { return true }) + if (cache) + this._linkedEntriesCache = { + version: this._cacheVersion, + hasMissingBoundSourceWidget, + entries: deduplicatedEntries + } + return deduplicatedEntries } + private _hasMissingBoundSourceWidget(): boolean { + return this.inputs.some((input) => { + const boundWidget = + input._widget && isPromotedWidgetView(input._widget) + ? input._widget + : undefined + if (!boundWidget) return false + + const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId) + return ( + boundNode?.widgets?.some( + (widget) => widget.name === boundWidget.sourceWidgetName + ) !== true + ) + }) + } + private _getPromotedViews(): PromotedWidgetView[] { const store = usePromotionStore() const entries = store.getPromotionsRef(this.rootGraph.id, this.id) + const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget() + const cachedViews = this._promotedViewsCache + if ( + cachedViews?.version === this._cacheVersion && + cachedViews.entriesRef === entries && + cachedViews.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget + ) + return cachedViews.views + const linkedEntries = this._getLinkedPromotionEntries() + const { displayNameByViewKey, reconcileEntries } = this._buildPromotionReconcileState(entries, linkedEntries) - return this._promotedViewManager.reconcile(reconcileEntries, (entry) => - createPromotedWidgetView( - this, - entry.interiorNodeId, - entry.widgetName, - entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined - ) + const views = this._promotedViewManager.reconcile( + reconcileEntries, + (entry) => + createPromotedWidgetView( + this, + entry.interiorNodeId, + entry.widgetName, + entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined + ) ) + + this._promotedViewsCache = { + version: this._cacheVersion, + entriesRef: entries, + hasMissingBoundSourceWidget, + views + } + + return views + } + + private _invalidatePromotedViewsCache(): void { + this._cacheVersion++ } private _syncPromotions(): void { @@ -163,10 +287,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { const store = usePromotionStore() const entries = store.getPromotionsRef(this.rootGraph.id, this.id) - const linkedEntries = this._getLinkedPromotionEntries() - const { mergedEntries, shouldPersistLinkedOnly } = - this._buildPromotionPersistenceState(entries, linkedEntries) - if (!shouldPersistLinkedOnly) return + const linkedEntries = this._getLinkedPromotionEntries(false) + // Intentionally preserve independent store promotions when linked coverage is partial; + // tests assert that mixed linked/independent states must not collapse to linked-only. + const { mergedEntries } = this._buildPromotionPersistenceState( + entries, + linkedEntries + ) const hasChanged = mergedEntries.length !== entries.length || @@ -181,7 +308,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } private _buildPromotionReconcileState( - entries: Array<{ interiorNodeId: string; widgetName: string }>, + entries: PromotionEntry[], linkedEntries: LinkedPromotionEntry[] ): { displayNameByViewKey: Map @@ -197,48 +324,64 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { ) const linkedReconcileEntries = this._buildLinkedReconcileEntries(linkedEntries) - const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries) + const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly( + linkedEntries, + fallbackStoredEntries + ) + const reconcileEntries = shouldPersistLinkedOnly + ? linkedReconcileEntries + : [...linkedReconcileEntries, ...fallbackStoredEntries] return { displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries), - reconcileEntries: shouldPersistLinkedOnly - ? linkedReconcileEntries - : [...linkedReconcileEntries, ...fallbackStoredEntries] + reconcileEntries } } private _buildPromotionPersistenceState( - entries: Array<{ interiorNodeId: string; widgetName: string }>, + entries: PromotionEntry[], linkedEntries: LinkedPromotionEntry[] ): { - mergedEntries: Array<{ interiorNodeId: string; widgetName: string }> - shouldPersistLinkedOnly: boolean + mergedEntries: PromotionEntry[] } { const { linkedPromotionEntries, fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(entries, linkedEntries) - const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries) + const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly( + linkedEntries, + fallbackStoredEntries + ) return { mergedEntries: shouldPersistLinkedOnly ? linkedPromotionEntries - : [...linkedPromotionEntries, ...fallbackStoredEntries], - shouldPersistLinkedOnly + : [...linkedPromotionEntries, ...fallbackStoredEntries] } } private _collectLinkedAndFallbackEntries( - entries: Array<{ interiorNodeId: string; widgetName: string }>, + entries: PromotionEntry[], linkedEntries: LinkedPromotionEntry[] ): { - linkedPromotionEntries: Array<{ - interiorNodeId: string - widgetName: string - }> - fallbackStoredEntries: Array<{ interiorNodeId: string; widgetName: string }> + linkedPromotionEntries: PromotionEntry[] + fallbackStoredEntries: PromotionEntry[] } { const linkedPromotionEntries = this._toPromotionEntries(linkedEntries) - const fallbackStoredEntries = this._getFallbackStoredEntries( + const excludedEntryKeys = new Set( + linkedPromotionEntries.map((entry) => + this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName) + ) + ) + const connectedEntryKeys = this._getConnectedPromotionEntryKeys() + for (const key of connectedEntryKeys) { + excludedEntryKeys.add(key) + } + + const prePruneFallbackStoredEntries = this._getFallbackStoredEntries( entries, + excludedEntryKeys + ) + const fallbackStoredEntries = this._pruneStaleAliasFallbackEntries( + prePruneFallbackStoredEntries, linkedPromotionEntries ) @@ -249,14 +392,37 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } private _shouldPersistLinkedOnly( - linkedEntries: LinkedPromotionEntry[] + linkedEntries: LinkedPromotionEntry[], + fallbackStoredEntries: PromotionEntry[] ): boolean { - return this.inputs.length > 0 && linkedEntries.length === this.inputs.length + if ( + !(this.inputs.length > 0 && linkedEntries.length === this.inputs.length) + ) + return false + + const linkedWidgetNames = new Set( + linkedEntries.map((entry) => entry.widgetName) + ) + + const hasFallbackToKeep = fallbackStoredEntries.some((entry) => { + const sourceNode = this.subgraph.getNodeById(entry.interiorNodeId) + const hasSourceWidget = + sourceNode?.widgets?.some( + (widget) => widget.name === entry.widgetName + ) === true + if (hasSourceWidget) return true + + // If the fallback widget name overlaps a linked widget name, keep it + // until aliasing can be positively proven. + return linkedWidgetNames.has(entry.widgetName) + }) + + return !hasFallbackToKeep } private _toPromotionEntries( linkedEntries: LinkedPromotionEntry[] - ): Array<{ interiorNodeId: string; widgetName: string }> { + ): PromotionEntry[] { return linkedEntries.map(({ interiorNodeId, widgetName }) => ({ interiorNodeId, widgetName @@ -264,33 +430,98 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } private _getFallbackStoredEntries( - entries: Array<{ interiorNodeId: string; widgetName: string }>, - linkedPromotionEntries: Array<{ - interiorNodeId: string - widgetName: string - }> - ): Array<{ interiorNodeId: string; widgetName: string }> { - const linkedKeys = new Set( - linkedPromotionEntries.map((entry) => - this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName) - ) - ) + entries: PromotionEntry[], + excludedEntryKeys: Set + ): PromotionEntry[] { return entries.filter( (entry) => - !linkedKeys.has( + !excludedEntryKeys.has( this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName) ) ) } + private _pruneStaleAliasFallbackEntries( + fallbackStoredEntries: PromotionEntry[], + linkedPromotionEntries: PromotionEntry[] + ): PromotionEntry[] { + if ( + fallbackStoredEntries.length === 0 || + linkedPromotionEntries.length === 0 + ) + return fallbackStoredEntries + + const linkedConcreteKeys = new Set( + linkedPromotionEntries + .map((entry) => this._resolveConcretePromotionEntryKey(entry)) + .filter((key): key is string => key !== undefined) + ) + if (linkedConcreteKeys.size === 0) return fallbackStoredEntries + + const prunedEntries: PromotionEntry[] = [] + + for (const entry of fallbackStoredEntries) { + const concreteKey = this._resolveConcretePromotionEntryKey(entry) + if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue + + prunedEntries.push(entry) + } + + return prunedEntries + } + + private _resolveConcretePromotionEntryKey( + entry: PromotionEntry + ): string | undefined { + const result = resolveConcretePromotedWidget( + this, + entry.interiorNodeId, + entry.widgetName + ) + if (result.status !== 'resolved') return undefined + + return this._makePromotionEntryKey( + String(result.resolved.node.id), + result.resolved.widget.name + ) + } + + private _getConnectedPromotionEntryKeys(): Set { + const connectedEntryKeys = new Set() + + for (const input of this.inputs) { + const subgraphInput = input._subgraphSlot + if (!subgraphInput) continue + + const connectedWidgets = subgraphInput.getConnectedWidgets() + + for (const widget of connectedWidgets) { + if (!hasWidgetNode(widget)) continue + + connectedEntryKeys.add( + this._makePromotionEntryKey(String(widget.node.id), widget.name) + ) + } + } + + return connectedEntryKeys + } + private _buildLinkedReconcileEntries( linkedEntries: LinkedPromotionEntry[] ): Array<{ interiorNodeId: string; widgetName: string; viewKey: string }> { - return linkedEntries.map(({ inputName, interiorNodeId, widgetName }) => ({ - interiorNodeId, - widgetName, - viewKey: this._makePromotionViewKey(inputName, interiorNodeId, widgetName) - })) + return linkedEntries.map( + ({ inputKey, inputName, interiorNodeId, widgetName }) => ({ + interiorNodeId, + widgetName, + viewKey: this._makePromotionViewKey( + inputKey, + interiorNodeId, + widgetName, + inputName + ) + }) + ) } private _buildDisplayNameByViewKey( @@ -299,9 +530,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { return new Map( linkedEntries.map((entry) => [ this._makePromotionViewKey( - entry.inputName, + entry.inputKey, entry.interiorNodeId, - entry.widgetName + entry.widgetName, + entry.inputName ), entry.inputName ]) @@ -316,11 +548,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } private _makePromotionViewKey( - inputName: string, + inputKey: string, interiorNodeId: string, - widgetName: string + widgetName: string, + inputName = '' ): string { - return `${inputName}:${interiorNodeId}:${widgetName}` + return JSON.stringify([inputKey, interiorNodeId, widgetName, inputName]) } private _resolveLegacyEntry( @@ -378,22 +611,34 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { (e) => { const subgraphInput = e.detail.input const { name, type } = subgraphInput - const existingInput = this.inputs.find((i) => i.name === name) + const existingInput = this.inputs.find( + (input) => input._subgraphSlot === subgraphInput + ) if (existingInput) { const linkId = subgraphInput.linkIds[0] - const { inputNode, input } = subgraph.links[linkId].resolve(subgraph) - const widget = inputNode?.widgets?.find?.((w) => w.name === name) - if (widget && inputNode) + if (linkId === undefined) return + + const link = this.subgraph.getLink(linkId) + if (!link) return + + const { inputNode, input } = link.resolve(subgraph) + if (!inputNode || !input) return + + const widget = inputNode.getWidgetFromSlot(input) + if (widget) this._setWidget( subgraphInput, existingInput, widget, - input?.widget, + input.widget, inputNode ) return } - const input = this.addInput(name, type) + const input = this.addInput(name, type, { + _subgraphSlot: subgraphInput + }) + this._invalidatePromotedViewsCache() this._addSubgraphInputListeners(subgraphInput, input) }, @@ -407,6 +652,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { if (widget) this.ensureWidgetRemoved(widget) this.removeInput(e.detail.index) + this._invalidatePromotedViewsCache() this._syncPromotions() this.setDirtyCanvas(true, true) }, @@ -442,6 +688,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { if (input._widget) { input._widget.label = newName } + this._invalidatePromotedViewsCache() this.graph?.trigger('node:slot-label:changed', { nodeId: this.id, slotType: NodeSlotType.INPUT @@ -493,6 +740,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { subgraphInput: SubgraphInput, input: INodeInputSlot & Partial ) { + input._subgraphSlot = subgraphInput + if ( input._listenerController && typeof input._listenerController.abort === 'function' @@ -505,36 +754,39 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { subgraphInput.events.addEventListener( 'input-connected', (e) => { - const widget = subgraphInput._widget - if (!widget) return + this._invalidatePromotedViewsCache() - // If this widget is already promoted, demote it first - // so it transitions cleanly to being linked via SubgraphInput. + // `SubgraphInput.connect()` dispatches before appending to `linkIds`, + // so resolve by current links would miss this new connection. + // Keep the earliest bound view once present, and only bind from event + // payload when this input has no representative yet. const nodeId = String(e.detail.node.id) if ( usePromotionStore().isPromoted( this.rootGraph.id, this.id, nodeId, - widget.name + e.detail.widget.name ) ) { usePromotionStore().demote( this.rootGraph.id, this.id, nodeId, - widget.name + e.detail.widget.name ) } - const widgetLocator = e.detail.input.widget - this._setWidget( - subgraphInput, - input, - widget, - widgetLocator, - e.detail.node - ) + const didSetWidgetFromEvent = !input._widget + if (didSetWidgetFromEvent) + this._setWidget( + subgraphInput, + input, + e.detail.widget, + e.detail.input.widget, + e.detail.node + ) + this._syncPromotions() }, { signal } @@ -543,9 +795,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { subgraphInput.events.addEventListener( 'input-disconnected', () => { - // If the input is connected to more than one widget, don't remove the widget + this._invalidatePromotedViewsCache() + + // If links remain, rebind to the current representative. const connectedWidgets = subgraphInput.getConnectedWidgets() - if (connectedWidgets.length > 0) return + if (connectedWidgets.length > 0) { + this._resolveInputWidget(subgraphInput, input) + this._syncPromotions() + return + } if (input._widget) this.ensureWidgetRemoved(input._widget) @@ -558,6 +816,62 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { ) } + private _rebindInputSubgraphSlots(): void { + this._invalidatePromotedViewsCache() + + const subgraphSlots = [...this.subgraph.inputNode.slots] + const slotsBySignature = new Map() + const slotsByName = new Map() + + for (const slot of subgraphSlots) { + const signature = `${slot.name}:${String(slot.type)}` + const signatureSlots = slotsBySignature.get(signature) + if (signatureSlots) { + signatureSlots.push(slot) + } else { + slotsBySignature.set(signature, [slot]) + } + + const nameSlots = slotsByName.get(slot.name) + if (nameSlots) { + nameSlots.push(slot) + } else { + slotsByName.set(slot.name, [slot]) + } + } + + const assignedSlotIds = new Set() + const takeUnassignedSlot = ( + slots: SubgraphInput[] | undefined + ): SubgraphInput | undefined => { + if (!slots) return undefined + return slots.find((slot) => !assignedSlotIds.has(String(slot.id))) + } + + for (const input of this.inputs) { + const existingSlot = input._subgraphSlot + if ( + existingSlot && + this.subgraph.inputNode.slots.some((slot) => slot === existingSlot) + ) { + assignedSlotIds.add(String(existingSlot.id)) + continue + } + + const signature = `${input.name}:${String(input.type)}` + const matchedSlot = + takeUnassignedSlot(slotsBySignature.get(signature)) ?? + takeUnassignedSlot(slotsByName.get(input.name)) + + if (matchedSlot) { + input._subgraphSlot = matchedSlot + assignedSlotIds.add(String(matchedSlot.id)) + } else { + delete input._subgraphSlot + } + } + } + override configure(info: ExportedSubgraphInstance): void { for (const input of this.inputs) { if ( @@ -570,8 +884,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { this.inputs.length = 0 this.inputs.push( - ...this.subgraph.inputNode.slots.map( - (slot) => + ...this.subgraph.inputNode.slots.map((slot) => + Object.assign( new NodeInputSlot( { name: slot.name, @@ -581,7 +895,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { link: null }, this - ) + ), + { + _subgraphSlot: slot + } + ) ) ) @@ -606,6 +924,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } override _internalConfigureAfterSlots() { + this._rebindInputSubgraphSlots() + // Ensure proxyWidgets is initialized so it serializes this.properties.proxyWidgets ??= [] @@ -613,10 +933,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { // 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) const store = usePromotionStore() + const entries = raw .map(([nodeId, widgetName]) => { if (nodeId === '-1') { @@ -633,6 +955,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { return { interiorNodeId: nodeId, widgetName } }) .filter((e): e is NonNullable => e !== null) + store.setPromotions(this.rootGraph.id, this.id, entries) // Write back resolved entries so legacy -1 format doesn't persist @@ -645,9 +968,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { // Check all inputs for connected widgets for (const input of this.inputs) { - const subgraphInput = this.subgraph.inputNode.slots.find( - (slot) => slot.name === input.name - ) + const subgraphInput = input._subgraphSlot if (!subgraphInput) { // Skip inputs that don't exist in the subgraph definition // This can happen when loading workflows with dynamically added inputs @@ -711,6 +1032,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { inputWidget: IWidgetLocator | undefined, interiorNode: LGraphNode ) { + this._invalidatePromotedViewsCache() this._flushPendingPromotions() const nodeId = String(interiorNode.id) @@ -760,8 +1082,18 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { nodeId, widgetName, () => - createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name), - this._makePromotionViewKey(subgraphInput.name, nodeId, widgetName) + createPromotedWidgetView( + this, + nodeId, + widgetName, + input.label ?? subgraphInput.name + ), + this._makePromotionViewKey( + String(subgraphInput.id), + nodeId, + widgetName, + input.label ?? input.name + ) ) // NOTE: This code creates linked chains of prototypes for passing across @@ -817,6 +1149,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { return super.addInput(name, type, inputProperties) } + override getSlotFromWidget( + widget: IBaseWidget | undefined + ): INodeInputSlot | undefined { + if (!widget || !isPromotedWidgetView(widget)) + return super.getSlotFromWidget(widget) + + return this.inputs.find((input) => input._widget === widget) + } + + override getWidgetFromSlot(slot: INodeInputSlot): IBaseWidget | undefined { + if (slot._widget) return slot._widget + return super.getWidgetFromSlot(slot) + } + override getInputLink(slot: number): LLink | null { // Output side: the link from inside the subgraph const innerLink = this.subgraph.outputNode.slots[slot].getLinks().at(0) @@ -946,18 +1292,24 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } private _removePromotedView(view: PromotedWidgetView): void { + this._invalidatePromotedViewsCache() + this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName) - // Reconciled views can also be keyed by inputName-scoped view keys. - // Remove both key shapes to avoid stale cache entries across promote/rebind flows. - this._promotedViewManager.removeByViewKey( - view.sourceNodeId, - view.sourceWidgetName, - this._makePromotionViewKey( - view.name, + for (const input of this.inputs) { + if (input._widget !== view || !input._subgraphSlot) continue + const inputName = input.label ?? input.name + + this._promotedViewManager.removeByViewKey( view.sourceNodeId, - view.sourceWidgetName + view.sourceWidgetName, + this._makePromotionViewKey( + String(input._subgraphSlot.id), + view.sourceNodeId, + view.sourceWidgetName, + inputName + ) ) - ) + } } override removeWidget(widget: IBaseWidget): void { @@ -996,6 +1348,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { override onRemoved(): void { this._eventAbortController.abort() + this._invalidatePromotedViewsCache() for (const widget of this.widgets) { if (isPromotedWidgetView(widget)) { @@ -1062,9 +1415,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { for (const input of this.inputs) { if (!input._widget) continue - const subgraphInput = this.subgraph.inputNode.slots.find( - (slot) => slot.name === input.name - ) + const subgraphInput = + input._subgraphSlot ?? + this.subgraph.inputNode.slots.find((slot) => slot.name === input.name) if (!subgraphInput) continue const connectedWidgets = subgraphInput.getConnectedWidgets() diff --git a/src/lib/litegraph/src/subgraph/__fixtures__/subgraphComplexPromotion1.ts b/src/lib/litegraph/src/subgraph/__fixtures__/subgraphComplexPromotion1.ts new file mode 100644 index 00000000000..28af0162d5c --- /dev/null +++ b/src/lib/litegraph/src/subgraph/__fixtures__/subgraphComplexPromotion1.ts @@ -0,0 +1,322 @@ +import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation' + +export const subgraphComplexPromotion1 = { + id: 'e49902fa-ee3e-40e6-a59e-c8931888ad0e', + revision: 0, + last_node_id: 21, + last_link_id: 23, + nodes: [ + { + id: 12, + type: 'PreviewAny', + pos: [1367.8236034435063, 305.51100163315823], + size: [225, 166], + flags: {}, + order: 3, + mode: 0, + inputs: [ + { + name: 'source', + type: '*', + link: 21 + } + ], + outputs: [], + properties: { + 'Node name for S&R': 'PreviewAny' + }, + widgets_values: [null, null, null] + }, + { + id: 13, + type: 'PreviewAny', + pos: [1271.9742739655217, 551.9124470179938], + size: [225, 166], + flags: {}, + order: 1, + mode: 0, + inputs: [ + { + name: 'source', + type: '*', + link: 19 + } + ], + outputs: [], + properties: { + 'Node name for S&R': 'PreviewAny' + }, + widgets_values: [null, null, null] + }, + { + id: 14, + type: 'PreviewAny', + pos: [1414.8695925586444, 847.9456885036253], + size: [225, 166], + flags: {}, + order: 2, + mode: 0, + inputs: [ + { + name: 'source', + type: '*', + link: 20 + } + ], + outputs: [], + properties: { + 'Node name for S&R': 'PreviewAny' + }, + widgets_values: [null, null, null] + }, + { + id: 21, + type: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f', + pos: [741.0375276545419, 560.8496560588814], + size: [225, 305.3333435058594], + flags: {}, + order: 0, + mode: 0, + inputs: [], + outputs: [ + { + name: 'STRING', + type: 'STRING', + links: [19] + }, + { + name: 'STRING_1', + type: 'STRING', + links: [20] + }, + { + name: 'STRING_2', + type: 'STRING', + links: [21] + } + ], + properties: { + proxyWidgets: [ + ['20', 'string_a'], + ['19', 'string_a'], + ['18', 'string_a'] + ] + }, + widgets_values: [] + } + ], + links: [ + [19, 21, 0, 13, 0, 'STRING'], + [20, 21, 1, 14, 0, 'STRING'], + [21, 21, 2, 12, 0, 'STRING'] + ], + groups: [], + definitions: { + subgraphs: [ + { + id: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f', + version: 1, + state: { + lastGroupId: 0, + lastNodeId: 21, + lastLinkId: 23, + lastRerouteId: 0 + }, + revision: 0, + config: {}, + name: 'New Subgraph', + inputNode: { + id: -10, + bounding: [596.9206067268835, 805.5404332481304, 120, 60] + }, + outputNode: { + id: -20, + bounding: [1376.7286067268833, 769.5404332481304, 120, 100] + }, + inputs: [ + { + id: '78479bf4-8145-41d5-9d11-c38e3149fc59', + name: 'string_a', + type: 'STRING', + linkIds: [22, 23], + pos: [696.9206067268835, 825.5404332481304] + } + ], + outputs: [ + { + id: 'aa263e4e-b558-4dbf-bcb9-ff0c1c72cbef', + name: 'STRING', + type: 'STRING', + linkIds: [16], + localized_name: 'STRING', + pos: [1396.7286067268833, 789.5404332481304] + }, + { + id: '8eee6fe3-dc2f-491a-9e01-04ef83309dad', + name: 'STRING_1', + type: 'STRING', + linkIds: [17], + localized_name: 'STRING_1', + pos: [1396.7286067268833, 809.5404332481304] + }, + { + id: 'a446d5b9-6042-434d-848a-5d3af5e8e0d4', + name: 'STRING_2', + type: 'STRING', + linkIds: [18], + localized_name: 'STRING_2', + pos: [1396.7286067268833, 829.5404332481304] + } + ], + widgets: [], + nodes: [ + { + id: 18, + type: 'StringConcatenate', + pos: [818.5102631756379, 706.4562049408103], + size: [480, 268], + flags: {}, + order: 0, + mode: 0, + inputs: [ + { + localized_name: 'string_a', + name: 'string_a', + type: 'STRING', + widget: { + name: 'string_a' + }, + link: 23 + } + ], + outputs: [ + { + localized_name: 'STRING', + name: 'STRING', + type: 'STRING', + links: [16] + } + ], + title: 'InnerCatB', + properties: { + 'Node name for S&R': 'StringConcatenate' + }, + widgets_values: ['Poop', '_B', ''] + }, + { + id: 19, + type: 'StringConcatenate', + pos: [812.9370280206649, 1040.648423402667], + size: [480, 268], + flags: {}, + order: 1, + mode: 0, + inputs: [], + outputs: [ + { + localized_name: 'STRING', + name: 'STRING', + type: 'STRING', + links: [17] + } + ], + title: 'InnerCatC', + properties: { + 'Node name for S&R': 'StringConcatenate' + }, + widgets_values: ['', '_C', ''] + }, + { + id: 20, + type: 'StringConcatenate', + pos: [824.7110975088726, 386.4230523609899], + size: [480, 268], + flags: {}, + order: 2, + mode: 0, + inputs: [ + { + localized_name: 'string_a', + name: 'string_a', + type: 'STRING', + widget: { + name: 'string_a' + }, + link: 22 + } + ], + outputs: [ + { + localized_name: 'STRING', + name: 'STRING', + type: 'STRING', + links: [18] + } + ], + title: 'InnerCatA', + properties: { + 'Node name for S&R': 'StringConcatenate' + }, + widgets_values: ['Poop', '_A', ''] + } + ], + groups: [], + links: [ + { + id: 16, + origin_id: 18, + origin_slot: 0, + target_id: -20, + target_slot: 0, + type: 'STRING' + }, + { + id: 17, + origin_id: 19, + origin_slot: 0, + target_id: -20, + target_slot: 1, + type: 'STRING' + }, + { + id: 18, + origin_id: 20, + origin_slot: 0, + target_id: -20, + target_slot: 2, + type: 'STRING' + }, + { + id: 22, + origin_id: -10, + origin_slot: 0, + target_id: 20, + target_slot: 0, + type: 'STRING' + }, + { + id: 23, + origin_id: -10, + origin_slot: 0, + target_id: 18, + target_slot: 0, + type: 'STRING' + } + ], + extra: { + workflowRendererVersion: 'Vue' + } + } + ] + }, + config: {}, + extra: { + ds: { + scale: 0.6638894832438259, + offset: [-408.2009703049473, -183.8039508449224] + }, + workflowRendererVersion: 'Vue', + frontendVersion: '1.42.3' + }, + version: 0.4 +} as const as unknown as ISerialisedGraph diff --git a/src/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers.test.ts b/src/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers.test.ts new file mode 100644 index 00000000000..d59cadb9658 --- /dev/null +++ b/src/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' + +import { LiteGraph } from '@/lib/litegraph/src/litegraph' + +import { + cleanupComplexPromotionFixtureNodeType, + setupComplexPromotionFixture +} from './subgraphHelpers' + +const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate' + +describe('setupComplexPromotionFixture', () => { + afterEach(() => { + cleanupComplexPromotionFixtureNodeType() + }) + + it('can clean up the globally registered fixture node type', () => { + setActivePinia(createTestingPinia({ stubActions: false })) + + setupComplexPromotionFixture() + expect( + LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE] + ).toBeDefined() + + cleanupComplexPromotionFixtureNodeType() + expect( + LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE] + ).toBeUndefined() + }) +}) diff --git a/src/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers.ts b/src/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers.ts index 5672dc68522..e8c79c315a3 100644 --- a/src/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers.ts +++ b/src/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers.ts @@ -8,7 +8,12 @@ import { expect } from 'vitest' import type { ISlotType, NodeId } from '@/lib/litegraph/src/litegraph' -import { LGraph, LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph' +import { + LGraph, + LGraphNode, + LiteGraph, + Subgraph +} from '@/lib/litegraph/src/litegraph' import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' import type { ExportedSubgraph, @@ -17,6 +22,27 @@ import type { import type { UUID } from '@/lib/litegraph/src/utils/uuid' import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid' +import { subgraphComplexPromotion1 } from './subgraphComplexPromotion1' + +const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate' + +class FixtureStringConcatenateNode extends LGraphNode { + constructor() { + super('StringConcatenate') + const input = this.addInput('string_a', 'STRING') + input.widget = { name: 'string_a' } + this.addOutput('STRING', 'STRING') + this.addWidget('text', 'string_a', '', () => {}) + this.addWidget('text', 'string_b', '', () => {}) + this.addWidget('text', 'delimiter', '', () => {}) + } +} + +export function cleanupComplexPromotionFixtureNodeType(): void { + if (!LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]) return + LiteGraph.unregisterNodeType(FIXTURE_STRING_CONCAT_TYPE) +} + interface TestSubgraphOptions { id?: UUID name?: string @@ -209,6 +235,48 @@ export function createTestSubgraphNode( return new SubgraphNode(parentGraph, subgraph, instanceData) } +export function setupComplexPromotionFixture(): { + graph: LGraph + subgraph: Subgraph + hostNode: SubgraphNode +} { + const fixture = structuredClone(subgraphComplexPromotion1) + const subgraphData = fixture.definitions?.subgraphs?.[0] + if (!subgraphData) + throw new Error('Expected fixture to contain one subgraph definition') + + cleanupComplexPromotionFixtureNodeType() + LiteGraph.registerNodeType( + FIXTURE_STRING_CONCAT_TYPE, + FixtureStringConcatenateNode + ) + + for (const node of subgraphData.nodes as Array<{ type: string }>) { + if (node.type === 'StringConcatenate') + node.type = FIXTURE_STRING_CONCAT_TYPE + } + + const hostNodeData = fixture.nodes.find((node) => node.id === 21) + if (!hostNodeData) + throw new Error('Expected fixture to contain subgraph instance node id 21') + + const graph = new LGraph() + const subgraph = graph.createSubgraph(subgraphData as ExportedSubgraph) + subgraph.configure(subgraphData as ExportedSubgraph) + const hostNode = new SubgraphNode( + graph, + subgraph, + hostNodeData as ExportedSubgraphInstance + ) + graph.add(hostNode) + + return { + graph, + subgraph, + hostNode + } +} + /** * Creates a nested hierarchy of subgraphs for testing deep nesting scenarios. * @param options Configuration for the nested structure diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts b/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts index 2174004779f..345c7d5d53f 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts @@ -1,14 +1,29 @@ import { createTestingPinia } from '@pinia/testing' import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' +import { setActivePinia } from 'pinia' +import { nextTick } from 'vue' +import { describe, expect, it, vi } from 'vitest' import type { SafeWidgetData, VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { useWidgetValueStore } from '@/stores/widgetValueStore' import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue' +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ + canvas: { + graph: { + rootGraph: { + id: 'graph-test' + } + } + } + }) +})) + describe('NodeWidgets', () => { const createMockWidget = ( overrides: Partial = {} @@ -40,12 +55,15 @@ describe('NodeWidgets', () => { }) const mountComponent = (nodeData?: VueNodeData) => { + const pinia = createTestingPinia({ stubActions: false }) + setActivePinia(pinia) + return mount(NodeWidgets, { props: { nodeData }, global: { - plugins: [createTestingPinia()], + plugins: [pinia], stubs: { // Stub InputSlot to avoid complex slot registration dependencies InputSlot: true @@ -117,4 +135,165 @@ describe('NodeWidgets', () => { } ) }) + + it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => { + const duplicateA = createMockWidget({ + name: 'string_a', + type: 'text', + nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19', + storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19', + storeName: 'string_a', + slotName: 'string_a' + }) + const duplicateB = createMockWidget({ + name: 'string_a', + type: 'text', + nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19', + storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19', + storeName: 'string_a', + slotName: 'string_a' + }) + const distinct = createMockWidget({ + name: 'string_a', + type: 'text', + nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20', + storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20', + storeName: 'string_a', + slotName: 'string_a' + }) + const nodeData = createMockNodeData('SubgraphNode', [ + duplicateA, + duplicateB, + distinct + ]) + + const wrapper = mountComponent(nodeData) + + expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2) + }) + + it('prefers a visible duplicate over a hidden duplicate when identities collide', () => { + const hiddenDuplicate = createMockWidget({ + name: 'string_a', + type: 'text', + nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19', + storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19', + storeName: 'string_a', + slotName: 'string_a', + options: { hidden: true } + }) + const visibleDuplicate = createMockWidget({ + name: 'string_a', + type: 'text', + nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19', + storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19', + storeName: 'string_a', + slotName: 'string_a', + options: { hidden: false } + }) + const nodeData = createMockNodeData('SubgraphNode', [ + hiddenDuplicate, + visibleDuplicate + ]) + + const wrapper = mountComponent(nodeData) + + expect(wrapper.findAll('.lg-node-widget')).toHaveLength(1) + }) + + it('does not deduplicate entries that share names but have different widget types', () => { + const textWidget = createMockWidget({ + name: 'string_a', + type: 'text', + nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19', + storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19', + storeName: 'string_a', + slotName: 'string_a' + }) + const comboWidget = createMockWidget({ + name: 'string_a', + type: 'combo', + nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19', + storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19', + storeName: 'string_a', + slotName: 'string_a' + }) + const nodeData = createMockNodeData('SubgraphNode', [ + textWidget, + comboWidget + ]) + + const wrapper = mountComponent(nodeData) + + expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2) + }) + + it('keeps unresolved same-name promoted entries distinct by source execution identity', () => { + const firstTransientEntry = createMockWidget({ + nodeId: undefined, + storeNodeId: undefined, + name: 'string_a', + storeName: 'string_a', + slotName: 'string_a', + type: 'text', + sourceExecutionId: '65:18' + }) + const secondTransientEntry = createMockWidget({ + nodeId: undefined, + storeNodeId: undefined, + name: 'string_a', + storeName: 'string_a', + slotName: 'string_a', + type: 'text', + sourceExecutionId: '65:19' + }) + const nodeData = createMockNodeData('SubgraphNode', [ + firstTransientEntry, + secondTransientEntry + ]) + + const wrapper = mountComponent(nodeData) + + expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2) + }) + + it('hides widgets when merged store options mark them hidden', async () => { + const nodeData = createMockNodeData('TestNode', [ + createMockWidget({ + nodeId: 'test_node', + name: 'test_widget', + options: { hidden: false } + }) + ]) + + const wrapper = mountComponent(nodeData) + const widgetValueStore = useWidgetValueStore() + widgetValueStore.registerWidget('graph-test', { + nodeId: 'test_node', + name: 'test_widget', + type: 'combo', + value: 'value', + options: { hidden: true }, + label: undefined, + serialize: true, + disabled: false + }) + + await nextTick() + + expect(wrapper.findAll('.lg-node-widget')).toHaveLength(0) + }) + + it('keeps AppInput ids mapped to node identity for selection', () => { + const nodeData = createMockNodeData('TestNode', [ + createMockWidget({ nodeId: 'test_node', name: 'seed_a', type: 'text' }), + createMockWidget({ nodeId: 'test_node', name: 'seed_b', type: 'text' }) + ]) + + const wrapper = mountComponent(nodeData) + const appInputWrappers = wrapper.findAllComponents({ name: 'AppInput' }) + const ids = appInputWrappers.map((component) => component.props('id')) + + expect(ids).toStrictEqual(['test_node', 'test_node']) + }) }) diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue index d643af42649..f172fe1d385 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue @@ -21,10 +21,7 @@ @pointermove="handleWidgetPointerEvent" @pointerup="handleWidgetPointerEvent" > -