Compare commits

...

7 Commits

Author SHA1 Message Date
Connor Byrne
2d6f1064f9 test: pin reactive-write budget for DomWidgets per-frame updates
Locks in the gain from the equality-check optimization with concrete
per-frame write counts. Asserts:

- Idle frames: 0 reactive writes per widget across all 5 fields
- Viewport pan: pos forced (1 write/widget/frame), other 4 fields skipped
- Selected-node drag: pos forced on all visible widgets so clip-paths
  refresh against the moved selection bounds
- Non-selected single-node movement: only the moved widget rewrites pos

A/B run vs origin/main confirmed the optimization (N=50 widgets, 600
frames, happy-dom):

  | scenario | main writes | branch writes | wall-time delta |
  |----------|-------------|---------------|-----------------|
  | idle     |  60,050     |       0       |   -52%          |
  | pan      |  60,000     |  29,950       |   -33%          |

Each reactive write fires the downstream watcher in DomWidget.vue,
which recomputes style and calls setStyle on the DOM element — so the
write count is a faithful proxy for setStyle/sec, the metric the PR
description targets.
2026-05-06 13:45:45 -07:00
Connor Byrne
d0e617af49 fix: refresh DomWidget disabled/clipping when non-reactive sources change
The per-frame mutation optimization broke two cases where DomWidget style
depends on values outside widgetState:

1. widget.computedDisabled is set by litegraph when an input is connected.
   The previous deep-watch on widgetState picked it up incidentally because
   pos/size were rewritten every frame; with equality-checked writes the
   secondary watcher never re-fires, so the connected widget no longer
   renders at opacity 0.5.

2. DomWidget clipping is computed against the selected node's renderArea
   (non-reactive). When the selected node is dragged, only its own widget's
   pos changes — widgets owned by other nodes never re-run updateDomClipping
   and their clip-path stays stale, so a background widget bleeds through
   the foreground node.

Mirror computedDisabled into widgetState each frame and treat selected-node
movement the same as a viewport change (force pos reassignment to refire
the downstream watcher). Restore the bot-regenerated baselines for the two
affected screenshot tests.
2026-05-04 14:15:04 -07:00
bymyself
30dad84223 test: restore bot baselines for non-pan-affected snapshots
dragged-node1 and primitive-node-connected-dom-widget tests don't involve
viewport pan/zoom — the original DOM widget reactivity bug didn't affect
their rendering. The bot's regenerated baselines from 2576e3024 capture
the expected post-equality-check rendering for these specific tests, and
were passing in earlier CI runs of this branch. Reverting to the main
baselines (commit e03949b7f) was over-correction.
2026-05-04 01:41:13 -07:00
bymyself
e03949b7fb fix: refresh DomWidget style on visibility transition; revert stale baselines
Two follow-ups to the previous viewport-tracking fix:

1) DomWidget.vue: when low_quality flips while a widget has hideOnZoom,
   the widget toggles invisible→visible without canvas-space pos
   changing. The position watcher must also fire on visibility changes,
   so updatePosition re-evaluates against the current ds.scale/offset
   instead of the stale style captured the last time the widget was
   updated. Without this, hideOnZoom widgets reappear at their pre-LOD
   screen position.

2) Revert the 21 PNG baselines from the [automated] regen commit. They
   were generated against the buggy pre-fix state where DOM widgets
   stayed at their old screen position during pan/zoom. With the fix,
   widgets correctly track the canvas viewport, and the original
   main-branch baselines apply.
2026-05-04 00:58:33 -07:00
bymyself
85ea84635a fix: refresh DomWidget pos when canvas viewport changes
The previous equality-check optimization in updateWidgets compared only
canvas-space pos, which is unchanged when the user pans or zooms.
Because lgCanvas.ds.offset and ds.scale are not reactive, screen-space
style only refreshes via the downstream pos/size watcher in DomWidget.
With the equality check, no reassignment fired and DOM widgets stayed
at their stale screen position while the canvas moved underneath them,
intercepting clicks intended for canvas-rendered controls (collapse
button, selection toolbox, etc.).

Track the viewport between frames and force a pos reassignment when
ds.offset or ds.scale changes. Idle frames (stationary canvas and
nodes) still skip reassignment, preserving the perf gain.
2026-05-04 00:25:46 -07:00
github-actions
2576e30243 [automated] Update test expectations 2026-05-03 23:13:55 -07:00
bymyself
22cc0728af fix: avoid per-frame reactive mutations in DomWidgets positioning
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb69-3404-726a-8888-182193115b88
2026-05-03 23:13:55 -07:00
6 changed files with 492 additions and 22 deletions

