Compare commits

...

8 Commits

Author SHA1 Message Date
bymyself
e0ba479d6d test: fix appModeTemplateViewport second toggle (no-op after new tab)
Loading a template via loadGraphData(..., null, { openSource: 'template' })
calls afterLoadNewGraph(null, ...), which creates a new temporary workflow
tab in graph mode and switches the active workflow to it. The original test
then toggled app mode a second time, but on the new (graph-mode) workflow
that toggle ENTERED app mode again instead of exiting — leaving the canvas
hidden (0x0) and timing out toBeVisible().

The canvas re-shows automatically when the new graph-mode workflow becomes
active, so the manual second toggle is unnecessary. The bug under test is
still covered: queued canvasScheduler ops would run against a 0x0 canvas
without the fix, corrupting ds.scale/ds.offset, which the assertions catch.
2026-05-01 23:22:28 -07:00
bymyself
52f64e5823 test: add Playwright regression test for app mode template viewport
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11420#discussion_r3107586364
2026-04-21 18:59:06 -07:00
bymyself
96451f3713 fix: name linearMode transition condition for clarity
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11420#discussion_r3107646467
2026-04-21 18:59:02 -07:00
bymyself
53b119d280 fix: address code review items for viewport PR
- Uncomment canvasNotMeasurable guards in DragAndScale fitToBounds/animateToBounds
- Make LGraphCanvas.resize() delegate to measureViewport/applyViewport
- Move devAssert to src/base/common/ (DDD layer compliance)
- Enhance devAssert with pluggable reporter hook; wire Sentry + toast in main.ts
- Set ADR 0009 status to Proposed (pending full migration in follow-up)
2026-04-20 19:42:05 -07:00
GitHub Action
a8f22f1a1b [automated] Apply ESLint and Oxfmt fixes 2026-04-21 02:18:03 +00:00
bymyself
a7a60c919c fix: unify canvas resize paths with CanvasViewport system (ADR 0009)
Two independent resize paths existed: resizeCanvas() in app.ts (DPR-aware)
and LGraphCanvas.resize() (DPR-unaware). Neither documented its dependency
on the other, creating implicit temporal coupling. The bg/fg canvas size
mismatch occurred because drawFrontCanvas() composites the bg canvas by
dividing its dimensions by DPR — assuming both canvases were DPR-scaled —
but LGraphCanvas.resize() set them to CSS pixels.

Introduce CanvasViewport: a plain frozen data object (ECS component style)
that serves as single source of truth for canvas sizing. measureViewport()
is a pure function producing viewport state from DOM measurements.
applyViewport() atomically sizes both fg and bg canvases and scales their
contexts, eliminating the possibility of a partial resize.

- Add devAssert() utility: throws in DEV, console.errors in prod
- Add fg/bg size invariant assertion in LGraphCanvas.draw()
- Make LGraphCanvas.resize() DPR-aware (scales both canvases + contexts)
- Replace resizeCanvas() internals with viewport system
- Wire viewport resize into canvasScheduler for app-mode transitions
- 13 new unit tests for viewport and assert modules
2026-04-20 19:14:09 -07:00
bymyself
ecf3d594c3 temp: test if guard is the thing breaking mobile tests 2026-04-20 17:18:08 -07:00
Glary-Bot
c3074a0a11 fix: add canvas scheduler to fix app mode template viewport corruption
- Add useCanvasScheduler: module-level singleton that queues canvas ops
  and flushes them in RAF when the canvas is visible. Auto-flushes on
  linearMode transitions.
- isCanvasReady checks both offsetParent and offsetWidth/offsetHeight
  to verify canvas has non-zero rendered dimensions.
- Add fitToBounds/animateToBounds zero-dimension bailout in DragAndScale
  to prevent scale=0 / offset=NaN when canvas is hidden.
- Replace scattered canvasVisible checks and inline RAF hacks in
  loadGraphData() with canvasScheduler.schedule().
- Replace double-nested RAF in subgraphNavigationStore with scheduler.
- All fitView RAF paths now go through canvasScheduler so clear()
  cancels stale ops across load boundaries.
- Each scheduled op is isolated with try/catch so one failure doesn't
  drop subsequent ops.
2026-04-19 22:33:55 +00:00
17 changed files with 919 additions and 33 deletions

View File

@@ -4,7 +4,7 @@
<button
:class="
cn(
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
'hardware-option flex h-[190px] w-[170px] flex-col items-center rounded-3xl border-4 bg-neutral-900/70 p-5 transition-all duration-200',
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
)
"
@@ -12,13 +12,13 @@
>
<!-- Icon/Logo Area - Rounded square container -->
<div
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
class="icon-container flex h-[110px] w-[110px] shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-800"
>
<img
v-if="imagePath"
:src="imagePath"
:alt="placeholderText"
class="w-full h-full object-cover"
class="size-full object-cover"
style="object-position: 57% center"
draggable="false"
/>
@@ -28,7 +28,7 @@
</div>
<!-- Text Content -->
<div v-if="subtitle" class="text-center mt-4">
<div v-if="subtitle" class="mt-4 text-center">
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
</div>
</button>

