From 01af701ab6fd53b273623a2b25cb238d3e69d405 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Tue, 28 Jul 2026 16:01:50 +0200 Subject: [PATCH] Add `useScrollRestoration` hook: preserve scroll position on back navigation and enhance router behavior for consistent user experience. --- web/hooks/use-scroll-restoration.ts | 95 +++++++++++++++++++++++++++++ web/pages/_app.tsx | 59 +++--------------- 2 files changed, 104 insertions(+), 50 deletions(-) create mode 100644 web/hooks/use-scroll-restoration.ts diff --git a/web/hooks/use-scroll-restoration.ts b/web/hooks/use-scroll-restoration.ts new file mode 100644 index 00000000..d8d69edb --- /dev/null +++ b/web/hooks/use-scroll-restoration.ts @@ -0,0 +1,95 @@ +import {debug} from 'common/logger' +import {Router} from 'next/router' +import {useEffect} from 'react' + +/** + * Scroll restoration that doesn't depend on *how* we go back. + * + * Next only restores scroll on the popstate path (and only with `experimental.scrollRestoration`), so any + * back that has to be emulated with a `router.push` — which is what the Android hardware back button falls + * back to on the entry the app booted on — lands at the top of the page. Here we snapshot the scroll + * position of the page we're leaving ourselves, and put it back whenever the next navigation is a + * *backward* one, whichever router call produced it. + */ + +const scrollPositions: Record = {} + +/** Whether the navigation currently in flight is a backward one (only then do we restore). */ +let isBackNavigation = false + +/** asPath of the page currently on screen; Router.asPath only updates once the change completes. */ +let currentPath: string | undefined + +/** How long to keep re-applying the scroll while the restored page is still filling in. */ +const RESTORE_TIMEOUT_MS = 1000 + +/** Call right before a `router.push` that is semantically a "back", so its scroll gets restored too. */ +export function markBackNavigation() { + isBackNavigation = true +} + +function restoreScroll(path: string) { + const target = scrollPositions[path] + if (!target) return + debug('restoring scroll', path, target) + + const deadline = Date.now() + RESTORE_TIMEOUT_MS + let cancelled = false + const cancel = () => { + cancelled = true + } + // Never fight the user for control of the scroll. + const events = ['wheel', 'touchstart', 'keydown'] as const + events.forEach((e) => window.addEventListener(e, cancel, {passive: true, once: true})) + const stop = () => events.forEach((e) => window.removeEventListener(e, cancel)) + + const attempt = () => { + if (cancelled) return stop() + window.scrollTo(0, target) + // The page can still be mounting (or its images loading), so the document may not be tall enough to + // reach the target yet. Keep trying until it is, or until we give up. + if (Math.abs(window.scrollY - target) > 1 && Date.now() < deadline) { + requestAnimationFrame(attempt) + } else { + stop() + } + } + requestAnimationFrame(attempt) +} + +export function useScrollRestoration() { + useEffect(() => { + // Matches the `url` Next hands us on routeChangeComplete (path + query, no origin). + currentPath ??= window.location.pathname + window.location.search + + // Browser / hardware back and forward both come through popstate; `router.back()` included. + const onPopState = () => { + isBackNavigation = true + } + + const onRouteChangeStart = () => { + if (currentPath) scrollPositions[currentPath] = window.scrollY + } + + const onRouteChangeComplete = (url: string) => { + currentPath = url + if (isBackNavigation) restoreScroll(url) + isBackNavigation = false + } + + const onRouteChangeError = () => { + isBackNavigation = false + } + + window.addEventListener('popstate', onPopState) + Router.events.on('routeChangeStart', onRouteChangeStart) + Router.events.on('routeChangeComplete', onRouteChangeComplete) + Router.events.on('routeChangeError', onRouteChangeError) + return () => { + window.removeEventListener('popstate', onPopState) + Router.events.off('routeChangeStart', onRouteChangeStart) + Router.events.off('routeChangeComplete', onRouteChangeComplete) + Router.events.off('routeChangeError', onRouteChangeError) + } + }, []) +} diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index beb20904..0fed0b0d 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -30,6 +30,7 @@ import {useHasLoaded} from 'web/hooks/use-has-loaded' import {HiddenProfilesProvider} from 'web/hooks/use-hidden-profiles' import {PinnedQuestionIdsProvider} from 'web/hooks/use-pinned-question-ids' import {ProfileProvider} from 'web/hooks/use-profile' +import {markBackNavigation, useScrollRestoration} from 'web/hooks/use-scroll-restoration' import {updateStatusBar} from 'web/hooks/use-theme' import {updateBackendLocale} from 'web/lib/api' import {DAYJS_LOCALE_IMPORTS, registerDatePickerLocale} from 'web/lib/dayjs' @@ -99,9 +100,6 @@ function printBuildInfo() { const navigationHistory: string[] = [] -/** Set while the boot history entry is being re-stamped, so the no-op route change isn't tracked twice. */ -let isStampingHistoryEntry = false - function useTrackedHistory() { // asPath includes the query string, so back restores the previous page // *with* its filters/search state (unlike usePathname, which drops it). @@ -118,42 +116,6 @@ function updateHistoryBack() { navigationHistory.pop() } -/** - * Next stamps its own state (`__N`, `key`) onto a history entry only when *it* navigates. The entry the - * app boots on therefore has `history.state === null`, and popping back to it fires a popstate that Next's - * router ignores — that's why going back to the startup page used to need a `router.push`. - * - * A push, though, is a forward navigation: it creates a fresh history entry, so Next never restores the - * scroll position it snapshotted (scroll restoration only runs in the popstate path). On Android — where the - * hardware back button is the only way back — that meant returning from a profile dropped you at the top of - * the grid. - * - * Re-writing the boot entry with a shallow `replace` gives it Next state, so a real `back()` works and - * restores scroll. Returns whether the entry is now safe to `back()` into. - */ -function useStampedInitialHistoryEntry() { - const router = useRouter() - const [stamped, setStamped] = useState(false) - const {isReady} = router - useEffect(() => { - if (typeof window === 'undefined' || !isReady || stamped) return - if ((window.history.state as any)?.__N) { - setStamped(true) - return - } - isStampingHistoryEntry = true - router - .replace(router.asPath, undefined, {shallow: true, scroll: false}) - .then(() => setStamped(!!(window.history.state as any)?.__N)) - .catch((e) => debug('failed to stamp initial history entry', e)) - .finally(() => { - isStampingHistoryEntry = false - }) - // Only ever runs for the entry the app booted on, once the router knows its real query. - }, [isReady]) - return stamped -} - // specially treated props that may be present in the server/static props type PageProps = {auth?: AuthUser} @@ -197,10 +159,7 @@ function MyApp(props: AppProps) { useEffect(() => { initTracking() - const handleRouteChange = () => { - if (isStampingHistoryEntry) return - posthog?.capture('$pageview') - } + const handleRouteChange = () => posthog?.capture('$pageview') Router.events.on('routeChangeComplete', handleRouteChange) return () => { @@ -209,21 +168,21 @@ function MyApp(props: AppProps) { }, []) useTrackedHistory() - const initialEntryStamped = useStampedInitialHistoryEntry() + useScrollRestoration() useEffect(() => { // All this is to handle the back button on mobile when returning to the startup page - // (a plain router.back() only reaches the startup page once its history entry carries Next state — - // see useStampedInitialHistoryEntry) + // (router.back() does not go back to the startup / home page) const handleBack = () => { debug('handleBack:', navigationHistory) - if (navigationHistory.length > 2 || (navigationHistory.length === 2 && initialEntryStamped)) { - // back() goes through popstate, which is the only path that restores the previous page's scroll. + if (navigationHistory.length > 2) { updateHistoryBack() router.back() } else if (navigationHistory.length === 2) { - // Fallback for when the boot entry couldn't be stamped: reachable, but scrolled to the top. const path = navigationHistory[0] updateHistoryBack() + // A push is a forward navigation, so nothing would restore the scroll position of the page we're + // going back to — useScrollRestoration needs to be told this one counts as a back. + markBackNavigation() router.push(path) } else if (Capacitor.isNativePlatform()) { App.minimizeApp() @@ -232,7 +191,7 @@ function MyApp(props: AppProps) { window.addEventListener('appBackButton', handleBack) return () => window.removeEventListener('appBackButton', handleBack) - }, [router, initialEntryStamped]) + }, [router]) useEffect(() => { if (!Capacitor.isNativePlatform()) return