/** * 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('.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(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]) }