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:
2026-06-23 14:54:55 +02:00
parent 54cc493230
commit 8aaddea4ef
3 changed files with 99 additions and 40 deletions

View File

@@ -8,7 +8,7 @@
* Gescrollt wird der Container `.app-main` (nicht das Fenster). Läuft in beiden Projekten
* (desktop + phone) — die Rennmausliste ist in beiden Viewports hoch genug zum Scrollen.
*/
import { test, expect, skipUnlessMock } from './fixtures'
import { test, expect, skipUnlessMock, de } from './fixtures'
const SCROLLER = '.app-main'
@@ -16,6 +16,26 @@ async function scrollerTop(page: import('@playwright/test').Page) {
return page.evaluate((sel) => document.querySelector(sel)?.scrollTop ?? 0, SCROLLER)
}
/**
* Realistisch runterscrollen: ein Warm-up-Schritt (synchronisiert die gemerkte Höhe nach
* dem ersten Render) + der eigentliche Schritt — wie echtes Scrollen, das viele Events feuert.
*/
async function scrollDown(page: import('@playwright/test').Page, target: number) {
await page.evaluate((sel) => {
const el = document.querySelector(sel)
if (el) el.scrollTop = 60
}, SCROLLER)
await page.waitForTimeout(80)
await page.evaluate(
([sel, t]) => {
const el = document.querySelector(sel as string)
if (el) el.scrollTop = Math.min(t as number, el.scrollHeight - el.clientHeight)
},
[SCROLLER, target] as const,
)
await page.waitForTimeout(150) // rAF-gedrosseltes Speichern abwarten
}
test.describe('Scroll-Wiederherstellung', () => {
test.beforeEach(() => skipUnlessMock())
@@ -32,11 +52,7 @@ test.describe('Scroll-Wiederherstellung', () => {
expect(scrollable, 'Liste muss scrollbar sein').toBeTruthy()
// Ein Stück runterscrollen.
await page.evaluate((sel) => {
const el = document.querySelector(sel)
if (el) el.scrollTop = Math.min(400, el.scrollHeight - el.clientHeight)
}, SCROLLER)
await page.waitForTimeout(150) // rAF-gedrosseltes Speichern abwarten
await scrollDown(page, 400)
const before = await scrollerTop(page)
expect(before).toBeGreaterThan(50)
@@ -65,11 +81,7 @@ test.describe('Scroll-Wiederherstellung', () => {
test('Rennmausliste: Position überlebt App-Hintergrund (visibilitychange)', async ({ page }) => {
await page.goto('/rennmaeuse')
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
await page.evaluate((sel) => {
const el = document.querySelector(sel)
if (el) el.scrollTop = Math.min(400, el.scrollHeight - el.clientHeight)
}, SCROLLER)
await page.waitForTimeout(150)
await scrollDown(page, 400)
const before = await scrollerTop(page)
test.skip(before < 50, 'Liste in diesem Viewport nicht hoch genug')
@@ -93,4 +105,24 @@ test.describe('Scroll-Wiederherstellung', () => {
.poll(async () => scrollerTop(page), { timeout: 4000 })
.toBeGreaterThan(before - 30)
})
test('Tickets: gewählter Tab überlebt Browser-Zurück (nicht zurück auf „Offen")', async ({ page }) => {
const tt = de.feedback.tickets
await page.goto('/hilfe/tickets')
// In die „Geschlossen"-Ansicht wechseln.
await page.locator('.tickets-filter').filter({ hasText: tt.filters.closed }).click()
await expect(
page.locator('.tickets-filter--active').filter({ hasText: tt.filters.closed }),
).toBeVisible()
// Über die Brotkrümel weg und per Browser-Zurück wieder her.
await page.locator('.tickets-breadcrumb a[href="/hilfe"]').click()
await expect(page).toHaveURL(/\/hilfe$/)
await page.goBack()
// Tab muss weiterhin „Geschlossen" sein (vorher sprang er zurück auf „Offen").
await expect(
page.locator('.tickets-filter--active').filter({ hasText: tt.filters.closed }),
).toBeVisible()
})
})

View File

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

View File

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