From 2c221604e595f9ae3813d2cf452de39ea905acae Mon Sep 17 00:00:00 2001 From: ampagent Date: Thu, 23 Jul 2026 08:27:52 +0000 Subject: [PATCH 1/4] perf: avoid per-frame reactive mutations in DomWidgets positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates unconditional 60fps reactive writes that triggered Vue's dependency tracking on every draw frame, causing cascading re-renders. DomWidgets.vue: - Track viewport (ds.offset/scale) and selected node bounds between frames. Only write widgetState.pos when the value actually changed, but force reassignment when viewport pans or selected node moves (needed because ds.offset is non-reactive — downstream watchers won't fire unless pos gets a new array identity). - Guard all per-frame writes (pos, size, zIndex, readonly, computedDisabled) with equality checks. - Snapshot widget.computedDisabled into widgetState each frame so DomWidget.vue can watch a reactive property instead of reading the non-reactive litegraph field directly. DomWidget.vue: - Replace the { deep: true } watcher over the entire widgetState object with two focused watchers: one for pos/size/visible (calls updatePosition) and one for zIndex/readonly/computedDisabled/ enableDomClipping (calls composeStyle only). - Read widgetState.computedDisabled instead of widget.computedDisabled in composeStyle() so the watcher can fire reactively. domWidgetStore.ts: - Add computedDisabled: boolean to DomWidgetState, initialized false. --- src/components/graph/DomWidgets.test.ts | 123 +++++++++++++++++- src/components/graph/DomWidgets.vue | 95 ++++++++++++-- .../graph/widgets/DomWidget.test.ts | 3 + src/components/graph/widgets/DomWidget.vue | 34 ++++- src/stores/domWidgetStore.ts | 8 ++ 5 files changed, 247 insertions(+), 16 deletions(-) diff --git a/src/components/graph/DomWidgets.test.ts b/src/components/graph/DomWidgets.test.ts index 6e16f88d0f5..842988eb464 100644 --- a/src/components/graph/DomWidgets.test.ts +++ b/src/components/graph/DomWidgets.test.ts @@ -50,7 +50,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: {} }) } @@ -139,4 +141,123 @@ describe('DomWidgets positioning', () => { expect(widgetState.visible).toBe(false) }) + + 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) + }) }) diff --git a/src/components/graph/DomWidgets.vue b/src/components/graph/DomWidgets.vue index c40fb2198a1..0ce740e4c08 100644 --- a/src/components/graph/DomWidgets.vue +++ b/src/components/graph/DomWidgets.vue @@ -24,6 +24,27 @@ const domWidgetStore = useDomWidgetStore() 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 @@ -31,6 +52,36 @@ const updateWidgets = () => { const lowQuality = lgCanvas.low_quality const currentGraph = lgCanvas.graph + 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 @@ -51,16 +102,40 @@ const updateWidgets = () => { if (widgetState.visible) { const margin = widget.margin - widgetState.pos = [ - posNode.pos[0] + margin, - posNode.pos[1] + margin + widget.y - ] - widgetState.size = [ - (widget.width ?? posNode.width) - margin * 2, - (widget.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 + widget.y + if ( + viewportChanged || + selectionChanged || + widgetState.pos[0] !== newPosX || + widgetState.pos[1] !== newPosY + ) { + widgetState.pos = [newPosX, newPosY] + } + + const newWidth = (widget.width ?? posNode.width) - margin * 2 + const newHeight = (widget.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 = widget.computedDisabled ?? false + if (widgetState.computedDisabled !== newComputedDisabled) { + widgetState.computedDisabled = newComputedDisabled + } } } } diff --git a/src/components/graph/widgets/DomWidget.test.ts b/src/components/graph/widgets/DomWidget.test.ts index a725560619b..1215142e645 100644 --- a/src/components/graph/widgets/DomWidget.test.ts +++ b/src/components/graph/widgets/DomWidget.test.ts @@ -80,6 +80,9 @@ function createWidgetState(disabled: boolean): DomWidgetState { state.zIndex = 2 state.size = [100, 40] + // DomWidgets.vue snapshots widget.computedDisabled into widgetState each frame. + // In unit tests there's no draw loop, so set the snapshot directly. + state.computedDisabled = disabled return reactive(state) } diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue index 194c84af6df..1ba53c9eee7 100644 --- a/src/components/graph/widgets/DomWidget.vue +++ b/src/components/graph/widgets/DomWidget.vue @@ -100,7 +100,7 @@ const updateDomClipping = () => { const { left, top } = useElementBounding(canvasStore.getCanvas().canvas) function composeStyle() { - const isDisabled = widget.computedDisabled + const isDisabled = widgetState.computedDisabled style.value = { ...positionStyle.value, @@ -115,15 +115,39 @@ 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, + enableDomClipping + ], + () => { + if (enableDomClipping.value) { + updateDomClipping() + } + composeStyle() + } ) // Recompose style when clippingStyle updates asynchronously via RAF. diff --git a/src/stores/domWidgetStore.ts b/src/stores/domWidgetStore.ts index 89e2f785e25..e73b308be18 100644 --- a/src/stores/domWidgetStore.ts +++ b/src/stores/domWidgetStore.ts @@ -13,6 +13,13 @@ export interface DomWidgetState extends PositionConfig { widget: Raw> 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 @@ -33,6 +40,7 @@ export const useDomWidgetStore = defineStore('domWidget', () => { widget: markRaw(widget) as unknown as Raw>, visible: true, readonly: false, + computedDisabled: false, zIndex: 0, pos: [0, 0], size: [0, 0], From d3062ea255bd92fd41f92bb5ab70be7659cf4edc Mon Sep 17 00:00:00 2001 From: ampagent Date: Wed, 12 Aug 2026 03:24:35 +0000 Subject: [PATCH 2/4] refactor: consolidate frame-tracking lets into objects; add perf invariant test Address @AustinMroz's style note: replace 7 bare module-level `let` variables with two plain objects (`lastViewport`, `lastSelected`), making the per-frame snapshot state easier to read and extend. Add a reactive-write budget test that directly validates the perf claim: instrument the `pos` setter on widgetState and assert zero writes across 20 idle frames (canvas and nodes both stationary). --- src/components/graph/DomWidgets.test.ts | 48 ++++++++++++++++++ src/components/graph/DomWidgets.vue | 65 +++++++++++-------------- 2 files changed, 77 insertions(+), 36 deletions(-) diff --git a/src/components/graph/DomWidgets.test.ts b/src/components/graph/DomWidgets.test.ts index 842988eb464..fa225362902 100644 --- a/src/components/graph/DomWidgets.test.ts +++ b/src/components/graph/DomWidgets.test.ts @@ -261,3 +261,51 @@ describe('DomWidgets positioning', () => { expect(widgetState.pos).not.toBe(posAfterFirstFrame) }) }) + +describe('DomWidgets reactive-write budget', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('produces zero reactive pos writes across N idle frames (perf invariant)', () => { + const canvasStore = useCanvasStore() + const domWidgetStore = useDomWidgetStore() + + const graph = new LGraph() + const node = createNode(graph, 1, 'node', [100, 200]) + const widget = createWidget('perf-widget', node, 12) + domWidgetStore.registerWidget(widget) + + const canvas = createCanvas(graph) + canvasStore.canvas = canvas + + render(DomWidgets, { global: { stubs: { DomWidget: true } } }) + + // Warm-up: first frame always writes to establish initial state. + drawFrame(canvas) + + const widgetState = domWidgetStore.widgetStates.get(widget.id) + if (!widgetState) throw new Error('Widget state not registered') + + // Count reactive writes via a setter proxy on pos. + let writeCount = 0 + const originalDescriptor = Object.getOwnPropertyDescriptor(widgetState, 'pos') + let _pos = widgetState.pos + Object.defineProperty(widgetState, 'pos', { + get: () => _pos, + set: (v) => { + writeCount++ + _pos = v + }, + configurable: true + }) + + // 20 idle frames: canvas stationary, no node movement, no pan. + for (let i = 0; i < 20; i++) drawFrame(canvas) + + // Restore original descriptor to avoid polluting other tests. + if (originalDescriptor) Object.defineProperty(widgetState, 'pos', originalDescriptor) + + expect(writeCount).toBe(0) + }) +}) diff --git a/src/components/graph/DomWidgets.vue b/src/components/graph/DomWidgets.vue index 0ce740e4c08..6e7c9335c89 100644 --- a/src/components/graph/DomWidgets.vue +++ b/src/components/graph/DomWidgets.vue @@ -24,26 +24,19 @@ const domWidgetStore = useDomWidgetStore() 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 +// Track canvas viewport and selected-node bounds between frames. +// lgCanvas.ds.offset, ds.scale, and node.pos/size are non-reactive plain +// props — Vue watchers in DomWidget won't fire unless widgetState.pos gets +// a new array identity. We force reassignment whenever these change so the +// downstream watcher re-runs updatePosition / updateDomClipping. +const lastViewport = { offsetX: Number.NaN, offsetY: Number.NaN, scale: Number.NaN } +const lastSelected = { + id: undefined as string | number | undefined, + posX: 0, + posY: 0, + width: 0, + height: 0 +} const updateWidgets = () => { const lgCanvas = canvasStore.canvas @@ -56,12 +49,12 @@ const updateWidgets = () => { 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 + lastViewport.offsetX !== viewportOffsetX || + lastViewport.offsetY !== viewportOffsetY || + lastViewport.scale !== viewportScale + lastViewport.offsetX = viewportOffsetX + lastViewport.offsetY = viewportOffsetY + lastViewport.scale = viewportScale const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0] const selectedNodeId = selectedNode?.id @@ -70,17 +63,17 @@ const updateWidgets = () => { const selectedWidth = selectedNode ? selectedNode.size[0] : 0 const selectedHeight = selectedNode ? selectedNode.size[1] : 0 const selectionChanged = - lastSelectedNodeId !== selectedNodeId || + lastSelected.id !== selectedNodeId || (!!selectedNode && - (lastSelectedPosX !== selectedPosX || - lastSelectedPosY !== selectedPosY || - lastSelectedWidth !== selectedWidth || - lastSelectedHeight !== selectedHeight)) - lastSelectedNodeId = selectedNodeId - lastSelectedPosX = selectedPosX - lastSelectedPosY = selectedPosY - lastSelectedWidth = selectedWidth - lastSelectedHeight = selectedHeight + (lastSelected.posX !== selectedPosX || + lastSelected.posY !== selectedPosY || + lastSelected.width !== selectedWidth || + lastSelected.height !== selectedHeight)) + lastSelected.id = selectedNodeId + lastSelected.posX = selectedPosX + lastSelected.posY = selectedPosY + lastSelected.width = selectedWidth + lastSelected.height = selectedHeight for (const widgetState of widgetStates.value) { const widget = widgetState.widget From 717dde4c3665c2c0181538bb12aab3e0806d66f7 Mon Sep 17 00:00:00 2001 From: christian-byrne Date: Sun, 23 Aug 2026 05:43:18 +0000 Subject: [PATCH 3/4] test(DomWidgets): use reference-identity check for perf invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object.defineProperty setter interception on a Pinia reactive proxy does not reliably intercept Vue's internal reactive writes. The same invariant (no pos writes on idle frames) is correctly captured by checking that the pos array reference is unchanged after N idle frames — identical to the existing passing test for the same property. Co-Authored-By: Claude Sonnet 4.6 --- src/components/graph/DomWidgets.test.ts | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/components/graph/DomWidgets.test.ts b/src/components/graph/DomWidgets.test.ts index fa225362902..8bd43ddd9ce 100644 --- a/src/components/graph/DomWidgets.test.ts +++ b/src/components/graph/DomWidgets.test.ts @@ -267,7 +267,7 @@ describe('DomWidgets reactive-write budget', () => { setActivePinia(createTestingPinia({ stubActions: false })) }) - it('produces zero reactive pos writes across N idle frames (perf invariant)', () => { + it('pos array identity is preserved across N idle frames (perf invariant)', () => { const canvasStore = useCanvasStore() const domWidgetStore = useDomWidgetStore() @@ -281,31 +281,17 @@ describe('DomWidgets reactive-write budget', () => { render(DomWidgets, { global: { stubs: { DomWidget: true } } }) - // Warm-up: first frame always writes to establish initial state. + // Warm-up: first frame writes initial pos. drawFrame(canvas) const widgetState = domWidgetStore.widgetStates.get(widget.id) if (!widgetState) throw new Error('Widget state not registered') - - // Count reactive writes via a setter proxy on pos. - let writeCount = 0 - const originalDescriptor = Object.getOwnPropertyDescriptor(widgetState, 'pos') - let _pos = widgetState.pos - Object.defineProperty(widgetState, 'pos', { - get: () => _pos, - set: (v) => { - writeCount++ - _pos = v - }, - configurable: true - }) + const posAfterWarmup = widgetState.pos // 20 idle frames: canvas stationary, no node movement, no pan. + // The pos array must not be reassigned (same reference = no reactive write). for (let i = 0; i < 20; i++) drawFrame(canvas) - // Restore original descriptor to avoid polluting other tests. - if (originalDescriptor) Object.defineProperty(widgetState, 'pos', originalDescriptor) - - expect(writeCount).toBe(0) + expect(widgetState.pos).toBe(posAfterWarmup) }) }) From 7aeacc9b489276f4a96ab88839941afe20cbfd22 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 23 Aug 2026 05:50:27 +0000 Subject: [PATCH 4/4] [automated] Apply ESLint and Oxfmt fixes --- src/components/graph/DomWidgets.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/graph/DomWidgets.vue b/src/components/graph/DomWidgets.vue index 6e7c9335c89..be7d94e0044 100644 --- a/src/components/graph/DomWidgets.vue +++ b/src/components/graph/DomWidgets.vue @@ -29,7 +29,11 @@ const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()]) // props — Vue watchers in DomWidget won't fire unless widgetState.pos gets // a new array identity. We force reassignment whenever these change so the // downstream watcher re-runs updatePosition / updateDomClipping. -const lastViewport = { offsetX: Number.NaN, offsetY: Number.NaN, scale: Number.NaN } +const lastViewport = { + offsetX: Number.NaN, + offsetY: Number.NaN, + scale: Number.NaN +} const lastSelected = { id: undefined as string | number | undefined, posX: 0,