Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ function withElementClientHeight(
}
}

// Waits out one scheduled rAF pass so "the pass ran and did nothing" is
// distinguishable from "the pass has not run yet".
async function flushScheduledViewportPass() {
await act(async () => {
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => resolve());
});
});
});
}

beforeEach(() => {
vi.spyOn(window, "scrollTo").mockImplementation(() => {});
});
Expand Down Expand Up @@ -310,6 +322,136 @@ describe("useMobileVisualViewportHeight", () => {
expect(window.scrollTo).not.toHaveBeenCalled();
});
});

it("writes shell geometry only when a pass computes new values", async () => {
const visualViewport = new FakeVisualViewport();
visualViewport.offsetTop = 0;
await withFakeVisualViewport(visualViewport, async () => {
render(<VisualViewportShell enabled />);
const shell = screen.getByTestId("shell");
const shellHeightRoot = screen.getByTestId("shell-height-root");
expect(shell.style.height).toBe("500px");
const setShellHeightProperty = vi.spyOn(
shellHeightRoot.style,
"setProperty",
);

// Same geometry again: the pass must return before any style write, or
// every keyboard/URL-bar animation frame invalidates the whole tree.
act(() => {
visualViewport.dispatchEvent(new Event("resize"));
});
await flushScheduledViewportPass();
expect(setShellHeightProperty).not.toHaveBeenCalled();

act(() => {
visualViewport.height = 480;
visualViewport.dispatchEvent(new Event("resize"));
});
await waitFor(() => expect(shell.style.height).toBe("480px"));
expect(setShellHeightProperty).toHaveBeenCalledTimes(1);
});
});

it("reads the containing block only when the layout viewport can change", async () => {
const visualViewport = new FakeVisualViewport();
visualViewport.offsetTop = 0;
let containingBlockReads = 0;
await withElementClientHeight(
document.body,
() => {
containingBlockReads += 1;
return 800;
},
async () =>
withFakeVisualViewport(visualViewport, async () => {
render(<VisualViewportShell enabled />);
const shell = screen.getByTestId("shell");
expect(shell.style.height).toBe("500px");
const readsAfterMount = containingBlockReads;

// Visual-viewport ticks pan or resize only the visual viewport;
// they must reuse the cached containing-block height instead of
// forcing a full-document layout per animation frame.
act(() => {
visualViewport.offsetTop = 40;
visualViewport.dispatchEvent(new Event("scroll"));
});
await waitFor(() => expect(shell.style.top).toBe("40px"));
act(() => {
visualViewport.height = 460;
visualViewport.dispatchEvent(new Event("resize"));
});
await waitFor(() => expect(shell.style.height).toBe("460px"));
expect(containingBlockReads).toBe(readsAfterMount);

act(() => {
window.dispatchEvent(new Event("resize"));
});
await waitFor(() =>
expect(containingBlockReads).toBe(readsAfterMount + 1),
);
}),
);
});

it("runs a geometry pass when an editor is focused programmatically", async () => {
const visualViewport = new FakeVisualViewport();
visualViewport.offsetTop = 0;
await withElementClientHeight(
document.body,
() => 500,
async () =>
withFakeVisualViewport(visualViewport, async () => {
render(<VisualViewportShell enabled />);
const shell = screen.getByTestId("shell");
const editor = screen.getByTestId("editor");
// Native layout matches the visual viewport: no override applied.
expect(shell.style.height).toBe("");

// The keyboard shortens the visual viewport around the same time
// the composer autofocuses, without any window resize; the focus
// pass must pick the change up on its own.
visualViewport.height = 300;
act(() => editor.focus());
await waitFor(() => expect(shell.style.height).toBe("300px"));
}),
);
});

it("ignores visual viewport pans without a keyboard or an applied override", async () => {
const visualViewport = new FakeVisualViewport();
visualViewport.offsetTop = 0;
await withElementClientHeight(
document.body,
() => 500,
async () =>
withFakeVisualViewport(visualViewport, async () => {
render(<VisualViewportShell enabled />);
const shell = screen.getByTestId("shell");
const editor = screen.getByTestId("editor");
expect(shell.style.height).toBe("");

// A URL-bar pan with no keyboard: nothing to compensate.
act(() => {
visualViewport.offsetTop = 340;
visualViewport.dispatchEvent(new Event("scroll"));
});
await flushScheduledViewportPass();
expect(window.scrollTo).not.toHaveBeenCalled();
expect(shell.style.top).toBe("");

// With a keyboard editor focused, the same pan is Safari's
// focus-reveal pan and must still be compensated.
act(() => editor.focus());
act(() => {
visualViewport.dispatchEvent(new Event("scroll"));
});
await waitFor(() => expect(shell.style.top).toBe("340px"));
expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
}),
);
});
});

