feat(ux): globale Scroll-Wiederherstellung für ALLE Listen/Seiten

Neuer Hook useScrollRestoration (einmal im AppShell gemountet) erhält die
Scrollposition je Route über:
- Zurück/Vorwärts-Navigation (POP restauriert, PUSH startet oben),
- Bildschirm-Sperre / App-Hintergrund (visibilitychange, pagehide/pageshow) —
  damit das Handy nach dem Entsperren nicht mehr oben landet.

Robust gegen nachladende Listen (rAF-Nachjustierung bis ~2,5 s, während die Liste
wächst), bricht bei eigener Scroll-Eingabe ab, Deep-Links (?focus=, #anchor) haben
Vorrang. Die bisher nur in der Tickets-Seite eingebaute Logik wurde durch den
globalen Hook ersetzt.

tsc/eslint/vitest grün; e2e (navigation/listen/tickets, 42) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 13:48:02 +02:00
parent f9e672c707
commit ebc70cc0b8
3 changed files with 136 additions and 103 deletions

View File

@@ -11,17 +11,7 @@
* Rückfragen kommen hierher zurück), „Geschlossen" (Resolved, zeigt den Changelog/fixNote).
* Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS.
*/
import {
createContext,
Fragment,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { createContext, Fragment, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
@@ -272,11 +262,6 @@ export default function TicketsPage() {
// selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen.
const [view, setView] = useState<TicketView>('open')
const [userPicked, setUserPicked] = useState(false)
// Scrollposition über Verlassen/Zurückkehren UND Bildschirm-Sperre/Wiederaufwachen hinweg
// erhalten (Handy sperrt → Seite wird eingefroren/verworfen → kommt sonst oben an). Siehe die
// Scroll-Effekte unten (Speichern beim Ausblenden, Wiederherstellen beim Anzeigen + nach Laden).
const lastScrollRef = useRef(0)
const restoringRef = useRef(false)
async function handleToggleStatus(ticket: FeedbackTicket) {
try {
@@ -448,93 +433,8 @@ export default function TicketsPage() {
return () => clearTimeout(timer)
}, [focusId, displayed])
// Scrollposition laufend merken (außer während wir gerade selbst wiederherstellen, damit ein
// vom Browser beim Aufwachen ausgelöster Sprung nach oben die gemerkte Position nicht killt).
const SCROLL_KEY = 'tickets-scroll-y'
const saveScroll = useCallback(() => {
if (restoringRef.current) return
lastScrollRef.current = window.scrollY
try {
sessionStorage.setItem(SCROLL_KEY, String(window.scrollY))
} catch {
/* sessionStorage nicht verfügbar */
}
}, [])
useEffect(() => {
const onScroll = () => {
if (document.visibilityState !== 'visible') return
saveScroll()
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [saveScroll])
// Position SICHER festhalten, kurz bevor die Seite eingefroren/verworfen wird (Handy-Sperre,
// App-Wechsel, Tab schließen) — da feuert evtl. kein Scroll-Event mehr.
useEffect(() => {
const persist = () => {
try {
sessionStorage.setItem(SCROLL_KEY, String(lastScrollRef.current || window.scrollY))
} catch {
/* ignorieren */
}
}
const onVisibility = () => {
if (document.visibilityState === 'hidden') persist()
}
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('pagehide', persist)
return () => {
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('pagehide', persist)
}
}, [])
// Gemerkte Position wiederherstellen — robust gegen nachwachsende Inhalte (mehrere Versuche per
// rAF) und gegen den Aufwach-Sprung nach oben. ?focus= hat Vorrang.
const restoreScroll = useCallback(() => {
if (focusId) return
let y = NaN
try {
y = Number(sessionStorage.getItem(SCROLL_KEY))
} catch {
/* ignorieren */
}
if (!Number.isFinite(y) || y <= 0) return
restoringRef.current = true
let tries = 0
const tick = () => {
window.scrollTo(0, y)
tries += 1
const close = Math.abs(window.scrollY - y) <= 2
const cannotReach = document.documentElement.scrollHeight <= y + window.innerHeight
if (!close && !cannotReach && tries < 30) {
requestAnimationFrame(tick)
} else {
restoringRef.current = false
}
}
requestAnimationFrame(tick)
}, [focusId])
// Wiederherstellen nach dem Laden der Tickets (frischer Mount / Reload nach Tab-Verwerfung).
useEffect(() => {
if (rows) restoreScroll()
}, [rows, restoreScroll])
// … und beim Wiederanzeigen der Seite (Handy entsperrt / aus dem Hintergrund) OHNE Reload.
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') restoreScroll()
}
const onPageShow = () => restoreScroll()
document.addEventListener('visibilitychange', onVisible)
window.addEventListener('pageshow', onPageShow)
return () => {
document.removeEventListener('visibilitychange', onVisible)
window.removeEventListener('pageshow', onPageShow)
}
}, [restoreScroll])
// (Scroll-Wiederherstellung läuft global über useScrollRestoration im AppShell — gilt für alle
// Listen, inkl. Handy-Sperre/Hintergrund. ?focus= unten hat hier Vorrang.)
// Nackte Tier-IDs in den sichtbaren Tickets sammeln und ihre Namen einmalig auflösen,
// damit die KI im Text einfach eine Tier-ID hinterlegen kann (→ Name + Link zur Akte).