test: subgraph integration contracts and expanded Playwright coverage (#10123)

## Summary

Add integration contract tests (unit) and expanded Playwright coverage
for subgraph promotion, hydration, navigation, and lifecycle edge
behaviors.

## Changes

- **What**: 22 unit/integration tests across 9 files covering promotion
store sync, widget view lifecycle, input link resolution, pseudo-widget
cache, navigation viewport restore, and subgraph operations. 13
Playwright E2E tests covering proxyWidgets hydration stability, promoted
source removal cleanup, pseudo-preview unpack/remove, multi-link
representative round-trip, nested promotion retarget, and navigation
state on workflow switch.
- **Helpers**: Added `isPseudoPreviewEntry`, `getPseudoPreviewWidgets`,
`getNonPreviewPromotedWidgets` to promotedWidgets helper. Added
`SubgraphHelper.getNodeCount()`.

## Review Focus

- Test-only PR — no production code changes
- Validates existing subgraph behaviors are covered by regression tests
before further feature work
- Phase 4 (unit/integration contracts) and Phase 5 (Playwright
expansion) of the subgraph test coverage plan

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10123-test-subgraph-integration-contracts-and-expanded-Playwright-coverage-3256d73d365081258023e3a763859e00)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-03-19 16:54:15 -07:00
committed by GitHub
parent 6dfc7b4306
commit 4d57c41fdb
59 changed files with 3455 additions and 1100 deletions

View File

@@ -0,0 +1,172 @@
{
"id": "9efdcc44-6372-4b4a-b6f9-789c67f052e1",
"revision": 0,
"last_node_id": 4,
"last_link_id": 0,
"nodes": [
{
"id": 4,
"type": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"pos": [689.0083557128902, 467.9999999999997],
"size": [431.8999938964844, 206.60000610351562],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [["3", "text", "2"]]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph",
"inputNode": {
"id": -10,
"bounding": [330, 367, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [983, 367, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CLIPTextEncode",
"pos": [510, 166],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["11111111111"]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [523, 438],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["22222222222"]
}
],
"groups": [],
"links": [],
"extra": {}
},
{
"id": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph",
"inputNode": {
"id": -10,
"bounding": [467, 446, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [932, 446, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"pos": [647, 389],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "text"],
["2", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 2.0975,
"offset": [-581.4780189305006, -356.3000030517576]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -33,6 +33,7 @@ export class NodeOperationsHelper {
})
}
/** Reads from `window.app.graph` (the root workflow graph). */
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)
}

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
@@ -6,6 +7,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import type { NodeReference } from '../utils/litegraphUtils'
import { SubgraphSlotReference } from '../utils/litegraphUtils'
@@ -322,4 +324,93 @@ export class SubgraphHelper {
)
await this.comfyPage.nextFrame()
}
async isInSubgraph(): Promise<boolean> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async exitViaBreadcrumb(): Promise<void> {
const breadcrumb = this.page.getByTestId(TestIds.breadcrumb.subgraph)
const parentLink = breadcrumb.getByRole('link').first()
if (await parentLink.isVisible()) {
await parentLink.click()
} else {
await this.page.evaluate(() => {
const canvas = window.app!.canvas
const graph = canvas.graph
if (!graph) return
canvas.setGraph(graph.rootGraph)
})
}
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
}
async countGraphPseudoPreviewEntries(): Promise<number> {
return this.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.reduce((count, node) => {
const proxyWidgets = node.properties?.proxyWidgets
if (!Array.isArray(proxyWidgets)) return count
return (
count +
proxyWidgets.filter(
(entry) =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[1] === 'string' &&
entry[1].startsWith('$$')
).length
)
}, 0)
})
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => {
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets
: []
const promotedWidgets = proxyWidgets
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
.map(
([interiorNodeId, widgetName]) =>
[interiorNodeId, widgetName] as [string, string]
)
return {
hostNodeId: String(node.id),
promotedWidgets
}
})
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
})
}
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
async getNodeCount(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.graph!.nodes?.length || 0
})
}
}

View File

@@ -75,6 +75,26 @@ export async function getPromotedWidgetCount(
return promotedWidgets.length
}
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
return entry[1].startsWith('$$')
}
export async function getPseudoPreviewWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter(isPseudoPreviewEntry)
}
export async function getNonPreviewPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
}
export async function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,

View File

@@ -43,6 +43,31 @@ test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
expect(rootIds).toEqual([1, 2, 5])
})
test('Promoted widget tuples are stable after full page reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const beforeSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(beforeSnapshot.length).toBeGreaterThan(0)
expect(
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
).toBe(true)
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
await expect(async () => {
const afterSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(afterSnapshot).toEqual(beforeSnapshot)
}).toPass({ timeout: 5_000 })
})
test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
@@ -654,6 +655,28 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.locator('.p-breadcrumb')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
await expect(breadcrumb).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect(breadcrumb).toBeHidden()
})
})
test.describe('DOM Widget Promotion', () => {

View File

@@ -1,160 +1,347 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { PromotedWidgetEntry } from '../helpers/promotedWidgets'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import {
getPromotedWidgetSnapshot,
getPromotedWidgets
getPromotedWidgets,
getPseudoPreviewWidgets,
getNonPreviewPromotedWidgets
} from '../helpers/promotedWidgets'
test.describe('Subgraph Lifecycle', { tag: ['@subgraph', '@widget'] }, () => {
test('hydrates legacy proxyWidgets deterministically across reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-duplicate-ids'
)
await comfyPage.nextFrame()
const domPreviewSelector = '.image-preview'
const firstSnapshot = await getPromotedWidgetSnapshot(comfyPage, '5')
expect(firstSnapshot.proxyWidgets.length).toBeGreaterThan(0)
expect(
firstSnapshot.proxyWidgets.every(([nodeId]) => nodeId !== '-1')
).toBe(true)
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-duplicate-ids'
)
await comfyPage.nextFrame()
const secondSnapshot = await getPromotedWidgetSnapshot(comfyPage, '5')
expect(secondSnapshot.proxyWidgets).toEqual(firstSnapshot.proxyWidgets)
expect(secondSnapshot.widgetNames).toEqual(firstSnapshot.widgetNames)
})
test('promoted view falls back to disconnected placeholder after source widget removal', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const projection = await comfyPage.page.evaluate(() => {
const expectPromotedWidgetsToResolveToInteriorNodes = async (
comfyPage: ComfyPage,
hostSubgraphNodeId: string,
widgets: PromotedWidgetEntry[]
) => {
const interiorNodeIds = widgets.map(([id]) => id)
const results = await comfyPage.page.evaluate(
([hostId, ids]) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById('11')
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
)
throw new Error('Expected host subgraph node 11')
const hostNode = graph.getNodeById(Number(hostId))
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
const beforeType = hostNode.widgets?.[0]?.type
const proxyWidgets = Array.isArray(hostNode.properties?.proxyWidgets)
? hostNode.properties.proxyWidgets.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
: []
const firstPromotion = proxyWidgets[0]
if (!firstPromotion)
throw new Error('Expected at least one promoted widget entry')
return ids.map((id) => {
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
return interiorNode !== null && interiorNode !== undefined
})
},
[hostSubgraphNodeId, interiorNodeIds] as const
)
const [sourceNodeId, sourceWidgetName] = firstPromotion
const subgraph = graph.subgraphs.get(hostNode.type)
const sourceNode = subgraph?.getNodeById(Number(sourceNodeId))
if (!sourceNode?.widgets)
throw new Error('Expected promoted source node widget list')
for (const exists of results) {
expect(exists).toBe(true)
}
}
sourceNode.widgets = sourceNode.widgets.filter(
(widget) => widget.name !== sourceWidgetName
)
test.describe(
'Subgraph Lifecycle Edge Behaviors',
{ tag: ['@subgraph'] },
() => {
test.describe('Deterministic Hydrate from Serialized proxyWidgets', () => {
test('proxyWidgets entries map to real interior node IDs after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
return {
beforeType,
afterType: hostNode.widgets?.[0]?.type
}
})
const widgets = await getPromotedWidgets(comfyPage, '11')
expect(widgets.length).toBeGreaterThan(0)
expect(projection.beforeType).toBe('customtext')
expect(projection.afterType).toBe('button')
})
test('unpacking one preview host keeps remaining pseudo-preview promotions resolvable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const beforeNode8 = await getPromotedWidgets(comfyPage, '8')
expect(beforeNode8).toEqual([['6', '$$canvas-image-preview']])
const cleanupResult = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const invalidPseudoEntries = () => {
const invalid: string[] = []
for (const node of graph.nodes) {
if (
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
)
continue
const subgraph = graph.subgraphs.get(node.type)
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
: []
for (const entry of proxyWidgets) {
if (entry[1] !== '$$canvas-image-preview') continue
const sourceNodeId = Number(entry[0])
const sourceNode = subgraph?.getNodeById(sourceNodeId)
if (!sourceNode) invalid.push(`${node.id}:${entry[0]}`)
}
for (const [interiorNodeId] of widgets) {
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
return invalid
}
const before = invalidPseudoEntries()
const hostNode = graph.getNodeById('7')
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
)
throw new Error('Expected preview host subgraph node 7')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
widgets
)
})
;(
graph as unknown as { unpackSubgraph: (node: unknown) => void }
).unpackSubgraph(hostNode)
test('proxyWidgets entries survive double round-trip without drift', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
return {
before,
after: invalidPseudoEntries(),
hasNode7: Boolean(graph.getNodeById('7')),
hasNode8: Boolean(graph.getNodeById('8'))
}
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
initialWidgets
)
const serialized1 = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await comfyPage.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized1 as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
const afterFirst = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterFirst
)
const serialized2 = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await comfyPage.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized2 as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
const afterSecond = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterSecond
)
expect(afterFirst).toEqual(initialWidgets)
expect(afterSecond).toEqual(initialWidgets)
})
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '2')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'2',
widgets
)
})
})
expect(cleanupResult.before).toEqual([])
expect(cleanupResult.after).toEqual([])
expect(cleanupResult.hasNode7).toBe(false)
expect(cleanupResult.hasNode8).toBe(true)
test.describe('Placeholder Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
const afterNode8 = await getPromotedWidgets(comfyPage, '8')
expect(afterNode8).toEqual([['6', '$$canvas-image-preview']])
})
})
test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const hostNode = window.app!.canvas.graph!.getNodeById('11')
const proxyWidgets = hostNode?.properties?.proxyWidgets
return {
proxyWidgetCount: Array.isArray(proxyWidgets)
? proxyWidgets.length
: 0,
firstWidgetType: hostNode?.widgets?.[0]?.type
}
})
})
.toEqual({
proxyWidgetCount: initialWidgets.length,
firstWidgetType: 'button'
})
})
test('Promoted widget disappears from DOM after interior node deletion', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
).toBe(true)
})
test('Non-preview widgets coexist with pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
comfyPage,
'5'
)
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
expect(
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
).toBe(true)
})
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.filter((n) => n.isSubgraphNode()).length
})
expect(subgraphNodeCount).toBe(0)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
})
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const nodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.canvas.graph!.getNodeById('5')
})
expect(nodeExists).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
expect(firstNodeBefore.length).toBeGreaterThan(0)
expect(secondNodeBefore.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('7')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const firstNodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.graph!.getNodeById('7')
})
expect(firstNodeExists).toBe(false)
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
expect(secondNodeAfter).toEqual(secondNodeBefore)
})
})
}
)

View File

@@ -0,0 +1,141 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
/**
* Regression tests for nested subgraph promotion where multiple interior
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
* with a "text" widget).
*
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
* The outer subgraph (node 4) promotes through node 3 using identity
* disambiguation (optional sourceNodeId in the promotion entry).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
*/
test.describe(
'Nested subgraph duplicate widget names',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Inner subgraph node has both text widgets promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const nonPreview = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return ((innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[])
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string' &&
!entry[1].startsWith('$$')
)
.map(
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
)
})
expect(nonPreview).toEqual([
['1', 'text'],
['2', 'text']
])
})
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const widgetValues = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
const textWidgets = widgetValues.filter((w) => w.name.startsWith('text'))
expect(textWidgets).toHaveLength(2)
const values = textWidgets.map((w) => w.value)
expect(values).toContain('11111111111')
expect(values).toContain('22222222222')
})
test.describe('Promoted border styling in Vue mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Node 4 is the outer SubgraphNode at root level.
// Its widgets are not promoted further (no parent subgraph),
// so none of its widget wrappers should carry the promoted ring.
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
await expect(outerNode).toBeVisible()
const outerPromotedRings = outerNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect(outerPromotedRings).toHaveCount(0)
// Navigate into the outer subgraph (node 4) to reach node 3
await comfyPage.vueNodes.enterSubgraph('4')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// Node 3 is the intermediate SubgraphNode whose "text" widgets
// are promoted up to the outer subgraph (node 4).
// Its widget wrappers should carry the promoted border ring.
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
await expect(intermediateNode).toBeVisible()
const intermediatePromotedRings = intermediateNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect(intermediatePromotedRings).toHaveCount(1)
})
})
}
)

View File

@@ -73,5 +73,59 @@ test.describe(
expect(progressAfter).toBeUndefined()
}).toPass({ timeout: 2_000 })
})
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return subgraphNode ? String(subgraphNode.id) : null
})
expect(subgraphNodeId).not.toBeNull()
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.7
}, subgraphNodeId!)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
subgraphNodeId!
)
await subgraphNode.navigateIntoSubgraph()
const inSubgraph = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
expect(inSubgraph).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect(async () => {
const subgraphProgressState = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
if (!subgraphNode) {
return { exists: false, progress: null }
}
return { exists: true, progress: subgraphNode.progress }
})
expect(subgraphProgressState.exists).toBe(true)
expect(subgraphProgressState.progress).toBeUndefined()
}).toPass({ timeout: 5_000 })
})
}
)

View File

@@ -2,7 +2,6 @@ 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'
@@ -12,25 +11,6 @@ import {
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 exitSubgraphToParent(comfyPage: ComfyPage): Promise<void> {
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
if (!canvas.graph) return
canvas.setGraph(canvas.graph.rootGraph)
})
await comfyPage.nextFrame()
}
test.describe(
'Subgraph Widget Promotion',
{ tag: ['@subgraph', '@widget'] },
@@ -190,7 +170,7 @@ test.describe(
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
@@ -262,7 +242,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent graph
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
@@ -296,7 +276,7 @@ test.describe(
)
await expect(interiorTextarea).toHaveValue(testContent)
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -342,7 +322,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should now have the promoted widget
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -377,7 +357,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
@@ -408,7 +388,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should have fewer widgets
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -564,6 +544,30 @@ test.describe(
expect(widgetCount).toBeGreaterThan(0)
})
test('Multi-link input representative stays stable through save/reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(beforeSnapshot.length).toBeGreaterThan(0)
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 afterSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(afterSnapshot).toEqual(beforeSnapshot)
})
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
comfyPage
}) => {
@@ -721,6 +725,44 @@ test.describe(
expect(nodeExists).toBe(false)
})
test('Nested promoted widget entries reflect interior changes after slot removal', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
expect(initialNames.length).toBeGreaterThan(0)
const outerSubgraph = await comfyPage.nodeOps.getNodeRefById('5')
await outerSubgraph.navigateIntoSubgraph()
const removedSlotName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.name ?? null
})
expect(removedSlotName).not.toBeNull()
await comfyPage.subgraph.rightClickInputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
const finalNames = await getPromotedWidgetNames(comfyPage, '5')
const expectedNames = [...initialNames]
const removedIndex = expectedNames.indexOf(removedSlotName!)
expect(removedIndex).toBeGreaterThanOrEqual(0)
expectedNames.splice(removedIndex, 1)
expect(finalNames).toEqual(expectedNames)
})
test('Removing I/O slot removes associated promoted widget', async ({
comfyPage
}) => {
@@ -743,7 +785,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back via breadcrumb
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// Widget count should be reduced
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getSourceNodeId } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -78,19 +79,22 @@ function isWidgetShownOnParents(
): boolean {
return parents.some((parent) => {
if (isPromotedWidgetView(widget)) {
return promotionStore.isPromoted(
parent.rootGraph.id,
parent.id,
widget.sourceNodeId,
widget.sourceWidgetName
)
const sourceNodeId = getSourceNodeId(widget)
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? widget.sourceNodeId
: String(widgetNode.id)
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: sourceNodeId
})
}
return promotionStore.isPromoted(
parent.rootGraph.id,
parent.id,
String(widgetNode.id),
widget.name
)
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: String(widgetNode.id),
sourceWidgetName: widget.name
})
})
}

