fix(ux): Scroll-Wiederherstellung am echten Scroll-Container (.app-main), nicht am Fenster

Die App scrollt NICHT das Fenster: .app-shell ist height:100dvh/overflow:hidden, der
Inhaltsbereich .app-main hat overflow-y:auto. window.scrollY/scrollTo waren daher
wirkungslos (am Desktop sichtbar). Der Hook arbeitet jetzt auf .app-main (scrollTop/
scrollTo, Scroll-Listener am Container) und beobachtet den Inhalt per MutationObserver,
um nach dem Nachladen exakt zur gemerkten Position zu springen. Fallback auf window,
falls der Container fehlt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:06:56 +02:00
parent b0e92c363a
commit 00a3919c9f

View File

@@ -1,25 +1,37 @@
/** /**
* Globale Scroll-Wiederherstellung (einmal im AppShell gemountet, gilt für ALLE Seiten/Listen). * Globale Scroll-Wiederherstellung (einmal im AppShell gemountet, gilt für ALLE Seiten/Listen).
* *
* Erhält die Fenster-Scrollposition je Route über: * WICHTIG: Gescrollt wird NICHT das Fenster, sondern der Inhaltsbereich `.app-main`
* (`.app-shell` ist height:100dvh/overflow:hidden). Daher arbeiten wir auf diesem Container.
*
* Erhält die Scrollposition je Route über:
* - Zurück/Vorwärts-Navigation (POP) → gemerkte Position wiederherstellen, * - Zurück/Vorwärts-Navigation (POP) → gemerkte Position wiederherstellen,
* - Bildschirm-Sperre / App-Hintergrund (visibilitychange, pagehide/pageshow) → Position * - Bildschirm-Sperre / App-Hintergrund (visibilitychange, pagehide/pageshow) → Position
* sicher festhalten und beim Zurückkommen wiederherstellen (sonst springt das Handy oben). * sichern und beim Zurückkommen wiederherstellen.
* Neue Navigation (PUSH/REPLACE) startet wie gewohnt oben. * Neue Navigation (PUSH/REPLACE) startet oben.
* *
* Robust gegen nachladende Listen: nach dem Wiederherstellen wird mehrfach (per rAF, bis ~2.5 s) * EVENT-basiert (zuverlässiger als ein Timer): ein MutationObserver auf dem Inhalt springt
* nachjustiert, während die Liste in die Höhe wächst — bricht aber ab, sobald die Nutzerin selbst * erneut zur Zielposition, sobald die Liste (nach-)lädt und höher wird — bricht aber sofort ab,
* scrollt (damit wir nie gegen sie ankämpfen). Deep-Links (?focus=, #anchor) haben Vorrang. * sobald die Nutzerin selbst scrollt. Deep-Links (?focus=, #anchor) haben Vorrang.
*/ */
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { useLocation, useNavigationType } from 'react-router-dom' import { useLocation, useNavigationType } from 'react-router-dom'
const PREFIX = 'scroll:' const PREFIX = 'scroll:'
// Sicherheitsnetz: Beobachtung der Höhenänderungen nach dieser Zeit beenden (kein Dauer-Listener, // Sicherheitsnetz: Inhalts-Beobachtung nach dieser Zeit beenden (kein Dauer-Listener).
// falls die Zielhöhe nie erreicht wird). Die eigentliche Wiederherstellung läuft EVENT-basiert
// (ResizeObserver), nicht über einen Polling-Timer.
const RESTORE_OBSERVE_CAP_MS = 15000 const RESTORE_OBSERVE_CAP_MS = 15000
function getScroller(): HTMLElement | null {
return document.querySelector<HTMLElement>('.app-main')
}
function getTop(el: HTMLElement | null): number {
return el ? el.scrollTop : window.scrollY
}
function setTop(el: HTMLElement | null, y: number): void {
if (el) el.scrollTo(0, y)
else window.scrollTo(0, y)
}
export function useScrollRestoration() { export function useScrollRestoration() {
const { pathname } = useLocation() const { pathname } = useLocation()
const navType = useNavigationType() const navType = useNavigationType()
@@ -31,7 +43,7 @@ export function useScrollRestoration() {
const persist = useCallback(() => { const persist = useCallback(() => {
try { try {
sessionStorage.setItem(keyFor(pathRef.current), String(window.scrollY)) sessionStorage.setItem(keyFor(pathRef.current), String(getTop(getScroller())))
} catch { } catch {
/* sessionStorage nicht verfügbar */ /* sessionStorage nicht verfügbar */
} }
@@ -39,7 +51,6 @@ export function useScrollRestoration() {
const restore = useCallback((path: string, respectDeepLink: boolean) => { const restore = useCallback((path: string, respectDeepLink: boolean) => {
if (respectDeepLink) { if (respectDeepLink) {
// Ein Deep-Link (Sprung zu einem Element / Anker) hat Vorrang vor der gemerkten Position.
if (window.location.hash) return if (window.location.hash) return
try { try {
if (new URLSearchParams(window.location.search).has('focus')) return if (new URLSearchParams(window.location.search).has('focus')) return
@@ -61,7 +72,7 @@ export function useScrollRestoration() {
if (done) return if (done) return
done = true done = true
restoringRef.current = false restoringRef.current = false
observer.disconnect() mo.disconnect()
window.removeEventListener('wheel', onUser) window.removeEventListener('wheel', onUser)
window.removeEventListener('touchmove', onUser) window.removeEventListener('touchmove', onUser)
window.removeEventListener('keydown', onUser) window.removeEventListener('keydown', onUser)
@@ -71,19 +82,20 @@ export function useScrollRestoration() {
const onUser = () => stop() const onUser = () => stop()
const tryScroll = () => { const tryScroll = () => {
if (done) return if (done) return
window.scrollTo(0, y) const el = getScroller()
// Erreicht? (Liste ist hoch genug.) Dann sind wir fertig. setTop(el, y)
if (Math.abs(window.scrollY - y) <= 2) stop() if (Math.abs(getTop(el) - y) <= 2) stop() // erreicht → fertig
} }
// EVENT-basiert: jedes Mal, wenn die Seite höher wird (Liste lädt nach / kommt zurück), // EVENT-basiert: jede DOM-Änderung im Inhalt (Liste lädt nach / kommt zurück) → erneut zur
// erneut zur gemerkten Position springen — zuverlässiger als ein fester Timer. // gemerkten Position springen. Zuverlässiger als ein fester Timer.
const observer = new ResizeObserver(() => requestAnimationFrame(tryScroll)) const mo = new MutationObserver(() => requestAnimationFrame(tryScroll))
const passive = { passive: true } as AddEventListenerOptions const passive = { passive: true } as AddEventListenerOptions
window.addEventListener('wheel', onUser, passive) window.addEventListener('wheel', onUser, passive)
window.addEventListener('touchmove', onUser, passive) window.addEventListener('touchmove', onUser, passive)
window.addEventListener('keydown', onUser) window.addEventListener('keydown', onUser)
const safety = window.setTimeout(stop, RESTORE_OBSERVE_CAP_MS) const safety = window.setTimeout(stop, RESTORE_OBSERVE_CAP_MS)
observer.observe(document.body) const scroller = getScroller()
if (scroller) mo.observe(scroller, { childList: true, subtree: true })
requestAnimationFrame(tryScroll) requestAnimationFrame(tryScroll)
}, []) }, [])
@@ -95,14 +107,14 @@ export function useScrollRestoration() {
} else { } else {
restoringRef.current = true restoringRef.current = true
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.scrollTo(0, 0) setTop(getScroller(), 0)
restoringRef.current = false restoringRef.current = false
}) })
} }
}, [pathname, navType, restore]) }, [pathname, navType, restore])
// Laufendes Mitschreiben (rAF-gedrosselt) + Sichern beim Ausblenden + Wiederherstellen beim // Laufendes Mitschreiben (rAF-gedrosselt) am Scroll-Container + Sichern beim Ausblenden +
// Wiederanzeigen (Handy entsperrt / aus dem Hintergrund). // Wiederherstellen beim Wiederanzeigen (Handy entsperrt / aus dem Hintergrund).
useEffect(() => { useEffect(() => {
const onScroll = () => { const onScroll = () => {
if (restoringRef.current || document.visibilityState !== 'visible') return if (restoringRef.current || document.visibilityState !== 'visible') return
@@ -118,12 +130,14 @@ export function useScrollRestoration() {
} }
const onPageShow = () => restore(pathRef.current, false) const onPageShow = () => restore(pathRef.current, false)
window.addEventListener('scroll', onScroll, { passive: true }) const scroller = getScroller()
const scrollTarget: HTMLElement | Window = scroller ?? window
scrollTarget.addEventListener('scroll', onScroll, { passive: true })
document.addEventListener('visibilitychange', onVisibility) document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('pagehide', persist) window.addEventListener('pagehide', persist)
window.addEventListener('pageshow', onPageShow) window.addEventListener('pageshow', onPageShow)
return () => { return () => {
window.removeEventListener('scroll', onScroll) scrollTarget.removeEventListener('scroll', onScroll)
document.removeEventListener('visibilitychange', onVisibility) document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('pagehide', persist) window.removeEventListener('pagehide', persist)
window.removeEventListener('pageshow', onPageShow) window.removeEventListener('pageshow', onPageShow)
@@ -131,10 +145,8 @@ export function useScrollRestoration() {
} }
}, [persist, restore]) }, [persist, restore])
// NUR Dev (Vite-HMR / React Fast Refresh): bei einem Hot-Update wird die Seite kurz neu // NUR Dev (Vite-HMR / React Fast Refresh): vor dem Hot-Update sichern, danach wiederherstellen.
// gerendert (Liste kollabiert → Sprung nach oben), ohne dass eine Navigation oder ein // In der Produktion ist import.meta.hot undefiniert → No-Op.
// pagehide/pageshow feuert. Daher hier explizit vor dem Update sichern und danach
// wiederherstellen. In der Produktion ist import.meta.hot undefiniert → No-Op.
useEffect(() => { useEffect(() => {
const hot = import.meta.hot const hot = import.meta.hot
if (!hot) return if (!hot) return