View File

@@ -0,0 +1,241 @@
/**
* Reactive-write regression tests for DomWidgets.updateWidgets.
*
* `updateWidgets` runs every canvas draw frame (60fps). Each reactive write to
* widgetState fields (pos / size / zIndex / readonly / computedDisabled) fires
* the downstream watchers in DomWidget.vue, which recompute style and call
* setStyle on the DOM element. Before the equality-check optimization, idle
* frames produced N writes per widget per frame across 5 fields = ~5N setStyle
* calls per frame for free.
*
* These tests pin down:
* - Idle frames produce zero reactive writes per widget after init.
* - Pan frames force pos reassignment but skip the other 4 fields.
* - Selected-node movement forces pos reassignment on all visible widgets
* (so non-selected widgets refresh their clip-path).
*/
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { watch } from 'vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
type TestWidget = BaseDOMWidget<object | string>
function createNode(graph: LGraph, id: number, pos: [number, number]) {
const node = new LGraphNode(`n${id}`)
node.id = id
node.pos = [...pos]
node.size = [240, 120]
graph.add(node)
return node
}
function createWidget(id: string, node: LGraphNode): TestWidget {
return fromPartial<TestWidget>({
id,
node,
name: 'w',
type: 'custom',
value: '',
options: {},
y: 12,
width: 120,
computedHeight: 40,
margin: 10,
isVisible: () => true
})
}
function createCanvas(graph: LGraph): LGraphCanvas {
return fromPartial<LGraphCanvas>({
graph,
low_quality: false,
read_only: false,
isNodeVisible: vi.fn(() => true),
ds: { offset: [0, 0], scale: 1 },
selected_nodes: {}
})
}
function drawFrame(canvas: LGraphCanvas) {
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
}
interface WriteCounts {
pos: number
size: number
zIndex: number
readonly: number
computedDisabled: number
}
function setupScene(nWidgets: number) {
setActivePinia(createTestingPinia({ stubActions: false }))
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graph = new LGraph()
const widgetIds: string[] = []
for (let i = 0; i < nWidgets; i++) {
const node = createNode(graph, i, [i * 50, 0])
const w = createWidget(`w${i}`, node)
domWidgetStore.registerWidget(w)
widgetIds.push(w.id)
}
const canvas = createCanvas(graph)
canvasStore.canvas = canvas
render(DomWidgets, { global: { stubs: { DomWidget: true } } })
const counts: WriteCounts = {
pos: 0,
size: 0,
zIndex: 0,
readonly: 0,
computedDisabled: 0
}
for (const id of widgetIds) {
const s = domWidgetStore.widgetStates.get(id)!
watch(
() => s.pos,
() => counts.pos++,
{ flush: 'sync' }
)
watch(
() => s.size,
() => counts.size++,
{ flush: 'sync' }
)
watch(
() => s.zIndex,
() => counts.zIndex++,
{ flush: 'sync' }
)
watch(
() => s.readonly,
() => counts.readonly++,
{ flush: 'sync' }
)
watch(
() => s.computedDisabled,
() => counts.computedDisabled++,
{ flush: 'sync' }
)
}
return { canvas, counts, graph, domWidgetStore }
}
describe('DomWidgets reactive-write budget', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('writes nothing on idle frames after the initial frame', () => {
const N = 20
const { canvas, counts } = setupScene(N)
drawFrame(canvas) // init frame seeds widgetState
counts.pos = 0
counts.size = 0
counts.zIndex = 0
counts.readonly = 0
counts.computedDisabled = 0
for (let i = 0; i < 60; i++) drawFrame(canvas)
expect(counts.pos).toBe(0)
expect(counts.size).toBe(0)
expect(counts.zIndex).toBe(0)
expect(counts.readonly).toBe(0)
expect(counts.computedDisabled).toBe(0)
})
it('on viewport pan, forces pos writes only — size/zIndex/readonly stay quiet', () => {
const N = 20
const { canvas, counts } = setupScene(N)
drawFrame(canvas)
counts.pos = 0
counts.size = 0
counts.zIndex = 0
counts.readonly = 0
counts.computedDisabled = 0
const FRAMES = 60
for (let i = 1; i <= FRAMES; i++) {
canvas.ds.offset[0] = i * 2
drawFrame(canvas)
}
expect(counts.pos).toBe(N * FRAMES)
expect(counts.size).toBe(0)
expect(counts.zIndex).toBe(0)
expect(counts.readonly).toBe(0)
expect(counts.computedDisabled).toBe(0)
})
it('on selected-node drag, forces pos writes on all visible widgets — size/zIndex/readonly stay quiet', () => {
const N = 20
const { canvas, counts, graph } = setupScene(N)
const draggedNode = graph.getNodeById(0)!
canvas.selected_nodes = { [draggedNode.id]: draggedNode }
drawFrame(canvas)
counts.pos = 0
counts.size = 0
counts.zIndex = 0
counts.readonly = 0
counts.computedDisabled = 0
const FRAMES = 60
for (let i = 1; i <= FRAMES; i++) {
draggedNode.pos[0] = i * 3
drawFrame(canvas)
}
// Selected node moves → all N widgets re-evaluate pos so their clip-path
// refreshes against the moved selection bounds.
expect(counts.pos).toBe(N * FRAMES)
expect(counts.size).toBe(0)
expect(counts.zIndex).toBe(0)
expect(counts.readonly).toBe(0)
expect(counts.computedDisabled).toBe(0)
})
it('on non-selected single-node movement, only the moved widget re-writes pos', () => {
const N = 20
const { canvas, counts, graph } = setupScene(N)
drawFrame(canvas)
counts.pos = 0
counts.size = 0
counts.zIndex = 0
counts.readonly = 0
counts.computedDisabled = 0
const movedNode = graph.getNodeById(5)!
const FRAMES = 60
for (let i = 1; i <= FRAMES; i++) {
movedNode.pos[0] = 250 + i
drawFrame(canvas)
}
expect(counts.pos).toBe(FRAMES) // 1 widget × FRAMES, not N × FRAMES
expect(counts.size).toBe(0)
expect(counts.zIndex).toBe(0)
expect(counts.readonly).toBe(0)
expect(counts.computedDisabled).toBe(0)
})
})

