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:
@@ -66,8 +66,12 @@ export function useScrollRestoration() {
|
|||||||
}
|
}
|
||||||
if (!Number.isFinite(y) || y <= 0) return
|
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
|
restoringRef.current = true
|
||||||
let done = false
|
let done = false
|
||||||
|
let quiet: number | undefined
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
if (done) return
|
if (done) return
|
||||||
done = true
|
done = true
|
||||||
@@ -77,18 +81,27 @@ export function useScrollRestoration() {
|
|||||||
window.removeEventListener('touchmove', onUser)
|
window.removeEventListener('touchmove', onUser)
|
||||||
window.removeEventListener('keydown', onUser)
|
window.removeEventListener('keydown', onUser)
|
||||||
window.clearTimeout(safety)
|
window.clearTimeout(safety)
|
||||||
|
window.clearTimeout(quiet)
|
||||||
}
|
}
|
||||||
// Sobald die Nutzerin selbst scrollt, brechen wir ab — nie gegen sie ankämpfen.
|
// Sobald die Nutzerin selbst scrollt, brechen wir ab — nie gegen sie ankämpfen.
|
||||||
const onUser = () => stop()
|
const onUser = () => stop()
|
||||||
const tryScroll = () => {
|
const assert = () => {
|
||||||
if (done) return
|
if (!done) setTop(getScroller(), y)
|
||||||
const el = getScroller()
|
|
||||||
setTop(el, y)
|
|
||||||
if (Math.abs(getTop(el) - y) <= 2) stop() // erreicht → fertig
|
|
||||||
}
|
}
|
||||||
// EVENT-basiert: jede DOM-Änderung im Inhalt (Liste lädt nach / kommt zurück) → erneut zur
|
// „Ruhe-Timer": erst freigeben, wenn der Inhalt ~350 ms lang stabil war (keine DOM-Änderung
|
||||||
// gemerkten Position springen. Zuverlässiger als ein fester Timer.
|
// mehr) — bis dahin wird die Position bei jeder Änderung erneut gesetzt.
|
||||||
const mo = new MutationObserver(() => requestAnimationFrame(tryScroll))
|
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
|
const passive = { passive: true } as AddEventListenerOptions
|
||||||
window.addEventListener('wheel', onUser, passive)
|
window.addEventListener('wheel', onUser, passive)
|
||||||
window.addEventListener('touchmove', onUser, passive)
|
window.addEventListener('touchmove', onUser, passive)
|
||||||
@@ -96,7 +109,8 @@ export function useScrollRestoration() {
|
|||||||
const safety = window.setTimeout(stop, RESTORE_OBSERVE_CAP_MS)
|
const safety = window.setTimeout(stop, RESTORE_OBSERVE_CAP_MS)
|
||||||
const scroller = getScroller()
|
const scroller = getScroller()
|
||||||
if (scroller) mo.observe(scroller, { childList: true, subtree: true })
|
if (scroller) mo.observe(scroller, { childList: true, subtree: true })
|
||||||
requestAnimationFrame(tryScroll)
|
assert()
|
||||||
|
armQuiet()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Auf Navigation reagieren: POP = wiederherstellen, sonst neue Seite oben starten.
|
// Auf Navigation reagieren: POP = wiederherstellen, sonst neue Seite oben starten.
|
||||||
@@ -118,6 +132,11 @@ export function useScrollRestoration() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (restoringRef.current || document.visibilityState !== 'visible') return
|
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
|
if (rafSaveRef.current != null) return
|
||||||
rafSaveRef.current = requestAnimationFrame(() => {
|
rafSaveRef.current = requestAnimationFrame(() => {
|
||||||
rafSaveRef.current = null
|
rafSaveRef.current = null
|
||||||
|
|||||||
Reference in New Issue
Block a user