fix(nav): Scroll-Position bei Browser-Back robust wiederherstellen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m44s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 09:18:00 +02:00
parent 4afc597689
commit 442076a278
3 changed files with 187 additions and 2 deletions

View File

@@ -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<string, number>;
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);
}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto, afterNavigate } from '$app/navigation';
import { goto, afterNavigate, beforeNavigate } from '$app/navigation';
import {
Settings,
CookingPot,
@@ -28,6 +28,7 @@
import { installPrompt } from '$lib/client/install-prompt.svelte';
import { registerServiceWorker } from '$lib/client/sw-register';
import { SearchStore } from '$lib/client/search.svelte';
import { recordScroll, restoreScroll } from '$lib/client/scroll-restore';
let { data, children } = $props();
@@ -97,7 +98,11 @@
}
}
afterNavigate(() => {
beforeNavigate(() => {
recordScroll();
});
afterNavigate((nav) => {
navStore.reset();
navOpen = false;
menuOpen = false;
@@ -107,6 +112,7 @@
// wurde.
void wishlistStore.refresh();
void shoppingCartStore.refresh();
restoreScroll(nav.type);
});
onMount(() => {