Compare commits

...

3 Commits

Author SHA1 Message Date
jaeone94
8b9d2d074b test: fix draft reload in subgraph position E2E test
Use page.reload() with explicit draft persistence polling instead of
comfyPage.setup(), which triggered a full navigation that bypassed
the draft auto-load flow.
2026-04-03 03:50:24 +09:00
jaeone94
fa0239a9ca test: add E2E test for subgraph node position preservation after draft reload
Verifies that entering a subgraph, reloading the page (draft auto-loads),
and auto-entering the subgraph via hash navigation does not corrupt
internal node positions.
2026-04-03 03:31:45 +09:00
jaeone94
63a56a8ad1 fix: prevent subgraph node position corruption during graph transitions
ResizeObserver was converting DOM positions back to canvas coordinates
using clientPosToCanvasPos, which depends on the current canvas
scale/offset. During graph transitions (e.g. entering a subgraph from
a draft-loaded workflow), the canvas viewport was stale (still had the
parent graph's zoom level), causing all subgraph node positions to be
permanently corrupted. The corruption accumulated across page refreshes
as drafts saved the corrupted positions.

Use the layout store's existing position (initialized from LiteGraph)
instead of reverse-converting DOM screen coordinates. Only fall back to
getBoundingClientRect conversion for nodes not yet in the layout store.

Fixes subgraph node positions drifting apart or compressing together
when entering a subgraph after draft workflow reload.
2026-04-03 03:24:37 +09:00
3 changed files with 112 additions and 24 deletions

View File

@@ -0,0 +1,84 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe(
'Subgraph node positions after draft reload',
{ tag: ['@subgraph'] },
() => {
test('Node positions are preserved after draft reload with subgraph auto-entry', async ({
comfyPage
}) => {
test.setTimeout(30000)
// Enable workflow persistence explicitly
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
// Load a workflow containing a subgraph
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
// Enter the subgraph and record internal node positions
await comfyPage.vueNodes.enterSubgraph()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
const positionsBefore = await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
})
expect(positionsBefore.length).toBeGreaterThan(0)
// Wait for the debounced draft persistence to flush to localStorage
await expect
.poll(
() =>
comfyPage.page.evaluate(() =>
Object.keys(localStorage).some((k) =>
k.startsWith('Comfy.Workflow.Draft.v2:')
)
),
{ timeout: 3000 }
)
.toBe(true)
// Reload the page (draft auto-loads with hash preserved)
await comfyPage.page.reload({ waitUntil: 'networkidle' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await comfyPage.page.waitForSelector('.p-blockui-mask', {
state: 'hidden'
})
await comfyPage.nextFrame()
// Wait for subgraph auto-entry via hash navigation
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), { timeout: 10000 })
.toBe(true)
// Verify all internal node positions are preserved
const positionsAfter = await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
})
for (const before of positionsBefore) {
const after = positionsAfter.find((n) => n.id === before.id)
expect(
after,
`Node ${before.id} should exist after reload`
).toBeDefined()
expect(after!.x).toBeCloseTo(before.x, 0)
expect(after!.y).toBeCloseTo(before.y, 0)
}
})
}
)

View File

@@ -181,7 +181,9 @@ describe('useVueNodeResizeTracking', () => {
resizeObserverState.callback?.([entry], createObserverMock())
expect(rectSpy).toHaveBeenCalledTimes(1)
// When layout store already has correct position, getBoundingClientRect
// is not needed — position is read from the store instead.
expect(rectSpy).not.toHaveBeenCalled()
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
@@ -192,13 +194,13 @@ describe('useVueNodeResizeTracking', () => {
resizeObserverState.callback?.([entry], createObserverMock())
expect(rectSpy).toHaveBeenCalledTimes(1)
expect(rectSpy).not.toHaveBeenCalled()
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
})
it('updates bounds on first observation when size matches but position differs', () => {
it('preserves layout store position when size matches but DOM position differs', () => {
const nodeId = 'test-node'
const width = 240
const height = 180
@@ -209,7 +211,6 @@ describe('useVueNodeResizeTracking', () => {
left: 100,
top: 200
})
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
seedNodeLayout({
nodeId,
@@ -221,20 +222,10 @@ describe('useVueNodeResizeTracking', () => {
resizeObserverState.callback?.([entry], createObserverMock())
expect(rectSpy).toHaveBeenCalledTimes(1)
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([
{
nodeId,
bounds: {
x: 100,
y: 200 + titleHeight,
width,
height
}
}
])
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
// Position from DOM should NOT override layout store position
expect(rectSpy).not.toHaveBeenCalled()
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
})
it('updates node bounds + slot layouts when size changes', () => {

View File

@@ -186,13 +186,26 @@ const resizeObserver = new ResizeObserver((entries) => {
continue
}
// Screen-space rect
const rect = element.getBoundingClientRect()
const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top])
const topLeftCanvas = { x: cx, y: cy }
// Use existing position from layout store (source of truth) rather than
// converting screen-space getBoundingClientRect() back to canvas coords.
// The DOM→canvas conversion depends on the current canvas scale/offset,
// which can be stale during graph transitions (e.g. entering a subgraph
// before fitView runs), producing corrupted positions.
const existingPos = nodeLayout?.position
let posX: number
let posY: number
if (existingPos) {
posX = existingPos.x
posY = existingPos.y
} else {
const rect = element.getBoundingClientRect()
const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top])
posX = cx
posY = cy + LiteGraph.NODE_TITLE_HEIGHT
}
const bounds: Bounds = {
x: topLeftCanvas.x,
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
x: posX,
y: posY,
width,
height
}