fix(ux): Scrollposition nicht durch Seitenwechsel-Kollaps auf 0 überschreiben

Echte Ursache (per Firefox/Chromium gegen die Live-App verifiziert): Beim Wegklicken
eines Listeneintrags wird die Seite ausgehängt → der Inhalt von .app-main kollabiert →
der Container springt auf scrollTop 0 → das löste ein scroll-Event aus, das 0 als neue
Position speicherte und die gemerkte Stelle ZERSTÖRTE, kurz bevor wir wegnavigierten.

Fix:
- scroll-Events ignorieren, wenn der Container gerade NICHT scrollbar ist
  (scrollHeight ~ clientHeight = Inhalt kollabiert/Seitenwechsel) → kein „unechter" 0-Save.
- Wiederherstellung hält den Schutz über die gesamte Settling-Phase: bei jeder DOM-Änderung
  Position erneut setzen, erst nach ~350 ms Ruhe freigeben (gegen Reflow-Races beim Nachladen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:28:32 +02:00
parent 00a3919c9f
commit bb1b8f8c76

View File

@@ -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