Compare commits

...

7 Commits

Author SHA1 Message Date
Alexander Brown
b581af0920 Merge branch 'main' into feat/expand-cdp-perf-metrics 2026-03-14 12:19:04 -07:00
Alexander Brown
74a48ab2aa fix: stabilize subgraph promoted widget identity and rendering (#9896)
## Summary

Fix subgraph promoted widget identity/rendering so on-node widgets stay
correct through configure/hydration churn, duplicate names, and
linked+independent coexistence.

## Changes

- **Subgraph promotion reconciliation**: stabilize linked-entry identity
by subgraph slot id, preserve deterministic linked representative
selection, and prune stale alias/fallback entries without dropping
legitimate independent promotions.
- **Promoted view resolution**: bind slot mapping by promoted view
object identity (`getSlotFromWidget` / `getWidgetFromSlot`) to avoid
same-name collisions.
- **On-node widget rendering**: harden `NodeWidgets` identity and dedup
to avoid visual aliasing, prefer visible duplicates over hidden stale
entries, include type/source execution identity, and avoid collapsing
transient unresolved entries.
- **Mapping correctness**: update `useGraphNodeManager` promoted source
mapping to resolve by input target only when the promoted view is
actually bound to that input.
- **Subgraph input uniqueness**: ensure empty-slot promotion creates
unique input names (`seed`, `seed_1`, etc.) for same-name multi-source
promotions.
- **Safety fix**: guard against undefined canvas in slot-link
interaction.
- **Tests/fixtures**: add focused regressions for fixture path
`subgraph_complex_promotion_1`, linked+independent same-name cases,
duplicate-name identity mapping, dedup behavior, and input-name
uniqueness.

## Review Focus

Validate behavior around transient configure/hydration states (`-1` id
to concrete id), duplicate-name promotions, linked representative
recovery, and that dedup never hides legitimate widgets while still
removing true duplicates.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9896-fix-stabilize-subgraph-promoted-widget-identity-and-rendering-3226d73d365081c8a1e8d0a5a22e826d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-14 11:30:31 -07:00
Christian Byrne
0875e2f50f fix: clear stale widget slotMetadata on link disconnect (#9885)
## Summary
Fixes text field becoming non-editable when a previously linked input is
removed from a custom node.

## Problem
When a widget's input was promoted to a slot, connected via a link, and
then the input was removed (e.g., by updating the custom node
definition), the widget retained stale `slotMetadata` with `linked:
true`. This prevented the widget from being editable.

## Solution
In `refreshNodeSlots`, removed the `if (slotInfo)` guard so
`widget.slotMetadata` is always assigned — either to valid metadata or
`undefined`. This ensures stale linked state is cleared when inputs no
longer match widgets.

## Acceptance Criteria
1. Text field remains editable after promote→connect→disconnect cycle
2. Text field returns to editable state when noodle disconnected
3. No mode switching needed to restore editability

## Testing
- Added regression test: "clears stale slotMetadata when input no longer
matches widget"
- All existing tests pass (18/18 in affected file)

---
**Note: This PR currently contains only the RED (failing test) commit
for TDD verification. The GREEN (fix) commit will be pushed after CI
confirms the test failure.**

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9885-fix-clear-stale-widget-slotMetadata-on-link-disconnect-3226d73d365081269319c027b42d9f6b)
by [Unito](https://www.unito.io)
2026-03-14 08:13:34 -07:00
GitHub Action
9987e7b2f5 [automated] Apply ESLint and Oxfmt fixes 2026-03-13 17:39:50 +00:00
bymyself
7c51076d92 fix: handle missing metrics in older baselines to prevent NaN 2026-03-13 10:37:04 -07:00
GitHub Action
4528d2ccb8 [automated] Apply ESLint and Oxfmt fixes 2026-03-13 17:26:51 +00:00
bymyself
43737181c1 feat: expand CDP perf metrics — add DOM nodes, script duration, event listeners
Add 4 new metrics from CDP Performance.getMetrics that are already returned
but were not being collected:

- domNodes (CDP Nodes): DOM node count delta — critical for detecting widget
  DOM leaks during node creation/destruction
- jsHeapTotalBytes (CDP JSHeapTotalSize): total heap size delta — combined
  with heapDeltaBytes shows GC pressure and fragmentation
- scriptDurationMs (CDP ScriptDuration): JS execution time separate from
  total task duration — reveals script vs rendering balance
- eventListeners (CDP JSEventListeners): event listener count delta —
  detects listener accumulation across widget lifecycle

3 of the 4 new metrics (domNodes, scriptDurationMs, eventListeners) are added
to REPORTED_METRICS for PR report display. jsHeapTotalBytes is collected but
used alongside heapDeltaBytes for GC pressure analysis, not standalone.
2026-03-13 10:23:31 -07:00
25 changed files with 2572 additions and 177 deletions

View File

@@ -8,6 +8,10 @@ interface PerfSnapshot {
TaskDuration: number
JSHeapUsedSize: number
Timestamp: number
Nodes: number
JSHeapTotalSize: number
ScriptDuration: number
JSEventListeners: number
}
export interface PerfMeasurement {
@@ -19,6 +23,10 @@ export interface PerfMeasurement {
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
domNodes: number
jsHeapTotalBytes: number
scriptDurationMs: number
eventListeners: number
}
export class PerformanceHelper {
@@ -59,7 +67,11 @@ export class PerformanceHelper {
LayoutDuration: get('LayoutDuration'),
TaskDuration: get('TaskDuration'),
JSHeapUsedSize: get('JSHeapUsedSize'),
Timestamp: get('Timestamp')
Timestamp: get('Timestamp'),
Nodes: get('Nodes'),
JSHeapTotalSize: get('JSHeapTotalSize'),
ScriptDuration: get('ScriptDuration'),
JSEventListeners: get('JSEventListeners')
}
}
@@ -90,7 +102,11 @@ export class PerformanceHelper {
layouts: delta('LayoutCount'),
layoutDurationMs: delta('LayoutDuration') * 1000,
taskDurationMs: delta('TaskDuration') * 1000,
heapDeltaBytes: delta('JSHeapUsedSize')
heapDeltaBytes: delta('JSHeapUsedSize'),
domNodes: delta('Nodes'),
jsHeapTotalBytes: delta('JSHeapTotalSize'),
scriptDurationMs: delta('ScriptDuration') * 1000,
eventListeners: delta('JSEventListeners')
}
}
}

View File

@@ -19,6 +19,10 @@ interface PerfMeasurement {
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
domNodes: number
jsHeapTotalBytes: number
scriptDurationMs: number
eventListeners: number
}
interface PerfReport {
@@ -32,11 +36,20 @@ const CURRENT_PATH = 'test-results/perf-metrics.json'
const BASELINE_PATH = 'temp/perf-baseline/perf-metrics.json'
const HISTORY_DIR = 'temp/perf-history'
type MetricKey = 'styleRecalcs' | 'layouts' | 'taskDurationMs'
type MetricKey =
| 'styleRecalcs'
| 'layouts'
| 'taskDurationMs'
| 'domNodes'
| 'scriptDurationMs'
| 'eventListeners'
const REPORTED_METRICS: { key: MetricKey; label: string; unit: string }[] = [
{ key: 'styleRecalcs', label: 'style recalcs', unit: '' },
{ key: 'layouts', label: 'layouts', unit: '' },
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' }
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' },
{ key: 'domNodes', label: 'DOM nodes', unit: '' },
{ key: 'scriptDurationMs', label: 'script duration', unit: 'ms' },
{ key: 'eventListeners', label: 'event listeners', unit: '' }
]
function groupByName(
@@ -76,9 +89,8 @@ function getHistoricalStats(
const group = groupByName(r.measurements)
const samples = group.get(testName)
if (samples) {
const mean =
samples.reduce((sum, s) => sum + s[metric], 0) / samples.length
values.push(mean)
const mean = meanMetric(samples, metric)
if (mean !== null) values.push(mean)
}
}
return computeStats(values)
@@ -98,8 +110,20 @@ function formatDelta(pct: number | null): string {
return `${sign}${pct.toFixed(0)}%`
}
function meanMetric(samples: PerfMeasurement[], key: MetricKey): number {
return samples.reduce((sum, s) => sum + s[key], 0) / samples.length
function getMetricValue(
sample: PerfMeasurement,
key: MetricKey
): number | null {
const value = sample[key]
return Number.isFinite(value) ? value : null
}
function meanMetric(samples: PerfMeasurement[], key: MetricKey): number | null {
const values = samples
.map((s) => getMetricValue(s, key))
.filter((v): v is number => v !== null)
if (values.length === 0) return null
return values.reduce((sum, v) => sum + v, 0) / values.length
}
function formatBytes(bytes: number): string {
@@ -127,8 +151,8 @@ function renderFullReport(
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prValues = prSamples.map((s) => s[key])
const prMean = prValues.reduce((a, b) => a + b, 0) / prValues.length
const prMean = meanMetric(prSamples, key)
if (prMean === null) continue
const histStats = getHistoricalStats(historical, testName, key)
const cv = computeCV(histStats)
@@ -140,6 +164,12 @@ function renderFullReport(
}
const baseVal = meanMetric(baseSamples, key)
if (baseVal === null) {
allRows.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
)
continue
}
const deltaPct =
baseVal === 0
? prMean === 0
@@ -218,8 +248,8 @@ function renderColdStartReport(
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prValues = prSamples.map((s) => s[key])
const prMean = prValues.reduce((a, b) => a + b, 0) / prValues.length
const prMean = meanMetric(prSamples, key)
if (prMean === null) continue
if (!baseSamples?.length) {
lines.push(
@@ -229,6 +259,12 @@ function renderColdStartReport(
}
const baseVal = meanMetric(baseSamples, key)
if (baseVal === null) {
lines.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
)
continue
}
const deltaPct =
baseVal === 0
? prMean === 0
@@ -254,18 +290,14 @@ function renderNoBaselineReport(
'|--------|-------|'
)
for (const [testName, prSamples] of prGroups) {
const prMean = (key: MetricKey) =>
prSamples.reduce((sum, s) => sum + s[key], 0) / prSamples.length
lines.push(
`| ${testName}: style recalcs | ${prMean('styleRecalcs').toFixed(0)} |`
)
lines.push(`| ${testName}: layouts | ${prMean('layouts').toFixed(0)} |`)
lines.push(
`| ${testName}: task duration | ${prMean('taskDurationMs').toFixed(0)}ms |`
)
for (const { key, label, unit } of REPORTED_METRICS) {
const prMean = meanMetric(prSamples, key)
if (prMean === null) continue
lines.push(`| ${testName}: ${label} | ${formatValue(prMean, unit)} |`)
}
const heapMean =
prSamples.reduce((sum, s) => sum + s.heapDeltaBytes, 0) / prSamples.length
prSamples.reduce((sum, s) => sum + (s.heapDeltaBytes ?? 0), 0) /
prSamples.length
lines.push(`| ${testName}: heap delta | ${formatBytes(heapMean)} |`)
}
return lines

View File

@@ -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({
<TransitionGroup name="list-scale">
<WidgetItem
v-for="{ widget, node } in widgets"
:key="`${node.id}-${widget.name}-${widget.type}`"
:key="getStableWidgetRenderKey(widget)"
:widget="widget"
:node="node"
:is-draggable="isDraggable"

View File

@@ -241,6 +241,88 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
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)
const nodeData = vueNodeData.get(String(node.id))!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
expect(widgetData.slotMetadata).toBeUndefined()
})
})
describe('Subgraph output slot label reactivity', () => {
@@ -390,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', () => {

View File

@@ -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,
@@ -520,8 +522,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
const slotInfo = slotMetadata.get(widget.slotName ?? widget.name)
if (slotInfo) widget.slotMetadata = slotInfo
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
}
}

View File

@@ -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()
})
})