View File

@@ -14,6 +14,10 @@ import {
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
getSourceNodeId,
getWidgetName
} from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
@@ -84,15 +88,27 @@ const widgetsList = computed((): NodeWidgetsList => {
const { widgets = [] } = node
const result: NodeWidgetsList = []
for (const { interiorNodeId, widgetName } of entries) {
for (const {
sourceNodeId: entryNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
} of entries) {
const widget = widgets.find((w) => {
if (isPromotedWidgetView(w)) {
if (
String(w.sourceNodeId) !== entryNodeId ||
w.sourceWidgetName !== sourceWidgetName
)
return false
if (!disambiguatingSourceNodeId) return true
return (
String(w.sourceNodeId) === interiorNodeId &&
w.sourceWidgetName === widgetName
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
}
return w.name === widgetName
return w.name === sourceWidgetName
})
if (widget) {
result.push({ node, widget })
@@ -113,12 +129,11 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
return allInteriorWidgets.filter(
({ node: interiorNode, widget }) =>
!promotionStore.isPromoted(
node.rootGraph.id,
node.id,
String(interiorNode.id),
widget.name
)
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: getSourceNodeId(widget)
})
)
})

View File

@@ -7,7 +7,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
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'
import { usePromotionStore } from '@/stores/promotionStore'
import WidgetActions from './WidgetActions.vue'
@@ -93,8 +95,11 @@ describe('WidgetActions', () => {
function createMockNode(): LGraphNode {
return {
id: 1,
type: 'TestNode'
} as LGraphNode
type: 'TestNode',
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [200, 100]
} as unknown as LGraphNode
}
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
@@ -206,4 +211,66 @@ describe('WidgetActions', () => {
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
})
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
mockGetInputSpecForWidget.mockReturnValue({
type: 'CUSTOM'
})
const parentSubgraphNode = {
id: 4,
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [300, 150]
} as unknown as SubgraphNode
const node = {
id: 4,
type: 'SubgraphNode',
rootGraph: { id: 'graph-test' }
} as unknown as LGraphNode
const widget = {
name: 'text',
type: 'text',
value: 'value',
label: 'Text',
options: {},
y: 0,
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
} as IBaseWidget
const promotionStore = usePromotionStore()
promotionStore.promote('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const wrapper = mount(WidgetActions, {
props: {
widget,
node,
label: 'Text',
parents: [parentSubgraphNode],
isShownOnParents: true
},
global: {
plugins: [i18n]
}
})
const hideButton = wrapper
.findAll('button')
.find((button) => button.text().includes('Hide input'))
expect(hideButton).toBeDefined()
await hideButton?.trigger('click')
expect(
promotionStore.isPromoted('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
})
})

View File

@@ -5,10 +5,11 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import {
demoteWidget,
getSourceNodeId,
promoteWidget
} from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -16,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -41,6 +43,7 @@ const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const promotionStore = usePromotionStore()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
@@ -73,26 +76,29 @@ function handleHideInput() {
if (!parents?.length) return
if (isPromotedWidgetView(widget)) {
const sourceWidget = resolvePromotedWidgetSource(node, widget)
if (!sourceWidget) {
console.error('Could not resolve source widget for promoted widget')
return
}
const disambiguatingSourceNodeId = getSourceNodeId(widget)
demoteWidget(sourceWidget.node, sourceWidget.widget, parents)
for (const parent of parents) {
const source: PromotedWidgetSource = {
sourceNodeId:
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id),
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId
}
promotionStore.demote(parent.rootGraph.id, parent.id, source)
parent.computeSize(parent.size)
}
canvasStore.canvas?.setDirty(true, true)
} else {
// For regular widgets (not yet promoted), use them directly
demoteWidget(node, widget, parents)
}
canvasStore.canvas?.setDirty(true, true)
}
function handleShowInput() {
if (!parents?.length) return
promoteWidget(node, widget, parents)
canvasStore.canvas?.setDirty(true, true)
}
function handleToggleFavorite() {

View File

@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isRecommendedWidget,
promoteWidget,
@@ -49,19 +51,29 @@ const activeWidgets = computed<WidgetItem[]>({
if (!node) return []
return promotionEntries.value.flatMap(
({ interiorNodeId, widgetName }): WidgetItem[] => {
if (interiorNodeId === '-1') {
const widget = node.widgets.find((w) => w.name === widgetName)
({
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}): WidgetItem[] => {
if (sourceNodeId === '-1') {
const widget = node.widgets.find((w) => w.name === sourceWidgetName)
if (!widget) return []
return [
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
]
}
const wNode = node.subgraph._nodes_by_id[interiorNodeId]
const wNode = node.subgraph._nodes_by_id[sourceNodeId]
if (!wNode) return []
const widget = getPromotableWidgets(wNode).find(
(w) => w.name === widgetName
)
const widget = getPromotableWidgets(wNode).find((w) => {
if (w.name !== sourceWidgetName) return false
if (disambiguatingSourceNodeId && isPromotedWidgetView(w))
return (
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
return true
})
if (!widget) return []
return [[wNode, widget]]
}
@@ -76,11 +88,16 @@ const activeWidgets = computed<WidgetItem[]>({
promotionStore.setPromotions(
node.rootGraph.id,
node.id,
value.map(([n, w]) => ({
interiorNodeId: String(n.id),
widgetName: getWidgetName(w)
}))
value.map(([n, w]) => {
const sid = getSourceNodeId(w)
return {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
...(sid && { disambiguatingSourceNodeId: sid })
}
})
)
refreshPromotedWidgetRendering()
}
})
@@ -103,12 +120,11 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
if (!node) return []
return interiorWidgets.value.filter(
([n, w]: WidgetItem) =>
!promotionStore.isPromoted(
node.rootGraph.id,
node.id,
String(n.id),
w.name
)
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: getSourceNodeId(w)
})
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {
@@ -137,8 +153,20 @@ const filteredActive = computed<WidgetItem[]>(() => {
)
})
function refreshPromotedWidgetRendering() {
const node = activeNode.value
if (!node) return
node.computeSize(node.size)
node.setDirtyCanvas(true, true)
canvasStore.canvas?.setDirty(true, true)
}
function toKey(item: WidgetItem) {
return `${item[0].id}: ${item[1].name}`
const sid = getSourceNodeId(item[1])
return sid
? `${item[0].id}: ${item[1].name}:${sid}`
: `${item[0].id}: ${item[1].name}`
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w) => [n, w])
@@ -147,49 +175,26 @@ function demote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
demoteWidget(node, widget, [subgraphNode])
promotionStore.demote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
getWidgetName(widget)
)
}
function promote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(node, widget, [subgraphNode])
promotionStore.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
widget.name
)
}
function showAll() {
const node = activeNode.value
if (!node) return
for (const [n, w] of filteredCandidates.value) {
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
for (const item of filteredCandidates.value) {
promote(item)
}
}
function hideAll() {
const node = activeNode.value
if (!node) return
for (const [n, w] of filteredActive.value) {
if (String(n.id) === '-1') continue
promotionStore.demote(
node.rootGraph.id,
node.id,
String(n.id),
getWidgetName(w)
)
for (const item of filteredActive.value) {
if (String(item[0].id) === '-1') continue
demote(item)
}
}
function showRecommended() {
const node = activeNode.value
if (!node) return
for (const [n, w] of recommendedWidgets.value) {
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
for (const item of recommendedWidgets.value) {
promote(item)
}
}

View File

@@ -413,12 +413,10 @@ describe('Subgraph Promoted Pseudo Widgets', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
'10',
'$$canvas-image-preview'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview'
})
const { vueNodeData } = useGraphNodeManager(graph)
const vueNode = vueNodeData.get(String(subgraphNode.id))
@@ -500,12 +498,10 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(independentNode.id),
'string_a'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'string_a'
})
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
@@ -523,6 +519,70 @@ describe('Nested promoted widget mapping', () => {
])
)
})
it('maps duplicate-name promoted views from same intermediate node to distinct store identities', () => {
const innerSubgraph = createTestSubgraph()
const firstTextNode = new LGraphNode('FirstTextNode')
firstTextNode.addWidget('text', 'text', '11111111111', () => undefined)
innerSubgraph.add(firstTextNode)
const secondTextNode = new LGraphNode('SecondTextNode')
secondTextNode.addWidget('text', 'text', '22222222222', () => undefined)
innerSubgraph.add(secondTextNode)
const outerSubgraph = createTestSubgraph()
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 3,
parentGraph: outerSubgraph
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 4 })
const graph = outerSubgraphNode.graph as LGraph
graph.add(outerSubgraphNode)
usePromotionStore().setPromotions(
innerSubgraphNode.rootGraph.id,
innerSubgraphNode.id,
[
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
]
)
usePromotionStore().setPromotions(
outerSubgraphNode.rootGraph.id,
outerSubgraphNode.id,
[
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(firstTextNode.id)
},
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(secondTextNode.id)
}
]
)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(outerSubgraphNode.id))
const promotedWidgets = nodeData?.widgets?.filter(
(widget) => widget.name === 'text'
)
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
).toEqual(
new Set([
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
])
)
})
})
describe('Promoted widget sourceExecutionId', () => {

View File

@@ -6,6 +6,7 @@ import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
@@ -224,19 +225,21 @@ function safeWidgetMapper(
function resolvePromotedSourceByInputName(inputName: string): {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
} | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName
sourceWidgetName: resolvedTarget.widgetName,
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
}
}
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
displayName: string
promotedSource: { sourceNodeId: string; sourceWidgetName: string } | null
promotedSource: PromotedWidgetSource | null
} {
if (!isPromotedWidgetView(widget)) {
return {
@@ -250,7 +253,8 @@ function safeWidgetMapper(
const displayName = promotedInputName ?? widget.name
const directSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
}
const promotedSource =
matchedInput?._widget === widget
@@ -297,7 +301,8 @@ function safeWidgetMapper(
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
promotedSource.sourceWidgetName,
promotedSource.disambiguatingSourceNodeId
)
: null
const resolvedSource =
@@ -310,7 +315,11 @@ function safeWidgetMapper(
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
? String(
sourceNode?.id ??
promotedSource?.disambiguatingSourceNodeId ??
promotedSource?.sourceNodeId
)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => ({
publishSubgraph: vi.fn(),
@@ -46,6 +46,10 @@ function createSubgraphNode(): SubgraphNode {
return node
}
function createRegularNode(): LGraphNode {
return new LGraphNode('testnode')
}
describe('useSubgraphOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -87,4 +91,16 @@ describe('useSubgraphOperations', () => {
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
it('addSubgraphToLibrary does not call publishSubgraph when selected item is not a SubgraphNode', async () => {
mocks.selectedItems = [createRegularNode()]
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { addSubgraphToLibrary } = useSubgraphOperations()
await addSubgraphToLibrary()
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
})

View File

@@ -112,8 +112,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'seed'
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -126,8 +125,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const mockUrls = ['/view?filename=output.png']
@@ -137,8 +135,8 @@ describe(usePromotedPreviews, () => {
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: mockUrls
}
@@ -151,8 +149,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10])
@@ -168,8 +165,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10])
@@ -192,14 +188,12 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'20',
'$$canvas-image-preview'
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10, 20])
@@ -221,8 +215,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const blobUrl = 'blob:http://localhost/glsl-preview'
@@ -232,8 +225,8 @@ describe(usePromotedPreviews, () => {
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
@@ -246,8 +239,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -259,8 +251,8 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
@@ -273,8 +265,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -286,8 +277,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'99',
'$$canvas-image-preview'
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -300,14 +290,12 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'seed'
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const mockUrls = ['/view?filename=img.png']

View File

@@ -8,8 +8,8 @@ import { usePromotionStore } from '@/stores/promotionStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
interface PromotedPreview {
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
type: 'image' | 'video' | 'audio'
urls: string[]
}
@@ -30,13 +30,15 @@ export function usePromotedPreviews(
if (!(node instanceof SubgraphNode)) return []
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
const pseudoEntries = entries.filter((e) => e.widgetName.startsWith('$$'))
const pseudoEntries = entries.filter((e) =>
e.sourceWidgetName.startsWith('$$')
)
if (!pseudoEntries.length) return []
const previews: PromotedPreview[] = []
for (const entry of pseudoEntries) {
const interiorNode = node.subgraph.getNodeById(entry.interiorNodeId)
const interiorNode = node.subgraph.getNodeById(entry.sourceNodeId)
if (!interiorNode) continue
// Read from both reactive refs to establish Vue dependency
@@ -45,7 +47,7 @@ export function usePromotedPreviews(
// access the computed would never re-evaluate.
const locatorId = createNodeLocatorId(
node.subgraph.id,
entry.interiorNodeId
entry.sourceNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
@@ -63,8 +65,8 @@ export function usePromotedPreviews(
: 'image'
previews.push({
interiorNodeId: entry.interiorNodeId,
widgetName: entry.widgetName,
sourceNodeId: entry.sourceNodeId,
sourceWidgetName: entry.sourceWidgetName,
type,
urls
})

View File

@@ -2,15 +2,28 @@ 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 = {
export interface ResolvedPromotedWidget {
node: LGraphNode
widget: IBaseWidget
}
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly sourceNodeId: string
readonly sourceWidgetName: string
/**
* The original leaf-level source node ID, used to distinguish promoted
* widgets with the same name on the same intermediate node. Unlike
* `sourceNodeId` (the direct interior node), this traces to the deepest
* origin.
*/
readonly disambiguatingSourceNodeId?: string
}
export function isPromotedWidgetView(

View File

@@ -27,6 +27,7 @@ import {
} from '@/stores/widgetValueStore'
import {
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState,
@@ -78,9 +79,9 @@ function setPromotions(
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
entries.map(([interiorNodeId, widgetName]) => ({
interiorNodeId,
widgetName
entries.map(([sourceNodeId, sourceWidgetName]) => ({
sourceNodeId,
sourceWidgetName
}))
)
}
@@ -114,6 +115,21 @@ describe(createPromotedWidgetView, () => {
const view = createPromotedWidgetView(subgraphNode, '42', 'myWidget')
expect(view.sourceNodeId).toBe('42')
expect(view.sourceWidgetName).toBe('myWidget')
expect(view.disambiguatingSourceNodeId).toBeUndefined()
})
test('exposes disambiguatingSourceNodeId when provided', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
'42',
'myWidget',
undefined,
'99'
)
expect(view.sourceNodeId).toBe('42')
expect(view.sourceWidgetName).toBe('myWidget')
expect(view.disambiguatingSourceNodeId).toBe('99')
})
test('name defaults to widgetName when no displayName given', () => {
@@ -454,8 +470,8 @@ describe('SubgraphNode.widgets getter', () => {
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([
{
interiorNodeId: String(innerNode.id),
widgetName: 'picker'
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'picker'
}
])
})
@@ -497,8 +513,8 @@ describe('SubgraphNode.widgets getter', () => {
expect(promotions).toHaveLength(1)
expect(promotions[0]).toStrictEqual({
interiorNodeId: String(secondNode.id),
widgetName: 'picker'
sourceNodeId: String(secondNode.id),
sourceWidgetName: 'picker'
})
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].value).toBe('b')
@@ -591,18 +607,14 @@ describe('SubgraphNode.widgets getter', () => {
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(promotedNode.id),
'string_a'
)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(linkedNodeA.id),
'string_a'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(promotedNode.id),
sourceWidgetName: 'string_a'
})
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(linkedNodeA.id),
sourceWidgetName: 'string_a'
})
const widgets = promotedWidgets(subgraphNode)
expect(widgets).toHaveLength(2)
@@ -651,12 +663,10 @@ describe('SubgraphNode.widgets getter', () => {
subgraph.add(independentNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(independentNode.id),
'string_a'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'string_a'
})
const widgets = promotedWidgets(subgraphNode)
const linkedView = widgets.find(
@@ -733,8 +743,8 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
{ interiorNodeId: '9999', widgetName: 'widgetB' }
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
{ sourceNodeId: '9999', sourceWidgetName: 'widgetB' }
])
})
@@ -764,8 +774,8 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
{ interiorNodeId: '9999', widgetName: 'widgetA' }
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
{ sourceNodeId: '9999', sourceWidgetName: 'widgetA' }
])
})
@@ -823,8 +833,8 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(linkedNodeA.id), widgetName: 'string_a' },
{ interiorNodeId: String(independentNode.id), widgetName: 'string_a' }
{ sourceNodeId: String(linkedNodeA.id), sourceWidgetName: 'string_a' },
{ sourceNodeId: String(independentNode.id), sourceWidgetName: 'string_a' }
])
})
@@ -868,8 +878,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(promotions).toStrictEqual([
{
interiorNodeId: linkedEntry[0],
widgetName: linkedEntry[1]
sourceNodeId: linkedEntry[0],
sourceWidgetName: linkedEntry[1]
}
])
})
@@ -925,8 +935,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(restoredPromotions).toStrictEqual([
{
interiorNodeId: String(activeAliasNode.id),
widgetName: 'string_a'
sourceNodeId: String(activeAliasNode.id),
sourceWidgetName: 'string_a'
}
])
@@ -1113,8 +1123,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(entries).toStrictEqual([
{
interiorNodeId: String(innerNodes[0].id),
widgetName: 'stringWidget'
sourceNodeId: String(innerNodes[0].id),
sourceWidgetName: 'stringWidget'
}
])
})
@@ -1142,8 +1152,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(restoredEntries).toStrictEqual([
{
interiorNodeId: String(innerNode.id),
widgetName: 'widgetA'
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'widgetA'
}
])
})
@@ -1280,8 +1290,8 @@ describe('SubgraphNode.widgets getter', () => {
const promotions = usePromotionStore().getPromotions(graph.id, hostNode.id)
expect(promotions).toStrictEqual([
{ interiorNodeId: '20', widgetName: 'string_a' },
{ interiorNodeId: '19', widgetName: 'string_a' }
{ sourceNodeId: '20', sourceWidgetName: 'string_a' },
{ sourceNodeId: '19', sourceWidgetName: 'string_a' }
])
const linkedView = hostWidgets[0]
@@ -1416,8 +1426,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(hydratedEntries).toStrictEqual([
{
interiorNodeId: String(innerNode.id),
widgetName: 'widgetA'
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'widgetA'
}
])
})
@@ -1435,12 +1445,12 @@ describe('widgets getter caching', () => {
const reconcileSpy = vi.spyOn(
subgraphNode as unknown as {
_buildPromotionReconcileState: (
entries: Array<{ interiorNodeId: string; widgetName: string }>,
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
}>
) => unknown
},
@@ -1465,12 +1475,12 @@ describe('widgets getter caching', () => {
const reconcileSpy = vi.spyOn(
subgraphNode as unknown as {
_buildPromotionReconcileState: (
entries: Array<{ interiorNodeId: string; widgetName: string }>,
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
}>
) => unknown
},
@@ -1638,7 +1648,7 @@ describe('promote/demote cycle', () => {
subgraphNode.id
)
expect(entries).toStrictEqual([
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
})
@@ -1709,6 +1719,197 @@ describe('disconnected state', () => {
})
})
function createThreeLevelNestedSubgraph() {
// Level C (innermost): concrete widget
const subgraphC = createTestSubgraph({
inputs: [{ name: 'c_input', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('c_input', '*')
const concreteWidget = concreteNode.addWidget(
'number',
'c_input',
100,
() => {}
)
concreteInput.widget = { name: 'c_input' }
subgraphC.add(concreteNode)
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 501 })
// Level B (middle): wraps C
const subgraphB = createTestSubgraph({
inputs: [{ name: 'b_input', type: '*' }]
})
subgraphB.add(subgraphNodeC)
subgraphNodeC._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 502 })
// Level A (outermost): wraps B
const subgraphA = createTestSubgraph({
inputs: [{ name: 'a_input', type: '*' }]
})
subgraphA.add(subgraphNodeB)
subgraphNodeB._internalConfigureAfterSlots()
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 503 })
return { concreteNode, concreteWidget, subgraphNodeA }
}
describe('three-level nested value propagation', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('value set at outermost level propagates to concrete widget', () => {
const { concreteNode, subgraphNodeA } = createThreeLevelNestedSubgraph()
expect(subgraphNodeA.widgets).toHaveLength(1)
expect(subgraphNodeA.widgets[0].value).toBe(100)
subgraphNodeA.widgets[0].value = 200
expect(concreteNode.widgets![0].value).toBe(200)
})
test('type resolves correctly through all three layers', () => {
const { subgraphNodeA } = createThreeLevelNestedSubgraph()
expect(subgraphNodeA.widgets[0].type).toBe('number')
})
test('concrete value change is visible at the outermost level', () => {
const { concreteWidget, subgraphNodeA } = createThreeLevelNestedSubgraph()
concreteWidget.value = 999
expect(subgraphNodeA.widgets[0].value).toBe(999)
})
test('nested duplicate-name promotions resolve and update independently by disambiguating source node id', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({ rootGraph })
const firstTextNode = new LGraphNode('FirstTextNode')
firstTextNode.addWidget('text', 'text', '11111111111', () => {})
innerSubgraph.add(firstTextNode)
const secondTextNode = new LGraphNode('SecondTextNode')
secondTextNode.addWidget('text', 'text', '22222222222', () => {})
innerSubgraph.add(secondTextNode)
const outerSubgraph = createTestSubgraph({ rootGraph })
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 3,
parentGraph: outerSubgraph
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
id: 4,
parentGraph: rootGraph
})
rootGraph.add(outerSubgraphNode)
usePromotionStore().setPromotions(
innerSubgraphNode.rootGraph.id,
innerSubgraphNode.id,
[
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
]
)
usePromotionStore().setPromotions(
outerSubgraphNode.rootGraph.id,
outerSubgraphNode.id,
[
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(firstTextNode.id)
},
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(secondTextNode.id)
}
]
)
const widgets = promotedWidgets(outerSubgraphNode)
expect(widgets).toHaveLength(2)
expect(
widgets.map((widget) => widget.disambiguatingSourceNodeId)
).toStrictEqual([String(firstTextNode.id), String(secondTextNode.id)])
expect(widgets.map((widget) => widget.value)).toStrictEqual([
'11111111111',
'22222222222'
])
widgets[1].value = 'updated-second'
expect(firstTextNode.widgets?.[0]?.value).toBe('11111111111')
expect(secondTextNode.widgets?.[0]?.value).toBe('updated-second')
expect(widgets[0].value).toBe('11111111111')
expect(widgets[1].value).toBe('updated-second')
})
})
describe('multi-link representative determinism for input-based promotion', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('first link is consistently chosen as representative for reads and writes', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'shared', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 601 })
subgraphNode.graph?.add(subgraphNode)
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('shared', '*')
firstNode.addWidget('text', 'shared', 'first-val', () => {})
firstInput.widget = { name: 'shared' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('shared', '*')
secondNode.addWidget('text', 'shared', 'second-val', () => {})
secondInput.widget = { name: 'shared' }
subgraph.add(secondNode)
const thirdNode = new LGraphNode('ThirdNode')
const thirdInput = thirdNode.addInput('shared', '*')
thirdNode.addWidget('text', 'shared', 'third-val', () => {})
thirdInput.widget = { name: 'shared' }
subgraph.add(thirdNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
subgraph.inputNode.slots[0].connect(secondInput, secondNode)
subgraph.inputNode.slots[0].connect(thirdInput, thirdNode)
const widgets = promotedWidgets(subgraphNode)
expect(widgets).toHaveLength(1)
expect(widgets[0].sourceNodeId).toBe(String(firstNode.id))
// Read returns the first link's value
expect(widgets[0].value).toBe('first-val')
// Write propagates to all linked nodes
widgets[0].value = 'updated'
expect(firstNode.widgets![0].value).toBe('updated')
expect(secondNode.widgets![0].value).toBe('updated')
expect(thirdNode.widgets![0].value).toBe('updated')
// Repeated reads are still deterministic
expect(widgets[0].value).toBe('updated')
})
})
function createFakeCanvasContext() {
return new Proxy({} as CanvasRenderingContext2D, {
get: () => vi.fn(() => ({ width: 10 }))
@@ -2057,12 +2258,12 @@ describe('promoted combo rendering', () => {
)
expect(promotions).toContainEqual({
interiorNodeId: String(subgraphNodeA.id),
widgetName: 'lora_name'
sourceNodeId: String(subgraphNodeA.id),
sourceWidgetName: 'lora_name'
})
expect(promotions).not.toContainEqual({
interiorNodeId: String(innerNode.id),
widgetName: 'lora_name'
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'lora_name'
})
})

View File

@@ -49,9 +49,16 @@ export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
displayName?: string
displayName?: string,
disambiguatingSourceNodeId?: string
): IPromotedWidgetView {
return new PromotedWidgetView(subgraphNode, nodeId, widgetName, displayName)
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId
)
}
class PromotedWidgetView implements IPromotedWidgetView {
@@ -80,7 +87,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
@@ -287,7 +295,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
this.sourceWidgetName,
this.disambiguatingSourceNodeId
)
}
@@ -301,7 +310,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
this.sourceWidgetName,
this.disambiguatingSourceNodeId
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
@@ -341,7 +351,9 @@ class PromotedWidgetView implements IPromotedWidgetView {
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName
boundWidget.sourceWidgetName === this.sourceWidgetName &&
boundWidget.disambiguatingSourceNodeId ===
this.disambiguatingSourceNodeId
)
}

