[backport core/1.41] fix: subgraph promoted widget input label rename (#10412)

Backport of #10195 to `core/1.41`.

## Summary

- Fix widget-input slot positioning for promoted subgraph inputs in both
LiteGraph and Vue rendering modes
- Sync `input.widget.name` with display name on label rename and initial
setup
- `_arrangeWidgetInputSlots` rewrite iterates `_concreteInputs` directly
- Add `promotedLabel` field to `SafeWidgetData` for correct label
display after rename

## Conflicts resolved

- `useGraphNodeManager.ts`: Added `tooltip` and `promotedLabel` fields
without `sourceExecutionId` (not on target branch)
- `SubgraphNode.ts`: Kept PR's comment about not changing
`input.widget.name`, deduplicated `_invalidatePromotedViewsCache()` call
- `NodeWidgets.vue`: Applied `widget.promotedLabel ??
widgetState?.label` without `linkedUpstream` (not on target branch)
- `widgetUtil.test.ts`: Took PR version with new `_subgraphSlot.label`
test case

Fixes #9998

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10412-backport-core-1-41-fix-subgraph-promoted-widget-input-label-rename-32d6d73d365081c7919ffaf5d37a8478)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Arthur R Longbottom <art.longbottom.jr@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jaeone94 <jaeone.prt@gmail.com>
This commit is contained in:
Alexander Brown
2026-03-23 18:05:08 -07:00
committed by GitHub
parent ad3147c4fc
commit 4b9c32d326
19 changed files with 1499 additions and 72 deletions

View File

@@ -0,0 +1,407 @@
{
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
"revision": 0,
"last_node_id": 19,
"last_link_id": 24,
"nodes": [
{
"id": 14,
"type": "CLIPLoader",
"pos": [143.16716182216328, 290.16372862874033],
"size": [270, 117.3125],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"links": [21]
}
],
"properties": {
"Node name for S&R": "CLIPLoader"
},
"widgets_values": [null, "stable_diffusion", "default"]
},
{
"id": 18,
"type": "PreviewImage",
"pos": [1305.1455526601603, 472.17095792625025],
"size": [225, 48],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 24
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 19,
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"pos": [794.198171390827, 452.45433419677147],
"size": [225, 172],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"label": "renamed_clip",
"name": "clip",
"type": "CLIP",
"link": 21
},
{
"label": "renamed_seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 22
},
{
"label": "renamed_vae",
"name": "vae",
"type": "VAE",
"link": 23
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [24]
}
],
"title": "Input Test Subgraph",
"properties": {
"proxyWidgets": [
["12", "seed"],
["15", "text"]
]
},
"widgets_values": []
},
{
"id": 13,
"type": "PrimitiveInt",
"pos": [155.04048166054417, 773.3816055422594],
"size": [270, 82],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [22]
}
],
"title": "Seed Int",
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 17,
"type": "VAELoader",
"pos": [163.6043676075426, 543.9624492717659],
"size": [270, 82.65625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [23]
}
],
"properties": {
"Node name for S&R": "VAELoader"
},
"widgets_values": ["pixel_space"]
}
],
"links": [
[21, 14, 0, 19, 0, "CLIP"],
[22, 13, 0, 19, 1, "INT"],
[23, 17, 0, 19, 2, "VAE"],
[24, 19, 0, 18, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 19,
"lastLinkId": 24,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Input Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [
358.8694807105848, 439.23932667242485, 123.14453125,
99.99999999999994
]
},
"outputNode": {
"id": -20,
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
},
"inputs": [
{
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
"name": "clip",
"type": "CLIP",
"linkIds": [16],
"localized_name": "clip",
"label": "renamed_clip",
"pos": [462.0140119605848, 459.23932667242485]
},
{
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
"name": "seed",
"type": "INT",
"linkIds": [15],
"localized_name": "seed",
"label": "renamed_seed",
"pos": [462.0140119605848, 479.23932667242485]
},
{
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
"name": "vae",
"type": "VAE",
"linkIds": [19],
"localized_name": "vae",
"label": "renamed_vae",
"pos": [462.0140119605848, 499.23932667242485]
}
],
"outputs": [
{
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [20],
"localized_name": "IMAGE",
"pos": [1428.5510580294986, 483.2512895126797]
}
],
"widgets": [],
"nodes": [
{
"id": 12,
"type": "KSampler",
"pos": [769.2424728654022, 512.726159169824],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 17
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [18]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 16,
"type": "VAEDecode",
"pos": [1208.5510580294986, 469.21581253470083],
"size": [140, 46],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 18
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 19
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [20]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 15,
"type": "CLIPTextEncode",
"pos": [681.4596332342014, 243.17567172890932],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 16
},
{
"label": "renamed_from_sidepanel",
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [17]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 17,
"origin_id": 15,
"origin_slot": 0,
"target_id": 12,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 18,
"origin_id": 12,
"origin_slot": 0,
"target_id": 16,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 0,
"target_id": 15,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 12,
"target_slot": 4,
"type": "INT"
},
{
"id": 19,
"origin_id": -10,
"origin_slot": 2,
"target_id": 16,
"target_slot": 1,
"type": "VAE"
},
{
"id": 20,
"origin_id": 16,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6727925600199565,
"offset": [446.69747171876463, 99.95078257277316]
}
},
"version": 0.4
}

