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); + }); +});