View File

@@ -0,0 +1,114 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
/**
* Regression test for viewport corruption when loading a template in app mode.
*
* Root cause: fitView() ran against a 0×0 canvas element hidden by
* display:none (linearMode=true), producing scale=0 and offset=NaN.
* The canvas scheduler now defers viewport ops until the canvas is visible.
*/
test.describe('App Mode Template Viewport', { tag: ['@canvas', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
await comfyPage.appMode.suppressVueNodeSwitchPopup()
})
test('loading a template in app mode does not corrupt viewport', async ({
comfyPage
}) => {
// Enter app mode (canvas becomes hidden via v-show / display:none)
await comfyPage.appMode.toggleAppMode()
await comfyExpect(comfyPage.canvas).toBeHidden()
// Load a template while canvas is hidden — this is the scenario
// that previously caused scale=0 / offset=NaN corruption.
// Note: loadGraphData(..., null, { openSource: 'template' }) creates a new
// temporary workflow tab in graph mode (see workflowService.afterLoadNewGraph),
// which switches the active workflow and re-shows the canvas automatically.
await comfyPage.page.evaluate(async () => {
const app = window.app!
const workflow = app.graph.serialize()
await app.loadGraphData(workflow as ComfyWorkflowJSON, true, true, null, {
openSource: 'template'
})
})
// Loading the template switched to a new graph-mode workflow, so the
// canvas should become visible and queued scheduler ops should flush.
await comfyExpect(comfyPage.canvas).toBeVisible()
// Wait a frame for the scheduler to flush
await comfyPage.nextFrame()
// Verify the viewport was NOT corrupted
const viewport = await comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return {
scale: ds.scale,
offsetX: ds.offset[0],
offsetY: ds.offset[1]
}
})
expect(viewport.scale, 'Scale must not be 0').toBeGreaterThan(0)
expect(Number.isFinite(viewport.offsetX), 'Offset X must not be NaN').toBe(
true
)
expect(Number.isFinite(viewport.offsetY), 'Offset Y must not be NaN').toBe(
true
)
})
test('nodes are visible after loading template in app mode and returning to graph', async ({
comfyPage
}) => {
// Enter app mode
await comfyPage.appMode.toggleAppMode()
await comfyExpect(comfyPage.canvas).toBeHidden()
// Load template while canvas is hidden — see note in the previous test
// about the new graph-mode workflow tab that this opens.
await comfyPage.page.evaluate(async () => {
const app = window.app!
const workflow = app.graph.serialize()
await app.loadGraphData(workflow as ComfyWorkflowJSON, true, true, null, {
openSource: 'template'
})
})
// The template load switches to a new graph-mode workflow, so the canvas
// should become visible without requiring a manual app-mode toggle.
await comfyExpect(comfyPage.canvas).toBeVisible()
await comfyPage.nextFrame()
// Verify nodes exist and are within the visible viewport
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const app = window.app!
const canvas = app.canvas
const nodes = app.graph._nodes
if (nodes.length === 0) return false
canvas.ds.computeVisibleArea(canvas.viewport)
const [vx, vy, vw, vh] = canvas.ds.visible_area
return nodes.some(
(n: { pos: number[]; size: number[] }) =>
n.pos[0] + n.size[0] > vx &&
n.pos[0] < vx + vw &&
n.pos[1] + n.size[1] > vy &&
n.pos[1] < vy + vh
)
}),
{ message: 'At least one node should be within the visible viewport' }
)
.toBe(true)
})
})

View File

