diff --git a/gerbil-manager-web/src/hooks/useScrollRestoration.ts b/gerbil-manager-web/src/hooks/useScrollRestoration.ts index 8c0e962..cab6b36 100644 --- a/gerbil-manager-web/src/hooks/useScrollRestoration.ts +++ b/gerbil-manager-web/src/hooks/useScrollRestoration.ts @@ -66,8 +66,12 @@ export function useScrollRestoration() { } if (!Number.isFinite(y) || y <= 0) return + // WICHTIG gegen Race: `restoringRef` bleibt während der GESAMTEN Nachlade-/Settling-Phase + // true. So kann ein durch das Re-Rendern/Nachladen ausgelöster Sprung nach oben (scroll→0) + // NICHT als neue Position gespeichert werden und die gemerkte Position überschreiben. restoringRef.current = true let done = false + let quiet: number | undefined const stop = () => { if (done) return done = true @@ -77,18 +81,27 @@ export function useScrollRestoration() { window.removeEventListener('touchmove', onUser) window.removeEventListener('keydown', onUser) window.clearTimeout(safety) + window.clearTimeout(quiet) } // Sobald die Nutzerin selbst scrollt, brechen wir ab — nie gegen sie ankämpfen. const onUser = () => stop() - const tryScroll = () => { - if (done) return - const el = getScroller() - setTop(el, y) - if (Math.abs(getTop(el) - y) <= 2) stop() // erreicht → fertig + const assert = () => { + if (!done) setTop(getScroller(), y) } - // 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)) + // „Ruhe-Timer": erst freigeben, wenn der Inhalt ~350 ms lang stabil war (keine DOM-Änderung + // mehr) — bis dahin wird die Position bei jeder Änderung erneut gesetzt. + const armQuiet = () => { + window.clearTimeout(quiet) + quiet = window.setTimeout(() => { + assert() + stop() + }, 350) + } + const onMutate = () => { + assert() + armQuiet() + } + const mo = new MutationObserver(onMutate) const passive = { passive: true } as AddEventListenerOptions window.addEventListener('wheel', onUser, passive) window.addEventListener('touchmove', onUser, passive) @@ -96,7 +109,8 @@ export function useScrollRestoration() { const safety = window.setTimeout(stop, RESTORE_OBSERVE_CAP_MS) const scroller = getScroller() if (scroller) mo.observe(scroller, { childList: true, subtree: true }) - requestAnimationFrame(tryScroll) + assert() + armQuiet() }, []) // Auf Navigation reagieren: POP = wiederherstellen, sonst neue Seite oben starten. @@ -118,6 +132,11 @@ export function useScrollRestoration() { useEffect(() => { const onScroll = () => { if (restoringRef.current || document.visibilityState !== 'visible') return + // Beim Seitenwechsel kollabiert der Inhalt kurz (scrollHeight ~ clientHeight) und der + // Container springt auf 0. Diesen „unechten" 0-Sprung NICHT speichern, sonst überschreibt + // er die gemerkte Position kurz bevor wir wegnavigieren. + const el = getScroller() + if (el && el.scrollHeight <= el.clientHeight + 4) return if (rafSaveRef.current != null) return rafSaveRef.current = requestAnimationFrame(() => { rafSaveRef.current = null