Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
dbd76a0
fix: make ensureCorrectLayoutScale idempotent to prevent node layout …
DrJKL Mar 9, 2026
98b77db
refactor: simplify ensureCorrectLayoutScale to one-time normalizer
DrJKL Mar 9, 2026
9e72a6e
fix: sync layout change notifications synchronously
DrJKL Mar 9, 2026
751cd9b
refactor: remove unused project* exports and simplify review findings
DrJKL Mar 10, 2026
56829f2
fix: extract MIN_NODE_WIDTH constant and sync inner wrapper min-width
DrJKL Mar 10, 2026
31b8178
fix: skip ResizeObserver updates for collapsed nodes
DrJKL Mar 10, 2026
22f383e
docs: add release process guide (#9548)
christian-byrne Mar 9, 2026
6b8ad37
fix: show load widget inputs in media dropdown (#9670)
DrJKL Mar 9, 2026
68689d5
fix: add isGraphReady guard to prevent premature graph access error l…
jaeone94 Mar 9, 2026
1a6788d
Restore hiding of linked inputs in app mode (#9671)
AustinMroz Mar 9, 2026
cce5daa
fix: dispatch cloud build on synchronize for preview-labeled PRs (#9636)
huntcsg Mar 9, 2026
7d603a5
Use preview downscaling in fewer places (#9678)
AustinMroz Mar 9, 2026
9478582
fix: reduce vue drag sync fan-out and per-frame overhead
DrJKL Mar 10, 2026
860f4b6
fix: skip no-op vue resize and slot sync work
DrJKL Mar 10, 2026
ff9e0e5
fix: simplify and harden vue resize no-op guards
DrJKL Mar 10, 2026
f06c9cd
fix: resolve coderabbit normalization and slot sync threads
DrJKL Mar 10, 2026
cd8e82b
Merge branch 'main' into drjkl/dark-energy
DrJKL Mar 10, 2026
033dfe4
fix: reduce layout and ui scheduling overhead on interaction paths
DrJKL Mar 10, 2026
48ba8b4
Merge branch 'main' into drjkl/dark-energy
DrJKL Mar 11, 2026
b86949b
Merge branch 'main' into drjkl/dark-energy
DrJKL Mar 12, 2026
b70eb1f
[automated] Apply ESLint and Oxfmt fixes
actions-user Mar 12, 2026
eefdebd
Merge branch 'main' into drjkl/dark-energy
DrJKL Mar 12, 2026
516e012
types: We need to fix all the Parameters utility usages. That's silly
DrJKL Mar 12, 2026
f6b7f5c
Function Declarations and avoiding stale flushes
DrJKL Mar 12, 2026
afd9445
fix: address review feedback on layout normalization
DrJKL Mar 13, 2026
8842d76
fix: address layout sync lint and node id lookup
DrJKL Mar 13, 2026
d157906
test: stabilize renderer toggle position assertions
DrJKL Mar 13, 2026
e5cc5db
Merge branch 'main' into drjkl/dark-energy
DrJKL Mar 13, 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
104 changes: 104 additions & 0 deletions browser_tests/tests/rendererToggleStability.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { Position } from '../fixtures/types'

type NodeSnapshot = { id: number } & Position

async function getAllNodePositions(
comfyPage: ComfyPage
): Promise<NodeSnapshot[]> {
return comfyPage.page.evaluate(() =>
window.app!.graph.nodes.map((n) => ({
id: n.id as number,
x: n.pos[0],
y: n.pos[1]
}))
)
}

async function getNodePosition(
comfyPage: ComfyPage,
nodeId: number
): Promise<Position | undefined> {
return comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)
if (!node) return

return {
x: node.pos[0],
y: node.pos[1]
}
}, nodeId)
}

async function expectNodePositionStable(
comfyPage: ComfyPage,
initial: NodeSnapshot,
mode: string
) {
await expect
.poll(
async () => {
const current = await getNodePosition(comfyPage, initial.id)
return current?.x ?? Number.NaN
},
{ message: `node ${initial.id} x drifted in ${mode} mode` }
)
.toBeCloseTo(initial.x, 1)

await expect
.poll(
async () => {
const current = await getNodePosition(comfyPage, initial.id)
return current?.y ?? Number.NaN
},
{ message: `node ${initial.id} y drifted in ${mode} mode` }
)
.toBeCloseTo(initial.y, 1)
}

async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
if (enabled) {
await comfyPage.vueNodes.waitForNodes()
}
await comfyPage.nextFrame()
}

test.describe(
'Renderer toggle stability',
{ tag: ['@node', '@canvas'] },
() => {
test('node positions do not drift when toggling between Vue and LiteGraph renderers', async ({
comfyPage
}) => {
const TOGGLE_COUNT = 5

const initialPositions = await getAllNodePositions(comfyPage)
expect(initialPositions.length).toBeGreaterThan(0)

for (let i = 0; i < TOGGLE_COUNT; i++) {
await setVueMode(comfyPage, true)
for (const initial of initialPositions) {
await expectNodePositionStable(
comfyPage,
initial,
`Vue toggle ${i + 1}`
)
}

await setVueMode(comfyPage, false)
for (const initial of initialPositions) {
await expectNodePositionStable(
comfyPage,
initial,
`LiteGraph toggle ${i + 1}`
)
}
}
})
}
)
85 changes: 85 additions & 0 deletions src/components/TopMenuSection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))

