feat(home): Pagination-Tiefe per Snapshot, scroll-restore deep-scroll-fest
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled

Bei tiefer Endless-Scroll (z. B. 60 nachgeladene Items) versagte der
generische scroll-restore: nach dem Back-Mount lud onMount() nur die
initialen 10 Items via loadAllMore(), das Dokument blieb ~1000px hoch
und der rAF-Poll konnte die gespeicherte scrollY (z. B. 4000) nie
erreichen — Best-Effort scrollTo clampte auf die erreichbare Hoehe.

Fix per SvelteKit-Snapshot, derselbe Mechanismus den die Page bereits
fuer SearchStore nutzt: Capture nimmt zusaetzlich allRecipes.length,
allSort und allExhausted mit. Restore setzt sort sofort und parkt die
Tiefe in pendingPagination. onMount sieht das Pending und ruft statt
loadAllMore() ein einmaliges rehydrateAll(sort, count, exhausted) —
ein Fetch mit limit=count rehydriert die ganze Liste atomar. Danach
hat das Dokument die Originalhoehe und der Layout-Restore-Poll laesst
die scrollY genau dort landen, wo der User vorher war.

API-Cap (src/routes/api/recipes/all/+server.ts) von 50 auf 200
angehoben — Recipe-Metadaten sind klein (~200 B/Stueck), 200er-
Response ~40 KB. Cap deckt realistische Scroll-Tiefen.

Reload (Cmd-R) behaelt das alte Verhalten: ohne Snapshot greift der
sort-aus-localStorage-Pfad, lade-Sequenz startet wieder bei 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 09:42:16 +02:00
parent f3e2cebfb4
commit 0bfeba2c0a
4 changed files with 1506 additions and 8 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"30c25419-6cff-4007-9cdc-dfbb83eda4c5","pid":31160,"acquiredAt":1776842935855}

1443
ci-log.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -42,10 +42,51 @@
let allChips: HTMLElement | undefined = $state(); let allChips: HTMLElement | undefined = $state();
let allObserver: IntersectionObserver | null = null; let allObserver: IntersectionObserver | null = null;
export const snapshot: Snapshot<SearchSnapshot> = { // Snapshot persists across history navigation. We capture not only the
capture: () => store.captureSnapshot(), // search store, but also the pagination depth ("user had loaded 60
restore: (s) => store.restoreSnapshot(s) // recipes via infinite scroll") so on back-nav we can re-hydrate the
// full list in one fetch — otherwise the document is too short and
// scroll-restore can't reach the saved Y position.
type HomeSnapshot = SearchSnapshot & {
allLoaded: number;
allSort: AllSort;
allExhausted: boolean;
}; };
let pendingPagination: { count: number; sort: AllSort; exhausted: boolean } | null = null;
export const snapshot: Snapshot<HomeSnapshot> = {
capture: () => ({
...store.captureSnapshot(),
allLoaded: allRecipes.length,
allSort,
allExhausted
}),
restore: (s) => {
store.restoreSnapshot(s);
if (s.allLoaded > 0) {
pendingPagination = {
count: s.allLoaded,
sort: s.allSort,
exhausted: s.allExhausted
};
allSort = s.allSort;
}
}
};
async function rehydrateAll(sort: AllSort, count: number, exhausted: boolean) {
allLoading = true;
try {
const res = await fetch(`/api/recipes/all?sort=${sort}&limit=${count}&offset=0`);
if (!res.ok) return;
const body = await res.json();
const hits = body.hits as SearchHit[];
allRecipes = hits;
allExhausted = exhausted || hits.length < count;
} finally {
allLoading = false;
}
}
async function loadRecent() { async function loadRecent() {
const res = await fetch('/api/recipes/search'); const res = await fetch('/api/recipes/search');
@@ -120,11 +161,20 @@
if (urlQ) store.query = urlQ; if (urlQ) store.query = urlQ;
void loadRecent(); void loadRecent();
void searchFilterStore.load(); void searchFilterStore.load();
if (pendingPagination) {
// Back-navigation: restore the full loaded depth in one shot so
// the document is tall enough for scroll-restore to land. Sort
// already came from the snapshot — don't read localStorage here.
const p = pendingPagination;
pendingPagination = null;
void rehydrateAll(p.sort, p.count, p.exhausted);
} else {
const saved = localStorage.getItem('kochwas.allSort'); const saved = localStorage.getItem('kochwas.allSort');
if (saved && ['name', 'rating', 'cooked', 'created'].includes(saved)) { if (saved && ['name', 'rating', 'cooked', 'created'].includes(saved)) {
allSort = saved as AllSort; allSort = saved as AllSort;
} }
void loadAllMore(); void loadAllMore();
}
}); });
// IntersectionObserver an den Sentinel hängen — wenn sichtbar, nachladen. // IntersectionObserver an den Sentinel hängen — wenn sichtbar, nachladen.

View File

@@ -11,7 +11,11 @@ const VALID_SORTS = new Set<AllRecipesSort>(['name', 'rating', 'cooked', 'create
export const GET: RequestHandler = async ({ url }) => { export const GET: RequestHandler = async ({ url }) => {
const sortRaw = (url.searchParams.get('sort') ?? 'name') as AllRecipesSort; const sortRaw = (url.searchParams.get('sort') ?? 'name') as AllRecipesSort;
if (!VALID_SORTS.has(sortRaw)) error(400, { message: 'Invalid sort' }); if (!VALID_SORTS.has(sortRaw)) error(400, { message: 'Invalid sort' });
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10))); // Cap is 200 (not 10's typical paging step) to support snapshot-based
// pagination restore on /+page.svelte: when the user navigates back
// after deep infinite-scroll, we re-hydrate the full loaded count in
// one round-trip so document height matches and scroll-restore lands.
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0)); const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
const hits = listAllRecipesPaginated(getDb(), sortRaw, limit, offset); const hits = listAllRecipesPaginated(getDb(), sortRaw, limit, offset);
return json({ sort: sortRaw, limit, offset, hits }); return json({ sort: sortRaw, limit, offset, hits });