View File

@@ -18,6 +18,7 @@ vi.mock('@/services/litegraphService', () => ({
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
hasUnpromotedWidgets,
isPreviewPseudoWidget,
promoteRecommendedWidgets,
pruneDisconnected
@@ -118,9 +119,12 @@ describe('pruneDisconnected', () => {
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' },
{ interiorNodeId: String(interiorNode.id), widgetName: 'missing-widget' },
{ interiorNodeId: '9999', widgetName: 'missing-node' }
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' },
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
},
{ sourceNodeId: '9999', sourceWidgetName: 'missing-node' }
])
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@@ -129,7 +133,9 @@ describe('pruneDisconnected', () => {
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' }])
).toEqual([
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' }
])
expect(warnSpy).toHaveBeenCalledOnce()
})
@@ -143,8 +149,8 @@ describe('pruneDisconnected', () => {
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{
interiorNodeId: String(interiorNode.id),
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
@@ -154,8 +160,8 @@ describe('pruneDisconnected', () => {
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([
{
interiorNodeId: String(interiorNode.id),
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
})
@@ -255,12 +261,10 @@ describe('promoteRecommendedWidgets', () => {
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
@@ -280,12 +284,52 @@ describe('promoteRecommendedWidgets', () => {
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
})
})
describe('hasUnpromotedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
})
it('returns false when all enabled widgets are already promoted', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'seed'
})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('ignores computed-disabled widgets', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
widget.computedDisabled = true
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type {
@@ -26,6 +27,30 @@ export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
}
export function getSourceNodeId(w: IBaseWidget): string | undefined {
if (!isPromotedWidgetView(w)) return undefined
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
}
function toPromotionSource(
node: PartialNode,
widget: IBaseWidget
): PromotedWidgetSource {
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: getSourceNodeId(widget)
}
}
function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
for (const parent of parents) {
parent.computeSize(parent.size)
parent.setDirtyCanvas(true, true)
}
useCanvasStore().canvas?.setDirty(true, true)
}
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
@@ -51,16 +76,14 @@ export function promoteWidget(
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const nodeId = String(
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
store.promote(parent.rootGraph.id, parent.id, source)
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Promoted widget "${widgetName}" on node ${node.id}`,
message: `Promoted widget "${source.sourceWidgetName}" on node ${node.id}`,
level: 'info'
})
}
@@ -71,16 +94,14 @@ export function demoteWidget(
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const nodeId = String(
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)
store.demote(parent.rootGraph.id, parent.id, source)
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Demoted widget "${widgetName}" on node ${node.id}`,
message: `Demoted widget "${source.sourceWidgetName}" on node ${node.id}`,
level: 'info'
})
}
@@ -110,10 +131,9 @@ export function addWidgetPromotionOptions(
) {
const store = usePromotionStore()
const parents = getParentNodes()
const nodeId = String(node.id)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
)
if (promotableParents.length > 0)
options.unshift({
@@ -147,10 +167,9 @@ export function tryToggleWidgetPromotion() {
const parents = getParentNodes()
if (!parents.length || !widget) return
const store = usePromotionStore()
const nodeId = String(node.id)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
@@ -219,12 +238,10 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const widget = node.widgets?.find(isPreviewPseudoWidget)
if (!widget) return
if (
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
widget.name
)
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(node.id),
sourceWidgetName: widget.name
})
)
return
promoteWidget(node, widget, [subgraphNode])
@@ -242,20 +259,18 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
const canvasSource: PromotedWidgetSource = {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
canvasSource
)
) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, canvasSource)
}
continue
}
@@ -271,8 +286,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(n.id),
getWidgetName(w)
toPromotionSource(n, w)
)
}
subgraphNode.computeSize(subgraphNode.size)
@@ -285,17 +299,16 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
subgraphNode.rootGraph.id,
subgraphNode.id
)
const removedEntries: Array<{ interiorNodeId: string; widgetName: string }> =
[]
const removedEntries: PromotedWidgetSource[] = []
const validEntries = entries.filter((entry) => {
const node = subgraph.getNodeById(entry.interiorNodeId)
const node = subgraph.getNodeById(entry.sourceNodeId)
if (!node) {
removedEntries.push(entry)
return false
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === entry.widgetName
(iw) => iw.name === entry.sourceWidgetName
)
if (!hasWidget) {
removedEntries.push(entry)
@@ -315,9 +328,26 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
}
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
refreshPromotedWidgetRendering([subgraphNode])
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
level: 'info'
})
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
(interiorNode.widgets ?? []).some(
(widget) =>
!widget.computedDisabled &&
!promotionStore.isPromoted(rootGraph.id, subgraphNodeId, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: widget.name
})
)
)
}

