fix(tickets): Tab + Scrollposition überleben Browser-Zurück
Zwei Ursachen, warum es auf der Tickets-Seite nicht ging (am echten Firefox/Chromium gegen die Live-App verifiziert): 1) Tab ging verloren: der gewählte Filter-Tab (z. B. „Geschlossen") war reiner Component-State und sprang nach „Zurück" (Remount) auf „Offen" zurück — also war man in der falschen, kurzen Liste. Jetzt wird der Tab in der Session gemerkt und wiederhergestellt (userPicked startet weiterhin false, damit ?focus=-Deep-Links weiter funktionieren). 2) Scroll-Save-Heuristik zu unzuverlässig: Beim Wegnavigieren schrumpft der Inhalt von .app-main → der Container klemmt auf eine kleinere Position → dieses scroll-Event überschrieb die gemerkte Stelle. Erkennung präzisiert: nur verwerfen, wenn Position UND scrollHeight gleichzeitig SINKEN (= Inhalt geschrumpft/Seitenwechsel). Eine echte Nutzer-Scrollung verkleinert die scrollHeight nie — damit kein fälschliches Verwerfen echter Positionen mehr (vorherige „Höhe geändert"-Heuristik schluckte zu viel). Tests: scroll-restore.spec erweitert (realistisches 2-Schritt-Scrollen + Tickets-Tab überlebt Zurück), desktop+phone grün; vitest 149 grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,8 @@ export function useScrollRestoration() {
|
||||
const pathRef = useRef(pathname)
|
||||
const restoringRef = useRef(false)
|
||||
const rafSaveRef = useRef<number | null>(null)
|
||||
const lastHeightRef = useRef(0)
|
||||
const lastTopRef = useRef(0)
|
||||
|
||||
const keyFor = (p: string) => PREFIX + p
|
||||
|
||||
@@ -66,12 +68,17 @@ 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.
|
||||
// WICHTIG gegen Race: `restoringRef` bleibt aktiv, bis die Zielposition ERREICHT ist (oder
|
||||
// die Nutzerin selbst scrollt / die harte Obergrenze greift). 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.
|
||||
//
|
||||
// KEIN „Ruhe-/Settling-Timer": Listen, die ihre Inhalte erst nach einer kurzen Pause am Stück
|
||||
// rendern (z. B. die Tickets-Liste: ein Fetch, dann alle Karten auf einmal), würden sonst
|
||||
// vorzeitig freigegeben — die Position griffe dann ins Leere. Stattdessen warten wir per
|
||||
// MutationObserver auf JEDE Inhaltsänderung und springen erneut, bis die Position sitzt.
|
||||
restoringRef.current = true
|
||||
let done = false
|
||||
let quiet: number | undefined
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
@@ -81,36 +88,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 assert = () => {
|
||||
if (!done) setTop(getScroller(), y)
|
||||
const tryReach = () => {
|
||||
if (done) return
|
||||
const el = getScroller()
|
||||
setTop(el, y)
|
||||
if (Math.abs(getTop(el) - y) <= 2) stop() // Zielposition sitzt → fertig
|
||||
}
|
||||
// „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)
|
||||
// EVENT-basiert: jede DOM-Änderung im Inhalt (Liste lädt/rendert) → erneut zur Zielposition.
|
||||
const mo = new MutationObserver(() => requestAnimationFrame(tryReach))
|
||||
const passive = { passive: true } as AddEventListenerOptions
|
||||
window.addEventListener('wheel', onUser, passive)
|
||||
window.addEventListener('touchmove', onUser, passive)
|
||||
window.addEventListener('keydown', onUser)
|
||||
// Harte Obergrenze nur als Notausstieg (z. B. wenn die Zielposition nie erreichbar ist, weil
|
||||
// der Inhalt jetzt kürzer ist). Hält bis dahin nur den Speicher-Schutz — harmlos.
|
||||
const safety = window.setTimeout(stop, RESTORE_OBSERVE_CAP_MS)
|
||||
const scroller = getScroller()
|
||||
if (scroller) mo.observe(scroller, { childList: true, subtree: true })
|
||||
assert()
|
||||
armQuiet()
|
||||
requestAnimationFrame(tryReach)
|
||||
}, [])
|
||||
|
||||
// Auf Navigation reagieren: POP = wiederherstellen, sonst neue Seite oben starten.
|
||||
@@ -131,12 +129,23 @@ export function useScrollRestoration() {
|
||||
// Wiederherstellen beim Wiederanzeigen (Handy entsperrt / aus dem Hintergrund).
|
||||
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 (el) {
|
||||
const prevTop = lastTopRef.current
|
||||
const prevHeight = lastHeightRef.current
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
if (restoringRef.current || document.visibilityState !== 'visible') return
|
||||
// ENTSCHEIDEND: Beim Seitenwechsel wird der Inhalt aus-/eingehängt → die scrollHeight
|
||||
// SCHRUMPFT und der Container KLEMMT auf eine kleinere Position. Genau dieser Fall
|
||||
// (Position UND Höhe gleichzeitig gesunken) ist ein „unechter" Sprung — NICHT speichern,
|
||||
// sonst überschreibt er die gemerkte Stelle der Seite, die wir gerade verlassen. Eine
|
||||
// echte Nutzer-Scrollung verkleinert die scrollHeight nie.
|
||||
if (el.scrollTop < prevTop && el.scrollHeight < prevHeight) return
|
||||
if (el.scrollHeight <= el.clientHeight + 4) return // nicht scrollbar
|
||||
} else if (restoringRef.current || document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
if (rafSaveRef.current != null) return
|
||||
rafSaveRef.current = requestAnimationFrame(() => {
|
||||
rafSaveRef.current = null
|
||||
@@ -150,6 +159,8 @@ export function useScrollRestoration() {
|
||||
const onPageShow = () => restore(pathRef.current, false)
|
||||
|
||||
const scroller = getScroller()
|
||||
lastHeightRef.current = scroller?.scrollHeight ?? 0
|
||||
lastTopRef.current = scroller?.scrollTop ?? 0
|
||||
const scrollTarget: HTMLElement | Window = scroller ?? window
|
||||
scrollTarget.addEventListener('scroll', onScroll, { passive: true })
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
|
||||
@@ -260,7 +260,18 @@ export default function TicketsPage() {
|
||||
// Default-Ansicht: „Offen" (neue, noch unbearbeitete Tickets) — fällt auf die erste
|
||||
// nicht-leere Gruppe zurück, falls „Offen" leer ist. Solange die Nutzerin keinen Filter
|
||||
// selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen.
|
||||
const [view, setView] = useState<TicketView>('open')
|
||||
// Der zuletzt gewählte Tab wird in der Session gemerkt, damit man nach „Zurück" (Remount)
|
||||
// NICHT wieder im falschen Tab („Offen") landet. userPicked startet bewusst false, damit ein
|
||||
// frischer ?focus=-Deep-Link weiterhin in den passenden Tab springen darf.
|
||||
const [view, setView] = useState<TicketView>(() => {
|
||||
try {
|
||||
const v = sessionStorage.getItem('tickets-view')
|
||||
if (v === 'dialog' || v === 'open' || v === 'closed' || v === 'deleted') return v
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
return 'open'
|
||||
})
|
||||
const [userPicked, setUserPicked] = useState(false)
|
||||
|
||||
async function handleToggleStatus(ticket: FeedbackTicket) {
|
||||
@@ -551,6 +562,11 @@ export default function TicketsPage() {
|
||||
onClick={() => {
|
||||
setUserPicked(true)
|
||||
setView(v)
|
||||
try {
|
||||
sessionStorage.setItem('tickets-view', v)
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t.filters[v]}
|
||||
|
||||
Reference in New Issue
Block a user