import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { createRoot, Root } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; import { useVisualViewportInsets } from './useVisualViewport'; /** * Fast-follow coverage for `useVisualViewportInsets` (BottomSheet's * keyboard-avoidance source of truth). Mirrors the bare createRoot + act * harness `useIsMobile.test.tsx` uses (no @testing-library/react in this * repo). Covers: * - the no-`visualViewport` fallback (older WebViews) returns * `{ height: window.innerHeight, keyboardInset: 0 }` AND registers no * listener (there's nothing to listen to). * - the keyboard-open math: `keyboardInset = innerHeight - (vv.height + * vv.offsetTop)`, recomputed when the mocked vv fires `resize`. * - both `resize` and `scroll` listeners are added on mount and the SAME * handler is removed for both on unmount (a leaked listener would keep * reacting after the owning component -- e.g. BottomSheet -- is gone). */ let container: HTMLDivElement; let root: Root; let originalInnerHeight: number; let originalVisualViewport: VisualViewport | null; function render(ui: React.ReactElement) { container = document.createElement('div'); document.body.appendChild(container); act(() => { root = createRoot(container); root.render(ui); }); } function unmount() { act(() => { root.unmount(); }); container.remove(); } /** A minimal fake `visualViewport`: just enough surface for the hook * (`height`, `offsetTop`, `addEventListener`/`removeEventListener`). */ function makeVisualViewport(height: number, offsetTop: number) { const listeners: Record void>> = { resize: [], scroll: [] }; const vv: any = { height, offsetTop, addEventListener: vi.fn((type: string, fn: () => void) => { listeners[type].push(fn); }), removeEventListener: vi.fn((type: string, fn: () => void) => { const i = listeners[type].indexOf(fn); if (i !== -1) listeners[type].splice(i, 1); }), }; return { vv, fire: (type: 'resize' | 'scroll') => listeners[type].forEach((fn) => fn()), listenerCount: (type: 'resize' | 'scroll') => listeners[type].length, }; } let probed: { height: number; keyboardInset: number } | null = null; const Probe: React.FC = () => { probed = useVisualViewportInsets(); return ; }; beforeEach(() => { originalInnerHeight = window.innerHeight; originalVisualViewport = window.visualViewport; probed = null; }); afterEach(() => { Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, configurable: true, writable: true, }); Object.defineProperty(window, 'visualViewport', { value: originalVisualViewport, configurable: true, }); }); describe('useVisualViewportInsets', () => { test('falls back to innerHeight/0 and registers no listener when visualViewport is undefined', () => { Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true }); Object.defineProperty(window, 'visualViewport', { value: undefined, configurable: true }); render(); expect(probed).toEqual({ height: 800, keyboardInset: 0 }); unmount(); }); test('computes keyboardInset from visualViewport and recomputes on resize', () => { Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true }); const { vv, fire } = makeVisualViewport(800, 0); Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true }); render(); // No keyboard open: vv fills the layout viewport exactly. expect(probed).toEqual({ height: 800, keyboardInset: 0 }); // Simulate the on-screen keyboard opening: the visual viewport shrinks. vv.height = 500; vv.offsetTop = 0; act(() => { fire('resize'); }); expect(probed).toEqual({ height: 500, keyboardInset: 300 }); unmount(); }); test('registers resize and scroll listeners on mount and removes both (same handler) on unmount', () => { Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true }); const { vv, listenerCount } = makeVisualViewport(800, 0); Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true }); render(); expect(vv.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function)); expect(vv.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function)); expect(listenerCount('resize')).toBe(1); expect(listenerCount('scroll')).toBe(1); const resizeHandler = vv.addEventListener.mock.calls.find((c: any[]) => c[0] === 'resize')[1]; const scrollHandler = vv.addEventListener.mock.calls.find((c: any[]) => c[0] === 'scroll')[1]; expect(resizeHandler).toBe(scrollHandler); unmount(); expect(vv.removeEventListener).toHaveBeenCalledWith('resize', resizeHandler); expect(vv.removeEventListener).toHaveBeenCalledWith('scroll', scrollHandler); expect(listenerCount('resize')).toBe(0); expect(listenerCount('scroll')).toBe(0); }); });