View File

@@ -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
}

View File

@@ -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<LGraphCanvas, 'frame'>
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<LGraphCanvas, 'frame'>
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', () => {})

View File

@@ -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

View File

@@ -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
}

View File

@@ -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> = {}): 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)
})
})

View File

@@ -0,0 +1,17 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
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
}

View File

@@ -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,

View File

@@ -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' }
])
}
)
})

View File

@@ -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)

View File

@@ -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)
})
})

View File

@@ -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<string>()
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<string, string>
@@ -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<string>
): 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<string> {
const connectedEntryKeys = new Set<string>()
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<ISubgraphInput>
) {
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<string, SubgraphInput[]>()
const slotsByName = new Map<string, SubgraphInput[]>()
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<string>()
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<typeof e> => 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()

View File

@@ -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

View File

@@ -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()
})
})

View File

@@ -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

View File

@@ -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<SafeWidgetData> = {}
@@ -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'])
})
})

View File

@@ -21,10 +21,7 @@
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
>
<template
v-for="(widget, index) in processedWidgets"
:key="`widget-${index}-${widget.name}`"
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
@@ -218,6 +215,7 @@ interface ProcessedWidget {
hidden: boolean
id: string
name: string
renderKey: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
type: string
@@ -245,12 +243,48 @@ function hasWidgetError(
)
}
function getWidgetIdentity(
widget: SafeWidgetData,
nodeId: string | number | undefined,
index: number
): {
dedupeIdentity?: string
renderKey: string
} {
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
const storeWidgetName = widget.storeName ?? widget.name
const slotNameForIdentity = widget.slotName ?? widget.name
const stableIdentityRoot = rawWidgetId
? `node:${String(stripGraphPrefix(rawWidgetId))}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: undefined
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}:${index}`
return {
dedupeIdentity,
renderKey
}
}
function isWidgetVisible(options: IWidgetOptions): boolean {
const hidden = options.hidden ?? false
const advanced = options.advanced ?? false
return !hidden && (!advanced || showAdvanced.value)
}
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
// nodeData.id is the local node ID; subgraph nodes need the full execution
// path (e.g. "65:63") to match keys in lastNodeErrors.
const nodeExecId = app.rootGraph
const nodeExecId = app.isGraphReady
? getExecutionIdFromNodeData(app.rootGraph, nodeData)
: String(nodeData.id ?? '')
@@ -260,10 +294,76 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
const uniqueWidgets: Array<{
widget: SafeWidgetData
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
for (const widget of widgets) {
for (const [index, widget] of widgets.entries()) {
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const storeWidgetName = widget.storeName ?? widget.name
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
: undefined
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(mergedOptions)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
if (existingIndex === undefined) {
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingWidget = uniqueWidgets[existingIndex]
if (existingWidget && !existingWidget.isVisible && visible) {
uniqueWidgets[existingIndex] = {
widget,
identity,
mergedOptions,
widgetState,
isVisible: true
}
}
}
for (const {
widget,
mergedOptions,
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const isPromotedView = !!widget.nodeId
const vueComponent =
@@ -272,32 +372,22 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const { slotMetadata } = widget
// Get metadata from store (registered during BaseWidget.setNodeId)
const bareWidgetId = stripGraphPrefix(
widget.storeNodeId ?? widget.nodeId ?? nodeId
)
const storeWidgetName = widget.storeName ?? widget.name
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
: undefined
// Get value from store (falls back to undefined if not registered)
const value = widgetState?.value as WidgetValue
// Build options from store state, with disabled override for
// slot-linked widgets or widgets with disabled state (e.g. display-only)
const storeOptions = widgetState?.options ?? {}
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...storeOptions, disabled: true }
: storeOptions
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
graphId &&
!isPromotedView &&
promotionStore.isPromotedByAny(graphId, String(bareWidgetId), widget.name)
? 'ring ring-component-node-widget-promoted'
: widget.options?.advanced
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
@@ -345,13 +435,14 @@ const processedWidgets = computed((): ProcessedWidget[] => {
}
result.push({
advanced: widget.options?.advanced ?? false,
advanced: mergedOptions.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(widget, nodeExecId, nodeErrors),
hidden: widget.options?.hidden ?? false,
hidden: mergedOptions.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,
renderKey,
type: widget.type,
vueComponent,
simplified,

View File

@@ -748,6 +748,7 @@ export function useSlotLinkInteraction({
})
function onDoubleClick(e: PointerEvent) {
if (!app.canvas) return
const { graph } = app.canvas
if (!graph) return
const node = graph.getNodeById(nodeId)
@@ -756,6 +757,7 @@ export function useSlotLinkInteraction({
node.onInputDblClick?.(index, e)
}
function onClick(e: PointerEvent) {
if (!app.canvas) return
const { graph } = app.canvas
if (!graph) return
const node = graph.getNodeById(nodeId)

View File

@@ -23,6 +23,13 @@ describe(usePromotionStore, () => {
expect(store.getPromotions(graphA, nodeId)).toEqual([])
})
it('returns a stable empty ref for unknown node', () => {
const first = store.getPromotionsRef(graphA, nodeId)
const second = store.getPromotionsRef(graphA, nodeId)
expect(second).toBe(first)
})
it('returns entries after setPromotions', () => {
const entries = [
{ interiorNodeId: '10', widgetName: 'seed' },

View File

@@ -9,6 +9,8 @@ interface PromotionEntry {
widgetName: string
}
const EMPTY_PROMOTIONS: PromotionEntry[] = []
export const usePromotionStore = defineStore('promotion', () => {
const graphPromotions = ref(new Map<UUID, Map<NodeId, PromotionEntry[]>>())
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
@@ -62,7 +64,9 @@ export const usePromotionStore = defineStore('promotion', () => {
graphId: UUID,
subgraphNodeId: NodeId
): PromotionEntry[] {
return _getPromotionsForGraph(graphId).get(subgraphNodeId) ?? []
return (
_getPromotionsForGraph(graphId).get(subgraphNodeId) ?? EMPTY_PROMOTIONS
)
}
function getPromotions(
@@ -99,6 +103,7 @@ export const usePromotionStore = defineStore('promotion', () => {
): void {
const promotions = _getPromotionsForGraph(graphId)
const oldEntries = promotions.get(subgraphNodeId) ?? []
_decrementKeys(graphId, oldEntries)
_incrementKeys(graphId, entries)
@@ -116,6 +121,7 @@ export const usePromotionStore = defineStore('promotion', () => {
widgetName: string
): void {
if (isPromoted(graphId, subgraphNodeId, interiorNodeId, widgetName)) return
const entries = getPromotionsRef(graphId, subgraphNodeId)
setPromotions(graphId, subgraphNodeId, [
...entries,