Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d820cff
fix: preserve combo value rendering for promoted subgraph widgets
DrJKL Feb 26, 2026
42201a2
fix: stabilize nested subgraph promoted widget resolution
DrJKL Feb 27, 2026
32c88d3
fix: use deep source keys for promoted widget values in vue mode
DrJKL Feb 27, 2026
38419cb
fix: stabilize nested promoted widget slot resolution
DrJKL Feb 27, 2026
b7ef63d
fix: keep referenced subgraph definitions on unpack
DrJKL Feb 27, 2026
4c0db3e
fix: remove unused exported resolution types
DrJKL Feb 27, 2026
25afb54
fix: simplify promotion merge flow and widget naming
DrJKL Feb 27, 2026
6ac2cfb
refactor: unify promoted widget resolution and subgraph input link tr…
DrJKL Feb 27, 2026
9595fec
fix: promoted DOM widgets disabled state on subgraph exterior
DrJKL Feb 27, 2026
4e2c244
[automated] Apply ESLint and Oxfmt fixes
actions-user Feb 27, 2026
8690d82
fix: clear slotMetadata before repopulating in reactiveComputed
DrJKL Feb 27, 2026
41907c0
fix: fall back to base widget disabled state in DomWidget override
DrJKL Feb 27, 2026
f1eea82
test: fix computedDisabled test to match actual setter behavior
DrJKL Feb 27, 2026
1e57842
Fix: promoted test
DrJKL Feb 27, 2026
9895929
fix: add nextFrame sync before drag screenshot assertion
DrJKL Feb 27, 2026
835f5c8
fix: address prioritized PR9282 review items
DrJKL Feb 28, 2026
356e726
fix: make subgraph input target type internal
DrJKL Feb 28, 2026
ce1a32d
fix: restore live promoted widget resolution behavior
DrJKL Feb 28, 2026
38b088d
fix: resolve remaining Christian PR9282 comments
DrJKL Feb 28, 2026
31c1ead
fix: cache promoted widget resolution per frame
DrJKL Feb 28, 2026
c12fc97
test: use subgraph fixtures in promoted widget resolution tests
DrJKL Feb 28, 2026
2dbc101
fix: return undefined on failed promoted widget state lookup
DrJKL Feb 28, 2026
62f23d0
test: use subgraph fixtures in input link resolution tests
DrJKL Feb 28, 2026
4fa4642
Merge branch 'main' into drjkl/preview-fix
DrJKL Feb 28, 2026
62654c6
test: use dom widget store in DomWidget test setup
DrJKL Feb 28, 2026
54d117c
Merge branch 'main' into drjkl/preview-fix
DrJKL Feb 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
760 changes: 760 additions & 0 deletions browser_tests/assets/subgraphs/subgraph-nested-promotion.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions browser_tests/tests/interaction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ test.describe('Node Interaction', () => {

test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
await comfyPage.nextFrame()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a stabilization fix for the test, the DOM widget z-index is inconsistent at point of screenshot otherwise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth coming back and adding a proper wait condition later.

await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})

Expand Down
68 changes: 68 additions & 0 deletions browser_tests/tests/subgraphPromotion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,74 @@ test.describe(
})
})

test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()

// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
// slot connected externally from the Outer node, so it should be
// disabled. The remaining promoted textarea widgets (value, value_1)
// are unlinked and should be enabled.
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('string_a')
expect(promotedNames).toContain('value')

const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})

const linkedWidget = disabledState.find((w) => w.name === 'string_a')
expect(linkedWidget?.disabled).toBe(true)

const unlinkedWidgets = disabledState.filter(
(w) => w.name !== 'string_a'
)
for (const w of unlinkedWidgets) {
expect(w.disabled).toBe(false)
}
})

test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()

// The promoted textareas that are NOT externally linked should be
// fully opaque and interactive.
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()

const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
const wrapper = textarea.locator('..')
const opacity = await wrapper.evaluate(
(el) => getComputedStyle(el).opacity
)

if (opacity === '1' && (await textarea.isEditable())) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
}
}
})
})

test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
comfyPage
Expand Down
116 changes: 116 additions & 0 deletions src/components/graph/widgets/DomWidget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'