@@ -0,0 +1,88 @@
# 9. Canvas Viewport System
Date: 2026-04-20
## Status
Proposed
## Context
LGraphCanvas uses a dual-canvas architecture: a foreground canvas (the DOM element) renders nodes, and a background canvas (offscreen) renders the grid, links, and groups. `drawFrontCanvas()` composites the background onto the foreground by dividing the background canvas dimensions by `devicePixelRatio` — assuming both canvases were DPR-scaled. `drawBackCanvas()` reinforces this assumption by applying `ctx.setTransform(scale, 0, 0, scale, 0, 0)` using DPR. Both canvases must have identical physical (DPR-scaled) dimensions for compositing to produce correct results.
Two independent resize paths exist today:
- **`resizeCanvas()` in app.ts** is DPR-aware: it multiplies CSS pixels by `devicePixelRatio` to set physical canvas dimensions and calls `ctx.scale()` on both contexts.
- **`LGraphCanvas.resize()`** is DPR-unaware: it sets both canvases to CSS pixel dimensions directly, producing canvases at 1× regardless of display density.
Neither path documents that it depends on the other, creating implicit temporal coupling. Code that calls one without the other produces a background/foreground size mismatch.
The original bug: when switching from app mode (canvas hidden via `v-show`) to graph mode, `resize()` was called to force dimensions onto the newly-visible canvas. Because `resize()` is DPR-unaware, the background canvas received CSS pixel dimensions while `drawFrontCanvas()` divided those dimensions by DPR (expecting physical pixels), producing a scaled-down composite. The canvas scheduler (`useCanvasScheduler`) solved the "hidden canvas" lifecycle problem (deferring draws until the canvas is visible) but left the DPR mismatch because it calls the DPR-unaware `LGraphCanvas.resize()`.
`window.devicePixelRatio` is read at 6+ call sites across LGraphCanvas (`drawFrontCanvas`, `drawBackCanvas`, `centerOnNode`, `renderInfo`, `processMouseDown` hit testing, font scaling) and 3+ call sites in app.ts/renderer code. Each reads independently with no shared source of truth, so any change to DPR handling requires auditing every call site.
## Decision
Introduce a `CanvasViewport` — a plain, frozen data object that serves as the single source of truth for canvas sizing:
```ts
interface CanvasViewport {
readonly cssWidth: number
readonly cssHeight: number
readonly dpr: number
readonly physicalWidth: number // cssWidth * dpr
readonly physicalHeight: number // cssHeight * dpr
readonly generation: number // monotonically increasing
}
```
Two functions operate on this type:
- **`measureViewport(container, dpr?)`** — a pure function that produces a new `CanvasViewport` from DOM measurements. Accepts an optional DPR override for testing and for scenarios where DPR changes mid-session (display switching).
- **`applyViewport(viewport, fgCanvas, bgCanvas)`** — a side-effecting function that atomically sizes both foreground and background canvases to the viewport's physical dimensions and scales their 2D contexts. Both canvases are updated in a single call, eliminating the possibility of a partial resize.
A `devAssert(condition, message)` utility throws in DEV mode and `console.error`s in production. It is used at draw boundaries to enforce invariants:
- Foreground and background canvas dimensions are equal.
- The viewport generation is fresh (not stale from a previous resize cycle).
The existing `LGraphCanvas.resize()` method and `resizeCanvas()` in app.ts are both replaced by calls through the viewport system. Both paths collapse into one: measure → apply → draw.
The viewport system composes with the existing `CanvasScheduler` — the scheduler handles **when** (deferring until the canvas is visible), the viewport handles **what** (correct DPR-scaled dimensions applied atomically to both canvases). Neither modifies the other.
### Design Principles
Following the ECS principles established in [ADR 0008](0008-entity-component-system.md):
- `CanvasViewport` is a **plain data component** — no methods, no back-references, frozen after creation.
- `measureViewport` is a **pure system function** — testable without DOM (accepts dimension inputs).
- `applyViewport` is a **side-effecting system** — testable with mock canvas objects.
- No methods are added to `LGraphCanvas` or any other entity class.
### Alternatives Considered
1. **Reactive derivation (Vue `computed`)** — rejected because it would require Vue reactivity inside litegraph internals, crossing a hard architectural boundary between the Vue application layer and the litegraph rendering layer.
2. **Transaction/batch-commit pattern** — rejected as overkill for a single async boundary (the `requestAnimationFrame` call). The measure/apply split achieves the same atomicity guarantee with less machinery.
3. **Just fixing `resizeCanvas()` to also update bgcanvas** — rejected because it doesn't address the scattered DPR reads or prevent future divergence. A point fix solves today's bug but leaves the same class of bug latent at every other DPR read site.
## Consequences
### Positive
- Single source of truth for canvas dimensions and DPR eliminates an entire class of sizing bugs where foreground and background canvases diverge.
- The generation counter enables stale-state detection — any consumer can verify it is reading from a consistent resize cycle.
- Phase separation (measure vs apply) makes the resize lifecycle explicit and assertable.
- Pure functions (`measureViewport`) are trivially testable without DOM fixtures.
- Composes cleanly with the existing `CanvasScheduler` without modifying it.
### Negative
- All existing `window.devicePixelRatio` reads in LGraphCanvas need migration to use the viewport's `dpr` field. This migration is incremental and not blocking.
- Adds a new abstraction layer that all canvas-sizing code must flow through.
- A lint rule banning direct `window.devicePixelRatio` reads in canvas code requires team awareness during the migration period.
## Notes
- References [ADR 0008](0008-entity-component-system.md) for the design principles (plain data components, pure system functions, no methods on entities).
- The `devAssert` utility is general-purpose and can be used beyond canvas sizing for any invariant that should be loud in development but non-fatal in production.
- Migration of existing DPR reads in LGraphCanvas (`centerOnNode`, `renderInfo`, `processMouseDown` hit testing, `drawBackCanvas` `setTransform`, font scaling) can be done incrementally in follow-up PRs. This ADR covers the foundation and the critical resize path.

View File

