Echte Ursache (per Firefox/Chromium gegen die Live-App verifiziert): Beim Wegklicken eines Listeneintrags wird die Seite ausgehängt → der Inhalt von .app-main kollabiert → der Container springt auf scrollTop 0 → das löste ein scroll-Event aus, das 0 als neue Position speicherte und die gemerkte Stelle ZERSTÖRTE, kurz bevor wir wegnavigierten. Fix: - scroll-Events ignorieren, wenn der Container gerade NICHT scrollbar ist (scrollHeight ~ clientHeight = Inhalt kollabiert/Seitenwechsel) → kein „unechter" 0-Save. - Wiederherstellung hält den Schutz über die gesamte Settling-Phase: bei jeder DOM-Änderung Position erneut setzen, erst nach ~350 ms Ruhe freigeben (gegen Reflow-Races beim Nachladen). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
6.9 KiB
TypeScript
184 lines
6.9 KiB
TypeScript
/**
|
|
* Globale Scroll-Wiederherstellung (einmal im AppShell gemountet, gilt für ALLE Seiten/Listen).
|
|
*
|
|
* 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
|
|
* sichern und beim Zurückkommen wiederherstellen.
|
|
* Neue Navigation (PUSH/REPLACE) startet oben.
|
|
*
|
|
* 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: 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()
|
|
const pathRef = useRef(pathname)
|
|
const restoringRef = useRef(false)
|
|
const rafSaveRef = useRef<number | null>(null)
|
|
|
|
const keyFor = (p: string) => PREFIX + p
|
|
|
|
const persist = useCallback(() => {
|
|
try {
|
|
sessionStorage.setItem(keyFor(pathRef.current), String(getTop(getScroller())))
|
|
} catch {
|
|
/* sessionStorage nicht verfügbar */
|
|
}
|
|
}, [])
|
|
|
|
const restore = useCallback((path: string, respectDeepLink: boolean) => {
|
|
if (respectDeepLink) {
|
|
if (window.location.hash) return
|
|
try {
|
|
if (new URLSearchParams(window.location.search).has('focus')) return
|
|
} catch {
|
|
/* ignorieren */
|
|
}
|
|
}
|
|
let y = NaN
|
|
try {
|
|
y = Number(sessionStorage.getItem(keyFor(path)))
|
|
} catch {
|
|
/* ignorieren */
|
|
}
|
|
if (!Number.isFinite(y) || y <= 0) return
|
|
|
|
// WICHTIG gegen Race: `restoringRef` bleibt während der GESAMTEN Nachlade-/Settling-Phase
|
|
// true. So kann ein durch das Re-Rendern/Nachladen ausgelöster Sprung nach oben (scroll→0)
|
|
// NICHT als neue Position gespeichert werden und die gemerkte Position überschreiben.
|
|
restoringRef.current = true
|
|
let done = false
|
|
let quiet: number | undefined
|
|
const stop = () => {
|
|
if (done) return
|
|
done = true
|
|
restoringRef.current = false
|
|
mo.disconnect()
|
|
window.removeEventListener('wheel', onUser)
|
|
window.removeEventListener('touchmove', onUser)
|
|
window.removeEventListener('keydown', onUser)
|
|
window.clearTimeout(safety)
|
|
window.clearTimeout(quiet)
|
|
}
|
|
// Sobald die Nutzerin selbst scrollt, brechen wir ab — nie gegen sie ankämpfen.
|
|
const onUser = () => stop()
|
|
const assert = () => {
|
|
if (!done) setTop(getScroller(), y)
|
|
}
|
|
// „Ruhe-Timer": erst freigeben, wenn der Inhalt ~350 ms lang stabil war (keine DOM-Änderung
|
|
// mehr) — bis dahin wird die Position bei jeder Änderung erneut gesetzt.
|
|
const armQuiet = () => {
|
|
window.clearTimeout(quiet)
|
|
quiet = window.setTimeout(() => {
|
|
assert()
|
|
stop()
|
|
}, 350)
|
|
}
|
|
const onMutate = () => {
|
|
assert()
|
|
armQuiet()
|
|
}
|
|
const mo = new MutationObserver(onMutate)
|
|
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)
|
|
const scroller = getScroller()
|
|
if (scroller) mo.observe(scroller, { childList: true, subtree: true })
|
|
assert()
|
|
armQuiet()
|
|
}, [])
|
|
|
|
// Auf Navigation reagieren: POP = wiederherstellen, sonst neue Seite oben starten.
|
|
useEffect(() => {
|
|
pathRef.current = pathname
|
|
if (navType === 'POP') {
|
|
restore(pathname, true)
|
|
} else {
|
|
restoringRef.current = true
|
|
requestAnimationFrame(() => {
|
|
setTop(getScroller(), 0)
|
|
restoringRef.current = false
|
|
})
|
|
}
|
|
}, [pathname, navType, restore])
|
|
|
|
// 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
|
|
// Beim Seitenwechsel kollabiert der Inhalt kurz (scrollHeight ~ clientHeight) und der
|
|
// Container springt auf 0. Diesen „unechten" 0-Sprung NICHT speichern, sonst überschreibt
|
|
// er die gemerkte Position kurz bevor wir wegnavigieren.
|
|
const el = getScroller()
|
|
if (el && el.scrollHeight <= el.clientHeight + 4) return
|
|
if (rafSaveRef.current != null) return
|
|
rafSaveRef.current = requestAnimationFrame(() => {
|
|
rafSaveRef.current = null
|
|
persist()
|
|
})
|
|
}
|
|
const onVisibility = () => {
|
|
if (document.visibilityState === 'hidden') persist()
|
|
else restore(pathRef.current, false)
|
|
}
|
|
const onPageShow = () => restore(pathRef.current, false)
|
|
|
|
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 () => {
|
|
scrollTarget.removeEventListener('scroll', onScroll)
|
|
document.removeEventListener('visibilitychange', onVisibility)
|
|
window.removeEventListener('pagehide', persist)
|
|
window.removeEventListener('pageshow', onPageShow)
|
|
if (rafSaveRef.current != null) cancelAnimationFrame(rafSaveRef.current)
|
|
}
|
|
}, [persist, restore])
|
|
|
|
// 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
|
|
const before = () => persist()
|
|
const after = () => restore(pathRef.current, false)
|
|
hot.on('vite:beforeUpdate', before)
|
|
hot.on('vite:afterUpdate', after)
|
|
hot.on('vite:beforeFullReload', before)
|
|
return () => {
|
|
hot.off?.('vite:beforeUpdate', before)
|
|
hot.off?.('vite:afterUpdate', after)
|
|
hot.off?.('vite:beforeFullReload', before)
|
|
}
|
|
}, [persist, restore])
|
|
}
|