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:
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function AnfragenPage() {
|
||||
filter: status === '' ? undefined : `status==${status}`,
|
||||
}),
|
||||
`${status}`,
|
||||
'anfragen',
|
||||
)
|
||||
const sentinelRef = useInfiniteSentinel(requests)
|
||||
const { items, total, loading } = requests
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user