View File

@@ -30,6 +30,7 @@ type PromotedWidgetStub = Pick<
> & {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
node?: SubgraphNode
}
@@ -51,7 +52,8 @@ function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode
node?: SubgraphNode,
disambiguatingSourceNodeId?: string
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
@@ -61,6 +63,7 @@ function createPromotedWidget(
value: undefined,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId,
node
}
return promotedWidget as IBaseWidget
@@ -94,6 +97,27 @@ describe('resolvePromotedWidgetAtHost', () => {
expect(resolved).toBeUndefined()
})
test('resolves duplicate-name promoted host widgets by disambiguating source node id', () => {
const host = createHostNode(100)
const sourceNode = addNodeToHost(host, 'source')
sourceNode.widgets = [
createPromotedWidget('text', String(sourceNode.id), 'text', host, '1'),
createPromotedWidget('text', String(sourceNode.id), 'text', host, '2')
]
const resolved = resolvePromotedWidgetAtHost(
host,
String(sourceNode.id),
'text',
'2'
)
expect(resolved).toBeDefined()
expect(
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
).toBe('2')
})
})
describe('resolveConcretePromotedWidget', () => {

View File

@@ -20,7 +20,8 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
function traversePromotedWidgetChain(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
widgetName: string,
sourceNodeId?: string
): PromotedWidgetResolutionResult {
const visited = new Set<string>()
const hostUidByObject = new WeakMap<SubgraphNode, number>()
@@ -28,6 +29,7 @@ function traversePromotedWidgetChain(
let currentHost = hostNode
let currentNodeId = nodeId
let currentWidgetName = widgetName
let currentSourceNodeId = sourceNodeId
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
let hostUid = hostUidByObject.get(currentHost)
@@ -37,7 +39,7 @@ function traversePromotedWidgetChain(
hostUidByObject.set(currentHost, hostUid)
}
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}`
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}:${currentSourceNodeId ?? ''}`
if (visited.has(key)) {
return { status: 'failure', failure: 'cycle' }
}
@@ -48,8 +50,10 @@ function traversePromotedWidgetChain(
return { status: 'failure', failure: 'missing-node' }
}
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === currentWidgetName
const sourceWidget = findWidgetByIdentity(
sourceNode.widgets,
currentWidgetName,
currentSourceNodeId
)
if (!sourceWidget) {
return { status: 'failure', failure: 'missing-widget' }
@@ -69,22 +73,42 @@ function traversePromotedWidgetChain(
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
currentSourceNodeId = undefined
}
return { status: 'failure', failure: 'max-depth-exceeded' }
}
function findWidgetByIdentity(
widgets: IBaseWidget[] | undefined,
widgetName: string,
sourceNodeId?: string
): IBaseWidget | undefined {
if (!widgets) return undefined
if (sourceNodeId) {
return widgets.find(
(entry) =>
isPromotedWidgetView(entry) &&
(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) ===
sourceNodeId &&
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
)
}
return widgets.find((entry) => entry.name === widgetName)
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
widgetName: string,
sourceNodeId?: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = node.widgets?.find(
(entry: IBaseWidget) => entry.name === widgetName
)
const widget = findWidgetByIdentity(node.widgets, widgetName, sourceNodeId)
if (!widget) return undefined
return { node, widget }
@@ -93,10 +117,11 @@ export function resolvePromotedWidgetAtHost(
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
widgetName: string
widgetName: string,
sourceNodeId?: string
): PromotedWidgetResolutionResult {
if (!hostNode.isSubgraphNode()) {
return { status: 'failure', failure: 'invalid-host' }
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
return traversePromotedWidgetChain(hostNode, nodeId, widgetName, sourceNodeId)
}

View File

@@ -14,7 +14,8 @@ export function resolvePromotedWidgetSource(
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
widget.sourceWidgetName,
widget.disambiguatingSourceNodeId
)
if (result.status === 'resolved') return result.resolved

View File

@@ -161,4 +161,33 @@ describe('resolveSubgraphInputLink', () => {
expect(result).toBe('ok')
expect(getWidgetFromSlot).toHaveBeenCalledTimes(1)
})
test('returns first link result with 3+ links connected', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'third_input', 'thirdWidget')
const result = resolveSubgraphInputLink(
subgraphNode,
'prompt',
({ targetInput }) => targetInput.name
)
expect(result).toBe('first_input')
})
test('returns undefined when all links fail to resolve', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
const result = resolveSubgraphInputLink(
subgraphNode,
'prompt',
() => undefined
)
expect(result).toBeUndefined()
})
})

View File

@@ -158,4 +158,93 @@ describe('resolveSubgraphInputTarget', () => {
expect(result).toBeUndefined()
})
test('resolves widget and non-widget inputs on the same nested SubgraphNode independently', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'width',
'audio'
])
const innerSubgraph = createTestSubgraph({
inputs: [
{ name: 'width', type: '*' },
{ name: 'audio', type: '*' }
]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 820
})
outerSubgraph.add(innerSubgraphNode)
const widthInput = innerSubgraphNode.addInput('width', '*')
innerSubgraphNode.addWidget('number', 'width', 0, () => undefined)
widthInput.widget = { name: 'width' }
const audioInput = innerSubgraphNode.addInput('audio', '*')
const widthSlot = outerSubgraph.inputNode.slots.find(
(slot) => slot.name === 'width'
)!
const audioSlot = outerSubgraph.inputNode.slots.find(
(slot) => slot.name === 'audio'
)!
widthSlot.connect(widthInput, innerSubgraphNode)
audioSlot.connect(audioInput, innerSubgraphNode)
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
).toMatchObject({
nodeId: '820',
widgetName: 'width'
})
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
).toBeUndefined()
})
test('three-level nesting returns immediate child target, not deepest', () => {
// outer → middle → inner (concrete)
const innerSubgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
concreteNode.id = 900
const concreteInput = concreteNode.addInput('seed_input', '*')
concreteNode.addWidget('number', 'seed', 0, () => undefined)
concreteInput.widget = { name: 'seed' }
innerSubgraph.add(concreteNode)
innerSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
const middleSubgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 901
})
middleSubgraph.add(innerSubgraphNode)
const middleInput = innerSubgraphNode.addInput('seed', '*')
innerSubgraphNode.addWidget('number', 'seed', 0, () => undefined)
middleInput.widget = { name: 'seed' }
middleSubgraph.inputNode.slots[0].connect(middleInput, innerSubgraphNode)
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'seed'
])
const middleSubgraphNode = createTestSubgraphNode(middleSubgraph, {
id: 902
})
outerSubgraph.add(middleSubgraphNode)
const outerInput = middleSubgraphNode.addInput('seed', '*')
middleSubgraphNode.addWidget('number', 'seed', 0, () => undefined)
outerInput.widget = { name: 'seed' }
outerSubgraph.inputNode.slots[0].connect(outerInput, middleSubgraphNode)
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed')
// Should return the immediate child (middle), not the deepest (concrete)
expect(result).toMatchObject({
nodeId: '902',
widgetName: 'seed'
})
})
})

View File

@@ -1,10 +1,12 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
type ResolvedSubgraphInputTarget = {
nodeId: string
widgetName: string
sourceNodeId?: string
}
export function resolveSubgraphInputTarget(
@@ -19,6 +21,16 @@ export function resolveSubgraphInputTarget(
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
if (isPromotedWidgetView(targetWidget)) {
return {
nodeId: String(inputNode.id),
widgetName: targetWidget.sourceWidgetName,
sourceNodeId:
targetWidget.disambiguatingSourceNodeId ??
targetWidget.sourceNodeId
}
}
return {
nodeId: String(inputNode.id),
widgetName: targetInput.name

View File

@@ -52,7 +52,7 @@ describe('Subgraph proxyWidgets', () => {
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(
@@ -61,7 +61,7 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.id
)
).toStrictEqual([
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
})
test('Can add multiple widgets with same name', () => {
@@ -72,8 +72,8 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' },
{ interiorNodeId: innerIds[1], widgetName: 'stringWidget' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' },
{ sourceNodeId: innerIds[1], sourceWidgetName: 'stringWidget' }
]
)
expect(subgraphNode.widgets.length).toBe(2)
@@ -88,8 +88,8 @@ describe('Subgraph proxyWidgets', () => {
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).toBe('widgetA')
@@ -97,8 +97,8 @@ describe('Subgraph proxyWidgets', () => {
// Reorder
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' }
])
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(subgraphNode.widgets[1].name).toBe('widgetA')
@@ -109,7 +109,7 @@ describe('Subgraph proxyWidgets', () => {
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
@@ -124,7 +124,7 @@ describe('Subgraph proxyWidgets', () => {
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
innerNodes[0].widgets[0].y = 10
@@ -143,7 +143,7 @@ describe('Subgraph proxyWidgets', () => {
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
@@ -164,24 +164,20 @@ describe('Subgraph proxyWidgets', () => {
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
// Promote once
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
innerIds[0],
'stringWidget'
)
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: innerIds[0],
sourceWidgetName: 'stringWidget'
})
expect(subgraphNode.widgets.length).toBe(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(1)
// Try to promote again - should not create duplicate
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
innerIds[0],
'stringWidget'
)
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: innerIds[0],
sourceWidgetName: 'stringWidget'
})
expect(subgraphNode.widgets.length).toBe(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
@@ -189,7 +185,7 @@ describe('Subgraph proxyWidgets', () => {
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
})
@@ -199,8 +195,8 @@ describe('Subgraph proxyWidgets', () => {
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
expect(subgraphNode.widgets).toHaveLength(2)
@@ -211,10 +207,12 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }])
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
})
test('removeWidgetByName removes from promotion list', () => {
test('removeWidget removes from promotion list', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
@@ -222,12 +220,13 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
subgraphNode.removeWidgetByName('widgetA')
const widgetA = subgraphNode.widgets.find((w) => w.name === 'widgetA')!
subgraphNode.removeWidget(widgetA)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
@@ -239,7 +238,7 @@ describe('Subgraph proxyWidgets', () => {
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
const view = subgraphNode.widgets[0]
@@ -260,7 +259,7 @@ describe('Subgraph proxyWidgets', () => {
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets).toHaveLength(1)
@@ -279,8 +278,8 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
@@ -291,4 +290,119 @@ describe('Subgraph proxyWidgets', () => {
[innerIds[0], 'widgetB']
])
})
test('multi-link representative is deterministic across repeated reads', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'shared_input', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
subgraphNode.graph!.add(subgraphNode)
const nodeA = new LGraphNode('NodeA')
const inputA = nodeA.addInput('shared_input', '*')
nodeA.addWidget('text', 'shared_input', 'first', () => {})
inputA.widget = { name: 'shared_input' }
subgraph.add(nodeA)
const nodeB = new LGraphNode('NodeB')
const inputB = nodeB.addInput('shared_input', '*')
nodeB.addWidget('text', 'shared_input', 'second', () => {})
inputB.widget = { name: 'shared_input' }
subgraph.add(nodeB)
const nodeC = new LGraphNode('NodeC')
const inputC = nodeC.addInput('shared_input', '*')
nodeC.addWidget('text', 'shared_input', 'third', () => {})
inputC.widget = { name: 'shared_input' }
subgraph.add(nodeC)
subgraph.inputNode.slots[0].connect(inputA, nodeA)
subgraph.inputNode.slots[0].connect(inputB, nodeB)
subgraph.inputNode.slots[0].connect(inputC, nodeC)
const firstRead = subgraphNode.widgets.map((w) => w.value)
const secondRead = subgraphNode.widgets.map((w) => w.value)
const thirdRead = subgraphNode.widgets.map((w) => w.value)
expect(firstRead).toStrictEqual(secondRead)
expect(secondRead).toStrictEqual(thirdRead)
expect(subgraphNode.widgets[0].value).toBe('first')
})
test('3-level nested promotion resolves concrete widget type and value', () => {
usePromotionStore()
// Level C: innermost subgraph with a concrete widget
const subgraphC = createTestSubgraph({
inputs: [{ name: 'deep_input', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('deep_input', '*')
concreteNode.addWidget('number', 'deep_input', 42, () => {})
concreteInput.widget = { name: 'deep_input' }
subgraphC.add(concreteNode)
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 301 })
// Level B: middle subgraph containing C
const subgraphB = createTestSubgraph({
inputs: [{ name: 'mid_input', type: '*' }]
})
subgraphB.add(subgraphNodeC)
subgraphNodeC._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 302 })
// Level A: outermost subgraph containing B
const subgraphA = createTestSubgraph({
inputs: [{ name: 'outer_input', type: '*' }]
})
subgraphA.add(subgraphNodeB)
subgraphNodeB._internalConfigureAfterSlots()
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 303 })
// Outermost promoted widget should resolve through all 3 levels
expect(subgraphNodeA.widgets).toHaveLength(1)
expect(subgraphNodeA.widgets[0].type).toBe('number')
expect(subgraphNodeA.widgets[0].value).toBe(42)
// Setting value at outermost level propagates to concrete widget
subgraphNodeA.widgets[0].value = 99
expect(concreteNode.widgets![0].value).toBe(99)
})
test('removeWidget cleans up promotion and input, then re-promote works', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
const view = subgraphNode.widgets[0]
subgraphNode.addInput('stringWidget', '*')
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
input._widget = view
// Remove: should clean up store AND input reference
subgraphNode.removeWidget(view)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(0)
expect(input._widget).toBeUndefined()
expect(subgraphNode.widgets).toHaveLength(0)
// Re-promote: should work correctly after cleanup
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].type).toBe('text')
expect(subgraphNode.widgets[0].value).toBe('value')
})
})

View File

@@ -1,56 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { usePromotionStore } from '@/stores/promotionStore'
import { hasUnpromotedWidgets } from './unpromotedWidgetUtils'
describe('hasUnpromotedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
})
it('returns false when all enabled widgets are already promoted', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(interiorNode.id),
'seed'
)
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('ignores computed-disabled widgets', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
widget.computedDisabled = true
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})

View File

@@ -1,20 +0,0 @@
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { usePromotionStore } from '@/stores/promotionStore'
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
(interiorNode.widgets ?? []).some(
(widget) =>
!widget.computedDisabled &&
!promotionStore.isPromoted(
rootGraph.id,
subgraphNodeId,
String(interiorNode.id),
widget.name
)
)
)
}

View File

