fix(ux): zuverlässige Scroll-Wiederherstellung für Infinite-Scroll-Listen

Zwei Probleme bei der großen Rennmausliste (und allen Infinite-Scroll-Listen) nach
Zurück-Navigation behoben:

1) Event- statt Timer-basiert: Die Scroll-Wiederherstellung hängt sich jetzt per
   ResizeObserver an die Höhenänderung der Seite (Liste ist wieder da) und springt dann
   zur gemerkten Position — statt auf einen festen Timer zu warten. Bricht bei eigener
   Scroll-Eingabe ab; Sicherheitsnetz nach 15 s.

2) Infinite-Scroll baut Höhe wieder auf: useInfiniteList merkt sich (optionaler
   restoreKey) die geladene Seitenzahl je Query und lädt sie nach einer Zurück-
   Navigation wieder nach — sonst war die Liste nur 1 Seite hoch und die Position
   unerreichbar. Aktiviert für Rennmäuse, Kontakte, Würfe, Becken, Anfragen, Verträge.

tsc/eslint/vitest grün; e2e (Listen + Navigation, 41) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:01:28 +02:00
parent 7262b34ae9
commit b0e92c363a
8 changed files with 75 additions and 26 deletions

View File

@@ -15,9 +15,10 @@ import { useCallback, useEffect, useRef } from 'react'
import { useLocation, useNavigationType } from 'react-router-dom'
const PREFIX = 'scroll:'
// Etwas großzügig, damit auch langsam (kalt) nachladende Listen + Dev-HMR-Updates die Position
// noch erreichen, während der Inhalt in die Höhe wächst.
const RESTORE_DEADLINE_MS = 4000
// 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.
const RESTORE_OBSERVE_CAP_MS = 15000
export function useScrollRestoration() {
const { pathname } = useLocation()
@@ -55,34 +56,35 @@ export function useScrollRestoration() {
if (!Number.isFinite(y) || y <= 0) return
restoringRef.current = true
const deadline = performance.now() + RESTORE_DEADLINE_MS
let aborted = false
const onUser = () => {
aborted = true
let done = false
const stop = () => {
if (done) return
done = true
restoringRef.current = false
observer.disconnect()
window.removeEventListener('wheel', onUser)
window.removeEventListener('touchmove', onUser)
window.removeEventListener('keydown', onUser)
window.clearTimeout(safety)
}
// Sobald die Nutzerin selbst scrollt, brechen wir ab — nie gegen sie ankämpfen.
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()
}
// 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))
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)
const safety = window.setTimeout(stop, RESTORE_OBSERVE_CAP_MS)
observer.observe(document.body)
requestAnimationFrame(tryScroll)
}, [])
// Auf Navigation reagieren: POP = wiederherstellen, sonst neue Seite oben starten.