refactor(viewport): centralize browser metrics
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user