Compare commits

...

25 Commits

Author SHA1 Message Date
dante01yoon
e5abb36cdc test: preserve viewport during subgraph entry 2026-04-15 18:45:51 +09:00
dante01yoon
6966a6053c test: keep subgraph enter button in viewport 2026-04-15 18:29:22 +09:00
dante01yoon
8eef299941 test: fix flaky nested subgraph enter click 2026-04-15 16:10:49 +09:00
dante01yoon
6adca6bfbe fix: address PR 10851 CI regression 2026-04-15 15:52:20 +09:00
dante01yoon
91a754f600 fix: address PR 10851 review feedback 2026-04-15 12:38:06 +09:00
Alexander Brown
10f3350956 Merge branch 'main' into fix/promoted-indicator-nested-subgraph 2026-04-14 15:20:16 -07:00
dante01yoon
edb2e530af fix: revert to programmatic subgraph navigation to avoid z-999 overlay
enterSubgraph helper clicks the button, but the canvas z-999 overlay
intercepts pointer events at root level. Use page.evaluate to navigate
programmatically instead.
2026-04-14 08:36:30 +09:00
dante01yoon
1c749b3907 refactor: address DrJKL review — use enterSubgraph helper, @vue-nodes tag, retrying assertion
- Replace manual page.evaluate subgraph navigation with enterSubgraph('5')
- Use @vue-nodes tag (from #11184) instead of manual beforeEach setting
- Replace expect.poll with comfyExpect retrying assertion per Playwright best practices
2026-04-14 08:10:09 +09:00
Alexander Brown
9ef65eda7a Merge branch 'main' into fix/promoted-indicator-nested-subgraph 2026-04-13 14:38:08 -07:00
dante01yoon
4a8fd059fe fix: use slot aliases for promoted widget rings 2026-04-12 17:25:53 +09:00
dante01yoon
e6e1b5ff05 fix: resolve promotion disambiguator at hydration time
Address Codex adversarial review findings:

1. [high] Fix at data layer instead of read-time fallback: during
   SubgraphNode._internalConfigureAfterSlots, resolve the concrete
   node for entries targeting SubgraphNodes and store the correct
   disambiguatingSourceNodeId. This ensures ALL promotion consumers
   (ring, side panel, demote) see consistent keys.

2. [medium] Prevent over-matching of duplicate-named widgets: replace
   the unconditional base-key fallback with dual-indexed ref counts.
   Disambiguated entries now increment both exact and base keys, so
   exact lookups (Vue renderer) match precisely while base lookups
   (legacy BaseWidget) still work without a disambiguator.

3. Simplify isWidgetPromoted to a pass-through — no special fallback
   logic needed since the data is correct at the source.
2026-04-12 10:54:53 +09:00
dante01yoon
0ddec541c5 Merge branch 'main' into fix/promoted-indicator-nested-subgraph
Resolve conflict in NodeWidgets.vue: take main's refactored
useProcessedWidgets composable and apply isWidgetPromoted fix there.
Fix import aliases in merged files to satisfy no-restricted-imports.
2026-04-12 08:55:05 +09:00
dante01yoon
c439282915 fix: revert extra proxyWidget that broke SubgraphEditor panel tests
The addition of ["6", "string_a"] to node 5's proxyWidgets created a
non-linked promotion (no corresponding subgraph input slot), breaking
three existing E2E tests that expect all widgets in the SubgraphEditor
panel to be linked. Revert the extra entry and relax the new test
assertion to toBeGreaterThan(0), which still validates the #10612 fix.
2026-04-12 07:32:43 +09:00
dante01yoon
278a1eddb6 fix: correct nested subgraph promotion fixture for E2E test
- Fix node 6 proxyWidget ["5","string_a"] → ["9","string_a"]: node 5
  does not exist inside Sub 1 (only nodes 9, 10, 11), causing the entry
  to be filtered out and leaving only 3 widgets instead of 4.
- Add ["6","string_a"] to node 5 proxyWidgets so the isWidgetPromoted
  fallback lookup ("6:string_a" without disambiguator) matches, enabling
  the promoted ring on node 6's string_a widgets inside Sub 0.
2026-04-09 13:25:32 +09:00
dante01yoon
2330807b06 refactor: move isWidgetPromoted to promotionStore for shared use
Move the dual-key promotion check (with/without disambiguatingSourceNodeId)
from promotionUtils.ts into promotionStore as isWidgetPromoted(). This
avoids circular dependency issues when BaseWidget and domWidget need the
same nested subgraph fallback logic. All three consumers now use the
shared store method.
2026-04-08 20:21:30 +09:00
dante01yoon
894768a4b6 fix: use programmatic subgraph navigation in E2E test
The enterSubgraph utility clicks the enter button, but the canvas
z-999 overlay intercepts the click on root-level nodes. Revert to
programmatic navigation via page.evaluate.
2026-04-08 19:23:41 +09:00
dante01yoon
210384bbf7 refactor: address PR review feedback
- Extract isWidgetPromoted into promotionUtils.ts for shared use
- Use enterSubgraph utility in E2E test instead of manual page.evaluate
- Use expect.poll instead of comfyExpect().toPass() for single assertion
- Assert exact promoted ring count (4) instead of > 0
- Switch unit test from VTU mount to VTL render/screen
2026-04-08 17:10:25 +09:00
dante01yoon
288f52d033 Revert "refactor: extract PromotionEntryResolver from SubgraphNode"
This reverts commit 8402c2ae96.
2026-04-08 16:45:41 +09:00
dante
8402c2ae96 refactor: extract PromotionEntryResolver from SubgraphNode
Move promotion entry resolution logic (linked/fallback merging, alias
pruning, persistence decisions) into a standalone module to reduce
SubgraphNode method count and improve readability.

- Extract resolvePromotionEntries, buildLinkedReconcileEntries,
  buildDisplayNameByViewKey, makePromotionViewKey into
  PromotionEntryResolver.ts
- Remove 12 private methods (~280 lines) from SubgraphNode
- Eliminate _makePromotionEntryKey wrapper (use store export directly)
- Add 10 characterization tests pinning promotion entry resolution
  behavior before refactoring
2026-04-08 15:54:38 +09:00
Alexander Brown
7f0ab180b9 Merge branch 'main' into fix/promoted-indicator-nested-subgraph 2026-04-07 10:10:21 -07:00
dante01yoon
735d639d64 fix: use programmatic subgraph navigation to avoid click interception in E2E 2026-04-05 11:00:14 +09:00
dante01yoon
71a4098aa9 test: add E2E test for promoted indicator on 3-level nested subgraph (#10612) 2026-04-05 09:41:15 +09:00
dante01yoon
c601aab2c3 docs: add comment explaining promoted indicator fallback logic 2026-04-05 09:35:29 +09:00
dante01yoon
fd9e732b7f fix: also check base key in promoted widget indicator lookup
The isPromotedByAny check was only looking up the key with
disambiguatingSourceNodeId, missing promotions stored without one.
Now tries both with and without the disambiguating segment so
nested subgraph promotions are detected regardless of storage shape.

Fixes #10612
2026-04-05 00:13:39 +09:00
dante01yoon
8a923a2094 test: add failing test for promoted indicator on nested subgraphs
Reproduces #10612 where the promoted widget ring indicator is missing
on nested subgraphs due to a key mismatch in isPromotedByAny lookup.
2026-04-05 00:09:32 +09:00
14 changed files with 534 additions and 99 deletions

View File

@@ -272,7 +272,7 @@
],
"properties": {
"proxyWidgets": [
["5", "string_a"],
["9", "string_a"],
["11", "value"],
["9", "value"],
["10", "string_a"]

View File

@@ -94,6 +94,50 @@ export class VueNodeHelpers {
await this.page.mouse.click(50, 50)
}
private async fitNodeInViewport(nodeId: string): Promise<void> {
await this.page.evaluate((id) => {
const app = window.app
const canvas = app?.canvas
const node = canvas?.graph?.getNodeById(id)
if (!canvas || !node) {
throw new Error(`Node ${id} not found`)
}
const [x, y, width, height] = node.getBounding()
canvas.ds.fitToBounds([x - 20, y - 20, width + 40, height + 80], {
zoom: 1
})
canvas.setDirty(true, true)
}, nodeId)
await this.page.evaluate(() => new Promise<number>(requestAnimationFrame))
}
private getVisibleClickPosition(
box: NonNullable<Awaited<ReturnType<Locator['boundingBox']>>>
) {
const viewport = this.page.viewportSize()
if (!viewport) {
return { x: box.width / 2, y: box.height * 0.75 }
}
const visibleLeft = Math.max(box.x, 0)
const visibleRight = Math.min(box.x + box.width, viewport.width)
const visibleTop = Math.max(box.y, 0)
const visibleBottom = Math.min(box.y + box.height, viewport.height)
if (visibleLeft >= visibleRight || visibleTop >= visibleBottom) {
throw new Error(
'subgraph-enter-button has no visible viewport intersection'
)
}
return {
x: visibleLeft - box.x + (visibleRight - visibleLeft) / 2,
y: Math.max(1, Math.min(box.height - 2, visibleBottom - box.y - 2))
}
}
/**
* Delete selected Vue nodes using Delete key
*/
@@ -187,24 +231,43 @@ export class VueNodeHelpers {
/**
* Enter the subgraph of a node.
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
* @param nodeId - The ID of the node to enter the subgraph of. If not
* provided, the first matched subgraph will be entered.
*/
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
const hostNode = nodeId
? this.getNodeLocator(nodeId)
: this.nodes
.filter({
has: this.page.getByTestId(TestIds.widgets.subgraphEnterButton)
})
.first()
const resolvedNodeId =
nodeId ?? (await hostNode.getAttribute('data-node-id'))
const editButton = hostNode.getByTestId(TestIds.widgets.subgraphEnterButton)
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.
// Click at the bottom 25% of the button which is the genuinely visible
// and unobstructed area outside the node body boundary.
const box = await editButton.boundingBox()
if (!box) {
throw new Error(
'subgraph-enter-button has no bounding box: element may be hidden or not in DOM'
)
const clickEnterButton = async () => {
// The footer tab sits below the node body and can be partially clipped
// by the viewport. Click inside the visible slice of the button instead
// of using the raw bounding-box center, which can land on the page root.
const box = await editButton.boundingBox()
if (!box) {
throw new Error(
'subgraph-enter-button has no bounding box: element may be hidden or not in DOM'
)
}
await editButton.click({
position: this.getVisibleClickPosition(box)
})
}
try {
await clickEnterButton()
} catch (error) {
if (!resolvedNodeId) throw error
await this.fitNodeInViewport(resolvedNodeId)
await clickEnterButton()
}
await editButton.click({
position: { x: box.width / 2, y: box.height * 0.75 }
})
}
}

View File

@@ -191,4 +191,59 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
})
}
)
/**
* Regression test for #10612: promoted widget indicator ring missing on
* nested subgraph nodes.
*
* Uses the 3-level nested fixture (subgraph-nested-promotion):
* Root → Sub 0 (node 5) → Sub 1 (node 6) → Sub 2 (node 9)
*
* Node 6 (Sub 1) has proxyWidgets promoting widgets from inner nodes,
* and those promotions are also promoted up to node 5 (Sub 0). When
* navigating into Sub 0, node 6 should show the promoted ring on its
* widgets. The fixture's proxyWidgets entries are scoped to Sub 1's
* local graph, so the nested `string_a` promotion correctly points at
* inner node 9 instead of the outer SubgraphNode 5.
*/
test.describe(
'Promoted indicator on 3-level nested subgraphs (#10612)',
{ tag: ['@widget', '@vue-nodes'] },
() => {
const WORKFLOW = 'subgraphs/subgraph-nested-promotion'
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
test('Intermediate SubgraphNode shows promoted ring inside parent subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// At root level, node 5 (Sub 0) is the outermost SubgraphNode.
// Its widgets are not promoted further, so no ring expected.
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await comfyExpect(outerNode).toBeVisible()
const outerRings = outerNode.locator(`.${PROMOTED_BORDER_CLASS}`)
await comfyExpect(outerRings).toHaveCount(0)
// Exercise the same enter-subgraph control users click.
await comfyPage.vueNodes.enterSubgraph('5')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// Node 6 (Sub 1) has proxyWidgets promoted up to Sub 0
// (node 5). Those promoted widgets should carry the ring.
const intermediateNode = comfyPage.vueNodes.getNodeLocator('6')
await comfyExpect(intermediateNode).toBeVisible()
const intermediateRings = intermediateNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await comfyExpect(intermediateRings).not.toHaveCount(0, {
timeout: 5000
})
})
}
)
})