View File

@@ -1,3 +1,5 @@
import type { Page } from '@playwright/test'
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
@@ -14,3 +16,30 @@ export function assertSubgraph(
)
}
}
/**
* Returns the widget-input slot Y position and the node title height
* for the promoted "text" input on the SubgraphNode.
*
* The slot Y should be at the widget row, not the header. A value near
* zero or negative indicates the slot is positioned at the header (the bug).
*/
export function getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}

View File

@@ -0,0 +1,86 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
test.describe(
'Subgraph promoted widget-input slot position',
{ tag: '@subgraph' },
() => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Render a few frames so arrange() runs
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await getTextSlotPosition(comfyPage.page, '11')
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
// The slot Y position should be well below the title area.
// If it's near 0 or negative, the slot is stuck at the header (the bug).
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
// Verify initial position is correct
const before = await getTextSlotPosition(comfyPage.page, '11')
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
// Navigate into subgraph and rename the text input
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(i: { type: string }) => i.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
await comfyPage.page.fill(dialog, '')
await comfyPage.page.fill(dialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify slot position is still at the widget row after rename
const after = await getTextSlotPosition(comfyPage.page, '11')
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
// widget.name is the stable identity key — it does NOT change on rename.
// The display label is on input.label, read via PromotedWidgetView.label.
expect(after!.widgetName).not.toBe('my_custom_prompt')
})
}
)

View File

@@ -0,0 +1,57 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
test.describe(
'Subgraph promoted widget DOM position',
{ tag: '@subgraph' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
// Verify widget is inside the node body, not the header
const headerBox = await nodeLocator
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
// Widget top should be below the header bottom
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
})
}
)

View File

@@ -0,0 +1,117 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
const RENAMED_LABEL = 'my_seed'
/**
* Regression test for subgraph input slot rename propagation.
*
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
* update the promoted widget label shown on the parent SubgraphNode and
* keep the widget positioned in the node body (not the header).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
*/
test.describe(
'Subgraph input slot rename propagation',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
comfyPage
}) => {
const { page } = comfyPage
// 1. Load workflow with subgraph containing a promoted seed widget input
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNode).toBeVisible()
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
// Verify widget is in the node body, not the header
const headerBox = await sgNode
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
// 3. Enter the subgraph and rename the seed slot.
// The subgraph IO rename uses canvas.prompt() which requires the
// litegraph context menu, so temporarily disable Vue nodes.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await sgNodeRef.navigateIntoSubgraph()
// Find the seed SubgraphInput slot
const seedSlotName = await page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph) return null
const inputs = (
graph as { inputs?: Array<{ name: string; type: string }> }
).inputs
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
})
expect(seedSlotName).not.toBeNull()
// 4. Right-click the seed input slot and rename it
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await page.waitForSelector(dialog, { state: 'visible' })
await page.fill(dialog, '')
await page.fill(dialog, RENAMED_LABEL)
await page.keyboard.press('Enter')
await page.waitForSelector(dialog, { state: 'hidden' })
// 5. Navigate back to parent graph and re-enable Vue nodes
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
// 6. Verify the widget label updated to the renamed value
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNodeAfter).toBeVisible()
const updatedLabel = await page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('19')
if (!node) return null
const w = node.widgets?.find((w: { name: string }) =>
w.name.includes('seed')
)
return w?.label || w?.name || null
})
expect(updatedLabel).toBe(RENAMED_LABEL)
// 7. Verify the widget is still in the body, not the header
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
await expect(seedWidgetAfter).toBeVisible()
const headerAfter = await sgNodeAfter
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetAfter = await seedWidgetAfter.boundingBox()
expect(headerAfter).not.toBeNull()
expect(widgetAfter).not.toBeNull()
expect(widgetAfter!.y).toBeGreaterThan(
headerAfter!.y + headerAfter!.height
)
})
}
)

View File

@@ -193,14 +193,16 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with displayName="value" (subgraph input
// Create a PromotedWidgetView with identityName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
// SafeWidgetData.name to sourceWidgetName ("prompt").
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
'value',
undefined,
'value'
)

View File

