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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user