Files
GerbilManager/gerbil-manager-web/src/hooks/useScrollRestoration.ts
Gulum bb1b8f8c76 fix(ux): Scrollposition nicht durch Seitenwechsel-Kollaps auf 0 überschreiben
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>
2026-06-23 14:28:32 +02:00

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])
}