fix(nav): Scroll-Position bei Browser-Back robust wiederherstellen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m44s
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:
76
src/lib/client/scroll-restore.ts
Normal file
76
src/lib/client/scroll-restore.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto, afterNavigate } from '$app/navigation';
|
import { goto, afterNavigate, beforeNavigate } from '$app/navigation';
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
CookingPot,
|
CookingPot,
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
import { installPrompt } from '$lib/client/install-prompt.svelte';
|
import { installPrompt } from '$lib/client/install-prompt.svelte';
|
||||||
import { registerServiceWorker } from '$lib/client/sw-register';
|
import { registerServiceWorker } from '$lib/client/sw-register';
|
||||||
import { SearchStore } from '$lib/client/search.svelte';
|
import { SearchStore } from '$lib/client/search.svelte';
|
||||||
|
import { recordScroll, restoreScroll } from '$lib/client/scroll-restore';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
|
|
||||||
@@ -97,7 +98,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
afterNavigate(() => {
|
beforeNavigate(() => {
|
||||||
|
recordScroll();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterNavigate((nav) => {
|
||||||
navStore.reset();
|
navStore.reset();
|
||||||
navOpen = false;
|
navOpen = false;
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
@@ -107,6 +112,7 @@
|
|||||||
// wurde.
|
// wurde.
|
||||||
void wishlistStore.refresh();
|
void wishlistStore.refresh();
|
||||||
void shoppingCartStore.refresh();
|
void shoppingCartStore.refresh();
|
||||||
|
restoreScroll(nav.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|||||||
103
tests/unit/scroll-restore.test.ts
Normal file
103
tests/unit/scroll-restore.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
import { recordScroll, restoreScroll } from '../../src/lib/client/scroll-restore';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'kochwas:scroll';
|
||||||
|
|
||||||
|
function setScrollY(y: number) {
|
||||||
|
Object.defineProperty(window, 'scrollY', { value: y, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDocHeight(h: number) {
|
||||||
|
Object.defineProperty(document.documentElement, 'scrollHeight', {
|
||||||
|
value: h,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setViewportHeight(h: number) {
|
||||||
|
Object.defineProperty(window, 'innerHeight', { value: h, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('scroll-restore', () => {
|
||||||
|
let scrollToSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
setScrollY(0);
|
||||||
|
setViewportHeight(800);
|
||||||
|
setDocHeight(800);
|
||||||
|
scrollToSpy = vi
|
||||||
|
.spyOn(window, 'scrollTo')
|
||||||
|
.mockImplementation(() => undefined as unknown as void);
|
||||||
|
window.history.replaceState({}, '', '/wishlist');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
scrollToSpy.mockRestore();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records scrollY keyed by pathname+search', () => {
|
||||||
|
setScrollY(1200);
|
||||||
|
recordScroll();
|
||||||
|
const map = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}');
|
||||||
|
expect(map['/wishlist']).toBe(1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps separate entries per URL', () => {
|
||||||
|
setScrollY(500);
|
||||||
|
recordScroll();
|
||||||
|
window.history.replaceState({}, '', '/?q=hi');
|
||||||
|
setScrollY(900);
|
||||||
|
recordScroll();
|
||||||
|
const map = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}');
|
||||||
|
expect(map['/wishlist']).toBe(500);
|
||||||
|
expect(map['/?q=hi']).toBe(900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips restore for non-popstate navigation', () => {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1000 }));
|
||||||
|
setDocHeight(5000);
|
||||||
|
restoreScroll('link');
|
||||||
|
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips restore when no entry stored', () => {
|
||||||
|
setDocHeight(5000);
|
||||||
|
restoreScroll('popstate');
|
||||||
|
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips restore for trivial scrollY (noise)', () => {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 10 }));
|
||||||
|
setDocHeight(5000);
|
||||||
|
restoreScroll('popstate');
|
||||||
|
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolls immediately when document is already tall enough', async () => {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1000 }));
|
||||||
|
setDocHeight(5000);
|
||||||
|
restoreScroll('popstate');
|
||||||
|
// rAF resolves on next microtask in jsdom
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||||
|
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, left: 0, behavior: 'instant' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits via rAF until document grows tall enough', async () => {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1500 }));
|
||||||
|
// Initially short — would clamp.
|
||||||
|
setDocHeight(900);
|
||||||
|
restoreScroll('popstate');
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||||
|
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Simulate async data loading and document growing.
|
||||||
|
setDocHeight(3000);
|
||||||
|
// Allow rAF to fire again.
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||||
|
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1500, left: 0, behavior: 'instant' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user