[backport core/1.40] fix: stabilize nested subgraph promoted widget resolution (#9282) (#9616)

Backport of #9282 to core/1.40. MUST — user-facing subgraph widget
resolution bug.

12 conflict files resolved:
- 8 modify/delete: new files introduced by the PR (kept as new)
- 1 add/add: resolveSubgraphInputTarget.ts (merged with existing from
#9542 backport)
- 3 content: accepted incoming changes

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9282
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Christian Byrne
2026-03-07 18:51:42 -08:00
committed by GitHub
parent bcb2bceb65
commit 1d14eadc70
24 changed files with 5120 additions and 109 deletions

View File

@@ -0,0 +1,760 @@
{
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
"revision": 0,
"last_node_id": 11,
"last_link_id": 18,
"nodes": [
{
"id": 2,
"type": "PreviewAny",
"pos": [1031, 434],
"size": [250, 178],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 5
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, null]
},
{
"id": 5,
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
"pos": [788, 433.5],
"size": [225, 380],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [5]
}
],
"properties": {
"proxyWidgets": [
["3", "string_a"],
["4", "value"],
["6", "value"],
["6", "value_1"]
]
},
"widgets_values": []
},
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [548, 451],
"size": [225, 142],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [4]
}
],
"title": "Outer",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Outer\n"]
}
],
"links": [
[4, 1, 0, 5, 0, "STRING"],
[5, 5, 0, 2, 0, "STRING"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 0",
"inputNode": {
"id": -10,
"bounding": [351, 432.5, 120, 120]
},
"outputNode": {
"id": -20,
"bounding": [1352, 294.5, 120, 60]
},
"inputs": [
{
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
"name": "string_a",
"type": "STRING",
"linkIds": [1],
"localized_name": "string_a",
"pos": [451, 452.5]
},
{
"id": "5fb3dcf7-9bfd-4b3c-a1b9-750b4f3edf19",
"name": "value",
"type": "STRING",
"linkIds": [13],
"pos": [451, 472.5]
},
{
"id": "55d24b8a-7c82-4b02-8e3d-ff31ffb8aa13",
"name": "value_1",
"type": "STRING",
"linkIds": [16],
"pos": [451, 492.5]
},
{
"id": "c1fe7cc3-547e-4fb0-b763-61888558d4bd",
"name": "value_1_1",
"type": "STRING",
"linkIds": [18],
"pos": [451, 512.5]
}
],
"outputs": [
{
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
"name": "STRING",
"type": "STRING",
"linkIds": [9],
"localized_name": "STRING",
"pos": [1372, 314.5]
}
],
"widgets": [],
"nodes": [
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [504, 437],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 13
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [2]
}
],
"title": "Inner 1",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 1\n"]
},
{
"id": 3,
"type": "StringConcatenate",
"pos": [743, 325],
"size": [347, 231],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 1
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
"pos": [1115, 301],
"size": [210, 196],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 7
},
{
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 16
},
{
"name": "value_1",
"type": "STRING",
"widget": {
"name": "value_1"
},
"link": 18
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [9]
}
],
"properties": {
"proxyWidgets": [
["5", "string_a"],
["11", "value"],
["9", "value"],
["10", "string_a"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "STRING"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "STRING"
},
{
"id": 6,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 1,
"target_id": 4,
"target_slot": 0,
"type": "STRING"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 2,
"target_id": 6,
"target_slot": 1,
"type": "STRING"
},
{
"id": 18,
"origin_id": -10,
"origin_slot": 3,
"target_id": 6,
"target_slot": 2,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 1",
"inputNode": {
"id": -10,
"bounding": [180, 739, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [1246, 612, 120, 60]
},
"inputs": [
{
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
"name": "string_a",
"type": "STRING",
"linkIds": [4],
"localized_name": "string_a",
"pos": [280, 759]
},
{
"id": "d50f6a62-0185-43d4-a174-a8a94bd8f6e7",
"name": "value",
"type": "STRING",
"linkIds": [14],
"pos": [280, 779]
},
{
"id": "6b78450e-5986-49cd-b743-c933e5a34a69",
"name": "value_1",
"type": "STRING",
"linkIds": [17],
"pos": [280, 799]
}
],
"outputs": [
{
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
"name": "STRING",
"type": "STRING",
"linkIds": [12],
"localized_name": "STRING",
"pos": [1266, 632]
}
],
"widgets": [],
"nodes": [
{
"id": 11,
"type": "PrimitiveStringMultiline",
"pos": [334, 742],
"size": [210, 88],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 14
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"title": "Inner 2",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 2\n"]
},
{
"id": 10,
"type": "StringConcatenate",
"pos": [581, 637],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [11]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 9,
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"pos": [1004, 613],
"size": [210, 142],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 11
},
{
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 17
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [12]
}
],
"properties": {
"proxyWidgets": [
["7", "string_a"],
["8", "value"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 11,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": 10,
"origin_slot": 0,
"target_id": 9,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 12,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 1,
"target_id": 11,
"target_slot": 0,
"type": "STRING"
},
{
"id": 17,
"origin_id": -10,
"origin_slot": 2,
"target_id": 9,
"target_slot": 1,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 2",
"inputNode": {
"id": -10,
"bounding": [262, 1222, 120, 80]
},
"outputNode": {
"id": -20,
"bounding": [1123.089999999999, 1125.1999999999998, 120, 60]
},
"inputs": [
{
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
"name": "string_a",
"type": "STRING",
"linkIds": [9],
"localized_name": "string_a",
"pos": [362, 1242]
},
{
"id": "3a545207-7202-42a9-a82f-3b62e1b0f459",
"name": "value",
"type": "STRING",
"linkIds": [15],
"pos": [362, 1262]
}
],
"outputs": [
{
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
"name": "STRING",
"type": "STRING",
"linkIds": [10],
"localized_name": "STRING",
"pos": [1143.089999999999, 1145.1999999999998]
}
],
"widgets": [],
"nodes": [
{
"id": 8,
"type": "PrimitiveStringMultiline",
"pos": [412.96000000000004, 1228.2399999999996],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [8]
}
],
"title": "Inner 3",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 3\n"]
},
{
"id": 7,
"type": "StringConcatenate",
"pos": [686.08, 1132.38],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 9
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 8
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [10]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
}
],
"groups": [],
"links": [
{
"id": 8,
"origin_id": 8,
"origin_slot": 0,
"target_id": 7,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 8,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [-412, 11]
},
"frontendVersion": "1.41.7"
},
"version": 0.4
}

View File

@@ -171,6 +171,7 @@ test.describe('Node Interaction', () => {
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})

View File

@@ -0,0 +1,690 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import { fitToViewInstant } from '../helpers/fitToView'
import {
getPromotedWidgetNames,
getPromotedWidgetCount,
getPromotedWidgets
} from '../helpers/promotedWidgets'
/**
* Check whether we're currently in a subgraph.
*/
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async function exitSubgraphViaBreadcrumb(comfyPage: ComfyPage): Promise<void> {
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
await breadcrumb.waitFor({ state: 'visible', timeout: 5000 })
const parentLink = breadcrumb.getByRole('link').first()
await expect(parentLink).toBeVisible()
await parentLink.click()
await comfyPage.nextFrame()
}
test.describe(
'Subgraph Widget Promotion',
{ tag: ['@subgraph', '@widget'] },
() => {
test.describe('Auto-promotion on Convert to Subgraph', () => {
test('Recommended widgets are auto-promoted when creating a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select just the KSampler node (id 3) which has a "seed" widget
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// SubgraphNode should exist
expect(await subgraphNode.exists()).toBe(true)
// The KSampler has a "seed" widget which is in the recommended list.
// The promotion store should have at least the seed widget promoted.
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames).toContain('seed')
// SubgraphNode should have widgets (promoted views)
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
expect(widgetCount).toBeGreaterThan(0)
})
test('CLIPTextEncode text widget is auto-promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select the positive CLIPTextEncode node (id 6)
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
await clipNode.click('title')
const subgraphNode = await clipNode.convertToSubgraph()
await comfyPage.nextFrame()
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames.length).toBeGreaterThan(0)
// CLIPTextEncode is in the recommendedNodes list, so its text widget
// should be promoted
expect(promotedNames).toContain('text')
})
test('SaveImage/PreviewImage nodes get pseudo-widget promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select the SaveImage node (id 9 in default workflow)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.click('title')
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(
comfyPage,
String(subgraphNode.id)
)
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
expect(promotedNames).toContain('filename_prefix')
})
})
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
test('Promoted text widget is visible on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
// The subgraph node (id 11) should have a text widget promoted
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await expect(textarea).toHaveCount(1)
})
test('Multiple promoted widgets all render on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Visibility in Vue Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted text widget renders on SubgraphNode in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
// SubgraphNode (id 11) should render with its body
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
// It should have the Enter Subgraph button
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
await expect(enterButton).toBeVisible()
// The promoted text widget should render inside the node
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
await expect(nodeBody).toBeVisible()
// Widgets section should exist and have at least one widget
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
})
test('Enter Subgraph button navigates into subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
const widgets = nodeBody.locator('.lg-node-widgets > div')
const count = await widgets.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Reactivity', () => {
test('Value changes on promoted widget sync to interior widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'promoted-value-sync-test'
// Type into the promoted textarea on the SubgraphNode
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Interior CLIPTextEncode textarea should have the same value
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
})
test('Value changes on interior widget sync to promoted widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'interior-value-sync-test'
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Type into the interior CLIPTextEncode textarea
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await interiorTextarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate back to parent graph
await exitSubgraphViaBreadcrumb(comfyPage)
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
})
test('Value persists through repeated navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'persistence-through-navigation'
// Set value on promoted widget
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
// Navigate in and out multiple times
for (let i = 0; i < 3; i++) {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
await exitSubgraphViaBreadcrumb(comfyPage)
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
}
})
})
test.describe('Manual Promote/Demote via Context Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can promote a widget from inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get the KSampler node (id 1) inside the subgraph
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
// Right-click on the KSampler's "steps" widget (index 2) to promote it
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
// Look for the Promote Widget menu entry
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
// SubgraphNode should now have the promoted widget
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(widgetCount).toBeGreaterThan(0)
})
test('Can un-promote a widget from inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
// First promote a canvas-rendered widget (KSampler "steps")
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await exitSubgraphViaBreadcrumb(comfyPage)
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(initialWidgetCount).toBeGreaterThan(0)
// Navigate back in and un-promote
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode2.navigateIntoSubgraph()
const stepsWidget2 = await (
await comfyPage.nodeOps.getNodeRefById('1')
).getWidget(2)
const widgetPos2 = await stepsWidget2.getPosition()
await comfyPage.canvas.click({
position: widgetPos2,
button: 'right',
force: true
})
await comfyPage.nextFrame()
const unpromoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Un-Promote Widget/ })
await expect(unpromoteEntry).toBeVisible()
await unpromoteEntry.click()
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
// SubgraphNode should have fewer widgets
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
})
})
test.describe('Pseudo-Widget Promotion', () => {
test('Promotion store tracks pseudo-widget entries for subgraph with preview node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames.length).toBeGreaterThan(0)
expect(promotedNames).toContain('filename_prefix')
})
test('Converting SaveImage to subgraph promotes its widgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select SaveImage (id 9)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.click('title')
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
// SaveImage is a recommended node, so filename_prefix should be promoted
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames.length).toBeGreaterThan(0)
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
expect(widgetCount).toBeGreaterThan(0)
})
})
test.describe('Legacy And Round-Trip Coverage', () => {
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
).toBe(false)
expect(
promotedWidgets.some(
([interiorNodeId, widgetName]) =>
interiorNodeId !== '-1' && widgetName === 'batch_size'
)
).toBe(true)
})
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(beforePromoted).toContain('text')
const serialized = await comfyPage.page.evaluate(() => {
return window.app!.graph!.serialize()
})
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
return window.app!.loadGraphData(workflow)
}, serialized as ComfyWorkflowJSON)
await comfyPage.nextFrame()
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(afterPromoted).toContain('text')
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(widgetCount).toBeGreaterThan(0)
})
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
const originalPos = await originalNode.getPosition()
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
await comfyPage.nextFrame()
const subgraphNodeIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((n) => String(n.id))
})
expect(subgraphNodeIds.length).toBeGreaterThan(1)
for (const nodeId of subgraphNodeIds) {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
}
})
})
test.describe('Vue Mode - Promoted Preview Content', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('SubgraphNode with preview node shows hasCustomContent area in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
// The node body should exist
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
await expect(nodeBody).toBeVisible()
})
})
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
// slot connected externally from the Outer node, so it should be
// disabled. The remaining promoted textarea widgets (value, value_1)
// are unlinked and should be enabled.
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('string_a')
expect(promotedNames).toContain('value')
const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
expect(linkedWidget?.disabled).toBe(true)
const unlinkedWidgets = disabledState.filter(
(w) => w.name !== 'string_a'
)
for (const w of unlinkedWidgets) {
expect(w.disabled).toBe(false)
}
})
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// The promoted textareas that are NOT externally linked should be
// fully opaque and interactive.
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
const wrapper = textarea.locator('..')
const opacity = await wrapper.evaluate(
(el) => getComputedStyle(el).opacity
)
if (opacity === '1' && (await textarea.isEditable())) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
}
}
})
})
test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
// Verify promotions exist
const namesBefore = await getPromotedWidgetNames(comfyPage, '11')
expect(namesBefore.length).toBeGreaterThan(0)
// Delete the subgraph node
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
// Node no longer exists, so promoted widgets should be gone
const nodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.canvas.graph!.getNodeById('11')
})
expect(nodeExists).toBe(false)
})
test('Removing I/O slot removes associated promoted widget', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(initialWidgetCount).toBeGreaterThan(0)
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Remove the text input slot
await comfyPage.subgraph.rightClickInputSlot('text')
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
// Navigate back via breadcrumb
await comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.waitFor({ state: 'visible', timeout: 5000 })
const homeBreadcrumb = comfyPage.page.getByRole('link', {
name: 'subgraph-with-promoted-text-widget'
})
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()
// Widget count should be reduced
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
})
})
}
)

