From b0e92c363aabc185f5d679f09b0db701a43693f3 Mon Sep 17 00:00:00 2001 From: Gulum Date: Tue, 23 Jun 2026 14:01:28 +0200 Subject: [PATCH] =?UTF-8?q?fix(ux):=20zuverl=C3=A4ssige=20Scroll-Wiederher?= =?UTF-8?q?stellung=20f=C3=BCr=20Infinite-Scroll-Listen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/hooks/useInfiniteList.ts | 42 ++++++++++++++ .../src/hooks/useScrollRestoration.ts | 54 +++++++++--------- gerbil-manager-web/src/pages/AnfragenPage.tsx | 1 + gerbil-manager-web/src/pages/BeckenPage.tsx | 1 + gerbil-manager-web/src/pages/GerbilsPage.tsx | Bin 15266 -> 15281 bytes gerbil-manager-web/src/pages/KontaktePage.tsx | 1 + .../src/pages/VertraegeListPage.tsx | 1 + .../src/pages/WuerfeListPage.tsx | 1 + 8 files changed, 75 insertions(+), 26 deletions(-) diff --git a/gerbil-manager-web/src/hooks/useInfiniteList.ts b/gerbil-manager-web/src/hooks/useInfiniteList.ts index 32581a1..1ddf2b4 100644 --- a/gerbil-manager-web/src/hooks/useInfiniteList.ts +++ b/gerbil-manager-web/src/hooks/useInfiniteList.ts @@ -50,17 +50,41 @@ interface Resolved { export function useInfiniteList( loader: (page: number) => Promise>, 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 { 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(() => { + 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({ 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( }, []) 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 } } diff --git a/gerbil-manager-web/src/hooks/useScrollRestoration.ts b/gerbil-manager-web/src/hooks/useScrollRestoration.ts index 26b677f..a650493 100644 --- a/gerbil-manager-web/src/hooks/useScrollRestoration.ts +++ b/gerbil-manager-web/src/hooks/useScrollRestoration.ts @@ -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. diff --git a/gerbil-manager-web/src/pages/AnfragenPage.tsx b/gerbil-manager-web/src/pages/AnfragenPage.tsx index 0567fe7..4c2a5fe 100644 --- a/gerbil-manager-web/src/pages/AnfragenPage.tsx +++ b/gerbil-manager-web/src/pages/AnfragenPage.tsx @@ -43,6 +43,7 @@ export default function AnfragenPage() { filter: status === '' ? undefined : `status==${status}`, }), `${status}`, + 'anfragen', ) const sentinelRef = useInfiniteSentinel(requests) const { items, total, loading } = requests diff --git a/gerbil-manager-web/src/pages/BeckenPage.tsx b/gerbil-manager-web/src/pages/BeckenPage.tsx index 1553cfd..84ddb07 100644 --- a/gerbil-manager-web/src/pages/BeckenPage.tsx +++ b/gerbil-manager-web/src/pages/BeckenPage.tsx @@ -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 diff --git a/gerbil-manager-web/src/pages/GerbilsPage.tsx b/gerbil-manager-web/src/pages/GerbilsPage.tsx index f8c198aa093a36e4f22c024ca49a052f62bd7a94..dddc14830dc5f1a177ddf120659a49ffdb14fa1d 100644 GIT binary patch delta 24 fcmZ2fzOj6Rkqozjf_i#tQBr13vHE5`nG$&baDWI= delta 12 Tcmdm3zNmbIk<4aCnF4tLB~%38 diff --git a/gerbil-manager-web/src/pages/KontaktePage.tsx b/gerbil-manager-web/src/pages/KontaktePage.tsx index 130041c..b9647ef 100644 --- a/gerbil-manager-web/src/pages/KontaktePage.tsx +++ b/gerbil-manager-web/src/pages/KontaktePage.tsx @@ -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 diff --git a/gerbil-manager-web/src/pages/VertraegeListPage.tsx b/gerbil-manager-web/src/pages/VertraegeListPage.tsx index e648933..941e2fe 100644 --- a/gerbil-manager-web/src/pages/VertraegeListPage.tsx +++ b/gerbil-manager-web/src/pages/VertraegeListPage.tsx @@ -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 diff --git a/gerbil-manager-web/src/pages/WuerfeListPage.tsx b/gerbil-manager-web/src/pages/WuerfeListPage.tsx index 2f7ccce..8db13b0 100644 --- a/gerbil-manager-web/src/pages/WuerfeListPage.tsx +++ b/gerbil-manager-web/src/pages/WuerfeListPage.tsx @@ -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)