fix: avoid forced layout in renderInfo by using canvas.height (#9304)

## What

Replace `canvas.offsetHeight` with `canvas.height / devicePixelRatio` in
`renderInfo` to avoid forced synchronous layout.

## Why

`renderInfo` is called ~2,631 times in a typical session. Each call
reads `this.canvas.offsetHeight`, which forces the browser to flush
pending style/layout changes synchronously. With PrimeVue injecting
styles dynamically and Vue patching the DOM, there are almost always
pending mutations — converting every canvas-only `renderInfo` call into
a forced layout.

## How

`canvas.height` is the DPR-scaled internal resolution (set in
`resizeCanvas` as `cssHeight * devicePixelRatio`). Dividing by
`devicePixelRatio` yields the same CSS pixel value as `offsetHeight`
without triggering layout.

## Verification

- [x] Unit test: verifies `offsetHeight` is not accessed when y is
provided
- [x] Unit test: verifies fallback uses `canvas.height /
devicePixelRatio`
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] All litegraph tests pass (538 passed)

## Perf Impact

Eliminates ~2,631 forced synchronous layouts per session from the canvas
info panel.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9304-fix-avoid-forced-layout-in-renderInfo-by-using-canvas-height-3156d73d36508171973dda289b30d5ee)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Christian Byrne
2026-03-13 08:35:03 -07:00
committed by GitHub
parent 79d0e6dc69
commit a9f9afd062
2 changed files with 67 additions and 1 deletions

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
describe('LGraphCanvas.renderInfo', () => {
let lgCanvas: LGraphCanvas
let ctx: CanvasRenderingContext2D
beforeEach(() => {
const canvasElement = document.createElement('canvas')
ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
font: '',
fillStyle: '',
textAlign: 'left' as CanvasTextAlign,
fillText: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
const graph = new LGraph()
lgCanvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true,
skip_events: true
})
})
it('does not access canvas.offsetHeight when y is provided', () => {
const spy = vi.spyOn(lgCanvas.canvas, 'offsetHeight', 'get')
lgCanvas.renderInfo(ctx, 10, 500)
expect(spy).not.toHaveBeenCalled()
})
it('uses canvas.height divided by devicePixelRatio as y fallback', () => {
lgCanvas.canvas.width = 1920
lgCanvas.canvas.height = 2160
const originalDPR = window.devicePixelRatio
Object.defineProperty(window, 'devicePixelRatio', {
value: 2,
configurable: true
})
try {
lgCanvas.renderInfo(ctx, 10, 0)
// lineCount = 5 (graph present, no info_text), lineHeight = 13
// y = canvas.height / DPR - (lineCount + 1) * lineHeight
expect(ctx.translate).toHaveBeenCalledWith(10, 2160 / 2 - 6 * 13)
} finally {
Object.defineProperty(window, 'devicePixelRatio', {
value: originalDPR,
configurable: true
})
}
})
})

View File

@@ -5264,7 +5264,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const lineHeight = 13
const lineCount = (this.graph ? 5 : 1) + (this.info_text ? 1 : 0)
x = x || 10
y = y || this.canvas.offsetHeight - (lineCount + 1) * lineHeight
y =
y ||
this.canvas.height /
((this.canvas.ownerDocument.defaultView ?? window).devicePixelRatio ||
1) -
(lineCount + 1) * lineHeight
ctx.save()
ctx.translate(x, y)