@@ -18,6 +18,7 @@ An Architecture Decision Record captures an important architectural decision mad
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0009](0009-canvas-viewport-system.md) | Canvas Viewport System | Accepted | 2026-04-20 |
## Creating a New ADR

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { devAssert, setDevAssertReporter } from '@/base/common/devAssert'
describe('devAssert', () => {
beforeEach(() => {
setDevAssertReporter(undefined as never)
})
it('does nothing when condition is true', () => {
expect(() => devAssert(true, 'should not fire')).not.toThrow()
})
it('throws in DEV mode when condition is false', () => {
expect(() => devAssert(false, 'test failure')).toThrow(
'[Invariant] test failure'
)
})
it('always console.errors when condition is false', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
devAssert(false, 'error msg')
} catch {
// expected in DEV
}
expect(spy).toHaveBeenCalledWith('[Invariant] error msg')
spy.mockRestore()
})
it('calls reporter when set', () => {
const reporter = vi.fn()
setDevAssertReporter(reporter)
try {
devAssert(false, 'reported msg')
} catch {
// expected in DEV
}
expect(reporter).toHaveBeenCalledWith('[Invariant] reported msg')
})
it('does not call reporter when condition is true', () => {
const reporter = vi.fn()
setDevAssertReporter(reporter)
devAssert(true, 'should not fire')
expect(reporter).not.toHaveBeenCalled()
})
it('console.errors in production when condition is false', () => {
const originalDev = import.meta.env.DEV
try {
import.meta.env.DEV = false
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
devAssert(false, 'prod failure')
expect(spy).toHaveBeenCalledWith('[Invariant] prod failure')
spy.mockRestore()
} finally {
import.meta.env.DEV = originalDev
}
})
})

View File

@@ -0,0 +1,21 @@
type AssertReporter = (formatted: string) => void
let reporter: AssertReporter | undefined
function setDevAssertReporter(fn: AssertReporter) {
reporter = fn
}
function devAssert(condition: boolean, message: string): asserts condition {
if (!condition) {
const formatted = `[Invariant] ${message}`
console.error(formatted)
reporter?.(formatted)
if (import.meta.env.DEV) {
throw new Error(formatted)
}
}
}
export { devAssert, setDevAssertReporter }

View File

@@ -198,6 +198,7 @@ export class DragAndScale {
this.element.width === 300 && this.element.height === 150
? [1920, 1080]
: [this.element.width, this.element.height]
if (width <= 0 || height <= 0) return
const cw = width / window.devicePixelRatio
const ch = height / window.devicePixelRatio
let targetScale = this.scale
@@ -250,6 +251,7 @@ export class DragAndScale {
const startTimestamp = performance.now()
const cw = this.element.width / window.devicePixelRatio
const ch = this.element.height / window.devicePixelRatio
if (cw <= 0 || ch <= 0) return
const startX = this.offset[0]
const startY = this.offset[1]
const startX2 = startX - cw / this.scale

View File

@@ -10,6 +10,11 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import {
applyViewport,
measureViewport
} from '@/renderer/core/canvas/canvasViewport'
import { devAssert } from '@/base/common/devAssert'
import { forEachNode } from '@/utils/graphTraversalUtil'
import { CanvasPointer } from './CanvasPointer'
@@ -4961,6 +4966,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!this.canvas || this.canvas.width == 0 || this.canvas.height == 0)
return
devAssert(
this.canvas.width === this.bgcanvas.width &&
this.canvas.height === this.bgcanvas.height,
`Canvas size mismatch: fg=${this.canvas.width}×${this.canvas.height} bg=${this.bgcanvas.width}×${this.bgcanvas.height}`
)
// fps counting
const now = LiteGraph.getTime()
this.render_time = (now - this.last_draw_time) * 0.001
@@ -6512,8 +6523,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
/**
* resizes the canvas to a given size, if no size is passed, then it tries to fill the parentNode
* @todo Remove or rewrite
* Resizes the canvas to a given size, if no size is passed, then it tries to fill the parentNode.
* Uses the CanvasViewport system internally for DPR-aware sizing.
*/
resize(width?: number, height?: number): void {
if (!width && !height) {
@@ -6526,12 +6537,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
height = parent.offsetHeight
}
if (this.canvas.width == width && this.canvas.height == height) return
const viewport = measureViewport(
width ?? 0,
height ?? 0,
window.devicePixelRatio ?? 1
)
this.canvas.width = width ?? 0
this.canvas.height = height ?? 0
this.bgcanvas.width = this.canvas.width
this.bgcanvas.height = this.canvas.height
if (
this.canvas.width === viewport.physicalWidth &&
this.canvas.height === viewport.physicalHeight
)
return
applyViewport(viewport, this.canvas, this.bgcanvas)
this.setDirty(true, true)
}

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { DragAndScale } from '@/lib/litegraph/src/litegraph'
function createCanvas(width: number, height: number): HTMLCanvasElement {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
return canvas
}
describe('DragAndScale.fitToBounds', () => {
beforeEach(() => {
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 1
})
})
it('returns early when element width is 0', () => {
const dragAndScale = new DragAndScale(createCanvas(0, 400))
dragAndScale.offset = [13, 29]
dragAndScale.scale = 2
dragAndScale.fitToBounds([0, 0, 500, 500])
expect(dragAndScale.offset).toEqual([13, 29])
expect(dragAndScale.scale).toBe(2)
})
it('returns early when element height is 0', () => {
const dragAndScale = new DragAndScale(createCanvas(400, 0))
dragAndScale.offset = [7, 11]
dragAndScale.scale = 0.6
dragAndScale.fitToBounds([0, 0, 500, 500])
expect(dragAndScale.offset).toEqual([7, 11])
expect(dragAndScale.scale).toBe(0.6)
})
it('uses fallback 1920x1080 when canvas is 300x150', () => {
const dragAndScale = new DragAndScale(createCanvas(300, 150))
dragAndScale.fitToBounds([0, 0, 600, 600])
expect(dragAndScale.scale).toBeCloseTo(1.35)
})
it('calculates the correct scale for normal dimensions', () => {
const dragAndScale = new DragAndScale(createCanvas(1000, 500))
dragAndScale.fitToBounds([0, 0, 500, 250])
expect(dragAndScale.scale).toBeCloseTo(1.25)
expect(dragAndScale.offset[0]).toBeCloseTo(150)
expect(dragAndScale.offset[1]).toBeCloseTo(75)
})
})