@@ -82,6 +82,10 @@ export interface SafeWidgetData {
* which differs from the subgraph node's input slot widget name.
*/
slotName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
}
export interface VueNodeData {
@@ -335,7 +339,9 @@ function safeWidgetMapper(
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
slotName: name !== widget.name ? widget.name : undefined
slotName: name !== widget.name ? widget.name : undefined,
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
}
} catch (error) {
return {
@@ -759,6 +765,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so promotedLabel reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}

View File

@@ -86,6 +86,24 @@ function useVueNodeLifecycleIndividual() {
() => !shouldRenderVueNodes.value,
() => {
disposeNodeManagerAndSyncs()
// Force arrange() on all nodes so input.pos is computed before
// the first legacy drawConnections frame (which may run before
// drawNode on the foreground canvas).
const graph = comfyApp.canvas?.graph
if (!graph) {
comfyApp.canvas?.setDirty(true, true)
return
}
for (const node of graph._nodes) {
if (node.flags.collapsed) continue
try {
node.arrange()
} catch {
/* skip nodes not fully initialized */
}
}
comfyApp.canvas?.setDirty(true, true)
}
)

View File

@@ -138,15 +138,18 @@ describe(createPromotedWidgetView, () => {
expect(view.name).toBe('myWidget')
})
test('name uses displayName when provided', () => {
test('name uses identityName when provided, label uses displayName', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
'1',
'myWidget',
'Custom Label'
'Custom Label',
undefined,
'my_slot'
)
expect(view.name).toBe('Custom Label')
expect(view.name).toBe('my_slot')
expect(view.label).toBe('Custom Label')
})
test('node getter returns the subgraphNode', () => {
@@ -334,11 +337,11 @@ describe(createPromotedWidgetView, () => {
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const bareId = String(innerNode.id)
// No displayName → falls back to widgetName
// No displayName → label is undefined (rendering uses widget.label ?? widget.name)
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
expect(view1.label).toBe('myWidget')
expect(view1.label).toBeUndefined()
// With displayName → falls back to displayName
// With displayName → label falls back to displayName
const view2 = createPromotedWidgetView(
subgraphNode,
bareId,
@@ -1012,7 +1015,9 @@ describe('SubgraphNode.widgets getter', () => {
const afterRename = promotedWidgets(subgraphNode)[0]
if (!afterRename) throw new Error('Expected linked promoted view')
expect(afterRename.name).toBe('seed_renamed')
// .name stays as identity (subgraph input name), .label updates for display
expect(afterRename.name).toBe('seed')
expect(afterRename.label).toBe('seed_renamed')
})
test('caches view objects across getter calls (stable references)', () => {

View File

@@ -27,6 +27,12 @@ import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidget
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
interface SubgraphSlotRef {
name: string
label?: string
displayName?: string
}
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
@@ -50,14 +56,16 @@ export function createPromotedWidgetView(
nodeId: string,
widgetName: string,
displayName?: string,
disambiguatingSourceNodeId?: string
disambiguatingSourceNodeId?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId
disambiguatingSourceNodeId,
identityName
)
}
@@ -83,12 +91,17 @@ class PromotedWidgetView implements IPromotedWidgetView {
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
/** Cached reference to the bound subgraph slot, set at construction. */
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string
readonly disambiguatingSourceNodeId?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
@@ -100,7 +113,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get name(): string {
return this.displayName ?? this.sourceWidgetName
return this.identityName ?? this.sourceWidgetName
}
get y(): number {
@@ -188,15 +201,58 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
// Fall back to persisted widget state (survives save/reload before
// the slot binding is established) then to construction displayName.
const state = this.getWidgetState()
return state?.label ?? this.displayName ?? this.sourceWidgetName
return state?.label ?? this.displayName
}
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
// Also persist to widget state store for save/reload resilience
const state = this.getWidgetState()
if (state) state.label = value
}
/**
* Returns the cached bound subgraph slot reference, refreshing only when
* the subgraph node's input list has changed (length mismatch).
*
* Note: Using length as the cache key works because the returned reference
* is the same mutable slot object. When slot properties (label, name) change,
* the caller reads fresh values from that reference. The cache only needs
* to invalidate when slots are added or removed, which changes length.
*/
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
this._boundSlot = this.findBoundSubgraphSlot()
this._boundSlotVersion = version
return this._boundSlot
}
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
for (const input of this.subgraphNode.inputs ?? []) {
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
if (!slot) continue
const w = input._widget
if (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === this.sourceNodeId &&
w.sourceWidgetName === this.sourceWidgetName
) {
return slot
}
}
return undefined
}
get hidden(): boolean {
return this.resolveDeepest()?.widget.hidden ?? false
}
@@ -238,21 +294,27 @@ class PromotedWidgetView implements IPromotedWidgetView {
const originalComputedHeight = projected.computedHeight
const originalComputedDisabled = projected.computedDisabled
const originalLabel = projected.label
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.computedDisabled = this.computedDisabled
projected.value = this.value
projected.label = this.label
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
try {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
} finally {
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
projected.label = originalLabel
}
}
onPointerDown(

View File

@@ -0,0 +1,207 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { LLink } from '@/lib/litegraph/src/LLink'
import { createMockCanvas2DContext } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn(),
setSource: vi.fn(),
batchUpdateNodeBounds: vi.fn(),
getCurrentSource: vi.fn(() => 'test'),
getCurrentActor: vi.fn(() => 'test'),
applyOperation: vi.fn(),
pendingSlotSync: false
}
}))
function createMockCtx(): CanvasRenderingContext2D {
return createMockCanvas2DContext({
translate: vi.fn(),
scale: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
closePath: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
createLinearGradient: vi.fn().mockReturnValue({
addColorStop: vi.fn()
}),
bezierCurveTo: vi.fn(),
quadraticCurveTo: vi.fn(),
isPointInStroke: vi.fn().mockReturnValue(false),
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline,
shadowColor: '',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
imageSmoothingEnabled: true
})
}
/**
* Creates a link between two nodes by directly mutating graph state,
* bypassing the layout store integration in connect().
*/
function createTestLink(
graph: LGraph,
sourceNode: LGraphNode,
outputSlot: number,
targetNode: LGraphNode,
inputSlot: number
): LLink {
const linkId = ++graph.state.lastLinkId
const link = new LLink(
linkId,
sourceNode.outputs[outputSlot].type,
sourceNode.id,
outputSlot,
targetNode.id,
inputSlot
)
graph._links.set(linkId, link)
sourceNode.outputs[outputSlot].links ??= []
sourceNode.outputs[outputSlot].links!.push(linkId)
targetNode.inputs[inputSlot].link = linkId
return link
}
describe('drawConnections widget-input slot positioning', () => {
let graph: LGraph
let canvas: LGraphCanvas
let canvasElement: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia())
canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi.fn().mockReturnValue(createMockCtx())
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true
})
LiteGraph.vueNodesMode = false
})
afterEach(() => {
LiteGraph.vueNodesMode = false
})
it('arranges widget-input slots before rendering links', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [200, 120]
const widget = targetNode.addWidget('text', 'value', '', null)
const input = targetNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(targetNode)
createTestLink(graph, sourceNode, 0, targetNode, 0)
// Before drawConnections, input.pos should not be set
expect(input.pos).toBeUndefined()
canvas.drawConnections(createMockCtx())
// After drawConnections, input.pos should be set to the widget row
expect(input.pos).toBeDefined()
expect(input.pos![1]).toBeGreaterThan(0)
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(input.pos![1]).toBe(widget.y + offset)
})
it('does not re-arrange nodes whose widget-input slots already have positions', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [200, 120]
targetNode.addWidget('text', 'value', '', null)
const input = targetNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(targetNode)
createTestLink(graph, sourceNode, 0, targetNode, 0)
// Pre-arrange so input.pos is already set
targetNode._setConcreteSlots()
targetNode.arrange()
expect(input.pos).toBeDefined()
const arrangeSpy = vi.spyOn(targetNode, 'arrange')
canvas.drawConnections(createMockCtx())
expect(arrangeSpy).not.toHaveBeenCalled()
})
it('positions widget-input slots when display name differs from slot.widget.name', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [200, 120]
// Widget has a display name that differs from the slot's widget.name
// (simulates a renamed subgraph label)
const widget = targetNode.addWidget('text', 'renamed_label', '', null)
const input = targetNode.addInput('renamed_label', 'STRING')
input.widget = { name: 'original_name' }
// Bind the widget as the slot's _widget (preferred over name-map lookup)
input._widget = widget
graph.add(targetNode)
createTestLink(graph, sourceNode, 0, targetNode, 0)
canvas.drawConnections(createMockCtx())
expect(input.pos).toBeDefined()
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(input.pos![1]).toBe(widget.y + offset)
})
})

