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.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { VoiceUser } from './VoiceUser';
|
import { VoiceUser } from './VoiceUser';
|
||||||
import { StreamTile } from './StreamTile';
|
import { StreamTile } from './StreamTile';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
@@ -17,8 +17,7 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
|||||||
|
|
||||||
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const { cols, tileWidth, tileHeight, ref: gridRef } = useGridLayout(tiles.length);
|
||||||
const { cols, tileWidth, tileHeight } = useGridLayout(containerRef, tiles.length);
|
|
||||||
|
|
||||||
// Reset strip visibility when focus target changes
|
// Reset strip visibility when focus target changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -138,7 +137,7 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
|||||||
|
|
||||||
{/* Bottom strip of other tiles */}
|
{/* Bottom strip of other tiles */}
|
||||||
{!stripHidden && otherTiles.length > 0 && (
|
{!stripHidden && otherTiles.length > 0 && (
|
||||||
<div className="h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-surface-base/50 overflow-x-auto no-scrollbar">
|
<div className="h-[120px] max-h-[20vh] min-h-[80px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-surface-base/50 overflow-x-auto no-scrollbar">
|
||||||
{otherTiles.map((t) => (
|
{otherTiles.map((t) => (
|
||||||
<div
|
<div
|
||||||
key={t.key}
|
key={t.key}
|
||||||
@@ -156,7 +155,7 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
|||||||
|
|
||||||
// Default grid mode — container-aware layout via ResizeObserver
|
// Default grid mode — container-aware layout via ResizeObserver
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="flex-1 overflow-hidden min-h-0">
|
<div ref={gridRef} className="flex-1 overflow-hidden min-h-0">
|
||||||
<div
|
<div
|
||||||
className="grid h-full w-full"
|
className="grid h-full w-full"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useGridLayout } from '../useGridLayout';
|
||||||
|
|
||||||
|
// --- ResizeObserver mock ---
|
||||||
|
let resizeCallback: ResizeObserverCallback | null = null;
|
||||||
|
let observedElements: Set<Element> = 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, type RefObject } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
interface GridLayoutOptions {
|
interface GridLayoutOptions {
|
||||||
gap?: number;
|
gap?: number;
|
||||||
@@ -11,31 +11,37 @@ interface GridLayout {
|
|||||||
rows: number;
|
rows: number;
|
||||||
tileWidth: number;
|
tileWidth: number;
|
||||||
tileHeight: number;
|
tileHeight: number;
|
||||||
|
ref: (element: HTMLElement | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGridLayout(
|
export function useGridLayout(
|
||||||
containerRef: RefObject<HTMLElement | null>,
|
|
||||||
tileCount: number,
|
tileCount: number,
|
||||||
options: GridLayoutOptions = {},
|
options: GridLayoutOptions = {},
|
||||||
): GridLayout {
|
): GridLayout {
|
||||||
const { gap = 8, aspectRatio = 16 / 9, padding = 12 } = options;
|
const { gap = 8, aspectRatio = 16 / 9, padding = 12 } = options;
|
||||||
|
|
||||||
const [layout, setLayout] = useState<GridLayout>({
|
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const [layout, setLayout] = useState({
|
||||||
cols: 1,
|
cols: 1,
|
||||||
rows: 1,
|
rows: 1,
|
||||||
tileWidth: 320,
|
tileWidth: 320,
|
||||||
tileHeight: 180,
|
tileHeight: 180,
|
||||||
});
|
});
|
||||||
|
|
||||||
const prevRef = useRef<GridLayout>(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(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
if (!element || tileCount === 0) return;
|
||||||
if (!el || tileCount === 0) return;
|
|
||||||
|
|
||||||
const compute = () => {
|
const compute = () => {
|
||||||
const containerWidth = el.clientWidth - padding * 2;
|
const containerWidth = element.clientWidth - padding * 2;
|
||||||
const containerHeight = el.clientHeight - padding * 2;
|
const containerHeight = element.clientHeight - padding * 2;
|
||||||
if (containerWidth <= 0 || containerHeight <= 0) return;
|
if (containerWidth <= 0 || containerHeight <= 0) return;
|
||||||
|
|
||||||
let bestCols = 1;
|
let bestCols = 1;
|
||||||
@@ -68,7 +74,7 @@ export function useGridLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bestRows = Math.ceil(tileCount / bestCols);
|
const bestRows = Math.ceil(tileCount / bestCols);
|
||||||
const next: GridLayout = {
|
const next = {
|
||||||
cols: bestCols,
|
cols: bestCols,
|
||||||
rows: bestRows,
|
rows: bestRows,
|
||||||
tileWidth: bestW,
|
tileWidth: bestW,
|
||||||
@@ -90,9 +96,9 @@ export function useGridLayout(
|
|||||||
compute();
|
compute();
|
||||||
|
|
||||||
const observer = new ResizeObserver(compute);
|
const observer = new ResizeObserver(compute);
|
||||||
observer.observe(el);
|
observer.observe(element);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [containerRef, tileCount, gap, aspectRatio, padding]);
|
}, [element, tileCount, gap, aspectRatio, padding]);
|
||||||
|
|
||||||
return layout;
|
return { ...layout, ref };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user