View File

@@ -0,0 +1,116 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import type { DomWidgetState } from '@/stores/domWidgetStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import DomWidget from './DomWidget.vue'
const mockUpdatePosition = vi.fn()
const mockUpdateClipPath = vi.fn()
const mockCanvasElement = document.createElement('canvas')
const mockCanvasStore = {
canvas: {
graph: {
getNodeById: vi.fn(() => true)
},
ds: {
offset: [0, 0],
scale: 1
},
canvas: mockCanvasElement,
selected_nodes: {}
},
getCanvas: () => ({ canvas: mockCanvasElement }),
linearMode: false
}
vi.mock('@/composables/element/useAbsolutePosition', () => ({
useAbsolutePosition: () => ({
style: reactive<Record<string, string>>({}),
updatePosition: mockUpdatePosition
})
}))
vi.mock('@/composables/element/useDomClipping', () => ({
useDomClipping: () => ({
style: reactive<Record<string, string>>({}),
updateClipPath: mockUpdateClipPath
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => false)
})
}))
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
const domWidgetStore = useDomWidgetStore()
const node = createMockLGraphNode({
id: 1,
constructor: {
nodeData: {}
}
})
const widget = {
id: 'dom-widget-id',
name: 'test_widget',
type: 'custom',
value: '',
options: {},
node,
computedDisabled: false
} as unknown as BaseDOMWidget<object | string>
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: createMockLGraphNode({ id: 2 }),
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
})
const state = domWidgetStore.widgetStates.get(widget.id)
if (!state) throw new Error('Expected registered DomWidgetState')
state.zIndex = 2
state.size = [100, 40]
return reactive(state)
}
describe('DomWidget disabled style', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
useDomWidgetStore().clear()
vi.clearAllMocks()
})
it('uses disabled style when promoted override widget is computedDisabled', async () => {
const widgetState = createWidgetState(true)
const wrapper = mount(DomWidget, {
props: {
widgetState
}
})
widgetState.zIndex = 3
await wrapper.vm.$nextTick()
const root = wrapper.get('.dom-widget').element as HTMLElement
expect(root.style.pointerEvents).toBe('none')
expect(root.style.opacity).toBe('0.5')
})
})

View File

