From 442076a2782c97c0a84a79770c87b9012149882e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:18:00 +0200 Subject: [PATCH] fix(nav): Scroll-Position bei Browser-Back robust wiederherstellen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pages, die ihre Daten in onMount per fetch laden (Home, Wunschliste, Einkaufsliste), waren bei popstate-Navigation kaputt: SvelteKit ruft scrollTo() synchron nach Mount, aber die Listen sind dann noch leer und das Dokument zu kurz — der Browser clamped auf 0. Neuer Helper src/lib/client/scroll-restore.ts merkt scrollY pro URL in sessionStorage (beforeNavigate) und stellt sie bei popstate per rAF- Polling wieder her, sobald document.scrollHeight gross genug ist (Hard-Budget 1.5s, danach best-effort scrollTo). Layout ruft die zwei Helper im beforeNavigate / afterNavigate. Pages mit SSR-Daten (z. B. /recipes) bleiben unbeeinflusst — dort matcht unser Wert SvelteKits eigenen scrollTo bereits beim ersten Frame. Tests: 7 neue Unit-Tests in tests/unit/scroll-restore.test.ts decken Recording, Pro-URL-Trennung, Skip fuer Forward-Nav, sofortiges und verzoegertes Restore ab. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/client/scroll-restore.ts | 76 ++++++++++++++++++++++ src/routes/+layout.svelte | 10 ++- tests/unit/scroll-restore.test.ts | 103 ++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 src/lib/client/scroll-restore.ts create mode 100644 tests/unit/scroll-restore.test.ts diff --git a/src/lib/client/scroll-restore.ts b/src/lib/client/scroll-restore.ts new file mode 100644 index 0000000..e923d3a --- /dev/null +++ b/src/lib/client/scroll-restore.ts @@ -0,0 +1,76 @@ +// Persistent scroll restoration across client navigations. +// +// SvelteKit only restores scroll synchronously after the new page mounts. +// Pages whose content is fetched in onMount/afterNavigate (e.g. home, +// wishlist, shopping-list) are still empty at that point, so the saved +// scrollY can't be reached and the browser clamps to 0. +// +// We patch this by saving scrollY on beforeNavigate and re-applying it +// after popstate as soon as the document is tall enough — using rAF +// polling with a hard time budget so we never spin. + +const STORAGE_KEY = 'kochwas:scroll'; +const POLL_BUDGET_MS = 1500; +const MIN_RESTORE_Y = 40; // ignore noise: don't override a default top scroll + +type ScrollMap = Record; + +function readMap(): ScrollMap { + if (typeof sessionStorage === 'undefined') return {}; + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as ScrollMap) : {}; + } catch { + return {}; + } +} + +function writeMap(map: ScrollMap): void { + if (typeof sessionStorage === 'undefined') return; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + } catch { + // quota exceeded — silently drop, scroll memory is best-effort + } +} + +function urlKey(): string { + if (typeof location === 'undefined') return ''; + return location.pathname + location.search; +} + +export function recordScroll(): void { + if (typeof window === 'undefined') return; + const key = urlKey(); + if (!key) return; + const map = readMap(); + map[key] = window.scrollY; + writeMap(map); +} + +export function restoreScroll(navType: string | null | undefined): void { + if (typeof window === 'undefined') return; + if (navType !== 'popstate') return; + const key = urlKey(); + if (!key) return; + const target = readMap()[key]; + if (!target || target < MIN_RESTORE_Y) return; + + const start = performance.now(); + const step = () => { + const docHeight = document.documentElement.scrollHeight; + const reachable = Math.max(0, docHeight - window.innerHeight); + if (reachable >= target - 4) { + window.scrollTo({ top: target, left: 0, behavior: 'instant' }); + return; + } + if (performance.now() - start >= POLL_BUDGET_MS) { + // Best effort — content never grew tall enough; clamp will land us + // at the bottom of what's available. + window.scrollTo({ top: target, left: 0, behavior: 'instant' }); + return; + } + requestAnimationFrame(step); + }; + requestAnimationFrame(step); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 51ff701..e675128 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,7 +1,7 @@