View File

@@ -70,6 +70,43 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
})
})
it('infers the leaf-node disambiguator for nested subgraph entries', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: 'number' }]
})
const samplerNode = new LGraphNode('Sampler')
const samplerInput = samplerNode.addInput('seed', 'number')
samplerNode.addWidget('number', 'noise_seed', 42, () => {})
samplerInput.widget = { name: 'noise_seed' }
innerSubgraph.add(samplerNode)
innerSubgraph.inputNode.slots[0].connect(samplerNode.inputs[0], samplerNode)
const outerSubgraph = createTestSubgraph({ rootGraph })
const nestedNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: outerSubgraph
})
outerSubgraph.add(nestedNode)
const hostNode = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph
})
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(nestedNode.id),
'seed'
)
expect(result).toEqual({
sourceNodeId: String(nestedNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: String(samplerNode.id)
})
})
it('strips a single legacy prefix from widget name', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({

View File

@@ -6,34 +6,30 @@ const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
type PromotedWidgetPatch = Omit<PromotedWidgetSource, 'sourceNodeId'>
function canResolve(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string,
disambiguator?: string
): boolean {
return (
resolveConcretePromotedWidget(
hostNode,
sourceNodeId,
widgetName,
disambiguator
).status === 'resolved'
)
}
function tryResolveCandidate(
function resolveCandidate(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string,
disambiguator?: string
): PromotedWidgetPatch | undefined {
if (!canResolve(hostNode, sourceNodeId, widgetName, disambiguator))
return undefined
const result = resolveConcretePromotedWidget(
hostNode,
sourceNodeId,
widgetName,
disambiguator
)
if (result.status !== 'resolved') return undefined
const sourceNode = hostNode.subgraph.getNodeById(sourceNodeId)
const inferredDisambiguator =
disambiguator ??
(sourceNode?.isSubgraphNode() ? String(result.resolved.node.id) : undefined)
return {
sourceWidgetName: widgetName,
...(disambiguator && { disambiguatingSourceNodeId: disambiguator })
...(inferredDisambiguator && {
disambiguatingSourceNodeId: inferredDisambiguator
})
}
}
@@ -59,7 +55,7 @@ function resolveLegacyPrefixedEntry(
]
for (const disambiguator of disambiguators) {
const resolved = tryResolveCandidate(
const resolved = resolveCandidate(
hostNode,
sourceNodeId,
remaining,
@@ -76,18 +72,19 @@ export function normalizeLegacyProxyWidgetEntry(
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetSource {
if (
canResolve(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
) {
const exactMatch = resolveCandidate(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
if (exactMatch) {
return {
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
sourceWidgetName: exactMatch.sourceWidgetName,
...(exactMatch.disambiguatingSourceNodeId && {
disambiguatingSourceNodeId: exactMatch.disambiguatingSourceNodeId
})
}
}
@@ -98,14 +95,14 @@ export function normalizeLegacyProxyWidgetEntry(
disambiguatingSourceNodeId
)
const patchDisambiguatingSourceNodeId =
const normalizedDisambiguatingSourceNodeId =
patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId
return {
sourceNodeId,
sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName,
...(patchDisambiguatingSourceNodeId && {
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
...(normalizedDisambiguatingSourceNodeId && {
disambiguatingSourceNodeId: normalizedDisambiguatingSourceNodeId
})
}
}

View File

@@ -0,0 +1,152 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import { describe, expect, test, vi } from 'vitest'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { usePromotionStore } from '@/stores/promotionStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: {
rootGraph: {
id: 'graph-test'
}
}
}
})
}))
const PROMOTED_CLASS = 'ring-component-node-widget-promoted'
const WidgetStub = defineComponent({
props: { widget: { type: Object, default: undefined } },
setup(props) {
return () =>
h('div', {
'data-testid': 'widget-stub',
class: props.widget?.borderStyle
})
}
})
vi.mock(
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
() => ({
getComponent: () => WidgetStub,
shouldExpand: () => false,
shouldRenderAsVue: () => true
})
)
function createMockWidget(
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData {
return {
nodeId: 'test_node',
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
}
}
function createMockNodeData(
nodeType: string,
widgets: SafeWidgetData[],
id: string
): VueNodeData {
return {
id,
type: nodeType,
widgets,
title: 'Test Node',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
}
}
function renderComponent(nodeData: VueNodeData, setupStores?: () => void) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return render(NodeWidgets, {
props: { nodeData },
global: {
plugins: [pinia],
stubs: { InputSlot: true },
mocks: { $t: (key: string) => key }
}
})
}
describe('promoted widget indicator on nested subgraphs', () => {
test('shows promoted ring when promotion includes disambiguatingSourceNodeId', async () => {
// Scenario: SubBNode (id=3) inside SubA promotes a widget from
// ConcreteNode (id=1). During hydration, SubgraphNode.configure
// resolves the concrete node and stores the promotion WITH
// disambiguatingSourceNodeId so that the exact key matches the
// renderer's lookup.
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')
renderComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
const widgets = screen.getAllByTestId('widget-stub')
const hasPromotedRing = widgets.some((el) =>
el.classList.contains(PROMOTED_CLASS)
)
expect(hasPromotedRing).toBe(true)
})
test('shows promoted ring via base-key lookup when disambiguator is unknown', async () => {
// Legacy callers (e.g. BaseWidget) may not have the disambiguator.
// The dual-indexed base key ensures the ring still shows.
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'test_node',
isDOMWidget: false
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
renderComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
const widgets = screen.getAllByTestId('widget-stub')
const hasPromotedRing = widgets.some((el) =>
el.classList.contains(PROMOTED_CLASS)
)
expect(hasPromotedRing).toBe(true)
})
})

View File

@@ -0,0 +1,31 @@
import { getActivePinia } from 'pinia'
import type { Pinia } from 'pinia'
import { usePromotionStore } from '@/stores/promotionStore'
let cachedPromotionStore: ReturnType<typeof usePromotionStore> | undefined
let cachedPinia: Pinia | undefined
function getPromotionStore() {
const activePinia = getActivePinia()
if (!cachedPromotionStore || cachedPinia !== activePinia) {
cachedPromotionStore = usePromotionStore(activePinia)
cachedPinia = activePinia
}
return cachedPromotionStore
}
export function isWidgetPromoted(
graphId: string,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): boolean {
return getPromotionStore().isWidgetPromoted(
graphId,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
}

View File

@@ -1,3 +1,4 @@
import { isWidgetPromoted } from '@/core/graph/subgraph/promotionLookup'
import { t } from '@/i18n'
import { drawTextInArea } from '@/lib/litegraph/src/draw'
import { cachedMeasureText } from '@/lib/litegraph/src/utils/textMeasureCache'
@@ -17,7 +18,6 @@ import type {
NodeBindable,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -211,10 +211,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
if (
graphId &&
!suppressPromotedOutline &&
usePromotionStore().isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
isWidgetPromoted(graphId, String(this.node.id), this.name)
)
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
return this.advanced

View File

@@ -247,6 +247,46 @@ describe('computeProcessedWidgets borderStyle', () => {
).toBe(true)
})
it('uses slotName when ancestor promotion keeps the host widget alias', () => {
const promotedWidget = createMockWidget({
name: 'string_a',
type: 'combo',
nodeId: 'inner-subgraph:10',
storeNodeId: 'inner-subgraph:10',
storeName: 'string_a',
slotName: 'value_1'
})
usePromotionStore().promote('graph-test', '5', {
sourceNodeId: '6',
sourceWidgetName: 'value_1',
disambiguatingSourceNodeId: '10'
})
const result = computeProcessedWidgets({
nodeData: {
id: '6',
type: 'SubgraphNode',
widgets: [promotedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
).toBe(true)
})
it('does not apply promoted border styling to outermost widgets', () => {
const promotedWidget = createMockWidget({
name: 'text',

View File

@@ -9,6 +9,7 @@ import type {
} from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { isWidgetPromoted } from '@/core/graph/subgraph/promotionLookup'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -29,7 +30,6 @@ import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
@@ -167,7 +167,6 @@ export function computeProcessedWidgets({
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
if (!nodeData?.widgets) return []
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const widgetValueStore = useWidgetValueStore()
@@ -253,7 +252,7 @@ export function computeProcessedWidgets({
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const promotionSourceNodeId = widget.storeName
const disambiguatingSourceNodeId = widget.storeName
? String(bareWidgetId)
: undefined
@@ -270,17 +269,23 @@ export function computeProcessedWidgets({
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
// Nested SubgraphNode promotions are keyed by the subgraph slot name.
// storeName still identifies the concrete leaf widget and is only used
// as the disambiguator when multiple promoted views share that slot.
const sourceWidgetName = widget.slotName ?? widget.name
const isPromoted =
graphId &&
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'
: undefined
isWidgetPromoted(
graphId,
hostNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
const borderStyle = isPromoted
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId

View File

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget'
const isPromotedByAnyMock = vi.hoisted(() => vi.fn())
const isWidgetPromotedMock = vi.hoisted(() => vi.fn())
// Mock dependencies
vi.mock('@/stores/domWidgetStore', () => ({
@@ -12,10 +12,8 @@ vi.mock('@/stores/domWidgetStore', () => ({
})
}))
vi.mock('@/stores/promotionStore', () => ({
usePromotionStore: () => ({
isPromotedByAny: isPromotedByAnyMock
})
vi.mock('@/core/graph/subgraph/promotionLookup', () => ({
isWidgetPromoted: isWidgetPromotedMock
}))
vi.mock('@/utils/formatUtil', () => ({
@@ -120,7 +118,7 @@ describe('DOMWidget draw promotion behavior', () => {
})
test('draws promoted outline for visible promoted widgets', () => {
isPromotedByAnyMock.mockReturnValue(true)
isWidgetPromotedMock.mockReturnValue(true)
const node = new LGraphNode('test-node')
const rootGraph = { id: 'root-graph-id' }
@@ -138,16 +136,17 @@ describe('DOMWidget draw promotion behavior', () => {
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
expect(isPromotedByAnyMock).toHaveBeenCalledWith('root-graph-id', {
sourceNodeId: '-1',
sourceWidgetName: 'seed'
})
expect(isWidgetPromotedMock).toHaveBeenCalledWith(
'root-graph-id',
'-1',
'seed'
)
expect(ctx.strokeRect).toHaveBeenCalledOnce()
expect(onDraw).toHaveBeenCalledWith(widget)
})
test('does not draw promoted outline when widget is not promoted', () => {
isPromotedByAnyMock.mockReturnValue(false)
isWidgetPromotedMock.mockReturnValue(false)
const node = new LGraphNode('test-node')
const rootGraph = { id: 'root-graph-id' }
@@ -187,7 +186,7 @@ describe('DOMWidget draw promotion behavior', () => {
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
expect(isPromotedByAnyMock).not.toHaveBeenCalled()
expect(isWidgetPromotedMock).not.toHaveBeenCalled()
expect(ctx.strokeRect).not.toHaveBeenCalled()
expect(onDraw).toHaveBeenCalledWith(widget)
})

View File

@@ -2,6 +2,7 @@ import _ from 'es-toolkit/compat'
import { type Component, toRaw } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isWidgetPromoted } from '@/core/graph/subgraph/promotionLookup'
import {
LGraphNode,
LegacyWidget,
@@ -13,7 +14,6 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { generateUUID } from '@/utils/formatUtil'
export interface BaseDOMWidget<
@@ -125,8 +125,6 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
declare readonly name: string
declare readonly options: DOMWidgetOptions<V>
declare callback?: (value: V) => void
readonly promotionStore = usePromotionStore()
readonly id: string
constructor(obj: {
@@ -189,11 +187,7 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
const graphId = this.node.graph?.rootGraph.id
const isPromoted =
graphId &&
this.promotionStore.isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
graphId && isWidgetPromoted(graphId, String(this.node.id), this.name)
if (!isPromoted) {
this.options.onDraw?.(this)
return

View File

@@ -193,6 +193,29 @@ describe(usePromotionStore, () => {
})
})
describe('isWidgetPromoted', () => {
it('matches exact disambiguated promotion keys', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
expect(store.isWidgetPromoted(graphA, '3', 'text', '1')).toBe(true)
expect(store.isWidgetPromoted(graphA, '3', 'text', '2')).toBe(false)
})
it('falls back to the base key when disambiguatingSourceNodeId is omitted', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
expect(store.isWidgetPromoted(graphA, '3', 'text')).toBe(true)
})
})
describe('setPromotions', () => {
it('replaces existing entries', () => {
store.promote(graphA, nodeId, {
@@ -817,7 +840,7 @@ describe(usePromotionStore, () => {
).toBe(true)
})
it('isPromotedByAny with disambiguatingSourceNodeId only matches keyed entries', () => {
it('isPromotedByAny with disambiguatingSourceNodeId matches exact key and base key', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
@@ -837,12 +860,14 @@ describe(usePromotionStore, () => {
disambiguatingSourceNodeId: '2'
})
).toBe(false)
// Base-key lookup succeeds because dual-indexing keeps a ref count
// on the base key for callers that lack a disambiguator.
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text'
})
).toBe(false)
).toBe(true)
})
it('setPromotions with disambiguatingSourceNodeId entries maintains correct ref-counts', () => {

View File

@@ -7,8 +7,12 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
const EMPTY_PROMOTIONS: PromotedWidgetSource[] = []
function makePromotionBaseKey(source: PromotedWidgetSource): string {
return `${source.sourceNodeId}:${source.sourceWidgetName}`
}
export function makePromotionEntryKey(source: PromotedWidgetSource): string {
const base = `${source.sourceNodeId}:${source.sourceWidgetName}`
const base = makePromotionBaseKey(source)
return source.disambiguatingSourceNodeId
? `${base}:${source.disambiguatingSourceNodeId}`
: base
@@ -40,14 +44,33 @@ export const usePromotionStore = defineStore('promotion', () => {
return nextRefCounts
}
function _incrementKey(refCounts: Map<string, number>, key: string): void {
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
}
function _decrementKey(refCounts: Map<string, number>, key: string): void {
const current = refCounts.get(key)
if (current === undefined) return
if (current <= 1) {
refCounts.delete(key)
} else {
refCounts.set(key, current - 1)
}
}
function _incrementKeys(
graphId: UUID,
entries: PromotedWidgetSource[]
): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = makePromotionEntryKey(e)
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
_incrementKey(refCounts, makePromotionEntryKey(e))
// Also index the base key so callers without a disambiguator can
// still query whether any widget with this name is promoted.
if (e.disambiguatingSourceNodeId) {
_incrementKey(refCounts, makePromotionBaseKey(e))
}
}
}
@@ -57,12 +80,9 @@ export const usePromotionStore = defineStore('promotion', () => {
): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = makePromotionEntryKey(e)
const count = (refCounts.get(key) ?? 1) - 1
if (count <= 0) {
refCounts.delete(key)
} else {
refCounts.set(key, count)
_decrementKey(refCounts, makePromotionEntryKey(e))
if (e.disambiguatingSourceNodeId) {
_decrementKey(refCounts, makePromotionBaseKey(e))
}
}
}
@@ -190,11 +210,31 @@ export const usePromotionStore = defineStore('promotion', () => {
graphRefCounts.value.delete(graphId)
}
/**
* Checks whether a widget is promoted by any subgraph node in the given
* graph. When disambiguatingSourceNodeId is provided, does an exact-key
* lookup; otherwise falls back to a base-key lookup (which succeeds if
* any widget with that name on the source node is promoted).
*/
function isWidgetPromoted(
graphId: UUID,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): boolean {
return isPromotedByAny(graphId, {
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
})
}
return {
getPromotionsRef,
getPromotions,
isPromoted,
isPromotedByAny,
isWidgetPromoted,
setPromotions,
promote,
demote,