@@ -105,13 +105,17 @@ watch(
updateDomClipping()
}
const override = widgetState.positionOverride
const isDisabled = override
? (override.widget.computedDisabled ?? widget.computedDisabled)
: widget.computedDisabled
style.value = {
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents:
widgetState.readonly || widget.computedDisabled ? 'none' : 'auto',
opacity: widget.computedDisabled ? 0.5 : 1
pointerEvents: widgetState.readonly || isDisabled ? 'none' : 'auto',
opacity: isDisabled ? 0.5 : 1
}
},
{ deep: true }

View File

@@ -265,3 +265,47 @@ describe('Subgraph Promoted Pseudo Widgets', () => {
expect(promotedWidget?.options?.canvasOnly).toBe(true)
})
})
describe('Nested promoted widget mapping', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('maps store identity to deepest concrete widget for two-layer promotions', () => {
const subgraphA = createTestSubgraph({
inputs: [{ name: 'a_input', type: '*' }]
})
const innerNode = new LGraphNode('InnerComboNode')
const innerInput = innerNode.addInput('picker_input', '*')
innerNode.addWidget('combo', 'picker', 'a', () => undefined, {
values: ['a', 'b']
})
innerInput.widget = { name: 'picker' }
subgraphA.add(innerNode)
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 })
const subgraphB = createTestSubgraph({
inputs: [{ name: 'b_input', type: '*' }]
})
subgraphB.add(subgraphNodeA)
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 })
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNodeB.id))
const mappedWidget = nodeData?.widgets?.[0]
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.storeName).toBe('picker')
expect(mappedWidget?.storeNodeId).toBe(
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
)
})
})

View File

@@ -6,7 +6,10 @@ import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -44,7 +47,9 @@ export interface WidgetSlotMetadata {
*/
export interface SafeWidgetData {
nodeId?: NodeId
storeNodeId?: NodeId
name: string
storeName?: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
@@ -155,7 +160,7 @@ export function getSharedWidgetEnhancements(
/**
* Validates that a value is a valid WidgetValue type
*/
const normalizeWidgetValue = (value: unknown): WidgetValue => {
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
@@ -187,11 +192,69 @@ function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function resolvePromotedSourceByInputName(inputName: string): {
sourceNodeId: string
sourceWidgetName: string
} | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName
}
}
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
displayName: string
promotedSource: { sourceNodeId: string; sourceWidgetName: string } | null
} {
if (!isPromotedWidgetView(widget)) {
return {
displayName: widget.name,
promotedSource: null
}
}
const promotedInputName = node.inputs?.find((input) => {
if (input.name === widget.name) return true
if (input._widget === widget) return true
return false
})?.name
const displayName = promotedInputName ?? widget.name
const promotedSource = resolvePromotedSourceByInputName(displayName) ?? {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
return {
displayName,
promotedSource
}
}
return function (widget) {
try {
const { displayName, promotedSource } =
resolvePromotedWidgetIdentity(widget)
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo = slotMetadata.get(widget.name)
const slotInfo =
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
@@ -206,36 +269,52 @@ function safeWidgetMapper(
}
// Extract only render-critical options (canvasOnly, advanced, read_only)
const options = widget.options
? {
canvasOnly: widget.options.canvasOnly,
advanced: widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
: undefined
const options = extractWidgetDisplayOptions(widget)
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const localId = isProxyWidget(widget)
? widget._overlay?.nodeId
const resolvedSourceResult =
isPromotedWidgetView(widget) && promotedSource
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
resolvedSourceResult?.status === 'resolved'
? resolvedSourceResult.resolved
: undefined
const sourceWidget = resolvedSource?.widget
const sourceNode = resolvedSource?.node
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const name = isProxyWidget(widget)
? widget._overlay.widgetName
: widget.name
const storeName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = storeName ?? displayName
return {
nodeId,
storeNodeId: nodeId,
name,
type: widget.type,
storeName,
type: effectiveWidget.type,
...sharedEnhancements,
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
options: isPromotedPseudoWidget
? { ...options, canvasOnly: true }
: options,
? {
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
canvasOnly: true
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
slotName: name !== widget.name ? widget.name : undefined
}
@@ -278,14 +357,18 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
slotMetadata.clear()
node.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
const slotInfo = {
index,
linked: input.link != null
})
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
const nodeType =
@@ -341,11 +424,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const slotMetadata = new Map<string, WidgetSlotMetadata>()
nodeRef.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
const slotInfo = {
index,
linked: input.link != null
})
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
// Update only widgets with new slot metadata, keeping other widget data intact

View File

@@ -0,0 +1,20 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export type ResolvedPromotedWidget = {
node: LGraphNode
widget: IBaseWidget
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly sourceNodeId: string
readonly sourceWidgetName: string
}
export function isPromotedWidgetView(
widget: IBaseWidget
): widget is PromotedWidgetView {
return 'sourceNodeId' in widget && 'sourceWidgetName' in widget
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,425 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
if (typeof value === 'number') return true
if (typeof value === 'boolean') return true
return value !== null && typeof value === 'object'
}
type LegacyMouseWidget = IBaseWidget & {
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
}
function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
return 'mouse' in widget && typeof widget.mouse === 'function'
}
const designTokenCache = new Map<string, string>()
export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
displayName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(subgraphNode, nodeId, widgetName, displayName)
}
class PromotedWidgetView implements IPromotedWidgetView {
[symbol: symbol]: boolean
readonly sourceNodeId: string
readonly sourceWidgetName: string
readonly serialize = false
last_y?: number
computedHeight?: number
private readonly graphId: string
private yValue = 0
private _computedDisabled = false
private projectedSourceNode?: LGraphNode
private projectedSourceWidget?: IBaseWidget
private projectedSourceWidgetType?: IBaseWidget['type']
private projectedWidget?: BaseWidget
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
this.graphId = subgraphNode.rootGraph.id
}
get node(): SubgraphNode {
return this.subgraphNode
}
get name(): string {
return this.displayName ?? this.sourceWidgetName
}
get y(): number {
return this.yValue
}
set y(value: number) {
this.yValue = value
this.syncDomOverride()
}
get computedDisabled(): boolean {
return this._computedDisabled
}
set computedDisabled(value: boolean | undefined) {
this._computedDisabled = value ?? false
}
get type(): IBaseWidget['type'] {
return this.resolveDeepest()?.widget.type ?? 'button'
}
get options(): IBaseWidget['options'] {
return this.resolveDeepest()?.widget.options ?? {}
}
get tooltip(): string | undefined {
return this.resolveDeepest()?.widget.tooltip
}
get linkedWidgets(): IBaseWidget[] | undefined {
return this.resolveDeepest()?.widget.linkedWidgets
}
get value(): IBaseWidget['value'] {
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
set value(value: IBaseWidget['value']) {
const state = this.getWidgetState()
if (state) {
state.value = value
return
}
const resolved = this.resolveAtHost()
if (resolved && isWidgetValue(value)) {
resolved.widget.value = value
}
}
get label(): string | undefined {
const state = this.getWidgetState()
return state?.label ?? this.displayName ?? this.sourceWidgetName
}
set label(value: string | undefined) {
const state = this.getWidgetState()
if (state) state.label = value
}
get hidden(): boolean {
return this.resolveDeepest()?.widget.hidden ?? false
}
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
const resolved = this.resolveDeepest()
const computeLayoutSize = resolved?.widget.computeLayoutSize
if (!computeLayoutSize) return undefined
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
}
get computeSize(): IBaseWidget['computeSize'] {
const resolved = this.resolveDeepest()
const computeSize = resolved?.widget.computeSize
if (!computeSize) return undefined
return (width?: number) => computeSize.call(resolved.widget, width)
}
draw(
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
widgetWidth: number,
y: number,
H: number,
lowQuality?: boolean
): void {
const resolved = this.resolveDeepest()
if (!resolved) {
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
return
}
if (isBaseDOMWidget(resolved.widget)) return this.syncDomOverride(resolved)
const projected = this.getProjectedWidget(resolved)
if (!projected || typeof projected.drawWidget !== 'function') return
const originalY = projected.y
const originalComputedHeight = projected.computedHeight
const originalComputedDisabled = projected.computedDisabled
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.computedDisabled = this.computedDisabled
projected.value = this.value
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
}
onPointerDown(
pointer: CanvasPointer,
_node: LGraphNode,
canvas: LGraphCanvas
): boolean {
const resolved = this.resolveAtHost()
if (!resolved) return false
const interior = resolved.widget
if (typeof interior.onPointerDown === 'function') {
const handled = interior.onPointerDown(pointer, this.subgraphNode, canvas)
if (handled) return true
}
const concrete = toConcreteWidget(interior, this.subgraphNode, false)
if (concrete)
return this.bindConcretePointerHandlers(pointer, canvas, concrete)
if (hasLegacyMouse(interior))
return this.handleLegacyMouse(pointer, interior)
return false
}
callback(
value: unknown,
canvas?: LGraphCanvas,
node?: LGraphNode,
pos?: Point,
e?: CanvasPointerEvent
) {
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
}
private resolveAtHost():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
)
}
private resolveDeepest():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
const frame = this.subgraphNode.rootGraph.primaryCanvas?.frame
if (frame !== undefined && this.cachedDeepestFrame === frame)
return this.cachedDeepestByFrame
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
if (frame !== undefined) {
this.cachedDeepestFrame = frame
this.cachedDeepestByFrame = resolved
}
return resolved
}
private getWidgetState() {
const resolved = this.resolveDeepest()
if (!resolved) return undefined
return useWidgetValueStore().getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
}
private getProjectedWidget(resolved: {
node: LGraphNode
widget: IBaseWidget
}): BaseWidget | undefined {
const shouldRebuild =
!this.projectedWidget ||
this.projectedSourceNode !== resolved.node ||
this.projectedSourceWidget !== resolved.widget ||
this.projectedSourceWidgetType !== resolved.widget.type
if (!shouldRebuild) return this.projectedWidget
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
if (!concrete) {
this.projectedWidget = undefined
this.projectedSourceNode = undefined
this.projectedSourceWidget = undefined
this.projectedSourceWidgetType = undefined
return undefined
}
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
this.projectedSourceNode = resolved.node
this.projectedSourceWidget = resolved.widget
this.projectedSourceWidgetType = resolved.widget.type
return this.projectedWidget
}
private bindConcretePointerHandlers(
pointer: CanvasPointer,
canvas: LGraphCanvas,
concrete: BaseWidget
): boolean {
const downEvent = pointer.eDown
if (!downEvent) return false
pointer.onClick = () =>
concrete.onClick({
e: downEvent,
node: this.subgraphNode,
canvas
})
pointer.onDrag = (eMove) =>
concrete.onDrag?.({
e: eMove,
node: this.subgraphNode,
canvas
})
return true
}
private handleLegacyMouse(
pointer: CanvasPointer,
interior: LegacyMouseWidget
): boolean {
const downEvent = pointer.eDown
if (!downEvent) return false
const downPosition: Point = [
downEvent.canvasX - this.subgraphNode.pos[0],
downEvent.canvasY - this.subgraphNode.pos[1]
]
interior.mouse(downEvent, downPosition, this.subgraphNode)
pointer.finally = () => {
const upEvent = pointer.eUp
if (!upEvent) return
const upPosition: Point = [
upEvent.canvasX - this.subgraphNode.pos[0],
upEvent.canvasY - this.subgraphNode.pos[1]
]
interior.mouse(upEvent, upPosition, this.subgraphNode)
}
return true
}
private syncDomOverride(
resolved:
| { node: LGraphNode; widget: IBaseWidget }
| undefined = this.resolveAtHost()
) {
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
node: this.subgraphNode,
widget: this
})
}
}
/** Checks if a widget is a BaseDOMWidget (DOMWidget or ComponentWidget). */
function isBaseDOMWidget(
widget: IBaseWidget
): widget is IBaseWidget & { id: string } {
return 'id' in widget && ('element' in widget || 'component' in widget)
}
function drawDisconnectedPlaceholder(
ctx: CanvasRenderingContext2D,
width: number,
y: number,
H: number
) {
const backgroundColor = readDesignToken(
'--color-secondary-background',
'#333'
)
const textColor = readDesignToken('--color-text-secondary', '#999')
const fontSize = readDesignToken('--text-xxs', '11px')
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
ctx.save()
ctx.fillStyle = backgroundColor
ctx.fillRect(15, y, width - 30, H)
ctx.fillStyle = textColor
ctx.font = `${fontSize} ${fontFamily}`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
ctx.restore()
}
function readDesignToken(token: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const cachedValue = designTokenCache.get(token)
if (cachedValue) return cachedValue
const value = getComputedStyle(document.documentElement)
.getPropertyValue(token)
.trim()
const resolvedValue = value || fallback
designTokenCache.set(token, resolvedValue)
return resolvedValue
}