View File

@@ -5774,6 +5774,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.globalAlpha = this.editor_alpha
// for every node
const nodes = graph._nodes
// Ensure widget-input slot positions are computed before rendering links.
// arrange() sets input.pos for widget-backed slots, but is normally called
// in drawNode (foreground canvas). drawConnections runs on the background
// canvas, which may render before drawNode has executed for this frame.
// The dirty flag avoids a per-frame O(N) scan of all inputs.
for (const node of nodes) {
if (node.flags.collapsed || !node._widgetSlotsDirty) continue
node._setConcreteSlots()
node.arrange()
}
for (const node of nodes) {
// for every input (we render just inputs because it is easier as every slot can only have one input)
const { inputs } = node

View File

@@ -294,6 +294,12 @@ export class LGraphNode
*/
freeWidgetSpace?: number
/**
* Set to true when widget-backed input slot positions need recalculation.
* Cleared after arrange() runs. Avoids per-frame O(N) scans in drawConnections.
*/
_widgetSlotsDirty = false
locked?: boolean
/** Execution order, automatically computed during run @see {@link LGraph.computeExecutionOrder} */
@@ -1991,6 +1997,7 @@ export class LGraphNode
this.widgets ||= []
const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget
this.widgets.push(widget)
this._widgetSlotsDirty = true
// Only register with store if node has a valid ID (is already in a graph).
// If the node isn't in a graph yet (id === -1), registration happens
@@ -2030,9 +2037,11 @@ export class LGraphNode
if (input._widget === widget) {
input._widget = undefined
input.widget = undefined
input.pos = undefined
}
}
}
this._widgetSlotsDirty = true
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
@@ -4205,40 +4214,29 @@ export class LGraphNode
* Arranges the layout of the node's widget input slots.
*/
private _arrangeWidgetInputSlots(): void {
if (!this.widgets) return
if (!this.widgets?.length) return
const slotByWidgetName = new Map<
string,
INodeInputSlot & { index: number }
>()
// Build a name→widget map for fast lookup.
const widgetByName = new Map<string, IBaseWidget>()
for (const w of this.widgets) widgetByName.set(w.name, w)
for (const [i, slot] of this.inputs.entries()) {
// Set widget-backed slot positions from widget Y coordinates.
// In Vue mode, promoted widget inputs are not rendered as <InputSlot>
// components (NodeSlots filters them out), so they have no DOM-registered
// position. input.pos serves as the fallback for getSlotPosition().
for (const [i, slot] of this._concreteInputs.entries()) {
if (!isWidgetInputSlot(slot)) continue
slotByWidgetName.set(slot.widget.name, { ...slot, index: i })
}
if (!slotByWidgetName.size) return
// Prefer the slot's direct _widget binding (1:1 for promoted inputs).
// Fall back to name-map lookup for regular nodes without _widget set.
// Note: the name-map is ambiguous if two promoted inputs share a label;
// _widget avoids this since it is a direct reference.
const widget = slot._widget ?? widgetByName.get(slot.widget.name)
if (!widget) continue
// Only set custom pos if not using Vue positioning
// Vue positioning calculates widget slot positions dynamically
if (!LiteGraph.vueNodesMode) {
for (const widget of this.widgets) {
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
const actualSlot = this._concreteInputs[slot.index]
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
actualSlot.pos = [offset, widget.y + offset]
this._measureSlot(actualSlot, slot.index, true)
}
} else {
// For Vue positioning, just measure the slots without setting pos
for (const widget of this.widgets) {
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
this._measureSlot(this._concreteInputs[slot.index], slot.index, true)
}
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.pos = [offset, widget.y + offset]
this._measureSlot(slot, i, true)
}
}
@@ -4268,6 +4266,7 @@ export class LGraphNode
: 0
this._arrangeWidgets(widgetStartY)
this._arrangeWidgetInputSlots()
this._widgetSlotsDirty = false
}
/**

View File

@@ -1,8 +1,8 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
type ViewManagerEntry = PromotedWidgetSource & { viewKey?: string }
type CreateView<TView> = (entry: ViewManagerEntry) => TView
type ViewManagerEntry = PromotedWidgetSource & {
viewKey?: string
}
/**
* Reconciles promoted widget entries to stable view instances.
@@ -15,9 +15,9 @@ export class PromotedWidgetViewManager<TView> {
private cachedViews: TView[] | null = null
private cachedEntryKeys: string[] | null = null
reconcile(
entries: readonly ViewManagerEntry[],
createView: CreateView<TView>
reconcile<TEntry extends ViewManagerEntry>(
entries: readonly TEntry[],
createView: (entry: TEntry) => TView
): TView[] {
const entryKeys = entries.map((entry) =>
this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey)

View File

@@ -9,7 +9,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -196,6 +196,258 @@ describe('SubgraphNode Synchronization', () => {
expect(subgraphNode.outputs[0].label).toBe('newOutput')
})
it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'STRING')
interiorNode.addWidget('text', 'value', '', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
expect(promotedInput.widget).toBeDefined()
const originalWidgetName = promotedInput.widget!.name
// Rename the subgraph input label
subgraph.inputs[0].label = 'my_custom_prompt'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[0],
index: 0,
oldName: 'text',
newName: 'my_custom_prompt'
})
// widget.name stays as the internal name — NOT the display label
expect(promotedInput.widget!.name).toBe(originalWidgetName)
// The display label is on input.label (live-read via PromotedWidgetView.label)
expect(promotedInput.label).toBe('my_custom_prompt')
// input.widget.name should still match a widget in node.widgets
const matchingWidget = subgraphNode.widgets?.find(
(w) => w.name === promotedInput.widget!.name
)
expect(matchingWidget).toBeDefined()
})
it('should preserve renamed label through serialize/configure round-trip', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'INT')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'INT')
interiorNode.addWidget('number', 'value', 0, () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedWidget = subgraphNode.widgets?.[0]
expect(promotedWidget).toBeDefined()
// Rename via the subgraph slot (simulates right-click rename)
subgraph.inputs[0].label = 'My Seed'
subgraphNode.inputs[0].label = 'My Seed'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[0],
index: 0,
oldName: 'seed',
newName: 'My Seed'
})
// Label should be visible before round-trip
const widgetBeforeRoundTrip = subgraphNode.widgets?.[0]
expect(widgetBeforeRoundTrip!.label || widgetBeforeRoundTrip!.name).toBe(
'My Seed'
)
// Serialize and reconfigure (simulates save/reload)
const serialized = subgraphNode.serialize()
subgraphNode.configure(serialized)
// Label should survive the round-trip
const widgetAfterRoundTrip = subgraphNode.widgets?.[0]
expect(widgetAfterRoundTrip).toBeDefined()
expect(widgetAfterRoundTrip!.label || widgetAfterRoundTrip!.name).toBe(
'My Seed'
)
})
})
describe('SubgraphNode widget name collision on rename', () => {
it('should not collapse two inputs when renamed to the same label', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'prompt_a', type: 'STRING' },
{ name: 'prompt_b', type: 'STRING' }
]
})
// Create two interior nodes with widgets
const nodeA = new LGraphNode('NodeA')
nodeA.addInput('value', 'STRING')
nodeA.inputs[0].widget = { name: 'value' }
nodeA.addOutput('out', 'STRING')
nodeA.addWidget('text', 'value', '', () => {})
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
const nodeB = new LGraphNode('NodeB')
nodeB.addInput('value', 'STRING')
nodeB.inputs[0].widget = { name: 'value' }
nodeB.addOutput('out', 'STRING')
nodeB.addWidget('text', 'value', '', () => {})
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.inputs).toHaveLength(2)
// widget.name is now nodeId:widgetName (stable composite key)
const key0 = subgraphNode.inputs[0].widget?.name
const key1 = subgraphNode.inputs[1].widget?.name
expect(key0).toBeDefined()
expect(key1).toBeDefined()
expect(key0).not.toBe(key1)
// Rename prompt_b to same LABEL as prompt_a
subgraph.inputs[1].label = 'prompt_a'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[1],
index: 1,
oldName: 'prompt_b',
newName: 'prompt_a'
})
// Both inputs survive — widget.name stays as composite key, no collision
expect(subgraphNode.inputs).toHaveLength(2)
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
// Display labels: input[1] was renamed
expect(subgraphNode.inputs[1].label).toBe('prompt_a')
// Distinct _widget bindings
expect(subgraphNode.inputs[0]._widget).toBeDefined()
expect(subgraphNode.inputs[1]._widget).toBeDefined()
expect(subgraphNode.inputs[0]._widget).not.toBe(
subgraphNode.inputs[1]._widget
)
})
it('should keep unique widget.name keys even with duplicate labels', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: 'INT' },
{ name: 'seed2', type: 'INT' }
]
})
const nodeA = new LGraphNode('NodeA')
nodeA.addInput('value', 'INT')
nodeA.inputs[0].widget = { name: 'value' }
nodeA.addOutput('out', 'INT')
nodeA.addWidget('number', 'value', 0, () => {})
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
const nodeB = new LGraphNode('NodeB')
nodeB.addInput('value', 'INT')
nodeB.inputs[0].widget = { name: 'value' }
nodeB.addOutput('out', 'INT')
nodeB.addWidget('number', 'value', 0, () => {})
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
const subgraphNode = createTestSubgraphNode(subgraph)
const key0 = subgraphNode.inputs[0].widget?.name
const key1 = subgraphNode.inputs[1].widget?.name
// Keys should be unique composite identifiers (nodeId:widgetName)
expect(key0).toBeDefined()
expect(key1).toBeDefined()
expect(key0).not.toBe(key1)
// Rename seed2 to "seed" — duplicate display label
subgraph.inputs[1].label = 'seed'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[1],
index: 1,
oldName: 'seed2',
newName: 'seed'
})
// Widget keys remain stable — rename only affects display label
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
// Distinct _widget bindings survive the rename
expect(subgraphNode.inputs[0]._widget).toBeDefined()
expect(subgraphNode.inputs[1]._widget).toBeDefined()
expect(subgraphNode.inputs[0]._widget).not.toBe(
subgraphNode.inputs[1]._widget
)
})
it('should not lose input when onGraphConfigured runs after duplicate rename', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'alpha', type: 'STRING' },
{ name: 'beta', type: 'STRING' }
]
})
const nodeA = new LGraphNode('NodeA')
nodeA.addInput('value', 'STRING')
nodeA.inputs[0].widget = { name: 'value' }
nodeA.addOutput('out', 'STRING')
nodeA.addWidget('text', 'value', '', () => {})
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
const nodeB = new LGraphNode('NodeB')
nodeB.addInput('value', 'STRING')
nodeB.inputs[0].widget = { name: 'value' }
nodeB.addOutput('out', 'STRING')
nodeB.addWidget('text', 'value', '', () => {})
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
const subgraphNode = createTestSubgraphNode(subgraph)
// Rename beta to "alpha" — collision
subgraph.inputs[1].label = 'alpha'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[1],
index: 1,
oldName: 'beta',
newName: 'alpha'
})
// Simulate onGraphConfigured check: for each input with widget,
// find a matching widget by name. If not found, the input gets removed.
for (const input of subgraphNode.inputs) {
if (!input.widget) continue
const name = input.widget.name
const w = subgraphNode.widgets?.find((w) => w.name === name)
// Every input should find at least one matching widget
expect(w).toBeDefined()
}
// Both inputs should survive
expect(subgraphNode.inputs).toHaveLength(2)
})
})
describe('SubgraphNode Lifecycle', () => {

View File

@@ -63,6 +63,8 @@ workflowSvg.src =
type LinkedPromotionEntry = PromotedWidgetSource & {
inputName: string
inputKey: string
/** The subgraph input slot's internal name (stable identity). */
slotName: string
}
// Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing
// the SVG's internal stylesheet on every ctx.drawImage() call per frame.
@@ -192,6 +194,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
sourceNodeId: boundWidget.sourceNodeId,
sourceWidgetName: boundWidget.sourceWidgetName
})
@@ -206,6 +209,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
...resolved
})
}
@@ -277,7 +281,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
entry.disambiguatingSourceNodeId
entry.disambiguatingSourceNodeId,
entry.slotName
)
)
@@ -333,6 +338,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
sourceWidgetName: string
viewKey?: string
disambiguatingSourceNodeId?: string
slotName?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
@@ -562,17 +568,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
sourceNodeId: string
sourceWidgetName: string
viewKey: string
disambiguatingSourceNodeId?: string
slotName: string
}> {
return linkedEntries.map(
({
inputKey,
inputName,
slotName,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}) => ({
sourceNodeId,
sourceWidgetName,
slotName,
disambiguatingSourceNodeId,
viewKey: this._makePromotionViewKey(
inputKey,
sourceNodeId,
@@ -780,9 +791,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!input) throw new Error('Subgraph input not found')
input.label = newName
if (input._widget) {
input._widget.label = newName
}
// Do NOT change input.widget.name — it is the stable internal
// identifier used by onGraphConfigured (widgetInputs.ts) to match
// inputs to widgets. Changing it to the display label would cause
// collisions when two promoted inputs share the same label.
// Display is handled via input.label and _widget.label.
if (input._widget) input._widget.label = newName
this.graph?.trigger('node:slot-label:changed', {
nodeId: this.id,
slotType: NodeSlotType.INPUT
@@ -1130,6 +1144,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/**
* Binds a promoted widget view to a subgraph input slot.
*
* Creates or retrieves a {@link PromotedWidgetView}, registers it in the
* promotion store, sets up the prototype chain for multi-level subgraph
* nesting, and dispatches the `widget-promoted` event.
*/
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
@@ -1183,7 +1204,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
})
}
// Create/retrieve the view from cache
// Create/retrieve the view from cache.
// The cache key uses `input.name` (the slot's internal name) rather
// than `subgraphInput.name` because nested subgraphs may remap
// the internal name independently of the interior node.
const view = this._promotedViewManager.getOrCreate(
nodeId,
widgetName,
@@ -1193,7 +1217,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
nodeId,
widgetName,
input.label ?? subgraphInput.name,
sourceNodeId
sourceNodeId,
subgraphInput.name
),
this._makePromotionViewKey(
String(subgraphInput.id),
@@ -1207,6 +1232,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
// creating new objects. Have care when making changes.
// Use subgraphInput.name as the stable identity — unique per subgraph
// slot, immune to label renames. Matches PromotedWidgetView.name.
// Display is handled via widget.label / PromotedWidgetView.label.
input.widget ??= { name: subgraphInput.name }
input.widget.name = subgraphInput.name
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)

View File

@@ -358,7 +358,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widgetState?.label,
label: widget.promotedLabel ?? widgetState?.label,
options: widgetOptions,
spec: widget.spec
}

View File

@@ -1,8 +1,19 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import { getWidgetDefaultValue, renameWidget } from '@/utils/widgetUtil'
vi.mock('@/core/graph/subgraph/resolvePromotedWidgetSource', () => ({
resolvePromotedWidgetSource: vi.fn()
}))
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
const mockedResolve = vi.mocked(resolvePromotedWidgetSource)
describe('getWidgetDefaultValue', () => {
it('returns undefined for undefined spec', () => {
@@ -37,3 +48,113 @@ describe('getWidgetDefaultValue', () => {
expect(getWidgetDefaultValue(spec)).toBeUndefined()
})
})
function makeWidget(overrides: Record<string, unknown> = {}): IBaseWidget {
return {
name: 'myWidget',
type: 'number',
value: 0,
label: undefined,
options: {},
...overrides
} as unknown as IBaseWidget
}
function makeNode({
isSubgraph = false,
inputs = [] as INodeInputSlot[]
}: {
isSubgraph?: boolean
inputs?: INodeInputSlot[]
} = {}): LGraphNode {
return {
id: 1,
inputs,
isSubgraphNode: () => isSubgraph
} as unknown as LGraphNode
}
describe('renameWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renames a regular widget and its matching input', () => {
const widget = makeWidget({ name: 'seed' })
const input = { name: 'seed', widget: { name: 'seed' } } as INodeInputSlot
const node = makeNode({ inputs: [input] })
const result = renameWidget(widget, node, 'My Seed')
expect(result).toBe(true)
expect(widget.label).toBe('My Seed')
expect(input.label).toBe('My Seed')
})
it('clears label when given empty string', () => {
const widget = makeWidget({ name: 'seed', label: 'Old Label' })
const node = makeNode()
renameWidget(widget, node, '')
expect(widget.label).toBeUndefined()
})
it('renames promoted widget source when node is a subgraph without explicit parents', () => {
const sourceWidget = makeWidget({ name: 'innerSeed' })
const interiorInput = {
name: 'innerSeed',
widget: { name: 'innerSeed' }
} as INodeInputSlot
const interiorNode = makeNode({ inputs: [interiorInput] })
mockedResolve.mockReturnValue({
widget: sourceWidget,
node: interiorNode
})
const promotedWidget = makeWidget({
name: 'seed',
sourceNodeId: '5',
sourceWidgetName: 'innerSeed'
})
const subgraphNode = makeNode({ isSubgraph: true })
const result = renameWidget(promotedWidget, subgraphNode, 'Renamed')
expect(result).toBe(true)
expect(sourceWidget.label).toBe('Renamed')
expect(interiorInput.label).toBe('Renamed')
expect(promotedWidget.label).toBe('Renamed')
})
it('updates _subgraphSlot.label when input has a subgraph slot', () => {
const widget = makeWidget({ name: 'seed' })
const subgraphSlot = { label: undefined as string | undefined }
const input = {
name: 'seed',
widget: { name: 'seed' },
_subgraphSlot: subgraphSlot
} as unknown as INodeInputSlot
const node = makeNode({ inputs: [input] })
renameWidget(widget, node, 'New Label')
expect(subgraphSlot.label).toBe('New Label')
})
it('does not resolve promoted widget source for non-subgraph node without parents', () => {
const promotedWidget = makeWidget({
name: 'seed',
sourceNodeId: '5',
sourceWidgetName: 'innerSeed'
})
const node = makeNode({ isSubgraph: false })
const result = renameWidget(promotedWidget, node, 'Renamed')
expect(result).toBe(true)
expect(mockedResolve).not.toHaveBeenCalled()
expect(promotedWidget.label).toBe('Renamed')
})
})

View File

@@ -1,7 +1,9 @@
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ISubgraphInput } from '@/lib/litegraph/src/interfaces'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -48,7 +50,10 @@ export function renameWidget(
newLabel: string,
parents?: SubgraphNode[]
): boolean {
if (isPromotedWidgetView(widget) && parents?.length) {
if (
isPromotedWidgetView(widget) &&
(parents?.length || node.isSubgraphNode())
) {
const sourceWidget = resolvePromotedWidgetSource(node, widget)
if (!sourceWidget) {
console.error('Could not resolve source widget for promoted widget')
@@ -73,8 +78,19 @@ export function renameWidget(
widget.label = newLabel || undefined
if (input) {
input.label = newLabel || undefined
const subgraphSlot = (input as Partial<ISubgraphInput>)._subgraphSlot
if (subgraphSlot) {
subgraphSlot.label = newLabel || undefined
}
}
// Fires for all node types; listeners guard against non-subgraph nodes.
node.graph?.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT
})
return true
}