feat(FN-5155): stabilize mobile keyboard viewport metrics

Stabilizes mobile keyboard viewport metrics in the `useMobileKeyboard` hook, with tests covering the hook and `ChatView` integration; a minor dashboard command adjustment is included.

Fusion-Task-Id: FN-5155
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 13:31:31 -07:00
committed by gsxdsm
parent 41d18b3f6d
commit 587e9b416c
6 changed files with 295 additions and 34 deletions

View File

@@ -1149,17 +1149,17 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
enabled: isMobile && (!!activeSession || roomThreadActive),
});
// Only opt into visual-viewport sizing when we have concrete keyboard
// displacement (overlap or offset). The shared hook can report `keyboardOpen`
// during iOS settle/shrink phases with zero overlap; forcing vv-height in that
// transient state shrinks the thread and pushes the composer upward.
const hasKeyboardViewportDisplacement = keyboardOverlap > 0 || viewportOffsetTop > 0;
// FN-5155: only publish keyboard-active viewport vars once the hook has a
// self-consistent open sample. A transient offsetTop without vvHeight causes
// mobile ChatView to shrink/translate the thread before the keyboard settles.
const hasKeyboardViewportMetrics = viewportHeight !== null;
const hasKeyboardViewportDisplacement = hasKeyboardViewportMetrics && (keyboardOverlap > 0 || viewportOffsetTop > 0);
const threadKeyboardStyle: CSSProperties =
keyboardOpen && hasKeyboardViewportDisplacement
? ({
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
"--vv-height": `${viewportHeight}px`,
} as CSSProperties)
: {};
const threadClassName = `chat-thread${keyboardOpen && hasKeyboardViewportDisplacement ? " chat-thread--keyboard-active" : ""}`;

View File

