diff --git a/components/MobilePwaLayout.test.mjs b/components/MobilePwaLayout.test.mjs index 12204cead..8880e3296 100644 --- a/components/MobilePwaLayout.test.mjs +++ b/components/MobilePwaLayout.test.mjs @@ -26,6 +26,9 @@ test("tracks the visual viewport while the software keyboard is open", () => { assert.match(appShellSource, /height: "var\(--app-viewport-height, 100dvh\)"/); assert.match(appShellSource, /right: "env\(safe-area-inset-right\)"/); assert.match(viewportHookSource, /window\.visualViewport/); + assert.match(viewportHookSource, /window\.requestAnimationFrame\(update\)/); + assert.match(viewportHookSource, /window\.addEventListener\("resize", scheduleUpdate\)/); + assert.match(viewportHookSource, /window\.addEventListener\("focusout", scheduleUpdate\)/); assert.match(viewportHookSource, /--app-viewport-height/); assert.match(viewportHookSource, /window\.scrollTo\(0, 0\)/); assert.match(cssSource, /height: var\(--app-viewport-height, 100dvh\)/); diff --git a/hooks/useViewportHeight.test.mjs b/hooks/useViewportHeight.test.mjs index 95641c73e..c2b39c695 100644 --- a/hooks/useViewportHeight.test.mjs +++ b/hooks/useViewportHeight.test.mjs @@ -14,7 +14,16 @@ test("uses the visual viewport for a focused editor when the keyboard shrinks it }), true); }); -test("keeps the dynamic viewport height when no editor is focused", () => { +test("does not keep the keyboard height after the visual viewport restores", () => { + assert.equal(shouldUseVisualViewportHeight({ + hasFocusedEditable: true, + innerHeight: 844, + viewportHeight: 844, + viewportScale: 1, + }), false); +}); + +test("restores the dynamic height as soon as the editor loses focus", () => { assert.equal(shouldUseVisualViewportHeight({ hasFocusedEditable: false, innerHeight: 844, diff --git a/hooks/useViewportHeight.ts b/hooks/useViewportHeight.ts index 6c2cff0d8..704b2eb39 100644 --- a/hooks/useViewportHeight.ts +++ b/hooks/useViewportHeight.ts @@ -40,8 +40,10 @@ export function useViewportHeight(): void { if (!viewport) return; const root = document.documentElement; + let frameId: number | null = null; const update = () => { + frameId = null; const keyboardOpen = shouldUseVisualViewportHeight({ hasFocusedEditable: hasFocusedEditableElement(), innerHeight: window.innerHeight, @@ -50,21 +52,42 @@ export function useViewportHeight(): void { }); if (keyboardOpen) { root.style.setProperty("--app-viewport-height", `${viewport.height}px`); - if (window.scrollX !== 0 || window.scrollY !== 0) { - window.scrollTo(0, 0); - } } else { root.style.removeProperty("--app-viewport-height"); } + + const pageWasShifted = window.scrollX !== 0 || window.scrollY !== 0; + const isUnscaled = Math.abs(viewport.scale - 1) < 0.01; + if (pageWasShifted && isUnscaled) { + window.scrollTo(0, 0); + } + }; + + // WebKit can dispatch the resize event before visualViewport.height has + // settled, especially when an installed PWA dismisses the keyboard. Reading + // it on the next animation frame prevents the keyboard-height CSS value + // from remaining after the keyboard has closed. + const scheduleUpdate = () => { + if (frameId !== null) window.cancelAnimationFrame(frameId); + frameId = window.requestAnimationFrame(update); }; - update(); - viewport.addEventListener("resize", update); - viewport.addEventListener("scroll", update); + scheduleUpdate(); + viewport.addEventListener("resize", scheduleUpdate); + viewport.addEventListener("scroll", scheduleUpdate); + window.addEventListener("resize", scheduleUpdate); + window.addEventListener("focusin", scheduleUpdate); + window.addEventListener("focusout", scheduleUpdate); + window.addEventListener("pageshow", scheduleUpdate); return () => { - viewport.removeEventListener("resize", update); - viewport.removeEventListener("scroll", update); + viewport.removeEventListener("resize", scheduleUpdate); + viewport.removeEventListener("scroll", scheduleUpdate); + window.removeEventListener("resize", scheduleUpdate); + window.removeEventListener("focusin", scheduleUpdate); + window.removeEventListener("focusout", scheduleUpdate); + window.removeEventListener("pageshow", scheduleUpdate); + if (frameId !== null) window.cancelAnimationFrame(frameId); root.style.removeProperty("--app-viewport-height"); }; }, []);