From 78555a43f80f509c00c804345f7760c244f5c88e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 25 Mar 2026 03:08:45 +0100 Subject: [PATCH] fix(voice): stabilize grid layout across focus-mode and fullscreen transitions Refactor useGridLayout from RefObject to callback-ref API so the ResizeObserver reattaches when the grid container remounts after focus-mode toggle. Fixes tiles rendering at stale fullscreen dimensions on 4:3 monitors. Also clamp focus-mode participant strip height (max-h-[20vh] min-h-[80px]) to prevent it from crowding the focused stream on short viewports. --- .../web/src/components/voice/VoiceGrid.tsx | 9 +- .../src/hooks/__tests__/useGridLayout.test.ts | 111 ++++++++++++++++++ packages/web/src/hooks/useGridLayout.ts | 30 +++-- 3 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 packages/web/src/hooks/__tests__/useGridLayout.test.ts diff --git a/packages/web/src/components/voice/VoiceGrid.tsx b/packages/web/src/components/voice/VoiceGrid.tsx index 026ebd00..27a9be79 100644 --- a/packages/web/src/components/voice/VoiceGrid.tsx +++ b/packages/web/src/components/voice/VoiceGrid.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { VoiceUser } from './VoiceUser'; import { StreamTile } from './StreamTile'; import { useVoiceStore } from '../../stores/voiceStore'; @@ -17,8 +17,7 @@ export function VoiceGrid({ participants }: VoiceGridProps) { const tiles = useMemo(() => deriveGridTiles(participants), [participants]); - const containerRef = useRef(null); - const { cols, tileWidth, tileHeight } = useGridLayout(containerRef, tiles.length); + const { cols, tileWidth, tileHeight, ref: gridRef } = useGridLayout(tiles.length); // Reset strip visibility when focus target changes useEffect(() => { @@ -138,7 +137,7 @@ export function VoiceGrid({ participants }: VoiceGridProps) { {/* Bottom strip of other tiles */} {!stripHidden && otherTiles.length > 0 && ( -
+
{otherTiles.map((t) => (
+
= new Set(); + +class MockResizeObserver { + constructor(cb: ResizeObserverCallback) { + resizeCallback = cb; + } + observe(el: Element) { observedElements.add(el); } + unobserve(el: Element) { observedElements.delete(el); } + disconnect() { observedElements.clear(); } +} + +function fireResize(el: Element, width: number, height: number) { + if (!resizeCallback) return; + Object.defineProperty(el, 'clientWidth', { value: width, configurable: true }); + Object.defineProperty(el, 'clientHeight', { value: height, configurable: true }); + resizeCallback([{ target: el } as ResizeObserverEntry], {} as ResizeObserver); +} + +beforeAll(() => { + vi.stubGlobal('ResizeObserver', MockResizeObserver); +}); + +beforeEach(() => { + resizeCallback = null; + observedElements.clear(); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +describe('useGridLayout', () => { + it('returns a stable callback ref', () => { + const { result, rerender } = renderHook(() => useGridLayout(4)); + const ref1 = result.current.ref; + rerender(); + expect(result.current.ref).toBe(ref1); + }); + + it('attaches observer when ref is called with an element', () => { + const { result } = renderHook(() => useGridLayout(4)); + const el = document.createElement('div'); + + act(() => { result.current.ref(el); }); + + expect(observedElements.has(el)).toBe(true); + }); + + it('computes layout from element dimensions', () => { + const { result } = renderHook(() => useGridLayout(4)); + const el = document.createElement('div'); + + act(() => { result.current.ref(el); }); + act(() => { fireResize(el, 800, 600); }); + + expect(result.current.tileWidth).toBeGreaterThan(0); + expect(result.current.tileHeight).toBeGreaterThan(0); + expect(result.current.cols).toBeGreaterThanOrEqual(1); + }); + + it('disconnects observer when ref is called with null', () => { + const { result } = renderHook(() => useGridLayout(4)); + const el = document.createElement('div'); + + act(() => { result.current.ref(el); }); + expect(observedElements.size).toBe(1); + + act(() => { result.current.ref(null); }); + expect(observedElements.size).toBe(0); + }); + + it('reattaches observer when element remounts (the critical bug scenario)', () => { + const { result } = renderHook(() => useGridLayout(4)); + + // Mount: grid mode + const el1 = document.createElement('div'); + act(() => { result.current.ref(el1); }); + act(() => { fireResize(el1, 1920, 1080); }); + const fullscreenWidth = result.current.tileWidth; + expect(fullscreenWidth).toBeGreaterThan(0); + + // Unmount: focus mode (grid div removed) + act(() => { result.current.ref(null); }); + expect(observedElements.size).toBe(0); + + // Remount: back to grid mode with smaller container + const el2 = document.createElement('div'); + act(() => { result.current.ref(el2); }); + act(() => { fireResize(el2, 800, 600); }); + + // Layout must reflect the NEW element's dimensions, not stale fullscreen ones + expect(result.current.tileWidth).toBeLessThan(fullscreenWidth); + expect(observedElements.has(el2)).toBe(true); + expect(observedElements.has(el1)).toBe(false); + }); + + it('does not observe when tileCount is 0', () => { + const { result } = renderHook(() => useGridLayout(0)); + const el = document.createElement('div'); + + act(() => { result.current.ref(el); }); + + expect(observedElements.size).toBe(0); + }); +}); diff --git a/packages/web/src/hooks/useGridLayout.ts b/packages/web/src/hooks/useGridLayout.ts index 87b1dde8..7c74012c 100644 --- a/packages/web/src/hooks/useGridLayout.ts +++ b/packages/web/src/hooks/useGridLayout.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, type RefObject } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; interface GridLayoutOptions { gap?: number; @@ -11,31 +11,37 @@ interface GridLayout { rows: number; tileWidth: number; tileHeight: number; + ref: (element: HTMLElement | null) => void; } export function useGridLayout( - containerRef: RefObject, tileCount: number, options: GridLayoutOptions = {}, ): GridLayout { const { gap = 8, aspectRatio = 16 / 9, padding = 12 } = options; - const [layout, setLayout] = useState({ + const [element, setElement] = useState(null); + + const [layout, setLayout] = useState({ cols: 1, rows: 1, tileWidth: 320, tileHeight: 180, }); - const prevRef = useRef(layout); + const prevRef = useRef(layout); + + // Stable callback ref — React calls this on mount (element) and unmount (null) + const ref = useCallback((el: HTMLElement | null) => { + setElement(el); + }, []); useEffect(() => { - const el = containerRef.current; - if (!el || tileCount === 0) return; + if (!element || tileCount === 0) return; const compute = () => { - const containerWidth = el.clientWidth - padding * 2; - const containerHeight = el.clientHeight - padding * 2; + const containerWidth = element.clientWidth - padding * 2; + const containerHeight = element.clientHeight - padding * 2; if (containerWidth <= 0 || containerHeight <= 0) return; let bestCols = 1; @@ -68,7 +74,7 @@ export function useGridLayout( } const bestRows = Math.ceil(tileCount / bestCols); - const next: GridLayout = { + const next = { cols: bestCols, rows: bestRows, tileWidth: bestW, @@ -90,9 +96,9 @@ export function useGridLayout( compute(); const observer = new ResizeObserver(compute); - observer.observe(el); + observer.observe(element); return () => observer.disconnect(); - }, [containerRef, tileCount, gap, aspectRatio, padding]); + }, [element, tileCount, gap, aspectRatio, padding]); - return layout; + return { ...layout, ref }; }