View File

@@ -0,0 +1,257 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
type PromotedWidgetStub = Pick<
IBaseWidget,
'name' | 'type' | 'options' | 'value' | 'y'
> & {
sourceNodeId: string
sourceWidgetName: string
node?: SubgraphNode
}
function createHostNode(id: number): SubgraphNode {
return createTestSubgraphNode(createTestSubgraph(), { id })
}
function addNodeToHost(host: SubgraphNode, title: string): LGraphNode {
const node = new LGraphNode(title)
host.subgraph.add(node)
return node
}
function addConcreteWidget(node: LGraphNode, name: string): IBaseWidget {
return node.addWidget('text', name, `${name}-value`, () => undefined)
}
function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
type: 'button',
options: {},
y: 0,
value: undefined,
sourceNodeId,
sourceWidgetName,
node
}
return promotedWidget as IBaseWidget
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('resolvePromotedWidgetAtHost', () => {
test('resolves a direct concrete widget on the host subgraph node', () => {
const host = createHostNode(100)
const concreteNode = addNodeToHost(host, 'leaf')
addConcreteWidget(concreteNode, 'seed')
const resolved = resolvePromotedWidgetAtHost(
host,
String(concreteNode.id),
'seed'
)
expect(resolved).toBeDefined()
expect(resolved?.node.id).toBe(concreteNode.id)
expect(resolved?.widget.name).toBe('seed')
})
test('returns undefined when host does not contain the target node', () => {
const host = createHostNode(100)
const resolved = resolvePromotedWidgetAtHost(host, 'missing', 'seed')
expect(resolved).toBeUndefined()
})
})
describe('resolveConcretePromotedWidget', () => {
test('resolves a direct concrete source widget', () => {
const host = createHostNode(100)
const concreteNode = addNodeToHost(host, 'leaf')
addConcreteWidget(concreteNode, 'seed')
const result = resolveConcretePromotedWidget(
host,
String(concreteNode.id),
'seed'
)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(concreteNode.id)
expect(result.resolved.widget.name).toBe('seed')
})
test('descends through nested promoted widgets to resolve concrete source', () => {
const rootHost = createHostNode(100)
const nestedHost = createHostNode(101)
const leafNode = addNodeToHost(nestedHost, 'leaf')
addConcreteWidget(leafNode, 'seed')
const sourceNode = addNodeToHost(rootHost, 'source')
sourceNode.widgets = [
createPromotedWidget('outer', String(leafNode.id), 'seed', nestedHost)
]
const result = resolveConcretePromotedWidget(
rootHost,
String(sourceNode.id),
'outer'
)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leafNode.id)
expect(result.resolved.widget.name).toBe('seed')
})
test('returns cycle failure when promoted widgets form a loop', () => {
const hostA = createHostNode(200)
const hostB = createHostNode(201)
const relayA = addNodeToHost(hostA, 'relayA')
const relayB = addNodeToHost(hostB, 'relayB')
relayA.widgets = [
createPromotedWidget('wA', String(relayB.id), 'wB', hostB)
]
relayB.widgets = [
createPromotedWidget('wB', String(relayA.id), 'wA', hostA)
]
const result = resolveConcretePromotedWidget(hostA, String(relayA.id), 'wA')
expect(result).toEqual({
status: 'failure',
failure: 'cycle'
})
})
test('does not report a cycle when different host objects share an id', () => {
const rootHost = createHostNode(41)
const nestedHost = createHostNode(41)
const leafNode = addNodeToHost(nestedHost, 'leaf')
addConcreteWidget(leafNode, 'w')
const sourceNode = addNodeToHost(rootHost, 'source')
sourceNode.widgets = [
createPromotedWidget('w', String(leafNode.id), 'w', nestedHost)
]
const result = resolveConcretePromotedWidget(
rootHost,
String(sourceNode.id),
'w'
)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leafNode.id)
expect(result.resolved.widget.name).toBe('w')
})
test('returns max-depth-exceeded for very deep non-cyclic promoted chains', () => {
const hosts = Array.from({ length: 102 }, (_, index) =>
createHostNode(index + 1)
)
const relayNodes = hosts.map((host, index) =>
addNodeToHost(host, `relay-${index}`)
)
for (let index = 0; index < relayNodes.length - 1; index += 1) {
relayNodes[index].widgets = [
createPromotedWidget(
`w-${index}`,
String(relayNodes[index + 1].id),
`w-${index + 1}`,
hosts[index + 1]
)
]
}
addConcreteWidget(
relayNodes[relayNodes.length - 1],
`w-${relayNodes.length - 1}`
)
const result = resolveConcretePromotedWidget(
hosts[0],
String(relayNodes[0].id),
'w-0'
)
expect(result).toEqual({
status: 'failure',
failure: 'max-depth-exceeded'
})
})
test('returns invalid-host for non-subgraph host node', () => {
const host = new LGraphNode('plain-host')
const result = resolveConcretePromotedWidget(host, 'x', 'y')
expect(result).toEqual({
status: 'failure',
failure: 'invalid-host'
})
})
test('returns missing-node when source node does not exist in host subgraph', () => {
const host = createHostNode(100)
const result = resolveConcretePromotedWidget(host, 'missing-node', 'seed')
expect(result).toEqual({
status: 'failure',
failure: 'missing-node'
})
})
test('returns missing-widget when source node exists but widget cannot be resolved', () => {
const host = createHostNode(100)
const sourceNode = addNodeToHost(host, 'source')
sourceNode.widgets = []
const result = resolveConcretePromotedWidget(
host,
String(sourceNode.id),
'missing-widget'
)
expect(result).toEqual({
status: 'failure',
failure: 'missing-widget'
})
})
})

