refactor(viewport): centralize browser metrics

This commit is contained in:
2026-07-16 19:43:03 +08:00
parent 990add97f8
commit b5990bca8b
7 changed files with 508 additions and 162 deletions
@@ -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<DOMRectReadOnly, "height">;
}
interface NavigatorWithVirtualKeyboard extends Navigator {
readonly virtualKeyboard?: VirtualKeyboardLike;
}
export interface UseChatKeyboardAvoidanceOptions {
active: boolean;
containerRef: RefObject<HTMLElement | null>;
@@ -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"
);
}
@@ -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<HTMLElement | null>,
): 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
);
}
@@ -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);
});
});
+169
View File
@@ -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<ViewportObserver>();
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
);
}
@@ -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 <ViewportCssVarsProvider>{children}</ViewportCssVarsProvider>;
}
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(
<Harness>
<input aria-label="message" />
</Harness>,
);
});
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);
}
+30 -20
View File
@@ -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();
};
}, []);