fix(nav): scroll-restore key auf nav.from.url, nicht location
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m51s

Live-Test auf kochwas-dev offenbarte: bei Browser-Back hat der Browser
die History bereits gepoppt, bevor SvelteKit beforeNavigate feuert —
location.pathname war damit schon die Ziel-URL. recordScroll() schrieb
also den 0-Wert der Recipe-Page in den Slot der Home-Page und wischte
den eigentlich gemerkten Wert (z. B. 500) raus. Restore las dann 0,
fiel unter MIN_RESTORE_Y und tat nichts.

Fix: recordScroll(nav.from?.url) und restoreScroll(type, nav.to?.url).
Helper bekommen die URL explizit reingereicht — keine Abhängigkeit
mehr von location und damit kein Race mit der Browser-History.

Tests: zusätzliche Regression "does not overwrite a stored URL when
called with a different from-url" plus Skip-Pfade fuer null URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 09:28:20 +02:00
parent 442076a278
commit f3e2cebfb4
3 changed files with 57 additions and 32 deletions

View File

@@ -20,6 +20,10 @@ function setViewportHeight(h: number) {
Object.defineProperty(window, 'innerHeight', { value: h, configurable: true });
}
function url(path: string): URL {
return new URL(path, 'https://example.test');
}
describe('scroll-restore', () => {
let scrollToSpy: ReturnType<typeof vi.spyOn>;
@@ -31,7 +35,6 @@ describe('scroll-restore', () => {
scrollToSpy = vi
.spyOn(window, 'scrollTo')
.mockImplementation(() => undefined as unknown as void);
window.history.replaceState({}, '', '/wishlist');
});
afterEach(() => {
@@ -39,64 +42,84 @@ describe('scroll-restore', () => {
vi.useRealTimers();
});
it('records scrollY keyed by pathname+search', () => {
it('records scrollY keyed by from-url pathname+search', () => {
setScrollY(1200);
recordScroll();
recordScroll(url('/wishlist'));
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');
recordScroll(url('/wishlist'));
setScrollY(900);
recordScroll();
recordScroll(url('/?q=hi'));
const map = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}');
expect(map['/wishlist']).toBe(500);
expect(map['/?q=hi']).toBe(900);
});
it('does not overwrite a stored URL when called with a different from-url', () => {
// This is the regression: on popstate, location.pathname is already
// the new URL. Recording must use nav.from.url (the page being left),
// not location, or we wipe the destination's saved scrollY.
setScrollY(500);
recordScroll(url('/'));
setScrollY(0);
recordScroll(url('/recipes/1'));
const map = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}');
expect(map['/']).toBe(500);
expect(map['/recipes/1']).toBe(0);
});
it('skips when from-url is missing', () => {
setScrollY(900);
recordScroll(null);
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('skips restore for non-popstate navigation', () => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1000 }));
setDocHeight(5000);
restoreScroll('link');
restoreScroll('link', url('/wishlist'));
expect(scrollToSpy).not.toHaveBeenCalled();
});
it('skips restore when to-url is missing', () => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1000 }));
restoreScroll('popstate', null);
expect(scrollToSpy).not.toHaveBeenCalled();
});
it('skips restore when no entry stored', () => {
setDocHeight(5000);
restoreScroll('popstate');
restoreScroll('popstate', url('/wishlist'));
expect(scrollToSpy).not.toHaveBeenCalled();
});
it('skips restore for trivial scrollY (noise)', () => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 10 }));
setDocHeight(5000);
restoreScroll('popstate');
restoreScroll('popstate', url('/wishlist'));
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
restoreScroll('popstate', url('/wishlist'));
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');
restoreScroll('popstate', url('/wishlist'));
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' });
});