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