feat(ux): globale Scroll-Wiederherstellung für ALLE Listen/Seiten

Neuer Hook useScrollRestoration (einmal im AppShell gemountet) erhält die
Scrollposition je Route über:
- Zurück/Vorwärts-Navigation (POP restauriert, PUSH startet oben),
- Bildschirm-Sperre / App-Hintergrund (visibilitychange, pagehide/pageshow) —
  damit das Handy nach dem Entsperren nicht mehr oben landet.

Robust gegen nachladende Listen (rAF-Nachjustierung bis ~2,5 s, während die Liste
wächst), bricht bei eigener Scroll-Eingabe ab, Deep-Links (?focus=, #anchor) haben
Vorrang. Die bisher nur in der Tickets-Seite eingebaute Logik wurde durch den
globalen Hook ersetzt.

tsc/eslint/vitest grün; e2e (navigation/listen/tickets, 42) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 13:48:02 +02:00
parent f9e672c707
commit ebc70cc0b8
3 changed files with 136 additions and 103 deletions

View File

@@ -0,0 +1,129 @@
/**
* Globale Scroll-Wiederherstellung (einmal im AppShell gemountet, gilt für ALLE Seiten/Listen).
*
* Erhält die Fenster-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.
*
* 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.
*/
import { useCallback, useEffect, useRef } from 'react'
import { useLocation, useNavigationType } from 'react-router-dom'
const PREFIX = 'scroll:'
const RESTORE_DEADLINE_MS = 2500
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(window.scrollY))
} catch {
/* sessionStorage nicht verfügbar */
}
}, [])
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
} catch {
/* ignorieren */
}
}
let y = NaN
try {
y = Number(sessionStorage.getItem(keyFor(path)))
} catch {
/* ignorieren */
}
if (!Number.isFinite(y) || y <= 0) return
restoringRef.current = true
const deadline = performance.now() + RESTORE_DEADLINE_MS
let aborted = false
const onUser = () => {
aborted = true
}
const passive = { passive: true } as AddEventListenerOptions
window.addEventListener('wheel', onUser, passive)
window.addEventListener('touchmove', onUser, passive)
window.addEventListener('keydown', onUser)
const stop = () => {
restoringRef.current = false
window.removeEventListener('wheel', onUser)
window.removeEventListener('touchmove', onUser)
window.removeEventListener('keydown', onUser)
}
const tick = () => {
if (aborted) {
stop()
return
}
window.scrollTo(0, y)
if (Math.abs(window.scrollY - y) <= 2 || performance.now() > deadline) {
stop()
return
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}, [])
// 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(() => {
window.scrollTo(0, 0)
restoringRef.current = false
})
}
}, [pathname, navType, restore])
// Laufendes Mitschreiben (rAF-gedrosselt) + Sichern beim Ausblenden + Wiederherstellen beim
// Wiederanzeigen (Handy entsperrt / aus dem Hintergrund).
useEffect(() => {
const onScroll = () => {
if (restoringRef.current || document.visibilityState !== 'visible') 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)
window.addEventListener('scroll', onScroll, { passive: true })
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('pagehide', persist)
window.addEventListener('pageshow', onPageShow)
return () => {
window.removeEventListener('scroll', onScroll)
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('pagehide', persist)
window.removeEventListener('pageshow', onPageShow)
if (rafSaveRef.current != null) cancelAnimationFrame(rafSaveRef.current)
}
}, [persist, restore])
}