From 9ed44326f821e6be63963b94416815985252449c Mon Sep 17 00:00:00 2001 From: Isaac Sanchez-Hawkins <266845420+isanchez404@users.noreply.github.com> Date: Tue, 19 May 2026 09:58:18 -0400 Subject: [PATCH] fix(ui): update viewport height without visualViewport (#1314) * fix(ui): update viewport height without visualViewport * fix(ui): initialize visual viewport state from helper --------- Co-authored-by: Isaac Sanchez --- .../ui/src/hooks/useVisualViewport.test.ts | 35 ++++++++++++++ packages/ui/src/hooks/useVisualViewport.ts | 47 ++++++++++++------- 2 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 packages/ui/src/hooks/useVisualViewport.test.ts diff --git a/packages/ui/src/hooks/useVisualViewport.test.ts b/packages/ui/src/hooks/useVisualViewport.test.ts new file mode 100644 index 00000000..4fb01d7f --- /dev/null +++ b/packages/ui/src/hooks/useVisualViewport.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test'; + +import { getVisualViewportState } from './useVisualViewport'; + +const withWindow = (value: { innerHeight: number; visualViewport?: { height: number } | null }, run: () => void) => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value, + }); + + try { + run(); + } finally { + if (originalWindow) { + Object.defineProperty(globalThis, 'window', originalWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } +}; + +describe('getVisualViewportState', () => { + test('falls back to innerHeight when visualViewport is unavailable', () => { + withWindow({ innerHeight: 812 }, () => { + expect(getVisualViewportState()).toEqual({ height: 812, keyboardHeight: 0 }); + }); + }); + + test('derives keyboard height from visualViewport height', () => { + withWindow({ innerHeight: 812, visualViewport: { height: 512 } }, () => { + expect(getVisualViewportState()).toEqual({ height: 512, keyboardHeight: 300 }); + }); + }); +}); diff --git a/packages/ui/src/hooks/useVisualViewport.ts b/packages/ui/src/hooks/useVisualViewport.ts index b45a493a..49b35de1 100644 --- a/packages/ui/src/hooks/useVisualViewport.ts +++ b/packages/ui/src/hooks/useVisualViewport.ts @@ -5,46 +5,59 @@ export interface VisualViewportState { keyboardHeight: number; } -const getInitialHeight = (): number => { - if (typeof window === 'undefined') return 0; - return window.visualViewport?.height ?? window.innerHeight; +export const getVisualViewportState = (): VisualViewportState => { + if (typeof window === 'undefined') { + return { height: 0, keyboardHeight: 0 }; + } + + const height = window.visualViewport?.height ?? window.innerHeight; + const keyboardHeight = window.visualViewport + ? Math.max(0, window.innerHeight - height) + : 0; + + return { height, keyboardHeight }; }; export const useVisualViewport = (): VisualViewportState => { - const [state, setState] = React.useState(() => ({ - height: getInitialHeight(), - keyboardHeight: 0, - })); + const [state, setState] = React.useState(getVisualViewportState); const rafIdRef = React.useRef(null); React.useEffect(() => { - if (typeof window === 'undefined' || !window.visualViewport) return; + if (typeof window === 'undefined') return; + + const visualViewport = window.visualViewport; const handleChange = () => { if (rafIdRef.current !== null) return; rafIdRef.current = requestAnimationFrame(() => { rafIdRef.current = null; - const vv = window.visualViewport!; - const height = vv.height; - const keyboardHeight = Math.max(0, window.innerHeight - height); + const nextState = getVisualViewportState(); setState((prev) => { - if (prev.height === height && prev.keyboardHeight === keyboardHeight) return prev; - return { height, keyboardHeight }; + if (prev.height === nextState.height && prev.keyboardHeight === nextState.keyboardHeight) return prev; + return nextState; }); }); }; - window.visualViewport.addEventListener('resize', handleChange); - window.visualViewport.addEventListener('scroll', handleChange, { passive: true }); + if (visualViewport) { + visualViewport.addEventListener('resize', handleChange); + visualViewport.addEventListener('scroll', handleChange, { passive: true }); + } else { + window.addEventListener('resize', handleChange); + } handleChange(); return () => { if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); } - window.visualViewport?.removeEventListener('resize', handleChange); - window.visualViewport?.removeEventListener('scroll', handleChange); + if (visualViewport) { + visualViewport.removeEventListener('resize', handleChange); + visualViewport.removeEventListener('scroll', handleChange); + } else { + window.removeEventListener('resize', handleChange); + } }; }, []);