From b5990bca8bb5384281661e5546a983a0159d4cdb Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 16 Jul 2026 19:43:03 +0800 Subject: [PATCH] refactor(viewport): centralize browser metrics --- docs/performance-baseline.md | 8 +- .../chat/hooks/use-chat-keyboard-avoidance.ts | 154 ++++++++-------- .../hooks/use-chat-keyboard-diagnostics.ts | 94 ++++------ .../__tests__/viewport_observer.test.tsx | 84 +++++++++ src/lib/browser/viewport_observer.ts | 169 ++++++++++++++++++ .../viewport-css-vars-provider.test.tsx | 111 ++++++++++++ src/providers/viewport-css-vars-provider.tsx | 50 +++--- 7 files changed, 508 insertions(+), 162 deletions(-) create mode 100644 src/lib/browser/__tests__/viewport_observer.test.tsx create mode 100644 src/lib/browser/viewport_observer.ts create mode 100644 src/providers/__tests__/viewport-css-vars-provider.test.tsx diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md index f9ba72d3..485250d2 100644 --- a/docs/performance-baseline.md +++ b/docs/performance-baseline.md @@ -30,10 +30,10 @@ Compressed sizes below are Turbopack module estimates. `Eager` is the route's sy | Route | Direct-import baseline | Current eager baseline | Change | Current async baseline | | --- | ---: | ---: | ---: | ---: | -| `/splash` | 646.2 KiB | 599.9 KiB | -46.3 KiB | 49.2 KiB | -| `/chat` | 669.8 KiB | 640.7 KiB | -29.1 KiB | 49.6 KiB | -| `/subscription` | 658.4 KiB | 617.3 KiB | -41.1 KiB | 20.6 KiB | -| `/tip` | 651.6 KiB | 611.2 KiB | -40.4 KiB | 20.6 KiB | +| `/splash` | 646.2 KiB | 600.6 KiB | -45.6 KiB | 49.2 KiB | +| `/chat` | 669.8 KiB | 641.1 KiB | -28.7 KiB | 49.6 KiB | +| `/subscription` | 658.4 KiB | 618.0 KiB | -40.4 KiB | 20.6 KiB | +| `/tip` | 651.6 KiB | 611.8 KiB | -39.8 KiB | 20.6 KiB | The earlier direct-import pass replaced root `@/utils` and `@/data/storage` barrel imports with symbol-level module paths. The current Client-boundary pass diff --git a/src/app/chat/hooks/use-chat-keyboard-avoidance.ts b/src/app/chat/hooks/use-chat-keyboard-avoidance.ts index 6b598142..58056ba8 100644 --- a/src/app/chat/hooks/use-chat-keyboard-avoidance.ts +++ b/src/app/chat/hooks/use-chat-keyboard-avoidance.ts @@ -2,6 +2,13 @@ import { useEffect, useRef, type RefObject } from "react"; +import { + isViewportGeometryObservation, + observeBrowserViewport, + readBrowserViewportMetrics, + type BrowserViewportMetrics, + type ViewportObservationReason, +} from "@/lib/browser/viewport_observer"; import { BrowserDetector } from "@/utils/browser-detect"; import { Logger } from "@/utils/logger"; import { PlatformDetector } from "@/utils/platform-detect"; @@ -19,14 +26,6 @@ const CHAT_KEYBOARD_FALLBACK_DELAY_MS = 150; const log = new Logger("UseChatKeyboardAvoidance"); -interface VirtualKeyboardLike extends EventTarget { - readonly boundingRect?: Pick; -} - -interface NavigatorWithVirtualKeyboard extends Navigator { - readonly virtualKeyboard?: VirtualKeyboardLike; -} - export interface UseChatKeyboardAvoidanceOptions { active: boolean; containerRef: RefObject; @@ -48,24 +47,15 @@ export function useChatKeyboardAvoidance({ return; } - const updateBaseline = () => { - if (activeRef.current) return; - baselineRef.current = readViewportBaseline(); - }; - const visualViewport = window.visualViewport; - - updateBaseline(); - window.addEventListener("resize", updateBaseline); - window.addEventListener("orientationchange", updateBaseline); - visualViewport?.addEventListener("resize", updateBaseline); - visualViewport?.addEventListener("scroll", updateBaseline); - - return () => { - window.removeEventListener("resize", updateBaseline); - window.removeEventListener("orientationchange", updateBaseline); - visualViewport?.removeEventListener("resize", updateBaseline); - visualViewport?.removeEventListener("scroll", updateBaseline); - }; + return observeBrowserViewport((observation) => { + if ( + activeRef.current || + !isViewportGeometryObservation(observation.reason) + ) { + return; + } + baselineRef.current = toKeyboardBaseline(observation.metrics); + }); }, []); useEffect(() => { @@ -75,18 +65,23 @@ export function useChatKeyboardAvoidance({ const enabled = isKeyboardAvoidanceEnabled(); if (!active || !enabled) { clearBottomInset(container); - if (!active && enabled) baselineRef.current = readViewportBaseline(); + if (!active && enabled) { + baselineRef.current = toKeyboardBaseline( + readBrowserViewportMetrics(), + ); + } return; } - const baseline = baselineRef.current ?? readViewportBaseline(); - const visualViewport = window.visualViewport; - const virtualKeyboard = getVirtualKeyboard(); + const baseline = + baselineRef.current ?? + toKeyboardBaseline(readBrowserViewportMetrics()); let frameId: number | null = null; let fallbackTimerId: number | null = null; let lastInset = -1; let pendingCanDismiss = false; let pendingReason = "initial"; + let pendingMetrics = readBrowserViewportMetrics(); let keyboardObserved = false; let fallbackApplied = false; let sessionDismissed = false; @@ -115,12 +110,16 @@ export function useChatKeyboardAvoidance({ fallbackTimerId = null; }; - const measure = (reason: string, canDismiss: boolean) => { + const measure = ( + reason: string, + canDismiss: boolean, + metrics: BrowserViewportMetrics, + ) => { if (sessionDismissed) return; const resolution = resolveChatKeyboardInset( baseline, - readViewportSnapshot(), + toKeyboardSnapshot(metrics), ); if (resolution.keyboardVisible) { @@ -138,9 +137,14 @@ export function useChatKeyboardAvoidance({ } }; - const scheduleMeasure = (reason: string, canDismiss: boolean) => { + const scheduleMeasure = ( + reason: string, + canDismiss: boolean, + metrics: BrowserViewportMetrics, + ) => { pendingReason = reason; pendingCanDismiss ||= canDismiss; + pendingMetrics = metrics; if (frameId != null) return; frameId = window.requestAnimationFrame(() => { @@ -148,46 +152,43 @@ export function useChatKeyboardAvoidance({ const nextReason = pendingReason; const nextCanDismiss = pendingCanDismiss; pendingCanDismiss = false; - measure(nextReason, nextCanDismiss); + measure(nextReason, nextCanDismiss, pendingMetrics); }); }; - const handleVisualResize = () => scheduleMeasure("visual-resize", true); - const handleVisualScroll = () => scheduleMeasure("visual-scroll", false); - const handleWindowResize = () => scheduleMeasure("window-resize", true); - const handleVirtualKeyboardChange = () => - scheduleMeasure("virtual-keyboard", true); const resetSession = (reason: string) => { sessionDismissed = true; clearFallbackTimer(); applyBottomInset(0, "reset"); log.debug("[chat-keyboard] reset", { reason }); }; - const handleOrientationChange = () => resetSession("orientationchange"); - const handleVisibilityChange = () => { - if (document.visibilityState === "hidden") { - resetSession("visibilitychange"); + const stopObserving = observeBrowserViewport((observation) => { + if (observation.reason === "orientationchange") { + resetSession(observation.reason); + return; } - }; + if ( + observation.reason === "visibilitychange" && + document.visibilityState === "hidden" + ) { + resetSession(observation.reason); + return; + } + if (!isViewportGeometryObservation(observation.reason)) return; - visualViewport?.addEventListener("resize", handleVisualResize); - visualViewport?.addEventListener("scroll", handleVisualScroll); - virtualKeyboard?.addEventListener( - "geometrychange", - handleVirtualKeyboardChange, - ); - window.addEventListener("resize", handleWindowResize); - window.addEventListener("orientationchange", handleOrientationChange); - document.addEventListener("visibilitychange", handleVisibilityChange); - - scheduleMeasure("focus", false); + scheduleMeasure( + observation.reason === "initial" ? "focus" : observation.reason, + canDismissKeyboardSession(observation.reason), + observation.metrics, + ); + }); fallbackTimerId = window.setTimeout(() => { fallbackTimerId = null; if (!activeRef.current || sessionDismissed) return; const resolution = resolveChatKeyboardInset( baseline, - readViewportSnapshot(), + toKeyboardSnapshot(readBrowserViewportMetrics()), ); if (resolution.keyboardVisible) { keyboardObserved = true; @@ -204,41 +205,40 @@ export function useChatKeyboardAvoidance({ return () => { if (frameId != null) window.cancelAnimationFrame(frameId); clearFallbackTimer(); - visualViewport?.removeEventListener("resize", handleVisualResize); - visualViewport?.removeEventListener("scroll", handleVisualScroll); - virtualKeyboard?.removeEventListener( - "geometrychange", - handleVirtualKeyboardChange, - ); - window.removeEventListener("resize", handleWindowResize); - window.removeEventListener("orientationchange", handleOrientationChange); - document.removeEventListener("visibilitychange", handleVisibilityChange); + stopObserving(); clearBottomInset(container); }; }, [active, containerRef]); } -function readViewportBaseline(): ChatKeyboardViewportBaseline { +function toKeyboardBaseline( + metrics: BrowserViewportMetrics, +): ChatKeyboardViewportBaseline { return { - layoutHeight: window.innerHeight, - layoutWidth: window.innerWidth, - visualHeight: window.visualViewport?.height ?? window.innerHeight, + layoutHeight: metrics.layoutHeight, + layoutWidth: metrics.layoutWidth, + visualHeight: metrics.visualHeight, }; } -function readViewportSnapshot(): ChatKeyboardViewportSnapshot { - const visualViewport = window.visualViewport; +function toKeyboardSnapshot( + metrics: BrowserViewportMetrics, +): ChatKeyboardViewportSnapshot { return { - layoutHeight: window.innerHeight, - visualHeight: visualViewport?.height ?? window.innerHeight, - visualScale: visualViewport?.scale ?? 1, - virtualKeyboardHeight: getVirtualKeyboard()?.boundingRect?.height ?? 0, + layoutHeight: metrics.layoutHeight, + visualHeight: metrics.visualHeight, + visualScale: metrics.visualScale, + virtualKeyboardHeight: metrics.virtualKeyboardRect?.height ?? 0, }; } -function getVirtualKeyboard(): VirtualKeyboardLike | null { +function canDismissKeyboardSession( + reason: ViewportObservationReason, +): boolean { return ( - (navigator as NavigatorWithVirtualKeyboard).virtualKeyboard ?? null + reason === "window.resize" || + reason === "visualViewport.resize" || + reason === "virtualKeyboard.geometrychange" ); } diff --git a/src/app/chat/hooks/use-chat-keyboard-diagnostics.ts b/src/app/chat/hooks/use-chat-keyboard-diagnostics.ts index f46ddc4f..70893257 100644 --- a/src/app/chat/hooks/use-chat-keyboard-diagnostics.ts +++ b/src/app/chat/hooks/use-chat-keyboard-diagnostics.ts @@ -2,23 +2,15 @@ import { useEffect, type RefObject } from "react"; +import { + observeBrowserViewport, + readBrowserViewportMetrics, +} from "@/lib/browser/viewport_observer"; import { BrowserDetector } from "@/utils/browser-detect"; import { PlatformDetector } from "@/utils/platform-detect"; const CHAT_KEYBOARD_DEBUG_PARAM = "keyboard_debug"; -interface VirtualKeyboardLike extends EventTarget { - readonly overlaysContent?: boolean; - readonly boundingRect?: Pick< - DOMRectReadOnly, - "height" | "width" | "x" | "y" - >; -} - -interface NavigatorWithVirtualKeyboard extends Navigator { - readonly virtualKeyboard?: VirtualKeyboardLike; -} - export interface ChatKeyboardDebugSnapshot { event: string; time: number; @@ -102,17 +94,6 @@ export function installChatKeyboardDiagnostics( return snapshot; }; - const listen = ( - target: EventTarget | null | undefined, - eventName: string, - label = eventName, - ) => { - if (!target) return; - const handler = () => takeSnapshot(label); - target.addEventListener(eventName, handler); - removers.push(() => target.removeEventListener(eventName, handler)); - }; - const cleanup = () => { if (disposed) return; disposed = true; @@ -126,17 +107,12 @@ export function installChatKeyboardDiagnostics( window.console.info("[keyboard-debug] removed"); }; const manualSnapshot = () => takeSnapshot("manual"); - const virtualKeyboard = getVirtualKeyboard(); - - listen(window, "resize", "window.resize"); - listen(window, "scroll", "window.scroll"); - listen(window, "orientationchange", "orientationchange"); - listen(document, "focusin", "focusin"); - listen(document, "focusout", "focusout"); - listen(document, "visibilitychange", "visibilitychange"); - listen(window.visualViewport, "resize", "visualViewport.resize"); - listen(window.visualViewport, "scroll", "visualViewport.scroll"); - listen(virtualKeyboard, "geometrychange", "virtualKeyboard.geometrychange"); + removers.push( + observeBrowserViewport( + (observation) => takeSnapshot(observation.reason), + { emitInitial: false }, + ), + ); window.__keyboardDebugSnapshot = manualSnapshot; window.__keyboardDebugCleanup = cleanup; @@ -149,8 +125,7 @@ function createChatKeyboardDebugSnapshot( event: string, containerRef: RefObject, ): ChatKeyboardDebugSnapshot { - const visualViewport = window.visualViewport; - const virtualKeyboardRect = getVirtualKeyboard()?.boundingRect ?? null; + const metrics = readBrowserViewportMetrics(); const activeElement = document.activeElement; const activeRect = activeElement instanceof HTMLElement @@ -166,26 +141,29 @@ function createChatKeyboardDebugSnapshot( return { event, time: Math.round(performance.now()), - windowInnerHeight: window.innerHeight, - documentClientHeight: document.documentElement.clientHeight, - scrollY: window.scrollY, - visualViewportSupported: visualViewport != null, - visualHeight: visualViewport?.height ?? null, - visualWidth: visualViewport?.width ?? null, - visualOffsetTop: visualViewport?.offsetTop ?? null, - visualOffsetLeft: visualViewport?.offsetLeft ?? null, - visualScale: visualViewport?.scale ?? null, - virtualKeyboardSupported: getVirtualKeyboard() != null, - virtualKeyboardOverlaysContent: - getVirtualKeyboard()?.overlaysContent ?? null, - virtualKeyboardRect: virtualKeyboardRect - ? { - x: virtualKeyboardRect.x, - y: virtualKeyboardRect.y, - width: virtualKeyboardRect.width, - height: virtualKeyboardRect.height, - } + windowInnerHeight: metrics.layoutHeight, + documentClientHeight: metrics.documentClientHeight, + scrollY: metrics.scrollY, + visualViewportSupported: metrics.visualViewportSupported, + visualHeight: metrics.visualViewportSupported + ? metrics.visualHeight : null, + visualWidth: metrics.visualViewportSupported + ? metrics.visualWidth + : null, + visualOffsetTop: metrics.visualViewportSupported + ? metrics.visualOffsetTop + : null, + visualOffsetLeft: metrics.visualViewportSupported + ? metrics.visualOffsetLeft + : null, + visualScale: metrics.visualViewportSupported + ? metrics.visualScale + : null, + virtualKeyboardSupported: metrics.virtualKeyboardSupported, + virtualKeyboardOverlaysContent: + metrics.virtualKeyboardOverlaysContent, + virtualKeyboardRect: metrics.virtualKeyboardRect, activeElement: activeElement?.tagName ?? null, activeBottom: activeRect?.bottom ?? null, activeFocused: activeElement !== document.body, @@ -217,9 +195,3 @@ function createChatKeyboardDebugSnapshot( }, }; } - -function getVirtualKeyboard(): VirtualKeyboardLike | null { - return ( - (navigator as NavigatorWithVirtualKeyboard).virtualKeyboard ?? null - ); -} diff --git a/src/lib/browser/__tests__/viewport_observer.test.tsx b/src/lib/browser/__tests__/viewport_observer.test.tsx new file mode 100644 index 00000000..0d2ad4f0 --- /dev/null +++ b/src/lib/browser/__tests__/viewport_observer.test.tsx @@ -0,0 +1,84 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { observeBrowserViewport } from "../viewport_observer"; + +class MockVisualViewport extends EventTarget { + height = 760; + offsetLeft = 0; + offsetTop = 0; + scale = 1; + width = 390; +} + +class MockVirtualKeyboard extends EventTarget { + overlaysContent = false; + boundingRect = { height: 0 }; +} + +describe("viewport observer", () => { + let visualViewport: MockVisualViewport; + let virtualKeyboard: MockVirtualKeyboard; + + beforeEach(() => { + visualViewport = new MockVisualViewport(); + virtualKeyboard = new MockVirtualKeyboard(); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewport, + }); + Object.defineProperty(navigator, "virtualKeyboard", { + configurable: true, + value: virtualKeyboard, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shares one browser listener set across multiple consumers", () => { + const addWindowListener = vi.spyOn(window, "addEventListener"); + const removeWindowListener = vi.spyOn(window, "removeEventListener"); + const first = vi.fn(); + const second = vi.fn(); + + const stopFirst = observeBrowserViewport(first); + const stopSecond = observeBrowserViewport(second); + + expect( + addWindowListener.mock.calls.filter( + ([event]) => String(event) === "resize", + ), + ).toHaveLength(1); + expect(first).toHaveBeenCalledWith( + expect.objectContaining({ reason: "initial" }), + ); + expect(second).toHaveBeenCalledWith( + expect.objectContaining({ reason: "initial" }), + ); + + visualViewport.height = 460; + visualViewport.dispatchEvent(new Event("resize")); + expect(first).toHaveBeenLastCalledWith( + expect.objectContaining({ + reason: "visualViewport.resize", + metrics: expect.objectContaining({ visualHeight: 460 }), + }), + ); + expect(second).toHaveBeenCalledTimes(2); + + stopFirst(); + visualViewport.dispatchEvent(new Event("scroll")); + expect(first).toHaveBeenCalledTimes(2); + expect(second).toHaveBeenCalledTimes(3); + + stopSecond(); + expect( + removeWindowListener.mock.calls.filter( + ([event]) => String(event) === "resize", + ), + ).toHaveLength(1); + }); +}); diff --git a/src/lib/browser/viewport_observer.ts b/src/lib/browser/viewport_observer.ts new file mode 100644 index 00000000..f482e671 --- /dev/null +++ b/src/lib/browser/viewport_observer.ts @@ -0,0 +1,169 @@ +export type ViewportObservationReason = + | "initial" + | "window.resize" + | "window.scroll" + | "orientationchange" + | "focusin" + | "focusout" + | "visibilitychange" + | "visualViewport.resize" + | "visualViewport.scroll" + | "virtualKeyboard.geometrychange"; + +export interface BrowserViewportMetrics { + layoutHeight: number; + layoutWidth: number; + documentClientHeight: number; + scrollY: number; + visualViewportSupported: boolean; + visualHeight: number; + visualWidth: number; + visualOffsetTop: number; + visualOffsetLeft: number; + visualScale: number; + virtualKeyboardSupported: boolean; + virtualKeyboardOverlaysContent: boolean | null; + virtualKeyboardRect: { + x: number; + y: number; + width: number; + height: number; + } | null; +} + +export interface ViewportObservation { + reason: ViewportObservationReason; + metrics: BrowserViewportMetrics; +} + +interface VirtualKeyboardLike extends EventTarget { + readonly overlaysContent?: boolean; + readonly boundingRect?: Pick< + DOMRectReadOnly, + "height" | "width" | "x" | "y" + >; +} + +interface NavigatorWithVirtualKeyboard extends Navigator { + readonly virtualKeyboard?: VirtualKeyboardLike; +} + +type ViewportObserver = (observation: ViewportObservation) => void; + +const observers = new Set(); +let removeBrowserListeners: (() => void) | null = null; + +export function observeBrowserViewport( + observer: ViewportObserver, + options: { emitInitial?: boolean } = {}, +): () => void { + if (typeof window === "undefined") return () => undefined; + + observers.add(observer); + ensureBrowserListeners(); + if (options.emitInitial !== false) { + observer({ + reason: "initial", + metrics: readBrowserViewportMetrics(), + }); + } + + return () => { + observers.delete(observer); + if (observers.size === 0) { + removeBrowserListeners?.(); + removeBrowserListeners = null; + } + }; +} + +export function readBrowserViewportMetrics(): BrowserViewportMetrics { + const visualViewport = window.visualViewport; + const virtualKeyboard = getVirtualKeyboard(); + const rect = virtualKeyboard?.boundingRect; + + return { + layoutHeight: window.innerHeight, + layoutWidth: window.innerWidth, + documentClientHeight: document.documentElement.clientHeight, + scrollY: window.scrollY, + visualViewportSupported: visualViewport != null, + visualHeight: visualViewport?.height ?? window.innerHeight, + visualWidth: visualViewport?.width ?? window.innerWidth, + visualOffsetTop: visualViewport?.offsetTop ?? 0, + visualOffsetLeft: visualViewport?.offsetLeft ?? 0, + visualScale: visualViewport?.scale ?? 1, + virtualKeyboardSupported: virtualKeyboard != null, + virtualKeyboardOverlaysContent: + virtualKeyboard?.overlaysContent ?? null, + virtualKeyboardRect: rect + ? { + x: rect.x ?? 0, + y: rect.y ?? 0, + width: rect.width ?? 0, + height: rect.height ?? 0, + } + : null, + }; +} + +export function isViewportGeometryObservation( + reason: ViewportObservationReason, +): boolean { + return ( + reason === "initial" || + reason === "window.resize" || + reason === "orientationchange" || + reason === "visualViewport.resize" || + reason === "visualViewport.scroll" || + reason === "virtualKeyboard.geometrychange" + ); +} + +function ensureBrowserListeners(): void { + if (removeBrowserListeners) return; + + const removers: Array<() => void> = []; + const listen = ( + target: EventTarget | null | undefined, + eventName: string, + reason: ViewportObservationReason, + ) => { + if (!target) return; + const handler = () => notifyObservers(reason); + target.addEventListener(eventName, handler); + removers.push(() => target.removeEventListener(eventName, handler)); + }; + + listen(window, "resize", "window.resize"); + listen(window, "scroll", "window.scroll"); + listen(window, "orientationchange", "orientationchange"); + listen(document, "focusin", "focusin"); + listen(document, "focusout", "focusout"); + listen(document, "visibilitychange", "visibilitychange"); + listen(window.visualViewport, "resize", "visualViewport.resize"); + listen(window.visualViewport, "scroll", "visualViewport.scroll"); + listen( + getVirtualKeyboard(), + "geometrychange", + "virtualKeyboard.geometrychange", + ); + + removeBrowserListeners = () => { + removers.forEach((remove) => remove()); + }; +} + +function notifyObservers(reason: ViewportObservationReason): void { + const observation = { + reason, + metrics: readBrowserViewportMetrics(), + }; + observers.forEach((observer) => observer(observation)); +} + +function getVirtualKeyboard(): VirtualKeyboardLike | null { + return ( + (navigator as NavigatorWithVirtualKeyboard).virtualKeyboard ?? null + ); +} diff --git a/src/providers/__tests__/viewport-css-vars-provider.test.tsx b/src/providers/__tests__/viewport-css-vars-provider.test.tsx new file mode 100644 index 00000000..f873b582 --- /dev/null +++ b/src/providers/__tests__/viewport-css-vars-provider.test.tsx @@ -0,0 +1,111 @@ +/* @vitest-environment jsdom */ + +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ViewportCssVarsProvider } from "../viewport-css-vars-provider"; + +vi.mock("@/utils/browser-detect", () => ({ + BrowserDetector: { + isFacebookInAppBrowser: vi.fn(() => true), + }, +})); + +vi.mock("@/utils/platform-detect", () => ({ + PlatformDetector: { + isIOS: vi.fn(() => true), + }, +})); + +class MockVisualViewport extends EventTarget { + height = 760; + offsetLeft = 0; + offsetTop = 0; + scale = 1; + width = 390; +} + +function Harness({ children }: { children: ReactNode }) { + return {children}; +} + +describe("ViewportCssVarsProvider", () => { + let container: HTMLDivElement; + let root: Root; + let visualViewport: MockVisualViewport; + + beforeEach(() => { + vi.useFakeTimers(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + visualViewport = new MockVisualViewport(); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 800, + }); + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 390, + }); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewport, + }); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => + window.setTimeout(() => callback(0), 0), + ), + ); + vi.stubGlobal( + "cancelAnimationFrame", + vi.fn((id: number) => window.clearTimeout(id)), + ); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.documentElement.removeAttribute("style"); + delete document.documentElement.dataset.viewportHeight; + delete document.documentElement.dataset.viewportWidth; + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("keeps stable viewport variables while the iOS Facebook keyboard is open", () => { + act(() => { + root.render( + + + , + ); + }); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(getRootVar("--app-viewport-height")).toBe("800px"); + expect(getRootVar("--app-visible-height")).toBe("760px"); + + const input = container.querySelector("input"); + if (!input) throw new Error("input was not rendered"); + input.focus(); + visualViewport.height = 460; + act(() => { + visualViewport.dispatchEvent(new Event("resize")); + vi.runOnlyPendingTimers(); + }); + + expect(getRootVar("--app-viewport-height")).toBe("800px"); + expect(getRootVar("--app-visible-height")).toBe("760px"); + }); +}); + +function getRootVar(name: string): string { + return document.documentElement.style.getPropertyValue(name); +} diff --git a/src/providers/viewport-css-vars-provider.tsx b/src/providers/viewport-css-vars-provider.tsx index 045283d6..22b1ea8e 100644 --- a/src/providers/viewport-css-vars-provider.tsx +++ b/src/providers/viewport-css-vars-provider.tsx @@ -2,6 +2,11 @@ import { useEffect, type ReactNode } from "react"; +import { + isViewportGeometryObservation, + observeBrowserViewport, + type BrowserViewportMetrics, +} from "@/lib/browser/viewport_observer"; import { BrowserDetector } from "@/utils/browser-detect"; import { Logger } from "@/utils/logger"; import { PlatformDetector } from "@/utils/platform-detect"; @@ -35,22 +40,31 @@ export function ViewportCssVarsProvider({ if (typeof window === "undefined") return; let frameId: number | null = null; - let stableLayoutHeight = window.innerHeight; - let stableVisibleHeight = window.visualViewport?.height ?? window.innerHeight; - let stableVisibleOffsetTop = window.visualViewport?.offsetTop ?? 0; + let pendingMetrics: BrowserViewportMetrics | null = null; + let stableLayoutHeight = 0; + let stableVisibleHeight = 0; + let stableVisibleOffsetTop = 0; + let hasStableMetrics = false; let iosFacebookKeyboardSuppressed = false; const isIOSFacebookInAppBrowser = PlatformDetector.isIOS() && BrowserDetector.isFacebookInAppBrowser(); const applyVars = () => { frameId = null; + const metrics = pendingMetrics; + if (!metrics) return; + const root = document.documentElement; - const viewport = window.visualViewport; - const layoutHeight = window.innerHeight; - const layoutWidth = window.innerWidth; - const visibleHeight = viewport?.height ?? layoutHeight; - const visibleWidth = viewport?.width ?? layoutWidth; - const visibleOffsetTop = viewport?.offsetTop ?? 0; + const layoutHeight = metrics.layoutHeight; + const visibleHeight = metrics.visualHeight; + const visibleWidth = metrics.visualWidth; + const visibleOffsetTop = metrics.visualOffsetTop; + if (!hasStableMetrics) { + stableLayoutHeight = layoutHeight; + stableVisibleHeight = visibleHeight; + stableVisibleOffsetTop = visibleOffsetTop; + hasStableMetrics = true; + } const shouldSuppressIOSFacebookKeyboardViewport = isIOSFacebookInAppBrowser && isEditableElementFocused() && @@ -105,24 +119,20 @@ export function ViewportCssVarsProvider({ root.dataset.viewportHeight = String(Math.round(nextVisibleHeight)); }; - const scheduleApply = () => { + const scheduleApply = (metrics: BrowserViewportMetrics) => { + pendingMetrics = metrics; if (frameId != null) return; frameId = window.requestAnimationFrame(applyVars); }; - applyVars(); - - window.addEventListener("resize", scheduleApply); - window.addEventListener("orientationchange", scheduleApply); - window.visualViewport?.addEventListener("resize", scheduleApply); - window.visualViewport?.addEventListener("scroll", scheduleApply); + const stopObserving = observeBrowserViewport((observation) => { + if (!isViewportGeometryObservation(observation.reason)) return; + scheduleApply(observation.metrics); + }); return () => { if (frameId != null) window.cancelAnimationFrame(frameId); - window.removeEventListener("resize", scheduleApply); - window.removeEventListener("orientationchange", scheduleApply); - window.visualViewport?.removeEventListener("resize", scheduleApply); - window.visualViewport?.removeEventListener("scroll", scheduleApply); + stopObserving(); }; }, []);