Add useScrollRestoration hook: preserve scroll position on back navigation and enhance router behavior for consistent user experience.

This commit is contained in:
MartinBraquet
2026-07-28 16:01:50 +02:00
parent 7a50931f49
commit 01af701ab6
2 changed files with 104 additions and 50 deletions

View File

@@ -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<string, number> = {}
/** 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)
}
}, [])
}

View File

@@ -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<PageProps>) {
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<PageProps>) {
}, [])
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<PageProps>) {
window.addEventListener('appBackButton', handleBack)
return () => window.removeEventListener('appBackButton', handleBack)
}, [router, initialEntryStamped])
}, [router])
useEffect(() => {
if (!Capacitor.isNativePlatform()) return