@@ -2,66 +2,47 @@ import { describe, expect, it } from 'vitest'
import { parseProxyWidgets } from './promotionSchema'
describe('parseProxyWidgets', () => {
describe('valid inputs', () => {
it('returns empty array for undefined', () => {
expect(parseProxyWidgets(undefined)).toEqual([])
})
it('returns empty array for empty array', () => {
expect(parseProxyWidgets([])).toEqual([])
})
it('parses a single entry', () => {
expect(parseProxyWidgets([['1', 'seed']])).toEqual([['1', 'seed']])
})
it('parses multiple entries', () => {
const input = [
['1', 'seed'],
['2', 'steps']
]
expect(parseProxyWidgets(input)).toEqual(input)
})
it('parses a JSON string', () => {
expect(parseProxyWidgets('[["1", "seed"]]')).toEqual([['1', 'seed']])
})
it('parses a double-encoded JSON string', () => {
expect(parseProxyWidgets('"[[\\"1\\", \\"seed\\"]]"')).toEqual([
['1', 'seed']
])
})
describe(parseProxyWidgets, () => {
it('parses 2-tuple arrays', () => {
const input = [
['10', 'seed'],
['11', 'steps']
]
expect(parseProxyWidgets(input)).toEqual([
['10', 'seed'],
['11', 'steps']
])
})
describe('invalid inputs (resilient)', () => {
it('returns empty array for malformed JSON string', () => {
expect(parseProxyWidgets('not valid json')).toEqual([])
})
it('parses 3-tuple arrays', () => {
const input = [
['3', 'text', '1'],
['3', 'text', '2']
]
expect(parseProxyWidgets(input)).toEqual([
['3', 'text', '1'],
['3', 'text', '2']
])
})
it('returns empty array for wrong tuple length', () => {
expect(parseProxyWidgets([['only-one']] as unknown as undefined)).toEqual(
[]
)
})
it('parses mixed 2-tuple and 3-tuple arrays', () => {
const input = [
['10', 'seed'],
['3', 'text', '1']
]
expect(parseProxyWidgets(input)).toEqual([
['10', 'seed'],
['3', 'text', '1']
])
})
it('returns empty array for wrong shape', () => {
expect(
parseProxyWidgets({ wrong: 'shape' } as unknown as undefined)
).toEqual([])
})
it('returns empty array for non-array input', () => {
expect(parseProxyWidgets(undefined)).toEqual([])
expect(parseProxyWidgets('not-json{')).toEqual([])
})
it('returns empty array for number', () => {
expect(parseProxyWidgets(42)).toEqual([])
})
it('returns empty array for null', () => {
expect(parseProxyWidgets(null as unknown as undefined)).toEqual([])
})
it('returns empty array for empty string', () => {
expect(parseProxyWidgets('')).toEqual([])
})
it('returns empty array for invalid tuples', () => {
expect(parseProxyWidgets([['only-one']])).toEqual([])
expect(parseProxyWidgets([['a', 'b', 'c', 'd']])).toEqual([])
})
})

View File

@@ -3,7 +3,11 @@ import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const proxyWidgetsPropertySchema = z.array(z.tuple([z.string(), z.string()]))
const proxyWidgetTupleSchema = z.union([
z.tuple([z.string(), z.string(), z.string()]),
z.tuple([z.string(), z.string()])
])
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets(

View File

@@ -244,7 +244,10 @@ describe('Graph Clearing and Callbacks', () => {
graph.id = graphId
const promotionStore = usePromotionStore()
promotionStore.promote(graphId, 1 as NodeId, '10', 'seed')
promotionStore.promote(graphId, 1 as NodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(graphId, {
@@ -258,14 +261,24 @@ describe('Graph Clearing and Callbacks', () => {
disabled: undefined
})
expect(promotionStore.isPromotedByAny(graphId, '10', 'seed')).toBe(true)
expect(
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
expect.objectContaining({ value: 1 })
)
graph.clear()
expect(promotionStore.isPromotedByAny(graphId, '10', 'seed')).toBe(false)
expect(
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
).toBeUndefined()

View File

@@ -2018,11 +2018,6 @@ export class LGraphNode
})
}
removeWidgetByName(name: string): void {
const widget = this.widgets?.find((x) => x.name === name)
if (widget) this.removeWidget(widget)
}
removeWidget(widget: IBaseWidget): void {
if (!this.widgets)
throw new Error('removeWidget called on node without widgets')

View File

@@ -3,13 +3,13 @@ import { describe, expect, test } from 'vitest'
import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager'
type TestPromotionEntry = {
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
viewKey?: string
}
function makeView(entry: TestPromotionEntry) {
const baseKey = `${entry.interiorNodeId}:${entry.widgetName}`
const baseKey = `${entry.sourceNodeId}:${entry.sourceWidgetName}`
return {
key: entry.viewKey ? `${baseKey}:${entry.viewKey}` : baseKey
@@ -19,7 +19,7 @@ function makeView(entry: TestPromotionEntry) {
describe('PromotedWidgetViewManager', () => {
test('returns memoized array when entries reference is unchanged', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const entries = [{ interiorNodeId: '1', widgetName: 'widgetA' }]
const entries = [{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }]
const first = manager.reconcile(entries, makeView)
const second = manager.reconcile(entries, makeView)
@@ -33,16 +33,16 @@ describe('PromotedWidgetViewManager', () => {
const firstPass = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }
],
makeView
)
const reordered = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
@@ -56,9 +56,9 @@ describe('PromotedWidgetViewManager', () => {
const first = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
@@ -68,14 +68,14 @@ describe('PromotedWidgetViewManager', () => {
])
manager.reconcile(
[{ interiorNodeId: '1', widgetName: 'widgetB' }],
[{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }],
makeView
)
const restored = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
@@ -89,8 +89,8 @@ describe('PromotedWidgetViewManager', () => {
const views = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
@@ -106,8 +106,8 @@ describe('PromotedWidgetViewManager', () => {
const firstPass = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
@@ -116,8 +116,8 @@ describe('PromotedWidgetViewManager', () => {
const secondPass = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)

View File

@@ -1,10 +1,8 @@
type PromotionEntry = {
interiorNodeId: string
widgetName: string
viewKey?: string
}
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
type CreateView<TView> = (entry: PromotionEntry) => TView
type ViewManagerEntry = PromotedWidgetSource & { viewKey?: string }
type CreateView<TView> = (entry: ViewManagerEntry) => TView
/**
* Reconciles promoted widget entries to stable view instances.
@@ -18,11 +16,11 @@ export class PromotedWidgetViewManager<TView> {
private cachedEntryKeys: string[] | null = null
reconcile(
entries: readonly PromotionEntry[],
entries: readonly ViewManagerEntry[],
createView: CreateView<TView>
): TView[] {
const entryKeys = entries.map((entry) =>
this.makeKey(entry.interiorNodeId, entry.widgetName, entry.viewKey)
this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey)
)
if (this.cachedViews && this.areEntryKeysEqual(entryKeys))
@@ -33,8 +31,8 @@ export class PromotedWidgetViewManager<TView> {
for (const entry of entries) {
const key = this.makeKey(
entry.interiorNodeId,
entry.widgetName,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey
)
if (seenKeys.has(key)) continue
@@ -61,12 +59,12 @@ export class PromotedWidgetViewManager<TView> {
}
getOrCreate(
interiorNodeId: string,
widgetName: string,
sourceNodeId: string,
sourceWidgetName: string,
createView: () => TView,
viewKey?: string
): TView {
const key = this.makeKey(interiorNodeId, widgetName, viewKey)
const key = this.makeKey(sourceNodeId, sourceWidgetName, viewKey)
const cached = this.viewCache.get(key)
if (cached) return cached
@@ -75,17 +73,17 @@ export class PromotedWidgetViewManager<TView> {
return view
}
remove(interiorNodeId: string, widgetName: string): void {
this.viewCache.delete(this.makeKey(interiorNodeId, widgetName))
remove(sourceNodeId: string, sourceWidgetName: string): void {
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName))
this.invalidateMemoizedList()
}
removeByViewKey(
interiorNodeId: string,
widgetName: string,
sourceNodeId: string,
sourceWidgetName: string,
viewKey: string
): void {
this.viewCache.delete(this.makeKey(interiorNodeId, widgetName, viewKey))
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName, viewKey))
this.invalidateMemoizedList()
}
@@ -110,11 +108,11 @@ export class PromotedWidgetViewManager<TView> {
}
private makeKey(
interiorNodeId: string,
widgetName: string,
sourceNodeId: string,
sourceWidgetName: string,
viewKey?: string
): string {
const baseKey = `${interiorNodeId}:${widgetName}`
const baseKey = `${sourceNodeId}:${sourceWidgetName}`
return viewKey ? `${baseKey}:${viewKey}` : baseKey
}
}

View File

@@ -221,20 +221,16 @@ describe('SubgraphConversion', () => {
const graphId = graph.id
const subgraphNodeId = subgraphNode.id
promotionStore.promote(
graphId,
subgraphNodeId,
String(innerNode.id),
'myWidget'
)
promotionStore.promote(graphId, subgraphNodeId, {
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'myWidget'
})
expect(
promotionStore.isPromoted(
graphId,
subgraphNodeId,
String(innerNode.id),
'myWidget'
)
promotionStore.isPromoted(graphId, subgraphNodeId, {
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'myWidget'
})
).toBe(true)
graph.unpackSubgraph(subgraphNode)

View File

@@ -496,9 +496,9 @@ describe('SubgraphIO - Empty Slot Connection', () => {
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toStrictEqual([
{ interiorNodeId: String(firstNode.id), widgetName: 'seed' },
{ interiorNodeId: String(secondNode.id), widgetName: 'seed' }
).toEqual([
{ sourceNodeId: String(firstNode.id), sourceWidgetName: 'seed' },
{ sourceNodeId: String(secondNode.id), sourceWidgetName: 'seed' }
])
}
)

View File

@@ -35,6 +35,7 @@ import {
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
@@ -44,7 +45,10 @@ import {
} from '@/composables/node/canvasImagePreviewTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
import {
makePromotionEntryKey,
usePromotionStore
} from '@/stores/promotionStore'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
@@ -56,16 +60,9 @@ 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 = {
type LinkedPromotionEntry = PromotedWidgetSource & {
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
}
type PromotionEntry = {
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.
@@ -103,7 +100,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
* `onAdded()`, so construction-time promotions require normal add-to-graph
* lifecycle to persist.
*/
private _pendingPromotions: PromotionEntry[] = []
private _pendingPromotions: PromotedWidgetSource[] = []
private _cacheVersion = 0
private _linkedEntriesCache?: {
version: number
@@ -112,7 +109,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _promotedViewsCache?: {
version: number
entriesRef: PromotionEntry[]
entriesRef: PromotedWidgetSource[]
hasMissingBoundSourceWidget: boolean
views: PromotedWidgetView[]
}
@@ -124,7 +121,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _resolveLinkedPromotionBySubgraphInput(
subgraphInput: SubgraphInput
): { interiorNodeId: string; widgetName: string } | undefined {
): PromotedWidgetSource | undefined {
// Preserve deterministic representative selection for multi-linked inputs:
// the first connected source remains the promoted linked view.
for (const linkId of subgraphInput.linkIds) {
@@ -142,15 +139,25 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (!targetWidget) continue
if (inputNode.isSubgraphNode())
return {
interiorNodeId: String(inputNode.id),
widgetName: targetInput.name
if (inputNode.isSubgraphNode()) {
if (isPromotedWidgetView(targetWidget)) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetWidget.sourceWidgetName,
disambiguatingSourceNodeId:
targetWidget.disambiguatingSourceNodeId ??
targetWidget.sourceNodeId
}
}
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
}
}
return {
interiorNodeId: String(inputNode.id),
widgetName: targetWidget.name
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetWidget.name
}
}
}
@@ -185,8 +192,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
interiorNodeId: boundWidget.sourceNodeId,
widgetName: boundWidget.sourceWidgetName
sourceNodeId: boundWidget.sourceNodeId,
sourceWidgetName: boundWidget.sourceWidgetName
})
continue
}
@@ -207,9 +214,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const deduplicatedEntries = linkedEntries.filter((entry) => {
const entryKey = this._makePromotionViewKey(
entry.inputKey,
entry.interiorNodeId,
entry.widgetName,
entry.inputName
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName,
entry.disambiguatingSourceNodeId
)
if (seenEntryKeys.has(entryKey)) return false
@@ -266,9 +274,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
(entry) =>
createPromotedWidgetView(
this,
entry.interiorNodeId,
entry.widgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
entry.disambiguatingSourceNodeId
)
)
@@ -303,23 +312,27 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
mergedEntries.length !== entries.length ||
mergedEntries.some(
(entry, index) =>
entry.interiorNodeId !== entries[index]?.interiorNodeId ||
entry.widgetName !== entries[index]?.widgetName
entry.sourceNodeId !== entries[index]?.sourceNodeId ||
entry.sourceWidgetName !== entries[index]?.sourceWidgetName ||
entry.disambiguatingSourceNodeId !==
entries[index]?.disambiguatingSourceNodeId
)
if (!hasChanged) return
store.setPromotions(this.rootGraph.id, this.id, mergedEntries)
}
private _buildPromotionReconcileState(
entries: PromotionEntry[],
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
displayNameByViewKey: Map<string, string>
reconcileEntries: Array<{
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
viewKey?: string
disambiguatingSourceNodeId?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
@@ -332,9 +345,19 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries,
fallbackStoredEntries
)
const fallbackReconcileEntries = fallbackStoredEntries.map((e) =>
e.disambiguatingSourceNodeId
? {
sourceNodeId: e.sourceNodeId,
sourceWidgetName: e.sourceWidgetName,
disambiguatingSourceNodeId: e.disambiguatingSourceNodeId,
viewKey: `src:${e.sourceNodeId}:${e.sourceWidgetName}:${e.disambiguatingSourceNodeId}`
}
: e
)
const reconcileEntries = shouldPersistLinkedOnly
? linkedReconcileEntries
: [...linkedReconcileEntries, ...fallbackStoredEntries]
: [...linkedReconcileEntries, ...fallbackReconcileEntries]
return {
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
@@ -343,10 +366,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _buildPromotionPersistenceState(
entries: PromotionEntry[],
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
mergedEntries: PromotionEntry[]
mergedEntries: PromotedWidgetSource[]
} {
const { linkedPromotionEntries, fallbackStoredEntries } =
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
@@ -363,16 +386,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _collectLinkedAndFallbackEntries(
entries: PromotionEntry[],
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
linkedPromotionEntries: PromotionEntry[]
fallbackStoredEntries: PromotionEntry[]
linkedPromotionEntries: PromotedWidgetSource[]
fallbackStoredEntries: PromotedWidgetSource[]
} {
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
const excludedEntryKeys = new Set(
linkedPromotionEntries.map((entry) =>
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
this._makePromotionEntryKey(
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
)
)
const connectedEntryKeys = this._getConnectedPromotionEntryKeys()
@@ -397,28 +424,38 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _shouldPersistLinkedOnly(
linkedEntries: LinkedPromotionEntry[],
fallbackStoredEntries: PromotionEntry[]
fallbackStoredEntries: PromotedWidgetSource[]
): boolean {
if (
!(this.inputs.length > 0 && linkedEntries.length === this.inputs.length)
)
return false
const linkedEntryKeys = new Set(
linkedEntries.map((entry) =>
this._makePromotionEntryKey(entry.sourceNodeId, entry.sourceWidgetName)
)
)
const linkedWidgetNames = new Set(
linkedEntries.map((entry) => entry.widgetName)
linkedEntries.map((entry) => entry.sourceWidgetName)
)
const hasFallbackToKeep = fallbackStoredEntries.some((entry) => {
const sourceNode = this.subgraph.getNodeById(entry.interiorNodeId)
const sourceNode = this.subgraph.getNodeById(entry.sourceNodeId)
if (!sourceNode) return linkedWidgetNames.has(entry.sourceWidgetName)
const hasSourceWidget =
sourceNode?.widgets?.some(
(widget) => widget.name === entry.widgetName
sourceNode.widgets?.some(
(widget) => widget.name === entry.sourceWidgetName
) === true
if (hasSourceWidget) return true
// If the fallback widget name overlaps a linked widget name, keep it
// If the fallback entry overlaps a linked entry, keep it
// until aliasing can be positively proven.
return linkedWidgetNames.has(entry.widgetName)
return linkedEntryKeys.has(
this._makePromotionEntryKey(entry.sourceNodeId, entry.sourceWidgetName)
)
})
return !hasFallbackToKeep
@@ -426,29 +463,36 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _toPromotionEntries(
linkedEntries: LinkedPromotionEntry[]
): PromotionEntry[] {
return linkedEntries.map(({ interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName
}))
): PromotedWidgetSource[] {
return linkedEntries.map(
({ sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId }) => ({
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
})
)
}
private _getFallbackStoredEntries(
entries: PromotionEntry[],
entries: PromotedWidgetSource[],
excludedEntryKeys: Set<string>
): PromotionEntry[] {
): PromotedWidgetSource[] {
return entries.filter(
(entry) =>
!excludedEntryKeys.has(
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
this._makePromotionEntryKey(
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
)
)
}
private _pruneStaleAliasFallbackEntries(
fallbackStoredEntries: PromotionEntry[],
linkedPromotionEntries: PromotionEntry[]
): PromotionEntry[] {
fallbackStoredEntries: PromotedWidgetSource[],
linkedPromotionEntries: PromotedWidgetSource[]
): PromotedWidgetSource[] {
if (
fallbackStoredEntries.length === 0 ||
linkedPromotionEntries.length === 0
@@ -462,7 +506,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
if (linkedConcreteKeys.size === 0) return fallbackStoredEntries
const prunedEntries: PromotionEntry[] = []
const prunedEntries: PromotedWidgetSource[] = []
for (const entry of fallbackStoredEntries) {
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
@@ -475,12 +519,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _resolveConcretePromotionEntryKey(
entry: PromotionEntry
entry: PromotedWidgetSource
): string | undefined {
const result = resolveConcretePromotedWidget(
this,
entry.interiorNodeId,
entry.widgetName
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
if (result.status !== 'resolved') return undefined
@@ -513,16 +558,27 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _buildLinkedReconcileEntries(
linkedEntries: LinkedPromotionEntry[]
): Array<{ interiorNodeId: string; widgetName: string; viewKey: string }> {
): Array<{
sourceNodeId: string
sourceWidgetName: string
viewKey: string
}> {
return linkedEntries.map(
({ inputKey, inputName, interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName,
({
inputKey,
inputName,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}) => ({
sourceNodeId,
sourceWidgetName,
viewKey: this._makePromotionViewKey(
inputKey,
interiorNodeId,
widgetName,
inputName
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
)
})
)
@@ -535,9 +591,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.map((entry) => [
this._makePromotionViewKey(
entry.inputKey,
entry.interiorNodeId,
entry.widgetName,
entry.inputName
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName,
entry.disambiguatingSourceNodeId
),
entry.inputName
])
@@ -545,19 +602,43 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _makePromotionEntryKey(
interiorNodeId: string,
widgetName: string
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): string {
return `${interiorNodeId}:${widgetName}`
return makePromotionEntryKey({
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
})
}
private _makePromotionViewKey(
inputKey: string,
interiorNodeId: string,
widgetName: string,
inputName = ''
sourceNodeId: string,
sourceWidgetName: string,
inputName = '',
disambiguatingSourceNodeId?: string
): string {
return JSON.stringify([inputKey, interiorNodeId, widgetName, inputName])
return disambiguatingSourceNodeId
? JSON.stringify([
inputKey,
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
])
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
}
private _serializeEntries(
entries: PromotedWidgetSource[]
): (string[] | [string, string, string])[] {
return entries.map((e) =>
e.disambiguatingSourceNodeId
? [e.sourceNodeId, e.sourceWidgetName, e.disambiguatingSourceNodeId]
: [e.sourceNodeId, e.sourceWidgetName]
)
}
private _resolveLegacyEntry(
@@ -777,20 +858,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Keep the earliest bound view once present, and only bind from event
// payload when this input has no representative yet.
const nodeId = String(e.detail.node.id)
const source: PromotedWidgetSource = {
sourceNodeId: nodeId,
sourceWidgetName: e.detail.widget.name
}
if (
usePromotionStore().isPromoted(
this.rootGraph.id,
this.id,
nodeId,
e.detail.widget.name
)
usePromotionStore().isPromoted(this.rootGraph.id, this.id, source)
) {
usePromotionStore().demote(
this.rootGraph.id,
this.id,
nodeId,
e.detail.widget.name
)
usePromotionStore().demote(this.rootGraph.id, this.id, source)
}
const didSetWidgetFromEvent = !input._widget
@@ -962,11 +1037,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const store = usePromotionStore()
const entries = raw
.map(([nodeId, widgetName]) => {
.map(([nodeId, widgetName, sourceNodeId]) => {
if (nodeId === '-1') {
const resolved = this._resolveLegacyEntry(widgetName)
if (resolved)
return { interiorNodeId: resolved[0], widgetName: resolved[1] }
return { sourceNodeId: resolved[0], sourceWidgetName: resolved[1] }
if (import.meta.env.DEV) {
console.warn(
`[SubgraphNode] Failed to resolve legacy -1 entry for widget "${widgetName}"`
@@ -974,7 +1049,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
return null
}
return { interiorNodeId: nodeId, widgetName }
const entry: PromotedWidgetSource = {
sourceNodeId: nodeId,
sourceWidgetName: widgetName,
...(sourceNodeId && { disambiguatingSourceNodeId: sourceNodeId })
}
return entry
})
.filter((e): e is NonNullable<typeof e> => e !== null)
@@ -982,10 +1062,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Write back resolved entries so legacy -1 format doesn't persist
if (raw.some(([id]) => id === '-1')) {
this.properties.proxyWidgets = entries.map((e) => [
e.interiorNodeId,
e.widgetName
])
this.properties.proxyWidgets = this._serializeEntries(entries)
}
// Check all inputs for connected widgets
@@ -1008,21 +1085,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
for (const node of this.subgraph.nodes) {
if (!supportsVirtualCanvasImagePreview(node)) continue
if (
store.isPromoted(
this.rootGraph.id,
this.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
)
continue
store.promote(
this.rootGraph.id,
this.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
const source: PromotedWidgetSource = {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
store.promote(this.rootGraph.id, this.id, source)
}
}
@@ -1078,6 +1146,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const nodeId = String(interiorNode.id)
const widgetName = interiorWidget.name
const sourceNodeId =
interiorNode.isSubgraphNode() && isPromotedWidgetView(interiorWidget)
? interiorWidget.sourceNodeId
: undefined
const previousView = input._widget
@@ -1087,12 +1159,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
(previousView.sourceNodeId !== nodeId ||
previousView.sourceWidgetName !== widgetName)
) {
usePromotionStore().demote(
this.rootGraph.id,
this.id,
previousView.sourceNodeId,
previousView.sourceWidgetName
)
usePromotionStore().demote(this.rootGraph.id, this.id, previousView)
this._removePromotedView(previousView)
}
@@ -1100,22 +1167,24 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (
!this._pendingPromotions.some(
(entry) =>
entry.interiorNodeId === nodeId && entry.widgetName === widgetName
entry.sourceNodeId === nodeId &&
entry.sourceWidgetName === widgetName &&
entry.disambiguatingSourceNodeId === sourceNodeId
)
) {
this._pendingPromotions.push({
interiorNodeId: nodeId,
widgetName
sourceNodeId: nodeId,
sourceWidgetName: widgetName,
...(sourceNodeId && { disambiguatingSourceNodeId: sourceNodeId })
})
}
} else {
// Add to promotion store
usePromotionStore().promote(
this.rootGraph.id,
this.id,
nodeId,
widgetName
)
usePromotionStore().promote(this.rootGraph.id, this.id, {
sourceNodeId: nodeId,
sourceWidgetName: widgetName,
disambiguatingSourceNodeId: sourceNodeId
})
}
// Create/retrieve the view from cache
@@ -1127,13 +1196,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this,
nodeId,
widgetName,
input.label ?? subgraphInput.name
input.label ?? subgraphInput.name,
sourceNodeId
),
this._makePromotionViewKey(
String(subgraphInput.id),
nodeId,
widgetName,
input.label ?? input.name
input.label ?? input.name,
sourceNodeId
)
)
@@ -1157,12 +1228,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
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
)
usePromotionStore().promote(this.rootGraph.id, this.id, entry)
}
this._pendingPromotions = []
@@ -1318,11 +1384,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
/** 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
const resolved = resolveConcretePromotedWidget(
this,
view.sourceNodeId,
view.sourceWidgetName,
view.disambiguatingSourceNodeId
)
if (resolved.status !== 'resolved') return
const interiorWidget = resolved.resolved.widget
if (
interiorWidget &&
'id' in interiorWidget &&
@@ -1347,7 +1417,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
String(input._subgraphSlot.id),
view.sourceNodeId,
view.sourceWidgetName,
inputName
inputName,
view.disambiguatingSourceNodeId
)
)
}
@@ -1357,20 +1428,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this.ensureWidgetRemoved(widget)
}
override removeWidgetByName(name: string): void {
const widget = this.widgets.find((w) => w.name === name)
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
)
usePromotionStore().demote(this.rootGraph.id, this.id, widget)
this._removePromotedView(widget)
}
for (const input of this.inputs) {
@@ -1472,10 +1533,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this.rootGraph.id,
this.id
)
this.properties.proxyWidgets = entries.map((e) => [
e.interiorNodeId,
e.widgetName
])
this.properties.proxyWidgets = this._serializeEntries(entries)
return super.serialize()
}

View File

@@ -147,37 +147,6 @@ describe('SubgraphWidgetPromotion', () => {
eventCapture.cleanup()
})
// BUG: removeWidgetByName calls demote but widgets getter rebuilds from
// promotionStore which still has the entry.
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/10174
it.skip('should fire widget-demoted event when removing promoted widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'number' }]
})
const { node } = createNodeWithWidget('Test Node')
const subgraphNode = setupPromotedWidget(subgraph, node)
expect(subgraphNode.widgets).toHaveLength(1)
const eventCapture = createEventCapture(subgraph.events, [
'widget-demoted'
])
// Remove the widget
subgraphNode.removeWidgetByName('input')
// Check event was fired
const demotedEvents = eventCapture.getEventsByType('widget-demoted')
expect(demotedEvents).toHaveLength(1)
expect(demotedEvents[0].detail.widget).toBeDefined()
expect(demotedEvents[0].detail.subgraphNode).toBe(subgraphNode)
// Widget should be removed
expect(subgraphNode.widgets).toHaveLength(0)
eventCapture.cleanup()
})
it('should handle multiple widgets on same node', () => {
const subgraph = createTestSubgraph({
inputs: [

View File

@@ -211,11 +211,10 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
if (
graphId &&
!suppressPromotedOutline &&
usePromotionStore().isPromotedByAny(
graphId,
String(this.node.id),
this.name
)
usePromotionStore().isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
)
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
return this.advanced

View File

@@ -172,7 +172,7 @@
/>
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
:key="`${preview.sourceNodeId}-${preview.sourceWidgetName}`"
:node-data="nodeData"
:media="preview"
/>
@@ -269,7 +269,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/unpromotedWidgetUtils'
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/promotionUtils'
import { st } from '@/i18n'
import {
LGraphCanvas,

View File

@@ -8,6 +8,7 @@ import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
@@ -41,9 +42,10 @@ describe('NodeWidgets', () => {
const createMockNodeData = (
nodeType: string = 'TestNode',
widgets: SafeWidgetData[] = []
widgets: SafeWidgetData[] = [],
id: string = '1'
): VueNodeData => ({
id: '1',
id,
type: nodeType,
widgets,
title: 'Test Node',
@@ -54,9 +56,10 @@ describe('NodeWidgets', () => {
outputs: []
})
const mountComponent = (nodeData?: VueNodeData) => {
const mountComponent = (nodeData?: VueNodeData, setupStores?: () => void) => {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return mount(NodeWidgets, {
props: {
@@ -75,6 +78,20 @@ describe('NodeWidgets', () => {
})
}
const getBorderStyles = (wrapper: ReturnType<typeof mount>) =>
(
wrapper.vm as unknown as { processedWidgets: unknown[] }
).processedWidgets.map(
(entry) =>
(
entry as {
simplified: {
borderStyle?: string
}
}
).simplified.borderStyle
)
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const widget = createMockWidget()
@@ -257,6 +274,81 @@ describe('NodeWidgets', () => {
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
})
it('does not deduplicate promoted duplicates that differ only by disambiguating source identity', () => {
const firstPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: 'outer-subgraph:1',
storeNodeId: 'outer-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const secondPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: 'outer-subgraph:2',
storeNodeId: 'outer-subgraph:2',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [
firstPromoted,
secondPromoted
])
const wrapper = mountComponent(nodeData)
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
})
it('applies promoted border styling to intermediate promoted widgets using host node identity', async () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
const wrapper = mountComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
await nextTick()
const borderStyles = getBorderStyles(wrapper)
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(true)
})
it('does not apply promoted border styling to outermost widgets', async () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '4')
const wrapper = mountComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
await nextTick()
const borderStyles = getBorderStyles(wrapper)
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(
false
)
})
it('hides widgets when merged store options mark them hidden', async () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({

View File

@@ -361,10 +361,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const hostNodeId = String(nodeId ?? '')
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const isPromotedView = !!widget.nodeId
const promotionSourceNodeId = widget.storeName
? String(bareWidgetId)
: undefined
const vueComponent =
getComponent(widget.type) ||
@@ -384,8 +387,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const borderStyle =
graphId &&
!isPromotedView &&
promotionStore.isPromotedByAny(graphId, String(bareWidgetId), widget.name)
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName: widget.storeName ?? widget.name,
disambiguatingSourceNodeId: promotionSourceNodeId
})
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'

View File

@@ -1,17 +1,12 @@
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
type ResolvedWidget = {
node: LGraphNode
widget: IBaseWidget
}
export function resolveWidgetFromHostNode(
hostNode: LGraphNode | undefined,
widgetName: string
): ResolvedWidget | undefined {
): ResolvedPromotedWidget | undefined {
if (!hostNode) return undefined
const widget = hostNode.widgets?.find((entry) => entry.name === widgetName)

View File

@@ -1133,6 +1133,12 @@ export class ComfyApp {
useMissingModelStore().clearMissingModels()
if (clean !== false) {
// Reset canvas context before configuring a new graph so subgraph UI
// state from the previous workflow cannot leak into the newly loaded
// one, and so `clean()` can clear the root graph even when the user is
// currently inside a subgraph.
this.canvas.setGraph(this.rootGraph)
this.clean()
}

View File

@@ -138,11 +138,10 @@ describe('DOMWidget draw promotion behavior', () => {
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
expect(isPromotedByAnyMock).toHaveBeenCalledWith(
'root-graph-id',
'-1',
'seed'
)
expect(isPromotedByAnyMock).toHaveBeenCalledWith('root-graph-id', {
sourceNodeId: '-1',
sourceWidgetName: 'seed'
})
expect(ctx.strokeRect).toHaveBeenCalledOnce()
expect(onDraw).toHaveBeenCalledWith(widget)
})

View File

@@ -190,11 +190,10 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
const graphId = this.node.graph?.rootGraph.id
const isPromoted =
graphId &&
this.promotionStore.isPromotedByAny(
graphId,
String(this.node.id),
this.name
)
this.promotionStore.isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
if (!isPromoted) {
this.options.onDraw?.(this)
return

View File

@@ -1,11 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveSubgraphPseudoWidgetCache } from '@/services/subgraphPseudoWidgetCache'
import type {
SubgraphPseudoWidget,
SubgraphPseudoWidgetCache,
SubgraphPseudoWidgetNode,
SubgraphPromotionEntry
SubgraphPseudoWidgetNode
} from '@/services/subgraphPseudoWidgetCache'
interface TestWidget extends SubgraphPseudoWidget {
@@ -30,8 +30,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
const getNodeById = vi.fn((id: string) =>
id === 'n1' ? interiorNode : undefined
)
const promotions: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'n1', widgetName: 'preview' }
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
@@ -48,8 +48,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
it('keeps $$ fallback behavior when the backing widget is missing', () => {
const interiorNode = node('n1', [widget('other')])
const promotions: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'n1', widgetName: '$$canvas-image-preview' }
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: '$$canvas-image-preview' }
]
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
@@ -64,8 +64,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
it('reuses cache when promotions and node identities are unchanged', () => {
const interiorNode = node('n1', [widget('preview', true)])
const promotions: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'n1', widgetName: 'preview' }
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const base = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
@@ -91,8 +91,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
it('rebuilds cache when promotions reference changes', () => {
const interiorNode = node('n1', [widget('preview', true)])
const promotionsA: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'n1', widgetName: 'preview' }
const promotionsA: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const base = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
@@ -100,8 +100,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
})
const promotionsB: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'n1', widgetName: 'preview' }
const promotionsB: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
@@ -116,8 +116,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
it('falls back to rebuild when a cached node reference goes stale', () => {
const oldNode = node('n1', [widget('preview', true)])
const promotions: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'n1', widgetName: 'preview' }
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const initial = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
@@ -138,16 +138,100 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
expect(result.nodes).toEqual([newNode])
})
it('rebuilds cache with different results when replacement node lacks the pseudo widget', () => {
const oldNode = node('n1', [widget('preview', true)])
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const initial = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
promotions,
getNodeById: () => oldNode,
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
})
expect(initial.nodes).toHaveLength(1)
const replacementNode = node('n1', [widget('other')])
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: initial.cache,
promotions,
getNodeById: () => replacementNode,
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
})
expect(result.cache).not.toBe(initial.cache)
expect(result.nodes).toEqual([])
expect(result.cache.entries).toHaveLength(0)
})
it('includes all pseudo-widget promotions across multiple interior nodes', () => {
const nodeA = node('n1', [widget('preview', true)])
const nodeB = node('n2', [widget('preview', true)])
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' },
{ sourceNodeId: 'n2', sourceWidgetName: 'preview' }
]
const getNodeById = (id: string) => {
if (id === 'n1') return nodeA
if (id === 'n2') return nodeB
return undefined
}
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
promotions,
getNodeById,
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
})
expect(result.nodes).toEqual([nodeA, nodeB])
expect(result.cache.entries).toHaveLength(2)
const reducedPromotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const reduced = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
promotions: reducedPromotions,
getNodeById,
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
})
expect(reduced.nodes).toEqual([nodeA])
expect(reduced.cache.entries).toHaveLength(1)
expect(reduced.cache.entries[0].sourceNodeId).toBe('n1')
})
it('excludes promotions where isPreviewPseudoWidget returns false', () => {
const interiorNode = node('n1', [widget('myWidget', false)])
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'myWidget' }
]
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
promotions,
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
})
expect(result.nodes).toEqual([])
expect(result.cache.entries).toHaveLength(0)
})
it('drops cached entries when node no longer resolves', () => {
const promotions: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'missing', widgetName: '$$canvas-image-preview' }
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'missing', sourceWidgetName: '$$canvas-image-preview' }
]
const cache: SubgraphPseudoWidgetCache<TestNode, TestWidget> = {
promotions,
entries: [
{
interiorNodeId: 'missing',
widgetName: '$$canvas-image-preview',
sourceNodeId: 'missing',
sourceWidgetName: '$$canvas-image-preview',
node: node('missing')
}
],

View File

@@ -1,7 +1,4 @@
export interface SubgraphPromotionEntry {
interiorNodeId: string
widgetName: string
}
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
export interface SubgraphPseudoWidget {
name: string
@@ -18,8 +15,8 @@ interface SubgraphPseudoWidgetCacheEntry<
TNode extends SubgraphPseudoWidgetNode<TWidget>,
TWidget extends SubgraphPseudoWidget
> {
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
node: TNode
}
@@ -27,7 +24,7 @@ export interface SubgraphPseudoWidgetCache<
TNode extends SubgraphPseudoWidgetNode<TWidget>,
TWidget extends SubgraphPseudoWidget
> {
promotions: readonly SubgraphPromotionEntry[]
promotions: readonly PromotedWidgetSource[]
entries: SubgraphPseudoWidgetCacheEntry<TNode, TWidget>[]
nodes: TNode[]
}
@@ -37,7 +34,7 @@ interface ResolveSubgraphPseudoWidgetCacheArgs<
TWidget extends SubgraphPseudoWidget
> {
cache: SubgraphPseudoWidgetCache<TNode, TWidget> | null
promotions: readonly SubgraphPromotionEntry[]
promotions: readonly PromotedWidgetSource[]
getNodeById: (nodeId: string) => TNode | undefined
isPreviewPseudoWidget: (widget: TWidget) => boolean
}
@@ -69,10 +66,10 @@ function isCacheStillValid<
isPreviewPseudoWidget: (widget: TWidget) => boolean
): boolean {
return cache.entries.every((entry) => {
const currentNode = getNodeById(entry.interiorNodeId)
const currentNode = getNodeById(entry.sourceNodeId)
if (!currentNode || currentNode !== entry.node) return false
return isPseudoPromotion(
entry.widgetName,
entry.sourceWidgetName,
currentNode.widgets,
isPreviewPseudoWidget
)
@@ -95,11 +92,11 @@ export function resolveSubgraphPseudoWidgetCache<
return { cache, nodes: cache.nodes }
const entries = promotions.flatMap((promotion) => {
const node = getNodeById(promotion.interiorNodeId)
const node = getNodeById(promotion.sourceNodeId)
if (!node) return []
if (
!isPseudoPromotion(
promotion.widgetName,
promotion.sourceWidgetName,
node.widgets,
isPreviewPseudoWidget
)

View File

@@ -32,8 +32,8 @@ describe(usePromotionStore, () => {
it('returns entries after setPromotions', () => {
const entries = [
{ interiorNodeId: '10', widgetName: 'seed' },
{ interiorNodeId: '11', widgetName: 'steps' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
]
store.setPromotions(graphA, nodeId, entries)
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
@@ -41,31 +41,52 @@ describe(usePromotionStore, () => {
it('returns a defensive copy', () => {
store.setPromotions(graphA, nodeId, [
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
const result = store.getPromotions(graphA, nodeId)
result.push({ interiorNodeId: '11', widgetName: 'steps' })
result.push({ sourceNodeId: '11', sourceWidgetName: 'steps' })
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
})
})
describe('isPromoted', () => {
it('returns false when nothing is promoted', () => {
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(false)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
})
it('returns true for a promoted entry', () => {
store.promote(graphA, nodeId, '10', 'seed')
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(true)
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
it('returns false for a different widget on the same node', () => {
store.promote(graphA, nodeId, '10', 'seed')
expect(store.isPromoted(graphA, nodeId, '10', 'steps')).toBe(false)
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'steps'
})
).toBe(false)
})
})
@@ -74,62 +95,141 @@ describe(usePromotionStore, () => {
const nodeB = 2 as NodeId
it('returns false when nothing is promoted', () => {
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
})
it('returns true when promoted by one parent', () => {
store.promote(graphA, nodeA, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
it('returns true when promoted by multiple parents', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
it('returns false after demoting from all parents', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '10', 'seed')
store.demote(graphA, nodeA, '10', 'seed')
store.demote(graphA, nodeB, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
})
it('returns true when still promoted by one parent after partial demote', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '10', 'seed')
store.demote(graphA, nodeA, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
it('returns false for different widget on same node', () => {
store.promote(graphA, nodeA, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'steps')).toBe(false)
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'steps'
})
).toBe(false)
})
})
describe('setPromotions', () => {
it('replaces existing entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.setPromotions(graphA, nodeId, [
{ interiorNodeId: '11', widgetName: 'steps' }
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(false)
expect(store.isPromoted(graphA, nodeId, '11', 'steps')).toBe(true)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
})
it('clears entries when set to empty array', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.setPromotions(graphA, nodeId, [])
expect(store.getPromotions(graphA, nodeId)).toEqual([])
})
it('preserves order', () => {
const entries = [
{ interiorNodeId: '10', widgetName: 'seed' },
{ interiorNodeId: '11', widgetName: 'steps' },
{ interiorNodeId: '12', widgetName: 'cfg' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
{ sourceNodeId: '12', sourceWidgetName: 'cfg' }
]
store.setPromotions(graphA, nodeId, entries)
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
@@ -138,74 +238,128 @@ describe(usePromotionStore, () => {
describe('promote', () => {
it('adds a new entry', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
})
it('does not duplicate existing entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
})
it('appends to existing entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '11', 'steps')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(2)
})
})
describe('demote', () => {
it('removes an existing entry', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.demote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.getPromotions(graphA, nodeId)).toEqual([])
})
it('is a no-op for non-existent entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.demote(graphA, nodeId, '99', 'nonexistent')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeId, {
sourceNodeId: '99',
sourceWidgetName: 'nonexistent'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
})
it('preserves other entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '11', 'steps')
store.demote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
store.demote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '11', widgetName: 'steps' }
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
})
})
describe('movePromotion', () => {
it('moves an entry from one index to another', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '11', 'steps')
store.promote(graphA, nodeId, '12', 'cfg')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
store.promote(graphA, nodeId, {
sourceNodeId: '12',
sourceWidgetName: 'cfg'
})
store.movePromotion(graphA, nodeId, 0, 2)
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '11', widgetName: 'steps' },
{ interiorNodeId: '12', widgetName: 'cfg' },
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
{ sourceNodeId: '12', sourceWidgetName: 'cfg' },
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
})
it('is a no-op for out-of-bounds indices', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.movePromotion(graphA, nodeId, 0, 5)
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
})
it('is a no-op when fromIndex equals toIndex', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '11', 'steps')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
store.movePromotion(graphA, nodeId, 1, 1)
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' },
{ interiorNodeId: '11', widgetName: 'steps' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
})
})
@@ -216,48 +370,109 @@ describe(usePromotionStore, () => {
it('tracks across setPromotions calls', () => {
store.setPromotions(graphA, nodeA, [
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
store.setPromotions(graphA, nodeB, [
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
// Remove from A — still promoted by B
store.setPromotions(graphA, nodeA, [])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
// Remove from B — now gone
store.setPromotions(graphA, nodeB, [])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
})
it('handles replacement via setPromotions correctly', () => {
store.setPromotions(graphA, nodeA, [
{ interiorNodeId: '10', widgetName: 'seed' },
{ interiorNodeId: '11', widgetName: 'steps' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
// Replace with different entries
store.setPromotions(graphA, nodeA, [
{ interiorNodeId: '11', widgetName: 'steps' },
{ interiorNodeId: '12', widgetName: 'cfg' }
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
{ sourceNodeId: '12', sourceWidgetName: 'cfg' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
expect(store.isPromotedByAny(graphA, '12', 'cfg')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '12',
sourceWidgetName: 'cfg'
})
).toBe(true)
})
it('stays consistent through movePromotion', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeA, '11', 'steps')
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
store.movePromotion(graphA, nodeA, 0, 1)
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
})
})
@@ -266,52 +481,412 @@ describe(usePromotionStore, () => {
const nodeB = 2 as NodeId
it('keeps promotions separate per subgraph node', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '20', 'cfg')
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '20',
sourceWidgetName: 'cfg'
})
expect(store.getPromotions(graphA, nodeA)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
expect(store.getPromotions(graphA, nodeB)).toEqual([
{ interiorNodeId: '20', widgetName: 'cfg' }
{ sourceNodeId: '20', sourceWidgetName: 'cfg' }
])
})
it('demoting from one node does not affect another', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '10', 'seed')
store.demote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.isPromoted(graphA, nodeA, '10', 'seed')).toBe(false)
expect(store.isPromoted(graphA, nodeB, '10', 'seed')).toBe(true)
expect(
store.isPromoted(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromoted(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
})
describe('clearGraph resets ref counts', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
it('resets isPromotedByAny after clearGraph', () => {
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
store.clearGraph(graphA)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(false)
})
})
describe('setPromotions idempotency', () => {
it('does not double ref counts when called twice with same entries', () => {
const entries = [
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
]
store.setPromotions(graphA, nodeId, entries)
store.setPromotions(graphA, nodeId, entries)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
store.setPromotions(graphA, nodeId, [])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(false)
})
})
describe('promote/demote interleaved with setPromotions', () => {
it('maintains consistent ref counts through mixed operations', () => {
const nodeA = 1 as NodeId
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
store.setPromotions(graphA, nodeA, [
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
store.demote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
})
})
describe('graph isolation', () => {
it('isolates promotions by graph id', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphB, nodeId, '20', 'steps')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphB, nodeId, {
sourceNodeId: '20',
sourceWidgetName: 'steps'
})
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
expect(store.getPromotions(graphB, nodeId)).toEqual([
{ interiorNodeId: '20', widgetName: 'steps' }
{ sourceNodeId: '20', sourceWidgetName: 'steps' }
])
})
it('clearGraph only removes one graph namespace', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphB, nodeId, '20', 'steps')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphB, nodeId, {
sourceNodeId: '20',
sourceWidgetName: 'steps'
})
store.clearGraph(graphA)
expect(store.getPromotions(graphA, nodeId)).toEqual([])
expect(store.getPromotions(graphB, nodeId)).toEqual([
{ interiorNodeId: '20', widgetName: 'steps' }
{ sourceNodeId: '20', sourceWidgetName: 'steps' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
expect(store.isPromotedByAny(graphB, '20', 'steps')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphB, {
sourceNodeId: '20',
sourceWidgetName: 'steps'
})
).toBe(true)
})
})
describe('sourceNodeId disambiguation', () => {
it('promote with disambiguatingSourceNodeId is found by matching isPromoted', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '99'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '99'
})
).toBe(true)
})
it('isPromoted with different disambiguatingSourceNodeId returns false', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '99'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '88'
})
).toBe(false)
})
it('isPromoted with undefined disambiguatingSourceNodeId does not match entry with disambiguatingSourceNodeId', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '99'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text'
})
).toBe(false)
})
it('two entries with same sourceNodeId/sourceWidgetName but different disambiguatingSourceNodeId coexist', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(2)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(true)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
).toBe(true)
})
it('demote with disambiguatingSourceNodeId removes only matching entry', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
store.demote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
).toBe(true)
})
it('isPromotedByAny with disambiguatingSourceNodeId only matches keyed entries', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text'
})
).toBe(false)
})
it('setPromotions with disambiguatingSourceNodeId entries maintains correct ref-counts', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
store.setPromotions(graphA, nodeA, [
{
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
}
])
store.setPromotions(graphA, nodeB, [
{
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
}
])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(true)
store.setPromotions(graphA, nodeA, [])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(true)
store.setPromotions(graphA, nodeB, [])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
})
})
})

View File

@@ -1,27 +1,32 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
interface PromotionEntry {
interiorNodeId: string
widgetName: string
const EMPTY_PROMOTIONS: PromotedWidgetSource[] = []
export function makePromotionEntryKey(source: PromotedWidgetSource): string {
const base = `${source.sourceNodeId}:${source.sourceWidgetName}`
return source.disambiguatingSourceNodeId
? `${base}:${source.disambiguatingSourceNodeId}`
: base
}
const EMPTY_PROMOTIONS: PromotionEntry[] = []
export const usePromotionStore = defineStore('promotion', () => {
const graphPromotions = ref(new Map<UUID, Map<NodeId, PromotionEntry[]>>())
const graphPromotions = ref(
new Map<UUID, Map<NodeId, PromotedWidgetSource[]>>()
)
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
function _getPromotionsForGraph(
graphId: UUID
): Map<NodeId, PromotionEntry[]> {
): Map<NodeId, PromotedWidgetSource[]> {
const promotions = graphPromotions.value.get(graphId)
if (promotions) return promotions
const nextPromotions = new Map<NodeId, PromotionEntry[]>()
const nextPromotions = new Map<NodeId, PromotedWidgetSource[]>()
graphPromotions.value.set(graphId, nextPromotions)
return nextPromotions
}
@@ -35,22 +40,24 @@ export const usePromotionStore = defineStore('promotion', () => {
return nextRefCounts
}
function _makeKey(interiorNodeId: string, widgetName: string): string {
return `${interiorNodeId}:${widgetName}`
}
function _incrementKeys(graphId: UUID, entries: PromotionEntry[]): void {
function _incrementKeys(
graphId: UUID,
entries: PromotedWidgetSource[]
): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = _makeKey(e.interiorNodeId, e.widgetName)
const key = makePromotionEntryKey(e)
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
}
}
function _decrementKeys(graphId: UUID, entries: PromotionEntry[]): void {
function _decrementKeys(
graphId: UUID,
entries: PromotedWidgetSource[]
): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = _makeKey(e.interiorNodeId, e.widgetName)
const key = makePromotionEntryKey(e)
const count = (refCounts.get(key) ?? 1) - 1
if (count <= 0) {
refCounts.delete(key)
@@ -63,7 +70,7 @@ export const usePromotionStore = defineStore('promotion', () => {
function getPromotionsRef(
graphId: UUID,
subgraphNodeId: NodeId
): PromotionEntry[] {
): PromotedWidgetSource[] {
return (
_getPromotionsForGraph(graphId).get(subgraphNodeId) ?? EMPTY_PROMOTIONS
)
@@ -72,34 +79,35 @@ export const usePromotionStore = defineStore('promotion', () => {
function getPromotions(
graphId: UUID,
subgraphNodeId: NodeId
): PromotionEntry[] {
): PromotedWidgetSource[] {
return [...getPromotionsRef(graphId, subgraphNodeId)]
}
function isPromoted(
graphId: UUID,
subgraphNodeId: NodeId,
interiorNodeId: string,
widgetName: string
source: PromotedWidgetSource
): boolean {
return getPromotionsRef(graphId, subgraphNodeId).some(
(e) => e.interiorNodeId === interiorNodeId && e.widgetName === widgetName
(e) =>
e.sourceNodeId === source.sourceNodeId &&
e.sourceWidgetName === source.sourceWidgetName &&
e.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
)
}
function isPromotedByAny(
graphId: UUID,
interiorNodeId: string,
widgetName: string
source: PromotedWidgetSource
): boolean {
const refCounts = _getRefCountsForGraph(graphId)
return (refCounts.get(_makeKey(interiorNodeId, widgetName)) ?? 0) > 0
return (refCounts.get(makePromotionEntryKey(source)) ?? 0) > 0
}
function setPromotions(
graphId: UUID,
subgraphNodeId: NodeId,
entries: PromotionEntry[]
entries: PromotedWidgetSource[]
): void {
const promotions = _getPromotionsForGraph(graphId)
const oldEntries = promotions.get(subgraphNodeId) ?? []
@@ -117,23 +125,24 @@ export const usePromotionStore = defineStore('promotion', () => {
function promote(
graphId: UUID,
subgraphNodeId: NodeId,
interiorNodeId: string,
widgetName: string
source: PromotedWidgetSource
): void {
if (isPromoted(graphId, subgraphNodeId, interiorNodeId, widgetName)) return
if (isPromoted(graphId, subgraphNodeId, source)) return
const entries = getPromotionsRef(graphId, subgraphNodeId)
setPromotions(graphId, subgraphNodeId, [
...entries,
{ interiorNodeId, widgetName }
])
const entry: PromotedWidgetSource = {
sourceNodeId: source.sourceNodeId,
sourceWidgetName: source.sourceWidgetName
}
if (source.disambiguatingSourceNodeId)
entry.disambiguatingSourceNodeId = source.disambiguatingSourceNodeId
setPromotions(graphId, subgraphNodeId, [...entries, entry])
}
function demote(
graphId: UUID,
subgraphNodeId: NodeId,
interiorNodeId: string,
widgetName: string
source: PromotedWidgetSource
): void {
const entries = getPromotionsRef(graphId, subgraphNodeId)
setPromotions(
@@ -141,7 +150,11 @@ export const usePromotionStore = defineStore('promotion', () => {
subgraphNodeId,
entries.filter(
(e) =>
!(e.interiorNodeId === interiorNodeId && e.widgetName === widgetName)
!(
e.sourceNodeId === source.sourceNodeId &&
e.sourceWidgetName === source.sourceWidgetName &&
e.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
)
)
)
}
@@ -169,7 +182,6 @@ export const usePromotionStore = defineStore('promotion', () => {
const [entry] = entries.splice(fromIndex, 1)
entries.splice(toIndex, 0, entry)
// Reordering does not change membership, so ref-counts remain valid.
promotions.set(subgraphNodeId, entries)
}

View File

@@ -7,10 +7,22 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
import type { Subgraph } from '@/lib/litegraph/src/LGraph'
type MockSubgraph = Pick<Subgraph, 'id' | 'rootGraph' | '_nodes' | 'nodes'>
function createMockSubgraph(id: string, rootGraph = app.rootGraph): Subgraph {
const mockSubgraph = {
id,
rootGraph,
_nodes: [],
nodes: []
} satisfies MockSubgraph
return mockSubgraph as unknown as Subgraph
}
vi.mock('@/scripts/app', () => {
const mockCanvas = {
subgraph: null,
@@ -25,14 +37,17 @@ vi.mock('@/scripts/app', () => {
setDirty: vi.fn()
}
const mockGraph = {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
}
return {
app: {
graph: {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
},
graph: mockGraph,
rootGraph: mockGraph,
canvas: mockCanvas
}
}
@@ -52,6 +67,14 @@ vi.mock('@vueuse/router', () => ({ useRouteHash: vi.fn() }))
describe('useSubgraphNavigationStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
app.rootGraph.subgraphs.clear()
app.canvas.subgraph = undefined
app.canvas.ds.scale = 1
app.canvas.ds.offset = [0, 0]
app.canvas.ds.state.scale = 1
app.canvas.ds.state.offset = [0, 0]
app.graph.getNodeById = vi.fn()
vi.resetAllMocks()
})
it('should not clear navigation stack when workflow internal state changes', async () => {
@@ -88,60 +111,109 @@ describe('useSubgraphNavigationStore', () => {
it('should preserve navigation stack per workflow', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
// Mock first workflow
const workflow1 = {
path: 'workflow1.json',
filename: 'workflow1.json',
changeTracker: createMockChangeTracker({
restore: vi.fn(),
store: vi.fn()
})
} as Partial<ComfyWorkflow> as ComfyWorkflow
filename: 'workflow1.json'
} as ComfyWorkflow
// Set the active workflow
workflowStore.activeWorkflow =
workflow1 as typeof workflowStore.activeWorkflow
// Simulate the restore process that happens when loading a workflow
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
// Verify navigation was set
expect(navigationStore.exportState()).toHaveLength(2)
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
// Switch to a different workflow with no subgraph state (root level)
const workflow2 = {
path: 'workflow2.json',
filename: 'workflow2.json',
changeTracker: createMockChangeTracker({
restore: vi.fn(),
store: vi.fn()
})
} as Partial<ComfyWorkflow> as ComfyWorkflow
filename: 'workflow2.json'
} as ComfyWorkflow
const sub1 = createMockSubgraph('sub-1')
const sub2 = createMockSubgraph('sub-2')
app.rootGraph.subgraphs.set(sub1.id, sub1)
app.rootGraph.subgraphs.set(sub2.id, sub2)
vi.mocked(findSubgraphPathById).mockImplementation((_rootGraph, id) => {
if (id === sub1.id) return [sub1.id]
if (id === sub2.id) return [sub1.id, sub2.id]
return null
})
// Workflow1 is in a nested subgraph (sub-1 -> sub-2)
app.canvas.subgraph = sub2
workflowStore.activeWorkflow =
workflow1 as typeof workflowStore.activeWorkflow
await nextTick()
expect(navigationStore.exportState()).toEqual([sub1.id, sub2.id])
// Switch to workflow2 at root level
app.canvas.subgraph = undefined
workflowStore.activeWorkflow =
workflow2 as typeof workflowStore.activeWorkflow
await nextTick()
expect(navigationStore.exportState()).toEqual([])
// Switch back to workflow1 in its subgraph
app.canvas.subgraph = sub2
workflowStore.activeWorkflow =
workflow1 as typeof workflowStore.activeWorkflow
await nextTick()
expect(navigationStore.exportState()).toEqual([sub1.id, sub2.id])
})
it('should reset navigation on workflow switch and restore on switch back', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
const workflow1 = {
path: 'workflow1.json',
filename: 'workflow1.json'
} as ComfyWorkflow
const workflow1Subgraph = createMockSubgraph('sub-1')
app.rootGraph.subgraphs.set(workflow1Subgraph.id, workflow1Subgraph)
vi.mocked(findSubgraphPathById).mockImplementation((_rootGraph, id) =>
id === workflow1Subgraph.id ? [workflow1Subgraph.id] : null
)
app.canvas.subgraph = workflow1Subgraph
workflowStore.activeWorkflow =
workflow1 as typeof workflowStore.activeWorkflow
await nextTick()
expect(navigationStore.exportState()).toEqual([workflow1Subgraph.id])
const workflow2 = {
path: 'workflow2.json',
filename: 'workflow2.json'
} as ComfyWorkflow
app.canvas.subgraph = undefined
workflowStore.activeWorkflow =
workflow2 as typeof workflowStore.activeWorkflow
await nextTick()
// Simulate the restore process for workflow2
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
navigationStore.restoreState([])
expect(navigationStore.exportState()).toEqual([])
// The navigation stack should be empty for workflow2 (at root level)
expect(navigationStore.exportState()).toHaveLength(0)
app.canvas.subgraph = workflow1Subgraph
// Switch back to workflow1
workflowStore.activeWorkflow =
workflow1 as typeof workflowStore.activeWorkflow
await nextTick()
// Simulate the restore process for workflow1 again
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
expect(navigationStore.exportState()).toEqual([workflow1Subgraph.id])
})
// The navigation stack should be restored for workflow1
expect(navigationStore.exportState()).toHaveLength(2)
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
it('should handle restoreState with unreachable subgraph IDs', () => {
const navigationStore = useSubgraphNavigationStore()
navigationStore.restoreState(['nonexistent-sub'])
expect(navigationStore.exportState()).toEqual(['nonexistent-sub'])
expect(navigationStore.navigationStack).toEqual([])
})
it('should clear navigation when activeSubgraph becomes undefined', async () => {
@@ -150,12 +222,7 @@ describe('useSubgraphNavigationStore', () => {
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
// Create mock subgraph and graph structure
const mockSubgraph = {
id: 'subgraph-1',
rootGraph: app.graph,
_nodes: [],
nodes: []
} as Partial<Subgraph> as Subgraph
const mockSubgraph = createMockSubgraph('subgraph-1', app.graph)
// Add the subgraph to the graph's subgraphs map
app.graph.subgraphs.set('subgraph-1', mockSubgraph)

View File

@@ -13,6 +13,8 @@ import { app } from '@/scripts/app'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
import { isNonNullish, isSubgraph } from '@/utils/typeGuardUtil'
export const VIEWPORT_CACHE_MAX_SIZE = 32
/**
* Stores the current subgraph navigation state; a stack representing subgraph
* navigation history from the root graph to the subgraph that is currently
@@ -34,7 +36,7 @@ export const useSubgraphNavigationStore = defineStore(
/** LRU cache for viewport states. Key: subgraph ID or 'root' for root graph */
const viewportCache = new QuickLRU<string, DragAndScaleState>({
maxSize: 32
maxSize: VIEWPORT_CACHE_MAX_SIZE
})
/**

View File

@@ -7,7 +7,10 @@ import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import {
useSubgraphNavigationStore,
VIEWPORT_CACHE_MAX_SIZE
} from '@/stores/subgraphNavigationStore'
const { mockSetDirty } = vi.hoisted(() => ({
mockSetDirty: vi.fn()
@@ -261,5 +264,64 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(navigationStore.viewportCache.has('root')).toBe(true)
expect(navigationStore.viewportCache.has('sub1')).toBe(true)
})
it('should save/restore viewports correctly across multiple subgraphs', () => {
const navigationStore = useSubgraphNavigationStore()
navigationStore.viewportCache.set('root', {
scale: 1,
offset: [0, 0]
})
navigationStore.viewportCache.set('sub-1', {
scale: 2,
offset: [100, 200]
})
navigationStore.viewportCache.set('sub-2', {
scale: 0.5,
offset: [-50, -75]
})
navigationStore.restoreViewport('sub-1')
expect(mockCanvas.ds.scale).toBe(2)
expect(mockCanvas.ds.offset).toEqual([100, 200])
navigationStore.restoreViewport('sub-2')
expect(mockCanvas.ds.scale).toBe(0.5)
expect(mockCanvas.ds.offset).toEqual([-50, -75])
navigationStore.restoreViewport('root')
expect(mockCanvas.ds.scale).toBe(1)
expect(mockCanvas.ds.offset).toEqual([0, 0])
})
it('should evict oldest viewport entry when LRU cache exceeds capacity', () => {
const navigationStore = useSubgraphNavigationStore()
const overflowEntryCount = VIEWPORT_CACHE_MAX_SIZE * 2 + 1
// QuickLRU uses double-buffering: effective capacity is up to 2 * maxSize.
// Fill enough entries so the earliest ones are fully evicted.
for (let i = 0; i < overflowEntryCount; i++) {
navigationStore.viewportCache.set(`sub-${i}`, {
scale: i + 1,
offset: [i * 10, i * 20]
})
}
expect(navigationStore.viewportCache.has('sub-0')).toBe(false)
expect(
navigationStore.viewportCache.has(`sub-${overflowEntryCount - 1}`)
).toBe(true)
mockCanvas.ds.scale = 99
mockCanvas.ds.offset = [999, 999]
mockSetDirty.mockClear()
navigationStore.restoreViewport('sub-0')
expect(mockCanvas.ds.scale).toBe(99)
expect(mockCanvas.ds.offset).toEqual([999, 999])
expect(mockSetDirty).not.toHaveBeenCalled()
})
})
})