vi.mock('@/scripts/app', () => ({
app: {
menu: {
element: document.createElement('div')
}
}
}))

type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
Expand Down Expand Up @@ -131,6 +139,18 @@ function createWrapper({
})
}

function getLegacyCommandsContainer(
wrapper: ReturnType<typeof createWrapper>
): HTMLElement {
const legacyContainer = wrapper.find(
'[data-testid="legacy-topbar-container"]'
).element
if (!(legacyContainer instanceof HTMLElement)) {
throw new Error('Expected legacy commands container to be present')
}
return legacyContainer
}

function createJob(id: string, status: JobStatus): JobListItem {
return {
id,
Expand Down Expand Up @@ -515,4 +535,69 @@ describe('TopMenuSection', () => {

expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
})

it('coalesces legacy topbar mutation scans to one check per frame', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')

const rafCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallbacks.push(cb)
return rafCallbacks.length
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())

const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.UseNewMenu') return 'Top'
if (key === 'Comfy.UI.TabBarLayout') return 'Integrated'
if (key === 'Comfy.RightSidePanel.IsOpen') return true
return undefined
})

const wrapper = createWrapper({ pinia, attachTo: document.body })

try {
await nextTick()

const actionbarContainer = wrapper.find('.actionbar-container')
expect(actionbarContainer.classes()).toContain('w-0')

const legacyContainer = getLegacyCommandsContainer(wrapper)
const querySpy = vi.spyOn(legacyContainer, 'querySelector')

if (rafCallbacks.length > 0) {
const initialCallbacks = [...rafCallbacks]
rafCallbacks.length = 0
initialCallbacks.forEach((callback) => callback(0))
await nextTick()
}
querySpy.mockClear()
querySpy.mockReturnValue(document.createElement('div'))

for (let index = 0; index < 3; index++) {
const outer = document.createElement('div')
const inner = document.createElement('div')
inner.textContent = `legacy-${index}`
outer.appendChild(inner)
legacyContainer.appendChild(outer)
}

await vi.waitFor(() => {
expect(rafCallbacks.length).toBeGreaterThan(0)
})
expect(querySpy).not.toHaveBeenCalled()

const callbacks = [...rafCallbacks]
rafCallbacks.length = 0
callbacks.forEach((callback) => callback(0))
await nextTick()

expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer.classes()).toContain('px-2')
} finally {
wrapper.unmount()
vi.unstubAllGlobals()
}
})
})
26 changes: 22 additions & 4 deletions src/components/TopMenuSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>

Expand Down Expand Up @@ -116,7 +117,7 @@
<script setup lang="ts">
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'

