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

@@ -50,17 +50,41 @@ interface Resolved<T> {
export function useInfiniteList<T>(
loader: (page: number) => Promise<Paged<T>>,
resetKey: string,
/**
* Optionaler stabiler Schlüssel je Liste/Route (z. B. "gerbils"). Wenn gesetzt, wird die
* geladene Seitenzahl in der Session gemerkt und nach einer Zurück-Navigation wieder
* aufgebaut — so ist die Liste hoch genug, dass die globale Scroll-Wiederherstellung greift.
*/
restoreKey?: string,
): InfiniteListState<T> {
const loaderRef = useRef(loader)
useEffect(() => {
loaderRef.current = loader
})
const storageKey = restoreKey ? `il-pages:${restoreKey}` : null
// Gemerkte Zielseite EINMAL beim Mount lesen (nur wenn dieselbe Filter-/Sortier-Signatur):
// bis hierhin werden nach einer Zurück-Navigation die Seiten wieder nachgeladen.
const [restoreTarget, setRestoreTarget] = useState<number>(() => {
if (!storageKey) return 0
try {
const saved = JSON.parse(sessionStorage.getItem(storageKey) || 'null')
if (saved && saved.key === resetKey && typeof saved.page === 'number' && saved.page > 1) {
return saved.page
}
} catch {
/* ignorieren */
}
return 0
})
const [req, setReq] = useState<Req>({ key: resetKey, page: 1, nonce: 0 })
// Adjust state during render when the query signature changes — the recommended
// pattern for deriving state from changed inputs (no setState-in-effect cascade).
if (req.key !== resetKey) {
setReq({ key: resetKey, page: 1, nonce: 0 })
setRestoreTarget(0) // Filter/Sortierung geändert → kein Seiten-Restore mehr
}
const queryId = `${req.nonce}:${req.key}`
@@ -123,6 +147,24 @@ export function useInfiniteList<T>(
}, [])
const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), [])
// Geladene Seitenzahl je Query merken (für die Wiederherstellung nach Zurück-Navigation).
useEffect(() => {
if (!storageKey || !sameQuery || inFlight) return
try {
sessionStorage.setItem(storageKey, JSON.stringify({ key: req.key, page: req.page }))
} catch {
/* ignorieren */
}
}, [storageKey, sameQuery, inFlight, req.key, req.page])
// Nach Zurück-Navigation die zuvor geladene Seitenzahl wieder aufbauen (eine Seite je
// abgeschlossener Anfrage), damit die Liste ihre alte Höhe erreicht.
useEffect(() => {
if (restoreTarget <= req.page || !sameQuery || inFlight || !hasMore) return
// eslint-disable-next-line react-hooks/set-state-in-effect -- kontrolliertes Nachladen bis zur gemerkten Seite
setReq((r) => ({ ...r, page: r.page + 1 }))
}, [restoreTarget, sameQuery, inFlight, hasMore, req.page])
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }
}

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.

View File

@@ -43,6 +43,7 @@ export default function AnfragenPage() {
filter: status === '' ? undefined : `status==${status}`,
}),
`${status}`,
'anfragen',
)
const sentinelRef = useInfiniteSentinel(requests)
const { items, total, loading } = requests

View File

@@ -19,6 +19,7 @@ export default function BeckenPage() {
const list = useInfiniteList(
(page) => listEnclosuresPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
`${filter ?? ''}`,
'becken',
)
const sentinelRef = useInfiniteSentinel(list)
const { items, total, loading } = list

View File

@@ -64,6 +64,7 @@ export default function KontaktePage() {
(page) =>
listContactsPaged({ filter: filter || undefined, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
filter,
'kontakte',
)
const sentinelRef = useInfiniteSentinel(list)
const { items, total, hasMore, loading, loadingMore } = list

View File

@@ -21,6 +21,7 @@ export default function VertraegeListPage() {
const contracts = useInfiniteList(
(page) => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc,id' }),
'contracts',
'vertraege',
)
const sentinelRef = useInfiniteSentinel(contracts)
const { items, total: totalCount, loading } = contracts

View File

@@ -78,6 +78,7 @@ export default function WuerfeListPage() {
const litters = useInfiniteList(
(page) => listLitters({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }),
`${filter}|${orderBy}`,
'wuerfe',
)
const littersSentinelRef = useInfiniteSentinel(litters)