import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import type { DomWidgetState } from '@/stores/domWidgetStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'

import DomWidget from './DomWidget.vue'

const mockUpdatePosition = vi.fn()
const mockUpdateClipPath = vi.fn()
const mockCanvasElement = document.createElement('canvas')
const mockCanvasStore = {
canvas: {
graph: {
getNodeById: vi.fn(() => true)
},
ds: {
offset: [0, 0],
scale: 1
},
canvas: mockCanvasElement,
selected_nodes: {}
},
getCanvas: () => ({ canvas: mockCanvasElement }),
linearMode: false
}

vi.mock('@/composables/element/useAbsolutePosition', () => ({
useAbsolutePosition: () => ({
style: reactive<Record<string, string>>({}),
updatePosition: mockUpdatePosition
})
}))

vi.mock('@/composables/element/useDomClipping', () => ({
useDomClipping: () => ({
style: reactive<Record<string, string>>({}),
updateClipPath: mockUpdateClipPath
})
}))

vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))

vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => false)
})
}))

function createWidgetState(overrideDisabled: boolean): DomWidgetState {
const domWidgetStore = useDomWidgetStore()
const node = createMockLGraphNode({
id: 1,
constructor: {
nodeData: {}
}
})

const widget = {
id: 'dom-widget-id',
name: 'test_widget',
type: 'custom',
value: '',
options: {},
node,
computedDisabled: false
} as unknown as BaseDOMWidget<object | string>

domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: createMockLGraphNode({ id: 2 }),
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
})

const state = domWidgetStore.widgetStates.get(widget.id)
if (!state) throw new Error('Expected registered DomWidgetState')

state.zIndex = 2
state.size = [100, 40]

return reactive(state)
}

describe('DomWidget disabled style', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})

afterEach(() => {
useDomWidgetStore().clear()
vi.clearAllMocks()
})

it('uses disabled style when promoted override widget is computedDisabled', async () => {
const widgetState = createWidgetState(true)
const wrapper = mount(DomWidget, {
props: {
widgetState
}
})

widgetState.zIndex = 3
await wrapper.vm.$nextTick()

const root = wrapper.get('.dom-widget').element as HTMLElement
expect(root.style.pointerEvents).toBe('none')
expect(root.style.opacity).toBe('0.5')
})
})
10 changes: 7 additions & 3 deletions src/components/graph/widgets/DomWidget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,17 @@ watch(
updateDomClipping()
}

const override = widgetState.positionOverride
const isDisabled = override
? (override.widget.computedDisabled ?? widget.computedDisabled)
: widget.computedDisabled

style.value = {
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents:
widgetState.readonly || widget.computedDisabled ? 'none' : 'auto',
opacity: widget.computedDisabled ? 0.5 : 1
pointerEvents: widgetState.readonly || isDisabled ? 'none' : 'auto',
opacity: isDisabled ? 0.5 : 1
}
},
{ deep: true }
Expand Down
44 changes: 44 additions & 0 deletions src/composables/graph/useGraphNodeManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,47 @@ describe('Subgraph Promoted Pseudo Widgets', () => {
expect(promotedWidget?.options?.canvasOnly).toBe(true)
})
})

describe('Nested promoted widget mapping', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})

it('maps store identity to deepest concrete widget for two-layer promotions', () => {
const subgraphA = createTestSubgraph({
inputs: [{ name: 'a_input', type: '*' }]
})
const innerNode = new LGraphNode('InnerComboNode')
const innerInput = innerNode.addInput('picker_input', '*')
innerNode.addWidget('combo', 'picker', 'a', () => undefined, {
values: ['a', 'b']
})
innerInput.widget = { name: 'picker' }
subgraphA.add(innerNode)
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)

const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 })

const subgraphB = createTestSubgraph({
inputs: [{ name: 'b_input', type: '*' }]
})
subgraphB.add(subgraphNodeA)
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)

const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 })
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)

const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNodeB.id))
const mappedWidget = nodeData?.widgets?.[0]

expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.storeName).toBe('picker')
expect(mappedWidget?.storeNodeId).toBe(
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
)
})
})
Loading
Loading