From 00a3919c9f1a163db8f21f109a49358fb4ffb8de Mon Sep 17 00:00:00 2001 From: Gulum Date: Tue, 23 Jun 2026 14:06:56 +0200 Subject: [PATCH] fix(ux): Scroll-Wiederherstellung am echten Scroll-Container (.app-main), nicht am Fenster Die App scrollt NICHT das Fenster: .app-shell ist height:100dvh/overflow:hidden, der Inhaltsbereich .app-main hat overflow-y:auto. window.scrollY/scrollTo waren daher wirkungslos (am Desktop sichtbar). Der Hook arbeitet jetzt auf .app-main (scrollTop/ scrollTo, Scroll-Listener am Container) und beobachtet den Inhalt per MutationObserver, um nach dem Nachladen exakt zur gemerkten Position zu springen. Fallback auf window, falls der Container fehlt. Co-Authored-By: Claude Opus 4.8 --- .../src/hooks/useScrollRestoration.ts | 68 +++++++++++-------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/gerbil-manager-web/src/hooks/useScrollRestoration.ts b/gerbil-manager-web/src/hooks/useScrollRestoration.ts index a650493..8c0e962 100644 --- a/gerbil-manager-web/src/hooks/useScrollRestoration.ts +++ b/gerbil-manager-web/src/hooks/useScrollRestoration.ts @@ -1,25 +1,37 @@ /** * Globale Scroll-Wiederherstellung (einmal im AppShell gemountet, gilt für ALLE Seiten/Listen). * - * Erhält die Fenster-Scrollposition je Route über: + * WICHTIG: Gescrollt wird NICHT das Fenster, sondern der Inhaltsbereich `.app-main` + * (`.app-shell` ist height:100dvh/overflow:hidden). Daher arbeiten wir auf diesem Container. + * + * Erhält die 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. + * sichern und beim Zurückkommen wiederherstellen. + * Neue Navigation (PUSH/REPLACE) startet 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. + * EVENT-basiert (zuverlässiger als ein Timer): ein MutationObserver auf dem Inhalt springt + * erneut zur Zielposition, sobald die Liste (nach-)lädt und höher wird — bricht aber sofort ab, + * sobald die Nutzerin selbst scrollt. Deep-Links (?focus=, #anchor) haben Vorrang. */ import { useCallback, useEffect, useRef } from 'react' import { useLocation, useNavigationType } from 'react-router-dom' const PREFIX = 'scroll:' -// 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. +// Sicherheitsnetz: Inhalts-Beobachtung nach dieser Zeit beenden (kein Dauer-Listener). const RESTORE_OBSERVE_CAP_MS = 15000 +function getScroller(): HTMLElement | null { + return document.querySelector('.app-main') +} +function getTop(el: HTMLElement | null): number { + return el ? el.scrollTop : window.scrollY +} +function setTop(el: HTMLElement | null, y: number): void { + if (el) el.scrollTo(0, y) + else window.scrollTo(0, y) +} + export function useScrollRestoration() { const { pathname } = useLocation() const navType = useNavigationType() @@ -31,7 +43,7 @@ export function useScrollRestoration() { const persist = useCallback(() => { try { - sessionStorage.setItem(keyFor(pathRef.current), String(window.scrollY)) + sessionStorage.setItem(keyFor(pathRef.current), String(getTop(getScroller()))) } catch { /* sessionStorage nicht verfügbar */ } @@ -39,7 +51,6 @@ export function useScrollRestoration() { 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 @@ -61,7 +72,7 @@ export function useScrollRestoration() { if (done) return done = true restoringRef.current = false - observer.disconnect() + mo.disconnect() window.removeEventListener('wheel', onUser) window.removeEventListener('touchmove', onUser) window.removeEventListener('keydown', onUser) @@ -71,19 +82,20 @@ export function useScrollRestoration() { 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() + const el = getScroller() + setTop(el, y) + if (Math.abs(getTop(el) - y) <= 2) stop() // erreicht → fertig } - // 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)) + // EVENT-basiert: jede DOM-Änderung im Inhalt (Liste lädt nach / kommt zurück) → erneut zur + // gemerkten Position springen. Zuverlässiger als ein fester Timer. + const mo = new MutationObserver(() => requestAnimationFrame(tryScroll)) const passive = { passive: true } as AddEventListenerOptions window.addEventListener('wheel', onUser, passive) window.addEventListener('touchmove', onUser, passive) window.addEventListener('keydown', onUser) const safety = window.setTimeout(stop, RESTORE_OBSERVE_CAP_MS) - observer.observe(document.body) + const scroller = getScroller() + if (scroller) mo.observe(scroller, { childList: true, subtree: true }) requestAnimationFrame(tryScroll) }, []) @@ -95,14 +107,14 @@ export function useScrollRestoration() { } else { restoringRef.current = true requestAnimationFrame(() => { - window.scrollTo(0, 0) + setTop(getScroller(), 0) restoringRef.current = false }) } }, [pathname, navType, restore]) - // Laufendes Mitschreiben (rAF-gedrosselt) + Sichern beim Ausblenden + Wiederherstellen beim - // Wiederanzeigen (Handy entsperrt / aus dem Hintergrund). + // Laufendes Mitschreiben (rAF-gedrosselt) am Scroll-Container + Sichern beim Ausblenden + + // Wiederherstellen beim Wiederanzeigen (Handy entsperrt / aus dem Hintergrund). useEffect(() => { const onScroll = () => { if (restoringRef.current || document.visibilityState !== 'visible') return @@ -118,12 +130,14 @@ export function useScrollRestoration() { } const onPageShow = () => restore(pathRef.current, false) - window.addEventListener('scroll', onScroll, { passive: true }) + const scroller = getScroller() + const scrollTarget: HTMLElement | Window = scroller ?? window + scrollTarget.addEventListener('scroll', onScroll, { passive: true }) document.addEventListener('visibilitychange', onVisibility) window.addEventListener('pagehide', persist) window.addEventListener('pageshow', onPageShow) return () => { - window.removeEventListener('scroll', onScroll) + scrollTarget.removeEventListener('scroll', onScroll) document.removeEventListener('visibilitychange', onVisibility) window.removeEventListener('pagehide', persist) window.removeEventListener('pageshow', onPageShow) @@ -131,10 +145,8 @@ export function useScrollRestoration() { } }, [persist, restore]) - // NUR Dev (Vite-HMR / React Fast Refresh): bei einem Hot-Update wird die Seite kurz neu - // gerendert (Liste kollabiert → Sprung nach oben), ohne dass eine Navigation oder ein - // pagehide/pageshow feuert. Daher hier explizit vor dem Update sichern und danach - // wiederherstellen. In der Produktion ist import.meta.hot undefiniert → No-Op. + // NUR Dev (Vite-HMR / React Fast Refresh): vor dem Hot-Update sichern, danach wiederherstellen. + // In der Produktion ist import.meta.hot undefiniert → No-Op. useEffect(() => { const hot = import.meta.hot if (!hot) return