@@ -3683,6 +3683,57 @@ describe("ChatView mobile behavior", () => {
}
});
it("FN-5155: mobile mode does not apply keyboard-active styles for impossible mid-transition viewport samples", async () => {
const restoreMatchMedia = mockMobileViewport();
const { mockVV } = mockMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
expect(thread).toBeInTheDocument();
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
textarea.focus();
});
Object.defineProperty(mockVV, "offsetTop", {
value: 180,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 820,
writable: true,
configurable: true,
});
act(() => {
document.dispatchEvent(new Event("focusin"));
});
await waitFor(() => {
// FN-5155 fix keeps transient impossible metrics out of ChatView until
// the viewport settles, so the composer never floats above the keyboard.
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false);
expect(thread.style.getPropertyValue("--vv-height")).toBe("");
expect(thread.style.getPropertyValue("--vv-offset-top")).toBe("");
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("");
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: removes keyboard-active class immediately on blur even before visualViewport settles", async () => {
const restoreMatchMedia = mockMobileViewport();
const { listeners, mockVV } = mockMobileVisualViewport({

View File

@@ -451,6 +451,147 @@ describe("useMobileKeyboard", () => {
input.remove();
});
it("FN-5155: ignores impossible focusin samples until the visualViewport settles", async () => {
vi.useFakeTimers();
try {
const { mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
expect(result.current.keyboardOpen).toBe(false);
input.focus();
Object.defineProperty(mockVV, "offsetTop", {
value: 180,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 820,
writable: true,
configurable: true,
});
act(() => {
document.dispatchEvent(new Event("focusin"));
});
// FN-5155: current main incorrectly treats this as open via the
// viewport-shrink fallback even though offsetTop + height exceeds the
// window height, so the impossible sample must be ignored.
expect(result.current.keyboardOpen).toBe(false);
expect(result.current.viewportHeight).toBeNull();
expect(result.current.viewportOffsetTop).toBe(0);
Object.defineProperty(mockVV, "offsetTop", {
value: 0,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 520,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: 520,
writable: true,
configurable: true,
});
await act(async () => {
await vi.advanceTimersByTimeAsync(60);
});
expect(result.current.keyboardOpen).toBe(true);
expect(result.current.keyboardOverlap).toBe(324);
expect(result.current.viewportHeight).toBe(520);
expect(result.current.viewportOffsetTop).toBe(0);
input.remove();
} finally {
vi.useRealTimers();
}
});
it("FN-5155: converges from stale visibility-restore metrics to the settled keyboard-open viewport", async () => {
vi.useFakeTimers();
try {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
expect(result.current.keyboardOpen).toBe(false);
input.focus();
Object.defineProperty(mockVV, "offsetTop", {
value: 160,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 820,
writable: true,
configurable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
// FN-5155: page-restore can surface the same impossible transient sample
// before resize settles; keep the hook closed until metrics agree.
expect(result.current.keyboardOpen).toBe(false);
expect(result.current.viewportHeight).toBeNull();
expect(result.current.viewportOffsetTop).toBe(0);
Object.defineProperty(mockVV, "offsetTop", {
value: 0,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 520,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: 520,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(60);
});
expect(result.current.keyboardOpen).toBe(true);
expect(result.current.keyboardOverlap).toBe(324);
expect(result.current.viewportHeight).toBe(520);
expect(result.current.viewportOffsetTop).toBe(0);
input.remove();
} finally {
vi.useRealTimers();
}
});
// FN-3290 regression: focusout must reset keyboard state when input blurs
describe("FN-3290: focusout resets keyboard state", () => {
it("resets keyboardOpen to false on focusout when viewport returns to baseline", async () => {

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
const IOS_FALLBACK_MIN_GAP_PX = 30;
const IOS_FALLBACK_MIN_FOCUSED_GAP_PX = 16;
const IOS_VIEWPORT_SHRINK_MIN_PX = 16;
const IMPOSSIBLE_VIEWPORT_EPSILON_PX = 2;
/** Whether the current device is likely mobile (touch-primary, small viewport). */
function isMobileDevice(): boolean {
@@ -50,9 +51,24 @@ interface KeyboardMetrics {
vvOffsetTop: number;
}
function getKeyboardMetrics(): KeyboardMetrics {
const CLOSED_KEYBOARD_METRICS: KeyboardMetrics = {
overlap: 0,
open: false,
vvHeight: null,
vvOffsetTop: 0,
};
function hasImpossibleViewportSample(): boolean {
if (typeof window === "undefined" || !window.visualViewport) {
return { overlap: 0, open: false, vvHeight: null, vvOffsetTop: 0 };
return false;
}
return window.visualViewport.offsetTop + window.visualViewport.height > window.innerHeight + IMPOSSIBLE_VIEWPORT_EPSILON_PX;
}
function getKeyboardMetrics(previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_METRICS): KeyboardMetrics {
if (typeof window === "undefined" || !window.visualViewport) {
return CLOSED_KEYBOARD_METRICS;
}
const vv = window.visualViewport;
@@ -64,6 +80,13 @@ function getKeyboardMetrics(): KeyboardMetrics {
updateBaselineViewportHeight(vv.height);
}
// FN-5155: iOS focus/restore can briefly report offsetTop from the keyboard
// transition while height is still near the pre-keyboard baseline. Reject
// that impossible snapshot and keep the last stable metrics until settle.
if (focused && hasImpossibleViewportSample()) {
return previousMetrics;
}
// Android/Chrome style overlap. Only treat as open while an input is
// actually focused — without this, the (often slow) visualViewport
// dismissal animation keeps reporting overlap > 0 for hundreds of ms
@@ -95,7 +118,7 @@ function getKeyboardMetrics(): KeyboardMetrics {
return { overlap: 0, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
}
return { overlap: 0, open: false, vvHeight: null, vvOffsetTop: 0 };
return CLOSED_KEYBOARD_METRICS;
}
/** Reset cached viewport baseline. Exported for tests only. */
@@ -114,6 +137,7 @@ export function useMobileKeyboard(
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
const [viewportOffsetTop, setViewportOffsetTop] = useState(0);
const [keyboardOpen, setKeyboardOpen] = useState(false);
const stableMetricsRef = useRef<KeyboardMetrics>(CLOSED_KEYBOARD_METRICS);
useEffect(() => {
if (!enabled || !isMobileDevice()) {
@@ -130,18 +154,23 @@ export function useMobileKeyboard(
setViewportHeight(null);
setViewportOffsetTop(0);
setKeyboardOpen(false);
stableMetricsRef.current = CLOSED_KEYBOARD_METRICS;
return;
}
const commitMetrics = (metrics: KeyboardMetrics) => {
stableMetricsRef.current = metrics;
setKeyboardOverlap(metrics.overlap);
setViewportHeight(metrics.vvHeight);
setViewportOffsetTop(metrics.vvOffsetTop);
setKeyboardOpen(metrics.open);
};
// Full update — used on resize and focus transitions. These are the
// events that signal an actual keyboard open/close, so we want to
// re-snapshot offsetTop/height/overlap.
const update = () => {
const metrics = getKeyboardMetrics();
setKeyboardOverlap(metrics.overlap);
setViewportHeight(metrics.vvHeight);
setViewportOffsetTop(metrics.vvOffsetTop);
setKeyboardOpen(metrics.open);
commitMetrics(getKeyboardMetrics(stableMetricsRef.current));
};
// Scroll-only update — fires on every visualViewport pan (60fps on
@@ -152,7 +181,8 @@ export function useMobileKeyboard(
// update height/keyboardOpen if those changed; offsetTop stays
// pinned to whatever resize/focus last set it.
const updateScrollOnly = () => {
const metrics = getKeyboardMetrics();
const metrics = getKeyboardMetrics(stableMetricsRef.current);
stableMetricsRef.current = metrics;
setKeyboardOverlap(metrics.overlap);
setViewportHeight(metrics.vvHeight);
setKeyboardOpen(metrics.open);
@@ -180,9 +210,16 @@ export function useMobileKeyboard(
// (e.g. switching tabs back with the keyboard up) where the
// timed reads miss the right window.
let rafId: number | null = null;
let headRafId: number | null = null;
let pollDeadline = 0;
let lastOffsetTop = -1;
let stableFrames = 0;
const cancelHeadUpdate = () => {
if (headRafId !== null && typeof window !== "undefined") {
window.cancelAnimationFrame(headRafId);
headRafId = null;
}
};
const cancelPoll = () => {
if (rafId !== null && typeof window !== "undefined") {
window.cancelAnimationFrame(rafId);
@@ -214,7 +251,18 @@ export function useMobileKeyboard(
rafId = window.requestAnimationFrame(pollFrame);
};
const updateWithTail = () => {
update();
cancelHeadUpdate();
if (isKeyboardFocusableElement(document.activeElement) && hasImpossibleViewportSample()) {
// FN-5155: focusin/page-restore can arrive before visualViewport height
// catches up to the keyboard transition. Defer the head commit one frame
// so the tail/poll can converge instead of publishing the stale sample.
headRafId = window.requestAnimationFrame(() => {
headRafId = null;
update();
});
} else {
update();
}
scheduleUpdate(50);
scheduleUpdate(200);
scheduleUpdate(500);
@@ -244,7 +292,9 @@ export function useMobileKeyboard(
for (const timeoutId of timeoutIds) {
clearTimeout(timeoutId);
}
cancelHeadUpdate();
cancelPoll();
stableMetricsRef.current = CLOSED_KEYBOARD_METRICS;
setKeyboardOverlap(0);
setViewportHeight(null);
setViewportOffsetTop(0);