View File

@@ -20,6 +20,9 @@ import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import { setDevAssertReporter } from '@/base/common/devAssert'
import { useToastStore } from '@/platform/updates/common/toastStore'
import App from './App.vue'
// Intentionally relative import to ensure the CSS is loaded in the right order (after litegraph.css)
import './assets/css/style.css'
@@ -108,6 +111,15 @@ app
modules: [VueFireAuth()]
})
setDevAssertReporter((message) => {
if (__IS_NIGHTLY__) {
useToastStore().addAlert(message)
}
if (isCloud || __DISTRIBUTION__ === 'desktop') {
Sentry.captureMessage(message, 'warning')
}
})
const bootstrapStore = useBootstrapStore(pinia)
void bootstrapStore.startStoreBootstrap()

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import {
applyViewport,
measureViewport
} from '@/renderer/core/canvas/canvasViewport'
function mockCanvas(
width = 0,
height = 0
): HTMLCanvasElement & { scaleArgs: number[][] } {
const scaleArgs: number[][] = []
return {
width,
height,
getContext: () => ({
scale: (x: number, y: number) => scaleArgs.push([x, y])
}),
scaleArgs
} as unknown as HTMLCanvasElement & { scaleArgs: number[][] }
}
describe('measureViewport', () => {
it('computes physical dimensions from CSS dimensions and DPR', () => {
const vp = measureViewport(800, 600, 2, 0)
expect(vp.cssWidth).toBe(800)
expect(vp.cssHeight).toBe(600)
expect(vp.dpr).toBe(2)
expect(vp.physicalWidth).toBe(1600)
expect(vp.physicalHeight).toBe(1200)
})
it('clamps DPR to minimum of 1', () => {
const vp = measureViewport(800, 600, 0.5, 0)
expect(vp.dpr).toBe(1)
expect(vp.physicalWidth).toBe(800)
expect(vp.physicalHeight).toBe(600)
})
it('clamps negative DPR to 1', () => {
const vp = measureViewport(100, 100, -1, 0)
expect(vp.dpr).toBe(1)
})
it('increments generation from previous value', () => {
const vp1 = measureViewport(800, 600, 1, 0)
expect(vp1.generation).toBe(1)
const vp2 = measureViewport(800, 600, 1, vp1.generation)
expect(vp2.generation).toBe(2)
})
it('rounds physical dimensions', () => {
const vp = measureViewport(801, 601, 1.5, 0)
expect(vp.physicalWidth).toBe(Math.round(801 * 1.5))
expect(vp.physicalHeight).toBe(Math.round(601 * 1.5))
})
it('returns a frozen object', () => {
const vp = measureViewport(800, 600, 2, 0)
expect(Object.isFrozen(vp)).toBe(true)
})
})
describe('applyViewport', () => {
it('sets both canvases to physical dimensions', () => {
const vp = measureViewport(800, 600, 2, 0)
const fg = mockCanvas()
const bg = mockCanvas()
applyViewport(vp, fg, bg)
expect(fg.width).toBe(1600)
expect(fg.height).toBe(1200)
expect(bg.width).toBe(1600)
expect(bg.height).toBe(1200)
})
it('scales both canvas contexts by DPR', () => {
const vp = measureViewport(800, 600, 2, 0)
const fg = mockCanvas()
const bg = mockCanvas()
applyViewport(vp, fg, bg)
expect(fg.scaleArgs).toEqual([[2, 2]])
expect(bg.scaleArgs).toEqual([[2, 2]])
})
it('produces identical dimensions on both canvases', () => {
const vp = measureViewport(1920, 1080, 2.5, 0)
const fg = mockCanvas(100, 100)
const bg = mockCanvas(200, 300)
applyViewport(vp, fg, bg)
expect(fg.width).toBe(bg.width)
expect(fg.height).toBe(bg.height)
})
it('handles DPR of 1 without scaling artifacts', () => {
const vp = measureViewport(800, 600, 1, 0)
const fg = mockCanvas()
const bg = mockCanvas()
applyViewport(vp, fg, bg)
expect(fg.width).toBe(800)
expect(fg.height).toBe(600)
expect(fg.scaleArgs).toEqual([[1, 1]])
})
})