describe("shouldRestoreIOSViewportOnKeyboardDismissal", () => {
Expand Down
80 changes: 68 additions & 12 deletions apps/app/src/components/layout/useMobileVisualViewportHeight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,22 @@ export function useMobileVisualViewportHeight(
if (!shell || !shellHeightRoot || !enabled || !visualViewport) return;

let animationFrame: number | null = null;
// The override last written to the shell, or null while none is applied.
// Writing shell `top`/`height` and the inherited `--bb-shell-height`
// invalidates computed style for the whole app tree, and passes run at
// visual-viewport event cadence (keyboard animation, URL-bar collapse),
// so a pass that recomputes unchanged geometry must not write at all.
let appliedOverride: { top: number; height: number } | null = null;
// Reading `document.body.clientHeight` forces a full-document layout. The
// shell's containing block only changes when the layout viewport does, so
// cache the read and mark it stale only on triggers that can resize the
// layout viewport — never on visualViewport ticks, which move or resize
// only the visual viewport.
let shellContainingBlockHeight = 0;
let shellContainingBlockHeightStale = true;
const clearViewportOverride = () => {
if (appliedOverride === null) return;
appliedOverride = null;
shell.style.removeProperty("top");
shell.style.removeProperty("height");
shellHeightRoot.style.removeProperty("--bb-shell-height");
Expand All @@ -68,12 +83,15 @@ export function useMobileVisualViewportHeight(
}

const visualViewportHeight = Math.round(visualViewport.height);
// `documentElement.clientHeight` is the visible viewport height for the
// root element, even when that root's actual CSS box extends behind an
// Android in-app browser toolbar. The body inherits the root box and
// therefore exposes the containing-block height the app shell really
// receives.
const shellContainingBlockHeight = document.body.clientHeight;
if (shellContainingBlockHeightStale) {
// `documentElement.clientHeight` is the visible viewport height for the
// root element, even when that root's actual CSS box extends behind an
// Android in-app browser toolbar. The body inherits the root box and
// therefore exposes the containing-block height the app shell really
// receives.
shellContainingBlockHeight = document.body.clientHeight;
shellContainingBlockHeightStale = false;
}
const hasVisualViewportPan =
visualViewport.offsetTop > 1 || window.scrollY > 0;
if (
Expand All @@ -91,7 +109,16 @@ export function useMobileVisualViewportHeight(
// compensation below also handles a visual-viewport-only pan.
window.scrollTo(0, 0);
}
shell.style.top = `${getVisualViewportPageTop(visualViewport)}px`;
const shellTop = getVisualViewportPageTop(visualViewport);
if (
appliedOverride !== null &&
appliedOverride.top === shellTop &&
appliedOverride.height === visualViewportHeight
) {
return;
}
appliedOverride = { top: shellTop, height: visualViewportHeight };
shell.style.top = `${shellTop}px`;
shell.style.height = `${visualViewportHeight}px`;
// Fixed-position descendants cannot inherit the shell element's pixel
// height. Publish the same correction through the existing shell-height
Expand All @@ -108,6 +135,28 @@ export function useMobileVisualViewportHeight(
}
animationFrame = window.requestAnimationFrame(updateHeight);
};
// For triggers where the layout viewport may have changed: window resize,
// rotation, and an editor gaining focus — the pass that sizes the shell
// for the arriving keyboard must start from the real containing block,
// and these triggers are rare enough that the forced layout is fine.
const scheduleContainingBlockUpdate = () => {
shellContainingBlockHeightStale = true;
scheduleUpdate();
};
const handleVisualViewportScroll = () => {
// Keyboard-less visual-viewport pans (URL-bar collapse, momentum
// settling) don't change the containing block and need no override —
// the pan compensation exists for the keyboard focus-reveal pan. Only
// an already-applied override still has to track pans, because embedded
// browsers apply one without any keyboard.
if (
appliedOverride === null &&
!isKeyboardFocusTarget(document.activeElement)
) {
return;
}
scheduleUpdate();
};

// Safari with its bottom toolbar visible does not update the visual
// viewport until the keyboard animation ends. Restore the normal shell
Expand All @@ -124,22 +173,29 @@ export function useMobileVisualViewportHeight(
};
const handleFocusIn = (event: FocusEvent) => {
if (!isKeyboardFocusTarget(event.target)) return;
scheduleUpdate();
// Programmatic focus (composer autofocus) can be the only trigger for a
// keyboard, so this must always schedule a full, freshly measured pass.
scheduleContainingBlockUpdate();
};

updateHeight();
visualViewport.addEventListener("resize", scheduleUpdate);
visualViewport.addEventListener("scroll", scheduleUpdate);
window.addEventListener("resize", scheduleUpdate);
visualViewport.addEventListener("scroll", handleVisualViewportScroll);
window.addEventListener("resize", scheduleContainingBlockUpdate);
window.addEventListener("orientationchange", scheduleContainingBlockUpdate);
if (restoreImmediatelyOnKeyboardDismissal) {
document.addEventListener("focusout", handleFocusOut);
document.addEventListener("focusin", handleFocusIn);
}

return () => {
visualViewport.removeEventListener("resize", scheduleUpdate);
visualViewport.removeEventListener("scroll", scheduleUpdate);
window.removeEventListener("resize", scheduleUpdate);
visualViewport.removeEventListener("scroll", handleVisualViewportScroll);
window.removeEventListener("resize", scheduleContainingBlockUpdate);
window.removeEventListener(
"orientationchange",
scheduleContainingBlockUpdate,
);
if (restoreImmediatelyOnKeyboardDismissal) {
document.removeEventListener("focusout", handleFocusOut);
document.removeEventListener("focusin", handleFocusIn);
Expand Down
Loading
Loading