From c40a8b759d97950e05f3ebd48437bf4de50a5e87 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 16 Sep 2026 17:16:03 +0200 Subject: [PATCH 1/3] fix(app): opt the FAQ page out of scroll anchoring Signed-off-by: Maciek --- app/src/pages/legal/FAQ.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/pages/legal/FAQ.tsx b/app/src/pages/legal/FAQ.tsx index 404401e8..6a8193e9 100644 --- a/app/src/pages/legal/FAQ.tsx +++ b/app/src/pages/legal/FAQ.tsx @@ -836,8 +836,10 @@ export default function FAQ(): JSX.Element { ); + // Answers animate their height; opt the page out of scroll anchoring so an + // anchoring engine (Safari 27+, Chromium) does not shift the viewport per frame. return ( -
+
{hasHistory && ( From 66979e3ae71b5a374c53351885db375d8a9d2c4b Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 16 Sep 2026 17:50:31 +0200 Subject: [PATCH 2/3] fix(app): drop nested scroll containers from the public layout and FAQ page Signed-off-by: Maciek --- app/src/App.tsx | 4 +++- app/src/pages/legal/FAQ.tsx | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 05b3ee80..c6af3e7c 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -362,7 +362,9 @@ async function profilesOnlyLoader() { // Unified base layout for public/protected wrappers function BaseLayout({ children, mode }: { children: React.ReactNode, mode: 'public' | 'app' }) { - const baseClasses = 'relative flex flex-col min-h-screen overflow-x-hidden bg-[var(--shadcn-ui-app-background)]'; + // overflow-x-clip, not -hidden: `hidden` computes overflow-y:auto and turns the + // wrapper into a scroll container nested inside the viewport scroller. + const baseClasses = 'relative flex flex-col min-h-screen overflow-x-clip bg-[var(--shadcn-ui-app-background)]'; if (mode === 'public') { return (
diff --git a/app/src/pages/legal/FAQ.tsx b/app/src/pages/legal/FAQ.tsx index 6a8193e9..fd0ebaa5 100644 --- a/app/src/pages/legal/FAQ.tsx +++ b/app/src/pages/legal/FAQ.tsx @@ -836,10 +836,8 @@ export default function FAQ(): JSX.Element {
); - // Answers animate their height; opt the page out of scroll anchoring so an - // anchoring engine (Safari 27+, Chromium) does not shift the viewport per frame. return ( -
+
{hasHistory && ( From db569a4bfa421b81aec9feb8b5613d09db722453 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 16 Sep 2026 17:59:47 +0200 Subject: [PATCH 3/3] fix(app): clip collapsed FAQ answers so they stop stretching the page Signed-off-by: Maciek --- .../e2e/layout/faq-expand-scroll.spec.ts | 89 +++++++++++++++++++ app/src/pages/legal/FAQ.tsx | 2 +- 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 app/src/__tests__/e2e/layout/faq-expand-scroll.spec.ts diff --git a/app/src/__tests__/e2e/layout/faq-expand-scroll.spec.ts b/app/src/__tests__/e2e/layout/faq-expand-scroll.spec.ts new file mode 100644 index 00000000..fb7c815d --- /dev/null +++ b/app/src/__tests__/e2e/layout/faq-expand-scroll.spec.ts @@ -0,0 +1,89 @@ +import { test, expect, type Page } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// Expanding an FAQ answer while scrolled must keep the viewport where it is. +// Mobile projects only: the report is a phone-viewport regression. + +const MOBILE_PROJECTS = ['chromium-mobile-dark', 'iphone15pro-dark']; +const LAST_QUESTION = 'Do you support 2FA?'; +// Height transition is 300ms; give layout time to settle before measuring. +const SETTLE_MS = 600; + +async function openFaqScrolledToLastQuestion(page: Page) { + await registerMocks(page); + await page.goto('/faq'); + await page.waitForLoadState('networkidle'); + const lastQuestion = page.getByRole('button', { name: LAST_QUESTION }); + await lastQuestion.scrollIntoViewIfNeeded(); + await page.waitForTimeout(SETTLE_MS); + const scrollYBefore = await page.evaluate(() => window.scrollY); + expect(scrollYBefore).toBeGreaterThan(500); + return { lastQuestion, scrollYBefore }; +} + +test.describe('FAQ expand keeps scroll position', () => { + // eslint-disable-next-line no-empty-pattern + test.beforeEach(async ({}, testInfo) => { + test.skip(!MOBILE_PROJECTS.includes(testInfo.project.name), 'mobile projects only'); + }); + + test('expanding the last answer does not move the viewport', async ({ page }) => { + const { lastQuestion, scrollYBefore } = await openFaqScrolledToLastQuestion(page); + await lastQuestion.click(); + await page.waitForTimeout(SETTLE_MS); + const scrollYAfter = await page.evaluate(() => window.scrollY); + expect(Math.abs(scrollYAfter - scrollYBefore)).toBeLessThan(8); + await expect(page.getByText('Two-Factor Authentication adds an additional layer')).toBeInViewport(); + }); + + test('expand all from a scrolled position does not jump to the top', async ({ page }) => { + const { scrollYBefore } = await openFaqScrolledToLastQuestion(page); + // The control sits at the top of the page; drive it without scrolling there. + await page.getByRole('button', { name: 'Expand All' }).dispatchEvent('click'); + await page.waitForTimeout(SETTLE_MS); + const scrollYAfter = await page.evaluate(() => window.scrollY); + expect(scrollYAfter).toBeGreaterThan(0); + // Content above grew, so the offset may rise; it must never fall back to the top. + expect(scrollYAfter).toBeGreaterThanOrEqual(scrollYBefore - 8); + }); + + test('no scrollable space below the footer links', async ({ page }) => { + await registerMocks(page); + await page.goto('/faq'); + await page.waitForLoadState('networkidle'); + // Collapsed answers must not push the document past the footer: absolutely + // positioned descendants need the clipped wrapper as their containing block. + const { scrollHeight, footerBottom } = await page.evaluate(() => { + const link = document.querySelector('a[title="Go to Terms of Service page"]') as HTMLElement; + const footer = link.closest('div')!.parentElement as HTMLElement; + return { + scrollHeight: document.documentElement.scrollHeight, + footerBottom: footer.getBoundingClientRect().bottom + window.scrollY, + }; + }); + // 32px is the page wrapper's bottom padding. + expect(scrollHeight - footerBottom).toBeLessThanOrEqual(40); + }); + + test('the document is the only vertical scroller', async ({ page }) => { + await registerMocks(page); + await page.goto('/faq'); + await page.waitForLoadState('networkidle'); + // A wrapper that clips or scrolls vertically AND holds more content than it + // shows is a nested scroller; those are what confuse mobile engines. + const nested = await page.evaluate(() => { + const found: string[] = []; + for (const el of document.querySelectorAll('body *')) { + const overflowY = getComputedStyle(el).overflowY; + if (overflowY === 'visible' || overflowY === 'clip') continue; + // Collapsed answers (height 0) and sr-only spans clip on purpose. + if (el.clientHeight <= 1) continue; + if (el.scrollHeight > el.clientHeight + 1) { + found.push(`${el.tagName.toLowerCase()}.${el.className.toString().slice(0, 60)} ${el.clientHeight}/${el.scrollHeight}`); + } + } + return found; + }); + expect(nested).toEqual([]); + }); +}); diff --git a/app/src/pages/legal/FAQ.tsx b/app/src/pages/legal/FAQ.tsx index fd0ebaa5..cbb70163 100644 --- a/app/src/pages/legal/FAQ.tsx +++ b/app/src/pages/legal/FAQ.tsx @@ -69,7 +69,7 @@ function FAQItem({ question, answer, globalToggleSignal, globalToggleState }: FA
{typeof answer === 'string' ? (