import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
Expand Down Expand Up @@ -264,6 +265,7 @@ const rightSidePanelTooltipConfig = computed(() =>
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)
let legacyContentCheckRafId: number | null = null

function checkLegacyContent() {
const el = legacyCommandsContainerRef.value
Expand All @@ -276,19 +278,35 @@ function checkLegacyContent() {
el.querySelector(':scope > * > *:not(:empty)') !== null
}

useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
function scheduleLegacyContentCheck() {
if (legacyContentCheckRafId !== null) return

legacyContentCheckRafId = requestAnimationFrame(() => {
legacyContentCheckRafId = null
checkLegacyContent()
})
}

useMutationObserver(legacyCommandsContainerRef, scheduleLegacyContentCheck, {
childList: true,
subtree: true,
characterData: true
subtree: true
})

onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'
legacyCommandsContainerRef.value.appendChild(app.menu.element)
checkLegacyContent()
}
})

onBeforeUnmount(() => {
if (legacyContentCheckRafId === null) return

cancelAnimationFrame(legacyContentCheckRafId)
legacyContentCheckRafId = null
})

const openCustomNodeManager = async () => {
try {
await managerState.openManager({
Expand Down
27 changes: 8 additions & 19 deletions src/composables/graph/useVueNodeLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'

function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
const layoutMutations = useLayoutMutations()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const nodeManager = shallowRef<GraphNodeManager | null>(null)
const { startSync } = useLayoutSync()
const { startSync, stopSync } = useLayoutSync()

const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
Expand Down Expand Up @@ -55,11 +54,13 @@ function useVueNodeLifecycleIndividual() {
)
}

// Initialize layout sync (one-way: Layout Store → LiteGraph)
// Start sync AFTER seeding so bootstrap operations don't trigger
// the Layout→LiteGraph writeback loop redundantly.
startSync(canvasStore.canvas)
}

const disposeNodeManagerAndSyncs = () => {
stopSync()
if (!nodeManager.value) return

try {
Expand All @@ -76,9 +77,6 @@ function useVueNodeLifecycleIndividual() {
(enabled) => {
if (enabled) {
initializeNodeManager()
ensureCorrectLayoutScale(
comfyApp.canvas?.graph?.extra.workflowRendererVersion
)
}
},
{ immediate: true }
Expand All @@ -87,26 +85,17 @@ function useVueNodeLifecycleIndividual() {
whenever(
() => !shouldRenderVueNodes.value,
() => {
ensureCorrectLayoutScale(
comfyApp.canvas?.graph?.extra.workflowRendererVersion
)
disposeNodeManagerAndSyncs()
comfyApp.canvas?.setDirty(true, true)
}
)

// Consolidated watch for slot layout sync management
// Clear stale slot layouts when switching modes
watch(
() => shouldRenderVueNodes.value,
(vueMode, oldVueMode) => {
const modeChanged = vueMode !== oldVueMode

// Clear stale slot layouts when switching modes
if (modeChanged) {
layoutStore.clearAllSlotLayouts()
}
},
{ immediate: true, flush: 'sync' }
() => {
layoutStore.clearAllSlotLayouts()
}
)

// Handle case where Vue nodes are enabled but graph starts empty
Expand Down
2 changes: 1 addition & 1 deletion src/lib/litegraph/src/LGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export type {
LGraphTriggerParam
} from './types/graphTriggers'

export type RendererType = 'LG' | 'Vue'
export type RendererType = 'LG' | 'Vue' | 'Vue-corrected'

export interface LGraphState {
lastGroupId: number
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import type { SafeParseReturnType } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { RendererType } from '@/lib/litegraph/src/LGraph'

const zRendererType = z.enum(['LG', 'Vue']) satisfies z.ZodType<RendererType>
const zRendererType = z.enum([
'LG',
'Vue',
'Vue-corrected'
]) satisfies z.ZodType<RendererType>

// GroupNode is hacking node id to be a string, so we need to allow that.
// innerNode.id = `${this.node.id}:${i}`
Expand Down
Loading
Loading