Compare commits

...

5 Commits

Author SHA1 Message Date
Alexander Brown
694d96d080 Merge branch 'main' into glary/fix-ghost-dom-widgets-on-subgraph-collapse 2026-05-18 17:15:21 -07:00
Alexander Brown
77066044cb Merge branch 'main' into glary/fix-ghost-dom-widgets-on-subgraph-collapse 2026-05-04 15:17:40 -07:00
Glary-Bot
68be64c6dd fix: clear stale position overrides on mode switch, guard syncDomOverride for collapsed hosts
Add defense-in-depth for ghost DOM widgets on collapsed SubgraphNodes:

- Clear all domWidgetStore position overrides when switching from Vue
  to Legacy mode (useVueNodeLifecycle.ts). The legacy renderer
  re-establishes needed overrides during its first draw cycle.

- Skip syncDomOverride when the SubgraphNode is collapsed
  (promotedWidgetView.ts). Prevents unnecessary override creation
  that could surface if the DomWidgets visibility logic changes.

- Add E2E tests for the Nodes 2.0 → Legacy toggle reproduction path:
  single toggle, repeated toggles, and expand-after-toggle scenarios.

- Add unit tests for syncDomOverride collapsed guard via draw() and
  y setter paths.

- Fixes #10000
2026-04-19 09:17:18 +00:00
Glary-Bot
b81674015c test: remove isVisible override that masked regression path
Per CodeRabbit review: the mock made the test pass via the early-exit
guard instead of exercising the new shouldBeVisible logic. Now uses
createWidget's default isVisible: () => true so only posNode.collapsed
can flip visibility. Added expand-back assertion for symmetry.
2026-04-19 08:21:04 +00:00
Glary-Bot
9c88bad70b fix: hide DOM widgets when SubgraphNode is collapsed
Add collapsed check to DomWidgets.vue visibility logic so promoted
DOM widgets (text inputs, textareas) are hidden when their positioning
node is collapsed. Previously, only viewport visibility and graph
membership were checked — collapsed SubgraphNodes still passed both
checks, leaving promoted widgets floating as ghost elements.

Introduces a compound shouldBeVisible boolean ordered by short-circuit
likelihood: nodeVisible (off-screen check, most common) before
posNode.collapsed (explicit user action, rare).

- Fixes #9994
2026-04-19 08:07:40 +00:00
6 changed files with 298 additions and 1 deletions

View File

