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
@@ -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();
};
}, []);