View File

@@ -49,7 +49,9 @@ function createCanvas(graph: LGraph): LGraphCanvas {
graph,
low_quality: false,
read_only: false,
isNodeVisible: vi.fn(() => true)
isNodeVisible: vi.fn(() => true),
ds: { offset: [0, 0], scale: 1 },
selected_nodes: {}
})
}
@@ -148,6 +150,125 @@ describe('DomWidgets transition grace characterization', () => {
expect(widgetState.pos).toEqual([310, 428])
})
it('forces pos reassignment on viewport pan even when canvas-space pos is unchanged', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graph = new LGraph()
const node = createNode(graph, 1, 'node', [100, 200])
const widget = createWidget('viewport-widget', node, 12)
domWidgetStore.registerWidget(widget)
const canvas = createCanvas(graph)
canvasStore.canvas = canvas
render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})
drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
const posAfterFirstFrame = widgetState.pos
expect(posAfterFirstFrame).toEqual([110, 222])
// Canvas pan: ds.offset is non-reactive, so the downstream watcher only
// fires if widgetState.pos is reassigned (a new array identity).
canvas.ds.offset[0] = 50
canvas.ds.offset[1] = 60
drawFrame(canvas)
expect(widgetState.pos).not.toBe(posAfterFirstFrame)
})
it('skips pos reassignment when viewport and canvas-space pos are both stable', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graph = new LGraph()
const node = createNode(graph, 1, 'node', [100, 200])
const widget = createWidget('idle-widget', node, 12)
domWidgetStore.registerWidget(widget)
const canvas = createCanvas(graph)
canvasStore.canvas = canvas
render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})
drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
const posAfterFirstFrame = widgetState.pos
// No pan, no node movement — pos array identity must be preserved
// (this is the perf optimization being protected).
drawFrame(canvas)
expect(widgetState.pos).toBe(posAfterFirstFrame)
})
it('mirrors widget.computedDisabled into widgetState each frame', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graph = new LGraph()
const node = createNode(graph, 1, 'node', [100, 200])
const widget = createWidget('disabled-widget', node, 12)
Object.assign(widget, { computedDisabled: false })
domWidgetStore.registerWidget(widget)
const canvas = createCanvas(graph)
canvasStore.canvas = canvas
render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})
drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
expect(widgetState.computedDisabled).toBe(false)
// Simulate litegraph connecting an input -> widget.computedDisabled flips.
Object.assign(widget, { computedDisabled: true })
drawFrame(canvas)
expect(widgetState.computedDisabled).toBe(true)
})
it('forces pos reassignment for widgets when the selected node moves', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graph = new LGraph()
const movingNode = createNode(graph, 1, 'moving', [100, 100])
const otherNode = createNode(graph, 2, 'other', [400, 100])
const widget = createWidget('clipped-widget', otherNode, 12)
domWidgetStore.registerWidget(widget)
const canvas = createCanvas(graph)
// movingNode is the selected node — its renderArea drives clipping for
// widgets owned by other nodes.
canvas.selected_nodes = { 1: movingNode }
canvasStore.canvas = canvas
render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})
drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
const posAfterFirstFrame = widgetState.pos
// Drag the selected node — otherNode (and its widget) hasn't moved, but
// the widget's clip-path depends on movingNode.renderArea, so the
// downstream pos watcher must re-fire.
movingNode.pos[0] = 150
drawFrame(canvas)
expect(widgetState.pos).not.toBe(posAfterFirstFrame)
})
it('cleans orphaned transition-grace ids after widget removal', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()

View File

@@ -25,6 +25,27 @@ const overrideTransitionGrace = new Set<string>()
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
// Track canvas viewport between frames. Screen-space position depends on
// lgCanvas.ds.offset and ds.scale, which are non-reactive. When the user
// pans or zooms, canvas-space `pos` is unchanged but the rendered style
// must update — force pos reassignment whenever the viewport changes so
// the downstream watcher in DomWidget recomputes style with current ds.
let lastViewportOffsetX = Number.NaN
let lastViewportOffsetY = Number.NaN
let lastViewportScale = Number.NaN
// Track the selected node's identity and bounds between frames. DOM widget
// clipping is computed against the selected node's renderArea (non-reactive).
// When the user drags or resizes the selected node, widgets owned by other
// nodes must re-evaluate their clip-path even though their own pos hasn't
// changed — force pos reassignment on those widgets so the downstream
// watcher in DomWidget re-runs updateDomClipping().
let lastSelectedNodeId: string | number | undefined
let lastSelectedPosX = 0
let lastSelectedPosY = 0
let lastSelectedWidth = 0
let lastSelectedHeight = 0
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
@@ -33,6 +54,36 @@ const updateWidgets = () => {
const currentGraph = lgCanvas.graph
const seenWidgetIds = new Set<string>()
const viewportOffsetX = lgCanvas.ds.offset[0]
const viewportOffsetY = lgCanvas.ds.offset[1]
const viewportScale = lgCanvas.ds.scale
const viewportChanged =
lastViewportOffsetX !== viewportOffsetX ||
lastViewportOffsetY !== viewportOffsetY ||
lastViewportScale !== viewportScale
lastViewportOffsetX = viewportOffsetX
lastViewportOffsetY = viewportOffsetY
lastViewportScale = viewportScale
const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0]
const selectedNodeId = selectedNode?.id
const selectedPosX = selectedNode ? selectedNode.pos[0] : 0
const selectedPosY = selectedNode ? selectedNode.pos[1] : 0
const selectedWidth = selectedNode ? selectedNode.size[0] : 0
const selectedHeight = selectedNode ? selectedNode.size[1] : 0
const selectionChanged =
lastSelectedNodeId !== selectedNodeId ||
(!!selectedNode &&
(lastSelectedPosX !== selectedPosX ||
lastSelectedPosY !== selectedPosY ||
lastSelectedWidth !== selectedWidth ||
lastSelectedHeight !== selectedHeight))
lastSelectedNodeId = selectedNodeId
lastSelectedPosX = selectedPosX
lastSelectedPosY = selectedPosY
lastSelectedWidth = selectedWidth
lastSelectedHeight = selectedHeight
for (const widgetState of widgetStates.value) {
const widget = widgetState.widget
seenWidgetIds.add(widget.id)
@@ -83,16 +134,42 @@ const updateWidgets = () => {
if (widgetState.visible) {
const margin = widget.margin
widgetState.pos = [
posNode.pos[0] + margin,
posNode.pos[1] + margin + posWidget.y
]
widgetState.size = [
(posWidget.width ?? posNode.width) - margin * 2,
(posWidget.computedHeight ?? 50) - margin * 2
]
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
widgetState.readonly = lgCanvas.read_only
const newPosX = posNode.pos[0] + margin
const newPosY = posNode.pos[1] + margin + posWidget.y
if (
viewportChanged ||
selectionChanged ||
widgetState.pos[0] !== newPosX ||
widgetState.pos[1] !== newPosY
) {
widgetState.pos = [newPosX, newPosY]
}
const newWidth = (posWidget.width ?? posNode.width) - margin * 2
const newHeight = (posWidget.computedHeight ?? 50) - margin * 2
if (
widgetState.size[0] !== newWidth ||
widgetState.size[1] !== newHeight
) {
widgetState.size = [newWidth, newHeight]
}
const newZIndex = getDomWidgetZIndex(posNode, currentGraph)
if (widgetState.zIndex !== newZIndex) {
widgetState.zIndex = newZIndex
}
const newReadonly = lgCanvas.read_only
if (widgetState.readonly !== newReadonly) {
widgetState.readonly = newReadonly
}
const newComputedDisabled = useOverride
? (override.widget.computedDisabled ?? widget.computedDisabled ?? false)
: (widget.computedDisabled ?? false)
if (widgetState.computedDisabled !== newComputedDisabled) {
widgetState.computedDisabled = newComputedDisabled
}
}
}

View File

@@ -54,7 +54,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
})
}))
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
function createWidgetState(computedDisabled: boolean): DomWidgetState {
const domWidgetStore = useDomWidgetStore()
const node = createMockLGraphNode({
id: 1,
@@ -76,7 +76,7 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState {
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: createMockLGraphNode({ id: 2 }),
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
widget: { computedDisabled } as DomWidgetState['widget']
})
const state = domWidgetStore.widgetStates.get(widget.id)
@@ -84,6 +84,7 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState {
state.zIndex = 2
state.size = [100, 40]
state.computedDisabled = computedDisabled
return reactive(state)
}
@@ -98,7 +99,7 @@ describe('DomWidget disabled style', () => {
vi.clearAllMocks()
})
it('uses disabled style when promoted override widget is computedDisabled', async () => {
it('uses disabled style when widgetState.computedDisabled is true', async () => {
const widgetState = createWidgetState(true)
const { container } = render(DomWidget, {
props: {

View File

@@ -104,10 +104,7 @@ const updateDomClipping = () => {
const { left, top } = useElementBounding(canvasStore.getCanvas().canvas)
function composeStyle() {
const override = widgetState.positionOverride
const isDisabled = override
? (override.widget.computedDisabled ?? widget.computedDisabled)
: widget.computedDisabled
const isDisabled = widgetState.computedDisabled
style.value = {
...positionStyle.value,
@@ -122,15 +119,40 @@ function composeStyle() {
}
watch(
[() => widgetState, left, top, enableDomClipping],
([widgetState]) => {
[
() => widgetState.pos,
() => widgetState.size,
// Visibility transitions (e.g. LOD low_quality flipping) must refresh
// style: while invisible, DomWidgets.vue does not update widgetState
// and ds.offset/ds.scale are non-reactive, so updatePosition must be
// re-run against the current viewport when the widget reappears.
() => widgetState.visible,
left,
top
],
() => {
updatePosition(widgetState)
if (enableDomClipping.value) {
updateDomClipping()
}
composeStyle()
},
{ deep: true }
}
)
watch(
[
() => widgetState.zIndex,
() => widgetState.readonly,
() => widgetState.computedDisabled,
() => widgetState.positionOverride,
enableDomClipping
],
() => {
if (enableDomClipping.value) {
updateDomClipping()
}
composeStyle()
}
)
// Recompose style when clippingStyle updates asynchronously via RAF.

View File

@@ -20,6 +20,13 @@ export interface DomWidgetState extends PositionConfig {
widget: Raw<BaseDOMWidget<object | string>>
visible: boolean
readonly: boolean
/**
* Mirrors `widget.computedDisabled` (set by litegraph when a widget input
* is connected). The underlying property is non-reactive, so DomWidgets.vue
* snapshots it into widgetState each frame and DomWidget.vue watches the
* snapshot to refresh opacity/pointer-events.
*/
computedDisabled: boolean
zIndex: number
/** If the widget belongs to the current graph/subgraph. */
active: boolean
@@ -42,6 +49,7 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
widget: markRaw(widget) as unknown as Raw<BaseDOMWidget<object | string>>,
visible: true,
readonly: false,
computedDisabled: false,
zIndex: 0,
pos: [0, 0],
size: [0, 0],