@@ -0,0 +1,168 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
async function toggleSubgraphCollapse(
comfyPage: ComfyPage,
nodeId: string
): Promise<void> {
await comfyPage.page.evaluate((id) => {
const node = window.app!.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found`)
node.collapse()
}, nodeId)
await comfyPage.nextFrame()
}
async function setVueMode(
comfyPage: ComfyPage,
enabled: boolean
): Promise<void> {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
if (enabled) {
await comfyPage.vueNodes.waitForNodes()
}
await comfyPage.nextFrame()
}
test.describe(
'Subgraph collapse hides DOM widgets',
{ tag: ['@subgraph'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
})
test('promoted DOM widget is hidden when SubgraphNode collapses and restored on expand', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
await expect(
visibleWidgets,
'Promoted text widget should be visible before collapse'
).toHaveCount(1)
await toggleSubgraphCollapse(comfyPage, '11')
await expect(visibleWidgets).toHaveCount(0)
await toggleSubgraphCollapse(comfyPage, '11')
await expect(visibleWidgets).toHaveCount(1)
})
test('all promoted DOM widgets are hidden when SubgraphNode collapses', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
await expect(
visibleWidgets,
'Both promoted text widgets should be visible before collapse'
).toHaveCount(2)
await toggleSubgraphCollapse(comfyPage, '11')
await expect(visibleWidgets).toHaveCount(0)
})
test('DOM widgets stay hidden after entering and exiting a collapsed subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await toggleSubgraphCollapse(comfyPage, '11')
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(0)
await comfyPage.page.evaluate(() => {
const node = window.app!.graph!.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
if (!node || !('subgraph' in node))
throw new Error('SubgraphNode not found')
window.app!.canvas.openSubgraph(node.subgraph, node)
})
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(0)
})
test('no ghost DOM widgets on collapsed subgraph after Vue-to-Legacy toggle', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
await expect(visibleWidgets).toHaveCount(1)
await toggleSubgraphCollapse(comfyPage, '11')
await expect(visibleWidgets).toHaveCount(0)
await setVueMode(comfyPage, true)
await setVueMode(comfyPage, false)
await expect(visibleWidgets).toHaveCount(0)
})
test('no ghost DOM widgets after repeated renderer toggles on collapsed subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await toggleSubgraphCollapse(comfyPage, '11')
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
for (let i = 0; i < 3; i++) {
await setVueMode(comfyPage, true)
await setVueMode(comfyPage, false)
}
await expect(visibleWidgets).toHaveCount(0)
})
test('widgets reappear after mode toggle then expand on collapsed subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await toggleSubgraphCollapse(comfyPage, '11')
await setVueMode(comfyPage, true)
await setVueMode(comfyPage, false)
await toggleSubgraphCollapse(comfyPage, '11')
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(1)
})
}
)

View File

@@ -57,6 +57,77 @@ function drawFrame(canvas: LGraphCanvas) {
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
}
describe('DomWidgets collapsed node visibility', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('hides widget when position-override node is collapsed', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graph = new LGraph()
const interiorNode = createNode(graph, 1, 'interior', [100, 200])
const overrideNode = createNode(graph, 2, 'subgraphHost', [300, 400])
const widget = createWidget('w-collapse-override', interiorNode, 14)
const overrideWidget = createWidget('w-override-pos', overrideNode, 22)
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: overrideNode,
widget: overrideWidget
})
const canvas = createCanvas(graph)
canvasStore.canvas = canvas
render(DomWidgets, { global: { stubs: { DomWidget: true } } })
// Expanded — widget visible
drawFrame(canvas)
const state = domWidgetStore.widgetStates.get(widget.id)!
expect(state.visible).toBe(true)
// Collapse override node — widget hidden
overrideNode.flags.collapsed = true
drawFrame(canvas)
expect(state.visible).toBe(false)
// Expand — widget restored
overrideNode.flags.collapsed = false
drawFrame(canvas)
expect(state.visible).toBe(true)
})
it('hides widget when own node (no override) is collapsed', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graph = new LGraph()
const node = createNode(graph, 1, 'collapsible', [100, 200])
const widget = createWidget('w-collapse-own', node, 14)
domWidgetStore.registerWidget(widget)
const canvas = createCanvas(graph)
canvasStore.canvas = canvas
render(DomWidgets, { global: { stubs: { DomWidget: true } } })
drawFrame(canvas)
const state = domWidgetStore.widgetStates.get(widget.id)!
expect(state.visible).toBe(true)
node.flags.collapsed = true
drawFrame(canvas)
expect(state.visible).toBe(false)
node.flags.collapsed = false
drawFrame(canvas)
expect(state.visible).toBe(true)
})
})
describe('DomWidgets transition grace characterization', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -75,10 +75,11 @@ const updateWidgets = () => {
const isInCorrectGraph = posNode.graph === currentGraph
const nodeVisible = lgCanvas.isNodeVisible(posNode)
const shouldBeVisible = nodeVisible && !posNode.collapsed
widgetState.visible =
shouldBeVisible &&
isInCorrectGraph &&
nodeVisible &&
!(widget.options.hideOnZoom && lowQuality)
if (widgetState.visible) {

View File

@@ -10,6 +10,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { app as comfyApp } from '@/scripts/app'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
@@ -87,6 +88,16 @@ function useVueNodeLifecycleIndividual() {
() => {
disposeNodeManagerAndSyncs()
// Clear stale DOM widget position overrides from the Vue rendering
// cycle. The legacy renderer re-establishes needed overrides during
// its first draw pass via PromotedWidgetView.draw / y setter.
const domWidgetStore = useDomWidgetStore()
for (const [widgetId, state] of domWidgetStore.widgetStates) {
if (state.positionOverride) {
domWidgetStore.clearPositionOverride(widgetId)
}
}
// Force arrange() on all nodes so input.pos is computed before
// the first legacy drawConnections frame (which may run before
// drawNode on the foreground canvas).

View File

@@ -2656,6 +2656,51 @@ describe('DOM widget promotion', () => {
)
})
test('syncDomOverride skips override when subgraphNode is collapsed', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'textarea')
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
subgraphNode.flags.collapsed = true
mockDomWidgetStore.setPositionOverride.mockClear()
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
expect(mockDomWidgetStore.setPositionOverride).not.toHaveBeenCalled()
})
test('syncDomOverride sets override when subgraphNode is expanded', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'textarea')
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
subgraphNode.flags.collapsed = false
mockDomWidgetStore.setPositionOverride.mockClear()
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
expect(mockDomWidgetStore.setPositionOverride).toHaveBeenCalledWith(
'dom-widget-textarea',
{ node: subgraphNode, widget: view }
)
})
test('y setter does not trigger override when subgraphNode is collapsed', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'textarea')
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
subgraphNode.flags.collapsed = true
mockDomWidgetStore.setPositionOverride.mockClear()
const view = subgraphNode.widgets[0]
view.y = 100
expect(mockDomWidgetStore.setPositionOverride).not.toHaveBeenCalled()
})
test('onRemoved clears position overrides for all promoted DOM widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'widgetA')

View File

@@ -535,6 +535,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
| undefined = this.resolveAtHost()
) {
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
if (this.subgraphNode.flags?.collapsed) return
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
node: this.subgraphNode,
widget: this