View File

@@ -0,0 +1,211 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
const testState = vi.hoisted(() => ({
canvasElement: {
offsetParent: {} as Element | null,
offsetWidth: 1920,
offsetHeight: 1080
},
pendingFrames: new Map<number, FrameRequestCallback>(),
nextFrameId: 1,
cancelAnimationFrame: vi.fn()
}))
const mockStore = reactive({
linearMode: false,
canvas: {
get canvas() {
return testState.canvasElement
}
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockStore
}))
function runNextAnimationFrame(): void {
const nextEntry = testState.pendingFrames.entries().next().value
if (!nextEntry) return
const [id, callback] = nextEntry
testState.pendingFrames.delete(id)
callback(performance.now())
}
describe('useCanvasScheduler', () => {
beforeEach(async () => {
mockStore.linearMode = false
testState.canvasElement.offsetParent = document.body
testState.canvasElement.offsetWidth = 1920
testState.canvasElement.offsetHeight = 1080
testState.pendingFrames.clear()
testState.nextFrameId = 1
testState.cancelAnimationFrame.mockReset()
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
const id = testState.nextFrameId++
testState.pendingFrames.set(id, cb)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
testState.cancelAnimationFrame(id)
testState.pendingFrames.delete(id)
})
vi.resetModules()
})
async function createScheduler() {
const mod = await import('@/renderer/core/canvas/useCanvasScheduler')
return mod.useCanvasScheduler()
}
it('schedule executes operation in next RAF when canvas is ready', async () => {
const scheduler = await createScheduler()
const op = vi.fn()
scheduler.schedule(op)
expect(op).not.toHaveBeenCalled()
runNextAnimationFrame()
expect(op).toHaveBeenCalledOnce()
})
it('schedule queues operation when canvas is not ready', async () => {
const scheduler = await createScheduler()
const op = vi.fn()
testState.canvasElement.offsetParent = null
scheduler.schedule(op)
expect(scheduler.pending()).toBe(1)
expect(op).not.toHaveBeenCalled()
expect(testState.pendingFrames.size).toBe(0)
})
it('schedule queues when canvas has zero dimensions', async () => {
const scheduler = await createScheduler()
const op = vi.fn()
testState.canvasElement.offsetWidth = 0
testState.canvasElement.offsetHeight = 0
scheduler.schedule(op)
expect(scheduler.pending()).toBe(1)
expect(op).not.toHaveBeenCalled()
expect(testState.pendingFrames.size).toBe(0)
})
it('flush executes queued operations when canvas becomes ready', async () => {
const scheduler = await createScheduler()
const first = vi.fn()
const second = vi.fn()
testState.canvasElement.offsetParent = null
scheduler.schedule(first)
scheduler.schedule(second)
testState.canvasElement.offsetParent = document.body
scheduler.flush()
expect(first).toHaveBeenCalledOnce()
expect(second).toHaveBeenCalledOnce()
expect(scheduler.pending()).toBe(0)
})
it('flush is a no-op when canvas is not ready', async () => {
const scheduler = await createScheduler()
const op = vi.fn()
testState.canvasElement.offsetParent = null
scheduler.schedule(op)
scheduler.flush()
expect(op).not.toHaveBeenCalled()
expect(scheduler.pending()).toBe(1)
})
it('clear discards all pending operations and cancels RAF', async () => {
const scheduler = await createScheduler()
const op = vi.fn()
scheduler.schedule(op)
expect(testState.pendingFrames.size).toBe(1)
scheduler.clear()
expect(scheduler.pending()).toBe(0)
expect(testState.cancelAnimationFrame).toHaveBeenCalledOnce()
runNextAnimationFrame()
expect(op).not.toHaveBeenCalled()
})
it('deduplicates RAF scheduling to one pending frame', async () => {
const scheduler = await createScheduler()
scheduler.schedule(vi.fn())
scheduler.schedule(vi.fn())
scheduler.schedule(vi.fn())
expect(testState.pendingFrames.size).toBe(1)
})
it('executes operations in FIFO order', async () => {
const scheduler = await createScheduler()
const calls: string[] = []
scheduler.schedule(() => calls.push('first'))
scheduler.schedule(() => calls.push('second'))
scheduler.schedule(() => calls.push('third'))
runNextAnimationFrame()
expect(calls).toEqual(['first', 'second', 'third'])
})
it('continues executing remaining ops when one throws', async () => {
const scheduler = await createScheduler()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const first = vi.fn()
const failing = vi.fn(() => {
throw new Error('op failed')
})
const third = vi.fn()
scheduler.schedule(first)
scheduler.schedule(failing)
scheduler.schedule(third)
runNextAnimationFrame()
expect(first).toHaveBeenCalledOnce()
expect(failing).toHaveBeenCalledOnce()
expect(third).toHaveBeenCalledOnce()
expect(consoleSpy).toHaveBeenCalledOnce()
consoleSpy.mockRestore()
})
it('auto-flushes queued ops when linearMode transitions to false', async () => {
const scheduler = await createScheduler()
const op = vi.fn()
testState.canvasElement.offsetParent = null
mockStore.linearMode = true
await nextTick()
scheduler.schedule(op)
expect(scheduler.pending()).toBe(1)
const framesBefore = testState.pendingFrames.size
testState.canvasElement.offsetParent = document.body
mockStore.linearMode = false
await nextTick()
expect(testState.pendingFrames.size).toBeGreaterThan(framesBefore)
while (testState.pendingFrames.size > 0) runNextAnimationFrame()
expect(op).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,63 @@
interface CanvasViewport {
readonly cssWidth: number
readonly cssHeight: number
readonly dpr: number
readonly physicalWidth: number
readonly physicalHeight: number
readonly generation: number
}
let currentGeneration = 0
function measureViewport(
cssWidth: number,
cssHeight: number,
rawDpr: number,
prevGeneration?: number
): CanvasViewport {
const dpr = Math.max(rawDpr, 1)
return Object.freeze({
cssWidth,
cssHeight,
dpr,
physicalWidth: Math.round(cssWidth * dpr),
physicalHeight: Math.round(cssHeight * dpr),
generation: (prevGeneration ?? currentGeneration) + 1
})
}
function measureViewportFromElement(
element: HTMLCanvasElement,
rawDpr?: number,
prevGeneration?: number
): CanvasViewport {
const saved = { w: element.width, h: element.height }
element.width = element.height = NaN
const { width, height } = element.getBoundingClientRect()
element.width = saved.w
element.height = saved.h
return measureViewport(
width,
height,
rawDpr ?? window.devicePixelRatio,
prevGeneration
)
}
function applyViewport(
viewport: CanvasViewport,
fg: HTMLCanvasElement,
bg: HTMLCanvasElement
): void {
fg.width = viewport.physicalWidth
fg.height = viewport.physicalHeight
bg.width = viewport.physicalWidth
bg.height = viewport.physicalHeight
fg.getContext('2d')?.scale(viewport.dpr, viewport.dpr)
bg.getContext('2d')?.scale(viewport.dpr, viewport.dpr)
currentGeneration = viewport.generation
}
export { measureViewport, measureViewportFromElement, applyViewport }

View File

@@ -0,0 +1,94 @@
import { createSharedComposable } from '@vueuse/core'
import { watch } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
type CanvasOp = () => void
interface CanvasScheduler {
/** Queue an op that runs in the next RAF when canvas is visible. */
schedule(op: CanvasOp): void
/** Execute all queued ops synchronously (if canvas is ready). */
flush(): void
/** Discard all pending ops and cancel any scheduled RAF. */
clear(): void
/** Number of queued ops. */
pending(): number
/** Whether the canvas element is visible and properly sized. */
isCanvasReady(): boolean
}
export const useCanvasScheduler = createSharedComposable(
(): CanvasScheduler => {
const canvasStore = useCanvasStore()
const queue: CanvasOp[] = []
let rafId: number | null = null
function isCanvasReady(): boolean {
try {
const el = canvasStore.canvas?.canvas
if (el == null || el.offsetParent === null) return false
return el.offsetWidth > 0 && el.offsetHeight > 0
} catch {
return false
}
}
function requestFlush(): void {
if (rafId != null || queue.length === 0) return
rafId = requestAnimationFrame(() => {
rafId = null
flush()
})
}
function schedule(op: CanvasOp): void {
queue.push(op)
if (isCanvasReady()) requestFlush()
}
function flush(): void {
if (!isCanvasReady()) return
const ops = queue.splice(0)
for (const [index, op] of ops.entries()) {
try {
op()
} catch (err) {
console.error(
'[CanvasScheduler] Scheduled canvas operation failed during flush',
{
error: err,
remainingInBatch: ops.length - index - 1,
pendingQueue: queue.length,
canvasReady: isCanvasReady()
}
)
}
}
}
function clear(): void {
queue.length = 0
if (rafId != null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
function pending(): number {
return queue.length
}
watch(
() => canvasStore.linearMode,
(isLinear, wasLinear) => {
const canvasBecameVisible = wasLinear && !isLinear
if (canvasBecameVisible && queue.length > 0) {
requestFlush()
}
}
)
return { schedule, flush, clear, pending, isCanvasReady }
}
)

View File

@@ -7,6 +7,11 @@ import { shallowRef } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { syncLayoutStoreNodeBoundsFromGraph } from '@/renderer/core/layout/sync/syncLayoutStoreFromGraph'
import { useCanvasScheduler } from '@/renderer/core/canvas/useCanvasScheduler'
import {
applyViewport,
measureViewportFromElement
} from '@/renderer/core/canvas/canvasViewport'
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { st, t } from '@/i18n'
@@ -967,15 +972,8 @@ export class ComfyApp {
}
private resizeCanvas(canvas: HTMLCanvasElement) {
// Limit minimal scale to 1, see https://github.com/comfyanonymous/ComfyUI/pull/845
const scale = Math.max(window.devicePixelRatio, 1)
// Clear fixed width and height while calculating rect so it uses 100% instead
canvas.height = canvas.width = NaN
const { width, height } = canvas.getBoundingClientRect()
canvas.width = Math.round(width * scale)
canvas.height = Math.round(height * scale)
canvas.getContext('2d')?.scale(scale, scale)
const viewport = measureViewportFromElement(canvas)
applyViewport(viewport, canvas, this.canvas.bgcanvas)
this.canvas?.draw(true, true)
}
@@ -1137,6 +1135,9 @@ export class ComfyApp {
silentAssetErrors?: boolean
} = {}
) {
const canvasScheduler = useCanvasScheduler()
canvasScheduler.clear()
const {
checkForRerouteMigration = false,
openSource,
@@ -1284,7 +1285,6 @@ export class ComfyApp {
}
}
const canvasVisible = !!(this.canvasEl.width && this.canvasEl.height)
const fitView = () => {
if (
restore_view &&
@@ -1307,7 +1307,7 @@ export class ComfyApp {
this.canvas.visible_area
)
) {
requestAnimationFrame(() => useLitegraphService().fitView())
canvasScheduler.schedule(() => useLitegraphService().fitView())
}
} else {
useLitegraphService().fitView()
@@ -1341,7 +1341,10 @@ export class ComfyApp {
)
}
if (canvasVisible) fitView()
canvasScheduler.schedule(() => {
this.resizeCanvas(this.canvasEl)
fitView()
})
} catch (error) {
useDialogService().showErrorDialog(error, {
title: t('errorDialog.loadWorkflowTitle'),
@@ -1431,13 +1434,6 @@ export class ComfyApp {
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
)
// If the canvas was not visible and we're a fresh load, resize the canvas and fit the view
// This fixes switching from app mode to a new graph mode workflow (e.g. load template)
if (!canvasVisible && (!workflow || typeof workflow === 'string')) {
this.canvas.resize()
requestAnimationFrame(() => fitView())
}
// Drop missing-node entries whose enclosing subgraph is
// muted/bypassed. The initial JSON scan only checks each node's
// own mode; the cascade from an inactive container is applied here

View File

@@ -9,6 +9,7 @@ import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasScheduler } from '@/renderer/core/canvas/useCanvasScheduler'
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
@@ -27,6 +28,7 @@ export const useSubgraphNavigationStore = defineStore(
() => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
const canvasScheduler = useCanvasScheduler()
const router = useRouter()
const routeHash = useRouteHash()
@@ -140,12 +142,12 @@ export const useSubgraphNavigationStore = defineStore(
}
// First visit — fit to content so subgraph nodes are visible
requestAnimationFrame(() => {
canvasScheduler.schedule(() => {
if (getActiveGraphId() !== graphId) return
if (!canvas.graph?.nodes?.length) return
useLitegraphService().fitView()
// fitView changes scale/offset, so re-sync slot positions for
// collapsed nodes whose DOM-relative measurement is now stale.
// Defer slot sync to the next frame so the browser paints the
// new scale/offset from fitView before slot geometry is measured.
requestAnimationFrame(() => {
if (getActiveGraphId() !== graphId) return
requestSlotLayoutSyncForAllNodes()

View File

@@ -21,7 +21,13 @@ const { mockSetDirty, mockFitView, mockRequestSlotSyncAll } = vi.hoisted(
)
vi.mock('@/scripts/app', () => {
const mockCanvasElement = {
offsetParent: document.body,
offsetWidth: 1920,
offsetHeight: 1080
}
const mockCanvas = {
canvas: mockCanvasElement,
subgraph: undefined as unknown,
graph: undefined as unknown,
ds: {
@@ -58,8 +64,20 @@ vi.mock('@/scripts/app', () => {
}
})
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
createSharedComposable: <Fn extends (...args: unknown[]) => unknown>(
fn: Fn
) => fn
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
linearMode: false,
canvas: app.canvas,
getCanvas: () => app.canvas
})
}))
@@ -76,6 +94,18 @@ vi.mock(
})
)
vi.mock('@/renderer/core/canvas/useCanvasScheduler', () => ({
useCanvasScheduler: () => ({
schedule: (op: () => void) => {
requestAnimationFrame(() => op())
},
flush: vi.fn(),
clear: vi.fn(),
pending: () => 0,
isCanvasReady: () => true
})
}))
const mockCanvas = app.canvas
let rafCallbacks: FrameRequestCallback[] = []