From dace98b23b98437d49e432166f024f64b7307a57 Mon Sep 17 00:00:00 2001 From: adamecker Date: Thu, 5 Nov 2026 21:06:34 +0000 Subject: [PATCH] fix(mirror): re-measure grid width when the mirror canvas resizes WidgetContainer measured its container width once on mount plus on window resize only. In mirror the canvas starts at the 1280 fallback width and widens to the target's real width once its viewport arrives; that widening is not a window resize, so the grid stayed laid out at the fallback size and the weather widget landed compact (294px iframe) instead of medium (332px) like on the wall. Watch the container element itself with a ResizeObserver (window resize kept as a no-ResizeObserver fallback) and report only on an actual width change, so no layout rebuild runs on a no-op. --- client/src/components/WidgetContainer.jsx | 41 +++---- client/src/utils/containerWidth.js | 68 ++++++++++++ client/src/utils/containerWidth.test.js | 127 ++++++++++++++++++++++ 3 files changed, 213 insertions(+), 23 deletions(-) create mode 100644 client/src/utils/containerWidth.js create mode 100644 client/src/utils/containerWidth.test.js diff --git a/client/src/components/WidgetContainer.jsx b/client/src/components/WidgetContainer.jsx index bb088388..38c2f1bf 100644 --- a/client/src/components/WidgetContainer.jsx +++ b/client/src/components/WidgetContainer.jsx @@ -19,6 +19,7 @@ import { canCommitResize } from '../utils/resizeGuard'; import { shouldAcceptLayoutChange, reconcileLayoutWithLive } from '../utils/layoutSync'; import { buildLayout, savedSourcesById } from '../utils/gridPlacement'; import { resizeButtonMetrics, widgetPixelSize } from '../utils/resizeButtons.js'; +import { createContainerWidthWatcher, gridColsForWidth } from '../utils/containerWidth.js'; import { readGridMetrics } from '../utils/gridMetrics'; import { frameDecoration } from '../utils/widgetFrame'; import FrameOrnaments from './FrameOrnaments'; @@ -198,31 +199,25 @@ const WidgetContainer = ({ }, 500); }, []); - // Update container width and grid columns based on screen size + // The container width drives the grid, so re-measure whenever the + // container element itself changes size — not just on window resize. + // In mirror the canvas starts at the 1280 fallback width and widens to + // the target's real width once its viewport arrives; that widening is + // not a window resize, so a window-only listener leaves the grid laid + // out at the fallback size (the weather widget then lands compact at + // 294px instead of medium at 332px). The watcher reports only on an + // actual width change, so no layout rebuild runs on a no-op. useEffect(() => { - const updateDimensions = () => { - if (containerRef.current) { - const computed = window.getComputedStyle(containerRef.current); - const paddingLeft = parseFloat(computed.paddingLeft) || 0; - const paddingRight = parseFloat(computed.paddingRight) || 0; - const width = Math.max(0, containerRef.current.clientWidth - paddingLeft - paddingRight); + const element = containerRef.current; + if (!element) return undefined; + const watcher = createContainerWidthWatcher({ + element, + onChange: (width) => { setContainerWidth(width); - - // Responsive grid columns - if (width < 600) { - setGridCols(4); // Mobile: 4 columns - } else if (width < 960) { - setGridCols(8); // Tablet: 8 columns - } else { - setGridCols(12); // Desktop: 12 columns - } - } - }; - - updateDimensions(); - window.addEventListener('resize', updateDimensions); - - return () => window.removeEventListener('resize', updateDimensions); + setGridCols(gridColsForWidth(width)); + }, + }); + return () => watcher.disconnect(); }, []); useEffect(() => { diff --git a/client/src/utils/containerWidth.js b/client/src/utils/containerWidth.js new file mode 100644 index 00000000..386cecd1 --- /dev/null +++ b/client/src/utils/containerWidth.js @@ -0,0 +1,68 @@ +// Container-width measurement for the dashboard grid (WidgetContainer). +// +// The grid's width is the container element's own width, so it has to be +// re-measured whenever that element changes size — not just on window +// resize. In mirror the canvas starts at the 1280 fallback width and +// widens to the target's real width once its viewport arrives; that +// widening is not a window resize, so a window-only listener leaves the +// grid laid out at the fallback size (the weather widget then lands +// compact at 294px instead of medium at 332px). +// +// The watcher reports only when the measured width actually changes, so a +// resize storm or a state update that leaves the width alone never +// re-runs the layout rebuild. The container is width:100% of its parent, +// so changing the grid's width prop never changes the container's own +// width — the observer cannot feed itself. + +export function measureContainerWidth(element) { + if (!element || typeof window === 'undefined') return null; + const computed = window.getComputedStyle(element); + const paddingLeft = parseFloat(computed.paddingLeft) || 0; + const paddingRight = parseFloat(computed.paddingRight) || 0; + return Math.max(0, element.clientWidth - paddingLeft - paddingRight); +} + +// Responsive grid columns, matching the breakpoints WidgetContainer has +// always used. +export function gridColsForWidth(width) { + if (width < 600) return 4; // Mobile: 4 columns + if (width < 960) return 8; // Tablet: 8 columns + return 12; // Desktop: 12 columns +} + +export function createContainerWidthWatcher({ element, onChange }) { + let lastWidth = null; + let observer = null; + + const check = () => { + const width = measureContainerWidth(element); + if (width === null || width === lastWidth) return; + lastWidth = width; + onChange(width); + }; + + // Initial measure, so the grid is right on first paint even if the + // observer never fires. + check(); + + if (typeof ResizeObserver !== 'undefined' && element) { + observer = new ResizeObserver(check); + observer.observe(element); + } + + // Window resize stays as a fallback for environments without + // ResizeObserver; the change guard above makes the duplicate harmless + // where both fire. + if (typeof window !== 'undefined') { + window.addEventListener('resize', check); + } + + return { + disconnect() { + if (observer) observer.disconnect(); + if (typeof window !== 'undefined') { + window.removeEventListener('resize', check); + } + }, + }; +} diff --git a/client/src/utils/containerWidth.test.js b/client/src/utils/containerWidth.test.js new file mode 100644 index 00000000..3a986baf --- /dev/null +++ b/client/src/utils/containerWidth.test.js @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + measureContainerWidth, + gridColsForWidth, + createContainerWidthWatcher, +} from './containerWidth.js'; + +function fakeElement({ clientWidth, paddingLeft = '16px', paddingRight = '16px' } = {}) { + return { clientWidth, __paddingLeft: paddingLeft, __paddingRight: paddingRight }; +} + +describe('measureContainerWidth', () => { + beforeEach(() => { + vi.stubGlobal('window', { + getComputedStyle: (el) => ({ + paddingLeft: el.__paddingLeft, + paddingRight: el.__paddingRight, + }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('subtracts horizontal padding from clientWidth', () => { + expect(measureContainerWidth(fakeElement({ clientWidth: 1440 }))).toBe(1408); + expect(measureContainerWidth(fakeElement({ clientWidth: 1280 }))).toBe(1248); + }); + + it('floors at 0 and returns null without an element', () => { + expect(measureContainerWidth(fakeElement({ clientWidth: 10 }))).toBe(0); + expect(measureContainerWidth(null)).toBe(null); + expect(measureContainerWidth(undefined)).toBe(null); + }); +}); + +describe('gridColsForWidth', () => { + it('uses 4 / 8 / 12 columns at the 600 / 960 breakpoints', () => { + expect(gridColsForWidth(0)).toBe(4); + expect(gridColsForWidth(599)).toBe(4); + expect(gridColsForWidth(600)).toBe(8); + expect(gridColsForWidth(959)).toBe(8); + expect(gridColsForWidth(960)).toBe(12); + expect(gridColsForWidth(1408)).toBe(12); + }); +}); + +describe('createContainerWidthWatcher', () => { + let element; + let observerInstances; + let resizeListeners; + + beforeEach(() => { + element = fakeElement({ clientWidth: 1280 }); + observerInstances = []; + resizeListeners = []; + vi.stubGlobal('window', { + getComputedStyle: (el) => ({ + paddingLeft: el.__paddingLeft, + paddingRight: el.__paddingRight, + }), + addEventListener: (type, fn) => { resizeListeners.push([type, fn]); }, + removeEventListener: (type, fn) => { + resizeListeners = resizeListeners.filter(([t, f]) => !(t === type && f === fn)); + }, + }); + vi.stubGlobal('ResizeObserver', class { + constructor(cb) { this.cb = cb; observerInstances.push(this); } + observe() {} + disconnect() { this.disconnected = true; } + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('reports the initial width immediately', () => { + const onChange = vi.fn(); + const watcher = createContainerWidthWatcher({ element, onChange }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(1248); + watcher.disconnect(); + }); + + it('reports a width change from the observer once, and ignores repeats at the same width', () => { + const onChange = vi.fn(); + const watcher = createContainerWidthWatcher({ element, onChange }); + expect(observerInstances).toHaveLength(1); + + // Mirror viewport arrives: canvas widens 1280 -> 1440. + element.clientWidth = 1440; + observerInstances[0].cb(); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenLastCalledWith(1408); + + // Same width again: no further report, so no layout rebuild. + observerInstances[0].cb(); + expect(onChange).toHaveBeenCalledTimes(2); + watcher.disconnect(); + }); + + it('falls back to the window resize listener without ResizeObserver', () => { + vi.stubGlobal('ResizeObserver', undefined); + const onChange = vi.fn(); + const watcher = createContainerWidthWatcher({ element, onChange }); + expect(onChange).toHaveBeenCalledTimes(1); + + element.clientWidth = 1440; + const resize = resizeListeners.find(([type]) => type === 'resize')[1]; + resize(); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenLastCalledWith(1408); + watcher.disconnect(); + }); + + it('disconnect stops the observer and the resize listener', () => { + const onChange = vi.fn(); + const watcher = createContainerWidthWatcher({ element, onChange }); + watcher.disconnect(); + expect(observerInstances[0].disconnected).toBe(true); + expect(resizeListeners).toHaveLength(0); + }); +});