View File

@@ -0,0 +1,102 @@
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
type PromotedWidgetResolutionFailure =
| 'invalid-host'
| 'cycle'
| 'missing-node'
| 'missing-widget'
| 'max-depth-exceeded'
type PromotedWidgetResolutionResult =
| { status: 'resolved'; resolved: ResolvedPromotedWidget }
| { status: 'failure'; failure: PromotedWidgetResolutionFailure }
const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
function traversePromotedWidgetChain(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
): PromotedWidgetResolutionResult {
const visited = new Set<string>()
const hostUidByObject = new WeakMap<SubgraphNode, number>()
let nextHostUid = 0
let currentHost = hostNode
let currentNodeId = nodeId
let currentWidgetName = widgetName
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
let hostUid = hostUidByObject.get(currentHost)
if (hostUid === undefined) {
hostUid = nextHostUid
nextHostUid += 1
hostUidByObject.set(currentHost, hostUid)
}
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}`
if (visited.has(key)) {
return { status: 'failure', failure: 'cycle' }
}
visited.add(key)
const sourceNode = currentHost.subgraph.getNodeById(currentNodeId)
if (!sourceNode) {
return { status: 'failure', failure: 'missing-node' }
}
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === currentWidgetName
)
if (!sourceWidget) {
return { status: 'failure', failure: 'missing-widget' }
}
if (!isPromotedWidgetView(sourceWidget)) {
return {
status: 'resolved',
resolved: { node: sourceNode, widget: sourceWidget }
}
}
if (!sourceWidget.node?.isSubgraphNode()) {
return { status: 'failure', failure: 'missing-node' }
}
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
}
return { status: 'failure', failure: 'max-depth-exceeded' }
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = node.widgets?.find(
(entry: IBaseWidget) => entry.name === widgetName
)
if (!widget) return undefined
return { node, widget }
}
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
widgetName: string
): PromotedWidgetResolutionResult {
if (!hostNode.isSubgraphNode()) {
return { status: 'failure', failure: 'invalid-host' }
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
}

View File

@@ -0,0 +1,22 @@
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidget | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved') return result.resolved
return undefined
}

View File

@@ -0,0 +1,147 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { resolveSubgraphInputLink } from '@/core/graph/subgraph/resolveSubgraphInputLink'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function createSubgraphSetup(inputName: string): {
subgraph: Subgraph
subgraphNode: SubgraphNode
} {
const subgraph = createTestSubgraph({
inputs: [{ name: inputName, type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 1 })
return { subgraph, subgraphNode }
}
function addLinkedInteriorInput(
subgraph: Subgraph,
inputName: string,
linkedInputName: string,
widgetName: string
): {
node: LGraphNode
linkId: number
} {
const inputSlot = subgraph.inputNode.slots.find(
(slot) => slot.name === inputName
)
if (!inputSlot) throw new Error(`Missing subgraph input slot: ${inputName}`)
const node = new LGraphNode(`Interior-${linkedInputName}`)
const input = node.addInput(linkedInputName, '*')
node.addWidget('text', widgetName, '', () => undefined)
input.widget = { name: widgetName }
subgraph.add(node)
inputSlot.connect(input, node)
if (input.link == null)
throw new Error(`Expected link to be created for input ${linkedInputName}`)
return { node, linkId: input.link }
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
describe('resolveSubgraphInputLink', () => {
test('returns undefined for non-subgraph nodes', () => {
const node = new LGraphNode('plain-node')
const result = resolveSubgraphInputLink(node, 'missing', () => 'resolved')
expect(result).toBeUndefined()
})
test('returns undefined when input slot is missing', () => {
const { subgraphNode } = createSubgraphSetup('existing')
const result = resolveSubgraphInputLink(
subgraphNode,
'missing',
() => 'resolved'
)
expect(result).toBeUndefined()
})
test('skips stale links where inputNode.inputs is unavailable', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
addLinkedInteriorInput(subgraph, 'prompt', 'seed_input', 'seed')
const stale = addLinkedInteriorInput(
subgraph,
'prompt',
'stale_input',
'stale'
)
const originalGetLink = subgraph.getLink.bind(subgraph)
vi.spyOn(subgraph, 'getLink').mockImplementation((linkId) => {
if (typeof linkId !== 'number') return originalGetLink(linkId)
if (linkId === stale.linkId) {
return {
resolve: () => ({
inputNode: {
inputs: undefined,
getWidgetFromSlot: () => ({ name: 'ignored' })
}
})
} as unknown as ReturnType<typeof subgraph.getLink>
}
return originalGetLink(linkId)
})
const result = resolveSubgraphInputLink(
subgraphNode,
'prompt',
({ targetInput }) => targetInput.name
)
expect(result).toBe('seed_input')
})
test('caches getTargetWidget result within the same callback evaluation', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('model')
const linked = addLinkedInteriorInput(
subgraph,
'model',
'model_input',
'modelWidget'
)
const getWidgetFromSlot = vi.spyOn(linked.node, 'getWidgetFromSlot')
const result = resolveSubgraphInputLink(
subgraphNode,
'model',
({ getTargetWidget }) => {
expect(getTargetWidget()?.name).toBe('modelWidget')
expect(getTargetWidget()?.name).toBe('modelWidget')
return 'ok'
}
)
expect(result).toBe('ok')
expect(getWidgetFromSlot).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,55 @@
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
type SubgraphInputLinkContext = {
inputNode: LGraphNode
targetInput: INodeInputSlot
getTargetWidget: () => ReturnType<LGraphNode['getWidgetFromSlot']>
}
export function resolveSubgraphInputLink<TResult>(
node: LGraphNode,
inputName: string,
resolve: (context: SubgraphInputLinkContext) => TResult | undefined
): TResult | undefined {
if (!node.isSubgraphNode()) return undefined
const inputSlot = node.subgraph.inputNode.slots.find(
(slot) => slot.name === inputName
)
if (!inputSlot) return undefined
// Iterate from newest to oldest so the latest connection wins.
for (let index = inputSlot.linkIds.length - 1; index >= 0; index -= 1) {
const linkId = inputSlot.linkIds[index]
const link = node.subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(node.subgraph)
if (!inputNode) continue
if (!Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
if (!targetInput) continue
let cachedTargetWidget:
| ReturnType<LGraphNode['getWidgetFromSlot']>
| undefined
let hasCachedTargetWidget = false
const resolved = resolve({
inputNode,
targetInput,
getTargetWidget: () => {
if (!hasCachedTargetWidget) {
cachedTargetWidget = inputNode.getWidgetFromSlot(targetInput)
hasCachedTargetWidget = true
}
return cachedTargetWidget
}
})
if (resolved !== undefined) return resolved
}
return undefined
}

View File

@@ -16,9 +16,6 @@ export function resolveSubgraphInputTarget(
inputName,
({ inputNode, targetInput, getTargetWidget }) => {
if (inputNode.isSubgraphNode()) {
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
return {
nodeId: String(inputNode.id),
widgetName: targetInput.name

View File

@@ -598,6 +598,27 @@ describe('Subgraph Unpacking', () => {
expect(unpackedTarget.inputs[0].link).not.toBeNull()
expect(unpackedTarget.inputs[1].link).toBeNull()
})
it('keeps subgraph definition when unpacking one instance while another remains', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
const firstInstance = createTestSubgraphNode(subgraph, { pos: [100, 100] })
const secondInstance = createTestSubgraphNode(subgraph, { pos: [300, 100] })
secondInstance.id = 2
rootGraph.add(firstInstance)
rootGraph.add(secondInstance)
rootGraph.unpackSubgraph(firstInstance)
expect(rootGraph.subgraphs.has(subgraph.id)).toBe(true)
const serialized = rootGraph.serialize()
const definitionIds =
serialized.definitions?.subgraphs?.map((definition) => definition.id) ??
[]
expect(definitionIds).toContain(subgraph.id)
})
})
describe('deduplicateSubgraphNodeIds (via configure)', () => {

View File

@@ -1064,13 +1064,23 @@ export class LGraph
}
if (node.isSubgraphNode()) {
forEachNode(node.subgraph, (innerNode) => {
innerNode.onRemoved?.()
innerNode.graph?.onNodeRemoved?.(innerNode)
if (innerNode.isSubgraphNode())
this.rootGraph.subgraphs.delete(innerNode.subgraph.id)
})
this.rootGraph.subgraphs.delete(node.subgraph.id)
const allGraphs = [this.rootGraph, ...this.rootGraph.subgraphs.values()]
const hasRemainingReferences = allGraphs.some((graph) =>
graph.nodes.some(
(candidate) =>
candidate !== node &&
candidate.isSubgraphNode() &&
candidate.type === node.subgraph.id
)
)
if (!hasRemainingReferences) {
forEachNode(node.subgraph, (innerNode) => {
innerNode.onRemoved?.()
innerNode.graph?.onNodeRemoved?.(innerNode)
})
this.rootGraph.subgraphs.delete(node.subgraph.id)
}
}
// callback
@@ -1862,6 +1872,7 @@ export class LGraph
})
)
)
return { subgraph, node: subgraphNode as SubgraphNode }
}
@@ -2048,7 +2059,6 @@ export class LGraph
})
}
this.remove(subgraphNode)
this.subgraphs.delete(subgraphNode.subgraph.id)
// Deduplicate links by (oid, oslot, tid, tslot) to prevent repeated
// disconnect/reconnect cycles on widget inputs that can shift slot indices.
@@ -2335,7 +2345,6 @@ export class LGraph
const usedSubgraphs = [...this._subgraphs.values()]
.filter((subgraph) => usedSubgraphIds.has(subgraph.id))
.map((x) => x.asSerialisable())
if (usedSubgraphs.length > 0) {
data.definitions = { subgraphs: usedSubgraphs }
}

View File

@@ -0,0 +1,128 @@
import { describe, expect, test } from 'vitest'
import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager'
type TestPromotionEntry = {
interiorNodeId: string
widgetName: string
viewKey?: string
}
function makeView(entry: TestPromotionEntry) {
const baseKey = `${entry.interiorNodeId}:${entry.widgetName}`
return {
key: entry.viewKey ? `${baseKey}:${entry.viewKey}` : baseKey
}
}
describe('PromotedWidgetViewManager', () => {
test('returns memoized array when entries reference is unchanged', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const entries = [{ interiorNodeId: '1', widgetName: 'widgetA' }]
const first = manager.reconcile(entries, makeView)
const second = manager.reconcile(entries, makeView)
expect(second).toBe(first)
expect(second[0]).toBe(first[0])
})
test('preserves view identity while reflecting order changes', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const firstPass = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
],
makeView
)
const reordered = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
],
makeView
)
expect(reordered[0]).toBe(firstPass[1])
expect(reordered[1]).toBe(firstPass[0])
})
test('deduplicates by first occurrence and clears stale cache entries', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const first = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
],
makeView
)
expect(first.map((view) => view.key)).toStrictEqual([
'1:widgetA',
'1:widgetB'
])
manager.reconcile(
[{ interiorNodeId: '1', widgetName: 'widgetB' }],
makeView
)
const restored = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
],
makeView
)
expect(restored[0]).toBe(first[1])
expect(restored[1]).not.toBe(first[0])
})
test('keeps distinct views for same source widget when viewKeys differ', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const views = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
expect(views).toHaveLength(2)
expect(views[0]).not.toBe(views[1])
expect(views[0].key).toBe('1:widgetA:slotA')
expect(views[1].key).toBe('1:widgetA:slotB')
})
test('removeByViewKey removes only the targeted keyed view', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const firstPass = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
manager.removeByViewKey('1', 'widgetA', 'slotA')
const secondPass = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
expect(secondPass[0]).not.toBe(firstPass[0])
expect(secondPass[1]).toBe(firstPass[1])
})
})

View File

@@ -0,0 +1,120 @@
type PromotionEntry = {
interiorNodeId: string
widgetName: string
viewKey?: string
}
type CreateView<TView> = (entry: PromotionEntry) => TView
/**
* Reconciles promoted widget entries to stable view instances.
*
* Keeps object identity stable by key while preserving the current
* promotion order and deduplicating duplicate entries by first occurrence.
*/
export class PromotedWidgetViewManager<TView> {
private viewCache = new Map<string, TView>()
private cachedViews: TView[] | null = null
private cachedEntryKeys: string[] | null = null
reconcile(
entries: readonly PromotionEntry[],
createView: CreateView<TView>
): TView[] {
const entryKeys = entries.map((entry) =>
this.makeKey(entry.interiorNodeId, entry.widgetName, entry.viewKey)
)
if (this.cachedViews && this.areEntryKeysEqual(entryKeys))
return this.cachedViews
const views: TView[] = []
const seenKeys = new Set<string>()
for (const entry of entries) {
const key = this.makeKey(
entry.interiorNodeId,
entry.widgetName,
entry.viewKey
)
if (seenKeys.has(key)) continue
seenKeys.add(key)
const existing = this.viewCache.get(key)
if (existing) {
views.push(existing)
continue
}
const nextView = createView(entry)
this.viewCache.set(key, nextView)
views.push(nextView)
}
for (const key of this.viewCache.keys()) {
if (!seenKeys.has(key)) this.viewCache.delete(key)
}
this.cachedViews = views
this.cachedEntryKeys = entryKeys
return views
}
getOrCreate(
interiorNodeId: string,
widgetName: string,
createView: () => TView,
viewKey?: string
): TView {
const key = this.makeKey(interiorNodeId, widgetName, viewKey)
const cached = this.viewCache.get(key)
if (cached) return cached
const view = createView()
this.viewCache.set(key, view)
return view
}
remove(interiorNodeId: string, widgetName: string): void {
this.viewCache.delete(this.makeKey(interiorNodeId, widgetName))
this.invalidateMemoizedList()
}
removeByViewKey(
interiorNodeId: string,
widgetName: string,
viewKey: string
): void {
this.viewCache.delete(this.makeKey(interiorNodeId, widgetName, viewKey))
this.invalidateMemoizedList()
}
clear(): void {
this.viewCache.clear()
this.invalidateMemoizedList()
}
invalidateMemoizedList(): void {
this.cachedViews = null
this.cachedEntryKeys = null
}
private areEntryKeysEqual(entryKeys: string[]): boolean {
if (!this.cachedEntryKeys) return false
if (this.cachedEntryKeys.length !== entryKeys.length) return false
for (let index = 0; index < entryKeys.length; index += 1) {
if (this.cachedEntryKeys[index] !== entryKeys[index]) return false
}
return true
}
private makeKey(
interiorNodeId: string,
widgetName: string,
viewKey?: string
): string {
const baseKey = `${interiorNodeId}:${widgetName}`
return viewKey ? `${baseKey}:${viewKey}` : baseKey
}
}

View File

@@ -87,7 +87,9 @@ export class SubgraphInput extends SubgraphSlot {
return
}
this._widget ??= inputWidget
// Keep the widget reference in sync with the active upstream widget.
// Stale references can appear across nested promotion rebinds.
this._widget = inputWidget
this.events.dispatch('input-connected', {
input: slot,
widget: inputWidget
@@ -207,6 +209,8 @@ export class SubgraphInput extends SubgraphSlot {
override disconnect(): void {
super.disconnect()
this._widget = undefined
this.events.dispatch('input-disconnected', { input: this })
}

View File

@@ -29,8 +29,15 @@ import type {
} from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget'
import {
createPromotedWidgetView,
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
@@ -41,6 +48,11 @@ const workflowSvg = new Image()
workflowSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E"
type LinkedPromotionEntry = {
inputName: string
interiorNodeId: string
widgetName: 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.
const workflowBitmapCache = createBitmapCache(workflowSvg, 32)
@@ -69,7 +81,265 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return true
}
override widgets: IBaseWidget[] = []
private _promotedViewManager =
new PromotedWidgetViewManager<PromotedWidgetView>()
/**
* Promotions buffered before this node is attached to a graph (`id === -1`).
* They are flushed in `_flushPendingPromotions()` from `_setWidget()` and
* `onAdded()`, so construction-time promotions require normal add-to-graph
* lifecycle to persist.
*/
private _pendingPromotions: Array<{
interiorNodeId: string
widgetName: string
}> = []
// 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
): { interiorNodeId: string; widgetName: string } | undefined {
const resolvedTarget = resolveSubgraphInputTarget(this, inputName)
if (!resolvedTarget) return undefined
return {
interiorNodeId: resolvedTarget.nodeId,
widgetName: resolvedTarget.widgetName
}
}
private _getLinkedPromotionEntries(): LinkedPromotionEntry[] {
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)
if (!resolved) continue
linkedEntries.push({ inputName: input.name, ...resolved })
}
const seenEntryKeys = new Set<string>()
const deduplicatedEntries = linkedEntries.filter((entry) => {
const entryKey = this._makePromotionViewKey(
entry.inputName,
entry.interiorNodeId,
entry.widgetName
)
if (seenEntryKeys.has(entryKey)) return false
seenEntryKeys.add(entryKey)
return true
})
return deduplicatedEntries
}
private _getPromotedViews(): PromotedWidgetView[] {
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
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
)
)
}
private _syncPromotions(): void {
if (this.id === -1) return
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 hasChanged =
mergedEntries.length !== entries.length ||
mergedEntries.some(
(entry, index) =>
entry.interiorNodeId !== entries[index]?.interiorNodeId ||
entry.widgetName !== entries[index]?.widgetName
)
if (!hasChanged) return
store.setPromotions(this.rootGraph.id, this.id, mergedEntries)
}
private _buildPromotionReconcileState(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedEntries: LinkedPromotionEntry[]
): {
displayNameByViewKey: Map<string, string>
reconcileEntries: Array<{
interiorNodeId: string
widgetName: string
viewKey?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
entries,
linkedEntries
)
const linkedReconcileEntries =
this._buildLinkedReconcileEntries(linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
return {
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
reconcileEntries: shouldPersistLinkedOnly
? linkedReconcileEntries
: [...linkedReconcileEntries, ...fallbackStoredEntries]
}
}
private _buildPromotionPersistenceState(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedEntries: LinkedPromotionEntry[]
): {
mergedEntries: Array<{ interiorNodeId: string; widgetName: string }>
shouldPersistLinkedOnly: boolean
} {
const { linkedPromotionEntries, fallbackStoredEntries } =
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
return {
mergedEntries: shouldPersistLinkedOnly
? linkedPromotionEntries
: [...linkedPromotionEntries, ...fallbackStoredEntries],
shouldPersistLinkedOnly
}
}
private _collectLinkedAndFallbackEntries(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedEntries: LinkedPromotionEntry[]
): {
linkedPromotionEntries: Array<{
interiorNodeId: string
widgetName: string
}>
fallbackStoredEntries: Array<{ interiorNodeId: string; widgetName: string }>
} {
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
const fallbackStoredEntries = this._getFallbackStoredEntries(
entries,
linkedPromotionEntries
)
return {
linkedPromotionEntries,
fallbackStoredEntries
}
}
private _shouldPersistLinkedOnly(
linkedEntries: LinkedPromotionEntry[]
): boolean {
return this.inputs.length > 0 && linkedEntries.length === this.inputs.length
}
private _toPromotionEntries(
linkedEntries: LinkedPromotionEntry[]
): Array<{ interiorNodeId: string; widgetName: string }> {
return linkedEntries.map(({ interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName
}))
}
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)
)
)
return entries.filter(
(entry) =>
!linkedKeys.has(
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
)
)
}
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)
}))
}
private _buildDisplayNameByViewKey(
linkedEntries: LinkedPromotionEntry[]
): Map<string, string> {
return new Map(
linkedEntries.map((entry) => [
this._makePromotionViewKey(
entry.inputName,
entry.interiorNodeId,
entry.widgetName
),
entry.inputName
])
)
}
private _makePromotionEntryKey(
interiorNodeId: string,
widgetName: string
): string {
return `${interiorNodeId}:${widgetName}`
}
private _makePromotionViewKey(
inputName: string,
interiorNodeId: string,
widgetName: string
): string {
return `${inputName}:${interiorNodeId}:${widgetName}`
}
private _resolveLegacyEntry(
widgetName: string
): [string, string] | undefined {
// Legacy -1 entries use the slot name as the widget name.
// Find the input with that name, then trace to the connected interior widget.
const input = this.inputs.find((i) => i.name === widgetName)
if (!input?._widget) return undefined
const widget = input._widget
if (isPromotedWidgetView(widget)) {
return [widget.sourceNodeId, widget.sourceWidgetName]
}
// Fallback: find via subgraph input slot connection
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
}
/** Manages lifecycle of all subgraph event listeners */
private _eventAbortController = new AbortController()
@@ -116,6 +386,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (widget) this.ensureWidgetRemoved(widget)
this.removeInput(e.detail.index)
this._syncPromotions()
this.setDirtyCanvas(true, true)
},
{ signal }
@@ -211,7 +482,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!widget) return
const widgetLocator = e.detail.input.widget
this._setWidget(subgraphInput, input, widget, widgetLocator)
this._setWidget(
subgraphInput,
input,
widget,
widgetLocator,
e.detail.node
)
this._syncPromotions()
},
{ signal }
)
@@ -228,6 +506,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
delete input.pos
delete input.widget
input._widget = undefined
this._syncPromotions()
},
{ signal }
)
@@ -332,74 +611,69 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
break
}
}
this._syncPromotions()
}
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
widget: Readonly<IBaseWidget>,
inputWidget: IWidgetLocator | undefined
interiorWidget: Readonly<IBaseWidget>,
inputWidget: IWidgetLocator | undefined,
interiorNode: LGraphNode
) {
// Use the first matching widget
const promotedWidget =
widget instanceof BaseWidget
? widget.createCopyForNode(this)
: { ...widget, node: this }
if (widget instanceof AssetWidget)
promotedWidget.options.nodeType ??= widget.node.type
this._flushPendingPromotions()
Object.assign(promotedWidget, {
get name() {
return subgraphInput.name
},
set name(value) {
console.warn(
'Promoted widget: setting name is not allowed',
this,
value
)
},
get localized_name() {
return subgraphInput.localized_name
},
set localized_name(value) {
console.warn(
'Promoted widget: setting localized_name is not allowed',
this,
value
)
},
get label() {
return subgraphInput.label
},
set label(value) {
console.warn(
'Promoted widget: setting label is not allowed',
this,
value
)
},
get tooltip() {
// Preserve the original widget's tooltip for promoted widgets
return widget.tooltip
},
set tooltip(value) {
console.warn(
'Promoted widget: setting tooltip is not allowed',
this,
value
const nodeId = String(interiorNode.id)
const widgetName = interiorWidget.name
const previousView = input._widget
if (
previousView &&
isPromotedWidgetView(previousView) &&
(previousView.sourceNodeId !== nodeId ||
previousView.sourceWidgetName !== widgetName)
) {
usePromotionStore().demote(
this.rootGraph.id,
this.id,
previousView.sourceNodeId,
previousView.sourceWidgetName
)
this._removePromotedView(previousView)
}
if (this.id === -1) {
if (
!this._pendingPromotions.some(
(entry) =>
entry.interiorNodeId === nodeId && entry.widgetName === widgetName
)
) {
this._pendingPromotions.push({
interiorNodeId: nodeId,
widgetName
})
}
})
} else {
// Add to promotion store
usePromotionStore().promote(
this.rootGraph.id,
this.id,
nodeId,
widgetName
)
}
const widgetCount = this.inputs.filter((i) => i.widget).length
this.widgets.splice(widgetCount, 0, promotedWidget)
// Dispatch widget-promoted event
this.subgraph.events.dispatch('widget-promoted', {
widget: promotedWidget,
subgraphNode: this
})
// Create/retrieve the view from cache
const view = this._promotedViewManager.getOrCreate(
nodeId,
widgetName,
() =>
createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name),
this._makePromotionViewKey(subgraphInput.name, nodeId, widgetName)
)
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
@@ -411,6 +685,26 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
input._widget = promotedWidget
}
private _flushPendingPromotions() {
if (this.id === -1 || this._pendingPromotions.length === 0) return
for (const entry of this._pendingPromotions) {
usePromotionStore().promote(
this.rootGraph.id,
this.id,
entry.interiorNodeId,
entry.widgetName
)
}
this._pendingPromotions = []
}
override onAdded(_graph: LGraph): void {
this._flushPendingPromotions()
this._syncPromotions()
}
/**
* Ensures the subgraph slot is in the params before adding the input as normal.
* @param name The name of the input slot.
@@ -540,9 +834,78 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return nodes
}
/** Clear the DOM position override for a promoted view's interior widget. */
private _clearDomOverrideForView(view: PromotedWidgetView): void {
const node = this.subgraph.getNodeById(view.sourceNodeId)
if (!node) return
const interiorWidget = node.widgets?.find(
(w: IBaseWidget) => w.name === view.sourceWidgetName
)
if (
interiorWidget &&
'id' in interiorWidget &&
('element' in interiorWidget || 'component' in interiorWidget)
) {
useDomWidgetStore().clearPositionOverride(String(interiorWidget.id))
}
}
private _removePromotedView(view: PromotedWidgetView): void {
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,
view.sourceNodeId,
view.sourceWidgetName
)
)
}
override removeWidget(widget: IBaseWidget): void {
this.ensureWidgetRemoved(widget)
}
override removeWidgetByName(name: string): void {
const widget = this.widgets.find((w) => w.name === name)
if (widget) {
if (widget) this.ensureWidgetRemoved(widget)
}
override ensureWidgetRemoved(widget: IBaseWidget): void {
if (isPromotedWidgetView(widget)) {
this._clearDomOverrideForView(widget)
usePromotionStore().demote(
this.rootGraph.id,
this.id,
widget.sourceNodeId,
widget.sourceWidgetName
)
this._removePromotedView(widget)
}
for (const input of this.inputs) {
if (input._widget === widget) {
input._widget = undefined
input.widget = undefined
}
}
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this
})
this._syncPromotions()
}
override onRemoved(): void {
this._eventAbortController.abort()
for (const widget of this.widgets) {
if (isPromotedWidgetView(widget)) {
this._clearDomOverrideForView(widget)
}
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this

View File

@@ -179,6 +179,8 @@ const processedWidgets = computed((): ProcessedWidget[] => {
for (const widget of widgets) {
if (!shouldRenderAsVue(widget)) continue
const isPromotedView = !!widget.nodeId
const vueComponent =
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
@@ -186,8 +188,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const { slotMetadata } = widget
// Get metadata from store (registered during BaseWidget.setNodeId)
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
const widgetState = widgetValueStore.getWidget(bareWidgetId, widget.name)
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
@@ -200,7 +207,6 @@ const processedWidgets = computed((): ProcessedWidget[] => {
? { ...storeOptions, disabled: true }
: storeOptions
// Derive border style from store metadata
const borderStyle =
widgetState?.promoted && String(widgetState?.nodeId) === String(nodeId)
? 'ring ring-component-node-widget-promoted'

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
class TestNode extends LGraphNode {
constructor(widgets: IBaseWidget[]) {
super('TestNode')
this.widgets = widgets
}
}
class TestSubgraphNode extends TestNode {
constructor(
widgets: IBaseWidget[],
innerNodesById: Record<string, LGraphNode> = {}
) {
super(widgets)
this.subgraph = {
getNodeById: (nodeId: string) => innerNodesById[nodeId]
} as SubgraphNode['subgraph']
}
override isSubgraphNode(): this is SubgraphNode {
return true
}
readonly subgraph: SubgraphNode['subgraph']
}
type TestPromotedWidget = IBaseWidget &
Pick<PromotedWidgetView, 'sourceNodeId' | 'sourceWidgetName'>
function createWidget(name: string): IBaseWidget {
return {
name,
type: 'text',
y: 0,
options: {}
}
}
function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string
): TestPromotedWidget {
return {
...createWidget(name),
sourceNodeId,
sourceWidgetName
}
}
function createHostNode(
widgets: IBaseWidget[],
options: {
isSubgraphNode?: boolean
innerNodesById?: Record<string, LGraphNode>
} = {}
): LGraphNode {
const { isSubgraphNode = false, innerNodesById = {} } = options
return isSubgraphNode
? new TestSubgraphNode(widgets, innerNodesById)
: new TestNode(widgets)
}
describe('resolveWidgetFromHostNode', () => {
it('returns host node widget for non-promoted widgets', () => {
const widget = createWidget('text_widget')
const hostNode = createHostNode([widget])
const resolved = resolveWidgetFromHostNode(hostNode, widget.name)
expect(resolved).toEqual({ node: hostNode, widget })
})
it('resolves promoted widget to the interior node widget', () => {
const innerWidget = createWidget('inner_text')
const innerNode = createHostNode([innerWidget])
const promotedWidget = createPromotedWidget(
'promoted_text',
'42',
'inner_text'
)
const hostNode = createHostNode([promotedWidget], {
isSubgraphNode: true,
innerNodesById: { '42': innerNode }
})
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
expect(resolved).toEqual({ node: innerNode, widget: innerWidget })
})
it('resolves nested promoted widget chain to deepest interior widget', () => {
const innerWidget = createWidget('inner_text')
const innerNode = createHostNode([innerWidget])
const middleNode = createHostNode([], {
isSubgraphNode: true,
innerNodesById: { '100': innerNode }
})
const middlePromotedWidget = {
...createPromotedWidget('inner_text', '100', 'inner_text'),
node: middleNode
} as TestPromotedWidget & { node: LGraphNode }
middleNode.widgets = [middlePromotedWidget]
const outerPromotedWidget = createPromotedWidget(
'outer_text',
'42',
'inner_text'
)
const hostNode = createHostNode([outerPromotedWidget], {
isSubgraphNode: true,
innerNodesById: { '42': middleNode }
})
const resolved = resolveWidgetFromHostNode(
hostNode,
outerPromotedWidget.name
)
expect(resolved).toEqual({ node: innerNode, widget: innerWidget })
})
it('returns undefined when promoted interior node is missing', () => {
const promotedWidget = createPromotedWidget(
'promoted_text',
'42',
'inner_text'
)
const hostNode = createHostNode([promotedWidget], {
isSubgraphNode: true
})
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
expect(resolved).toBeUndefined()
})
it('returns undefined when promoted interior widget is missing', () => {
const innerNode = createHostNode([])
const promotedWidget = createPromotedWidget(
'promoted_text',
'42',
'inner_text'
)
const hostNode = createHostNode([promotedWidget], {
isSubgraphNode: true,
innerNodesById: { '42': innerNode }
})
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
expect(resolved).toBeUndefined()
})
it('treats promoted-shaped widgets on non-subgraph nodes as local widgets', () => {
const widget = createPromotedWidget('promoted_text', '42', 'inner_text')
const hostNode = createHostNode([widget], {
isSubgraphNode: false
})
const resolved = resolveWidgetFromHostNode(hostNode, widget.name)
expect(resolved).toEqual({ node: hostNode, widget })
})
})