From f7f4b2b4ba5d7cb06885f541952c23a05e4820ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C5=ABdolfs=20O=C5=A1i=C5=86=C5=A1?= Date: Thu, 2 Jul 2026 17:49:23 +0200 Subject: [PATCH] Revalidate restored lists on history navigation --- src/lib/paginatedList.svelte.ts | 63 +++++++++++++++++++++++++++------ src/views/repo/Issues.svelte | 7 ++-- src/views/repo/Patches.svelte | 7 ++-- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/src/lib/paginatedList.svelte.ts b/src/lib/paginatedList.svelte.ts index e1a35a2e4..153a55c0a 100644 --- a/src/lib/paginatedList.svelte.ts +++ b/src/lib/paginatedList.svelte.ts @@ -34,12 +34,31 @@ export function createPaginatedList(opts: PaginatedListOptions) { ? readListState(opts.key()) : undefined; - let items = $state(restored?.items ?? opts.page().content); + // The snapshot is trusted as a scroll anchor, never as data — its contents + // are as old as the moment the user left. The route loader re-fetches the + // first page on every navigation (including history), so a snapshot no + // deeper than one page is replaced by that fresh page outright, at no + // extra cost. A deeper snapshot seeds the list for instant paint and the + // whole restored window is refetched in the background (see the + // revalidate call below). + let seedItems: T[]; + let seedMore: boolean; + let staleWindow: number | undefined; + if (restored !== undefined && restored.items.length > opts.pageSize) { + seedItems = restored.items; + seedMore = restored.more; + staleWindow = restored.items.length; + } else { + seedItems = opts.page().content; + seedMore = opts.page().more; + } + + let items = $state(seedItems); // Rows fetched so far — used as the pagination offset. Tracked separately // from items.length because appends are deduped (overlapping pages, e.g. // when the list grows underneath us, must still advance the offset). - let cursor = (restored?.items ?? opts.page().content).length; - let more = $state(restored?.more ?? opts.page().more); + let cursor = seedItems.length; + let more = $state(seedMore); let loadingMore = $state(false); let activeKey = opts.key(); @@ -111,19 +130,41 @@ export function createPaginatedList(opts: PaginatedListOptions) { } } - // Refetch the first page and replace the list (e.g. after a cache rebuild), - // aborting any in-flight page load. - async function reload(): Promise { - const page = await loader.run(async () => - opts.fetchPage(0, opts.pageSize), - ); - if (page !== undefined) { + // Refetch the first `window` rows in one request and replace the list + // wholesale: stale-while-revalidate after a history restore, first page + // after a cache rebuild. `loadingMore` stays set for the duration, which + // both surfaces the refresh and keeps the virtualizer's auto load-more + // from racing it and appending a page onto items about to be replaced. + // A newer load (e.g. fuzzy-focus load-all) superseding the refetch is + // fine: every competitor fetches a fresher, same-or-larger window. + async function revalidate(window: number = opts.pageSize): Promise { + loadingMore = true; + let superseded = false; + try { + const page = await loader.run(async () => opts.fetchPage(0, window)); + if (page === undefined) { + superseded = true; + return; + } items = page.content; cursor = page.content.length; more = page.more; + } catch (error) { + // The history-restore call site is fire-and-forget; a failed refresh + // logs and leaves the snapshot on screen rather than rejecting + // unhandled. + console.error("Revalidating the list failed: ", error); + } finally { + if (!superseded) { + loadingMore = false; + } } } + if (staleWindow !== undefined) { + void revalidate(staleWindow); + } + // Persist the loaded list + scroll position so back/forward can restore it. // Only the unfiltered list is cached, so its length matches virtua's size // snapshot. @@ -153,7 +194,7 @@ export function createPaginatedList(opts: PaginatedListOptions) { initialScrollOffset: restored?.scrollOffset, initialCache: restored?.cache, loadMore, - reload, + revalidate, persistScroll, }; } diff --git a/src/views/repo/Issues.svelte b/src/views/repo/Issues.svelte index 17ace4ebc..8253aa3f0 100644 --- a/src/views/repo/Issues.svelte +++ b/src/views/repo/Issues.svelte @@ -72,7 +72,10 @@ }); $effect(() => { - if (list.more === false) { + // Only record counts from settled, fresh data: while a history-restore + // revalidation is in flight (loadingMore), the snapshot's length/more are + // stale and would flag a cache mismatch that isn't there. + if (list.more === false && !list.loadingMore) { updateIssueCounts( list.items.length, { @@ -111,7 +114,7 @@ } catch (error) { console.error(error); } finally { - await list.reload(); + await list.revalidate(); resetIssueCounts(); delay(() => { diff --git a/src/views/repo/Patches.svelte b/src/views/repo/Patches.svelte index c118620c7..0356d428b 100644 --- a/src/views/repo/Patches.svelte +++ b/src/views/repo/Patches.svelte @@ -70,7 +70,10 @@ }); $effect(() => { - if (list.more === false) { + // Only record counts from settled, fresh data: while a history-restore + // revalidation is in flight (loadingMore), the snapshot's length/more are + // stale and would flag a cache mismatch that isn't there. + if (list.more === false && !list.loadingMore) { updatePatchCounts(list.items.length, project.meta.patches, status); } }); @@ -102,7 +105,7 @@ } catch (error) { console.error(error); } finally { - await list.reload(); + await list.revalidate(); resetPatchCounts(); delay(() => {