Restore list scroll position on back/forward

Navigating away from a list and back via history (the sidebar
back/forward buttons) returns to the same scroll position with the
previously loaded pages intact, instead of refetching only the first
page at the top.
This commit is contained in:
Rūdolfs Ošiņš
2026-07-03 11:27:58 +02:00
parent 2bc474b9ce
commit 32b1d0e272
7 changed files with 355 additions and 83 deletions
+4 -4
View File
@@ -74,7 +74,7 @@ impl PatchStorage for Sqlite {
) AS last_revision_timestamp
FROM patches AS p
WHERE repo = ?1
ORDER BY last_revision_timestamp DESC;
ORDER BY last_revision_timestamp DESC, id DESC;
",
)?;
stmt.bind((1, &rid))?;
@@ -99,7 +99,7 @@ impl PatchStorage for Sqlite {
FROM patches AS p
WHERE repo = ?1
AND patch->>'$.state.status' = ?2
ORDER BY last_revision_timestamp DESC;
ORDER BY last_revision_timestamp DESC, id DESC;
",
)?;
stmt.bind((1, &rid))?;
@@ -125,7 +125,7 @@ impl IssueStorage for Sqlite {
) AS created_timestamp
FROM issues AS i
WHERE repo = ?1
ORDER BY created_timestamp DESC;
ORDER BY created_timestamp DESC, id DESC;
",
)?;
stmt.bind((1, &rid))?;
@@ -150,7 +150,7 @@ impl IssueStorage for Sqlite {
FROM issues AS i
WHERE repo = ?1
AND issue->>'$.state.status' = ?2
ORDER BY created_timestamp DESC;
ORDER BY created_timestamp DESC, id DESC;
",
)?;
stmt.bind((1, &rid))?;
+39 -1
View File
@@ -1,8 +1,11 @@
<script lang="ts" generics="T">
import type { Snippet } from "svelte";
import type { VirtualizerHandle } from "virtua/svelte";
import { Virtualizer } from "virtua/svelte";
import type { ListCacheSnapshot } from "@app/lib/listState";
import { getScrollViewport } from "@app/components/ScrollArea.svelte";
interface Props {
@@ -20,6 +23,14 @@
startMargin?: number;
// Distance from the bottom (in pixels) at which to request the next page.
prefetchPx?: number;
// Restore a previously-captured scroll position (used on history nav).
initialCache?: ListCacheSnapshot;
initialScrollOffset?: number;
// Reports the current scroll position so callers can persist it.
onState?: (state: {
scrollOffset: number;
cache: ListCacheSnapshot;
}) => void;
}
const {
@@ -33,6 +44,9 @@
bufferSize = undefined,
startMargin = undefined,
prefetchPx = 800,
initialCache = undefined,
initialScrollOffset = undefined,
onState = undefined,
}: Props = $props();
const getViewport = getScrollViewport();
@@ -40,6 +54,27 @@
// `initialized`, which is deferred).
const viewport = $derived(getViewport());
let handle = $state<VirtualizerHandle | undefined>(undefined);
let restored = false;
$effect(() => {
// Restore the saved scroll offset once, after the virtualizer has mounted
// with the restored size cache (so the offset maps to the right item).
if (restored || !handle) return;
restored = true;
if (initialScrollOffset) {
handle.scrollTo(initialScrollOffset);
}
});
function reportState() {
if (!handle || !onState) return;
onState({
scrollOffset: handle.getScrollOffset(),
cache: handle.getCache(),
});
}
function maybeLoadMore() {
if (!onLoadMore || !hasMore || loadingMore) return;
const el = viewport;
@@ -62,13 +97,16 @@
{#if viewport}
<Virtualizer
bind:this={handle}
data={items}
scrollRef={viewport}
{getKey}
itemSize={estimatedItemSize}
{bufferSize}
{startMargin}
onscroll={maybeLoadMore}>
cache={initialCache}
onscroll={maybeLoadMore}
onscrollend={reportState}>
{#snippet children(item: T, index: number)}
{@render row(item, index)}
{/snippet}
+41
View File
@@ -0,0 +1,41 @@
import type { VirtualizerHandle } from "virtua/svelte";
// virtua's cache snapshot is opaque; derive its type from the handle so we
// don't depend on a non-public export.
export type ListCacheSnapshot = ReturnType<VirtualizerHandle["getCache"]>;
export interface ListState<T = unknown> {
// The full set of loaded (paginated) items, so a restored list isn't limited
// to the first page the route loader re-fetches.
items: T[];
more: boolean;
scrollOffset: number;
// virtua measurement snapshot, so item sizes (and thus the offset) are exact
// immediately on restore instead of drifting as rows are re-measured.
cache: ListCacheSnapshot | undefined;
}
// Keep only a handful of recently-visited lists; this is a scroll-restoration
// convenience, not a persistent store.
const MAX_ENTRIES = 20;
const states = new Map<string, ListState>();
export function saveListState(key: string, state: ListState): void {
// Re-insert to mark as most-recently-used.
states.delete(key);
states.set(key, state);
if (states.size > MAX_ENTRIES) {
const oldest = states.keys().next().value;
if (oldest !== undefined) {
states.delete(oldest);
}
}
}
export function readListState<T>(key: string): ListState<T> | undefined {
return states.get(key) as ListState<T> | undefined;
}
export function clearListState(key: string): void {
states.delete(key);
}
+27 -2
View File
@@ -17,6 +17,16 @@ export const activeUnloadedRouteStore = writable<Route>(InitialStore);
let currentUrl: URL | undefined;
// Set while a back/forward (popstate) navigation is in flight, then frozen onto
// `historyNavigation` for the route that becomes active, so views can choose to
// restore prior scroll state only for history navigations.
let pendingHistoryNavigation = false;
let historyNavigation = false;
export function isHistoryNavigation(): boolean {
return historyNavigation;
}
export async function loadFromLocation(): Promise<void> {
await navigateToUrl("replace", new URL(window.location.href));
}
@@ -36,6 +46,10 @@ async function navigateToUrl(
currentUrl.pathname === pathname &&
currentUrl.search === url.search
) {
// The navigation this flag was set for ends here; without the reset a
// same-URL popstate would leak it onto the next unrelated navigation,
// which would then wrongly restore cached list state.
pendingHistoryNavigation = false;
return;
}
@@ -51,7 +65,10 @@ async function navigateToUrl(
}
}
const offPopstate = on(window, "popstate", () => loadFromLocation());
const offPopstate = on(window, "popstate", () => {
pendingHistoryNavigation = true;
return loadFromLocation();
});
const offNavigateAnchor = on(document, "click", e => {
const [anchor] = e
@@ -79,10 +96,17 @@ async function navigate(
action: "push" | "replace",
newRoute: Route,
): Promise<void> {
const historyNav = pendingHistoryNavigation;
pendingHistoryNavigation = false;
isLoading.set(true);
const path = routeToPath(newRoute);
if (action === "push") {
if (
action === "push" &&
path !== window.location.pathname + window.location.search
) {
// Pushing the route that is already active would mint a duplicate
// history entry, making Back appear to do nothing.
window.history.pushState(newRoute, "", path);
} else if (action === "replace") {
window.history.replaceState(newRoute, "");
@@ -99,6 +123,7 @@ async function navigate(
return;
}
historyNavigation = historyNav;
activeRouteStore.set(loadedRoute);
activeUnloadedRouteStore.set(newRoute);
isLoading.set(false);
+99 -35
View File
@@ -16,6 +16,8 @@
resetIssueCounts,
updateIssueCounts,
} from "@app/lib/issueCounts.svelte";
import type { ListCacheSnapshot } from "@app/lib/listState";
import { readListState, saveListState } from "@app/lib/listState";
import { show } from "@app/lib/modal";
import * as mutexExecutor from "@app/lib/mutexExecutor";
import * as router from "@app/lib/router";
@@ -41,12 +43,32 @@
const { repo, issues, status }: Props = $props();
function listKey(filter: IssueStatus): string {
return `repo.issues:${repo.rid}:${filter}`;
}
// Restore the prior list and scroll position only when arriving via
// back/forward; a fresh navigation (sidebar, links) starts from the top.
// svelte-ignore state_referenced_locally
const restored = router.isHistoryNavigation()
? readListState<Issue>(listKey(status))
: undefined;
// Parent reuses this component across status filter changes; a sibling
// $effect resets pagination state when the issues prop changes.
// $effect resets pagination state when the filter changes.
// svelte-ignore state_referenced_locally
let items = $state(issues.content);
let items = $state(restored?.items ?? issues.content);
// 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).
// svelte-ignore state_referenced_locally
let more = $state(issues.more);
let cursor = $state((restored?.items ?? issues.content).length);
// svelte-ignore state_referenced_locally
let more = $state(restored?.more ?? issues.more);
const initialScrollOffset = restored?.scrollOffset;
const initialCache = restored?.cache;
// svelte-ignore state_referenced_locally
let activeKey = listKey(status);
let loadingMore = $state(false);
let loading = $state(false);
let searchInput = $state("");
@@ -63,13 +85,36 @@
const abort = async (): Promise<undefined> => undefined;
$effect(() => {
items = issues.content;
more = issues.more;
const key = listKey(status);
const fresh = issues;
// Skip the initial mount (state is seeded above, possibly restored); only
// reset when the filter actually changes.
if (key === activeKey) return;
activeKey = key;
items = fresh.content;
cursor = fresh.content.length;
more = fresh.more;
// Abort any in-flight loadMoreContent so it cannot append a page
// from the previous filter onto the just-reset items.
void loader.run(abort);
});
// 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.
function persistScroll(state: {
scrollOffset: number;
cache: ListCacheSnapshot;
}) {
if (searchInput !== "") return;
saveListState(listKey(status), {
items: [...items],
more,
scrollOffset: state.scrollOffset,
cache: state.cache,
});
}
$effect(() => {
if (more === false) {
updateIssueCounts(
@@ -121,6 +166,7 @@
if (page !== undefined) {
items = page.content;
cursor = page.content.length;
more = page.more;
}
@@ -141,7 +187,7 @@
return await invoke<PaginatedQuery<Issue[]>>("list_issues", {
rid: repo.rid,
status,
skip: all ? 0 : items.length,
skip: all ? 0 : cursor,
take: all ? undefined : DEFAULT_TAKE,
});
});
@@ -156,7 +202,17 @@
}
more = page.more;
items = all ? page.content : [...items, ...page.content];
if (all) {
items = page.content;
cursor = page.content.length;
} else {
// Drop ids already shown so duplicate keys never reach the virtual
// list (which would leave blank, persistent gaps); still advance the
// offset by the rows fetched so paging keeps moving forward.
const seen = new Set(items.map(i => i.id));
items = [...items, ...page.content.filter(i => !seen.has(i.id))];
cursor += page.content.length;
}
if (page.content.length === 0) more = false;
} finally {
if (!superseded) {
@@ -315,33 +371,38 @@
</div>
</Topbar>
<ScrollArea style="height: 100%; min-width: 0;">
<div bind:clientHeight={chromeHeight}>
{#if issueCountMismatch(status)}
<CobCacheWarning
noun="issues"
{cacheState}
onRebuild={rebuildIssueCache} />
{/if}
</div>
{#if searchResults.length === 0}
{#if searchResults.length === 0}
{#if issueCountMismatch(status)}
<CobCacheWarning
noun="issues"
{cacheState}
onRebuild={rebuildIssueCache} />
{/if}
<div
class="global-flex"
style:flex="1"
style:justify-content="center"
style:align-items="center">
<div
class="global-flex"
style:flex="1"
style:justify-content="center"
style:align-items="center">
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0}
No matching issues
{:else}
No {status === "all" ? "" : status} issues
{/if}
</div>
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0}
No matching issues
{:else}
No {status === "all" ? "" : status} issues
{/if}
</div>
</div>
{:else}
<ScrollArea style="height: 100%; min-width: 0;">
<div bind:clientHeight={chromeHeight}>
{#if issueCountMismatch(status)}
<CobCacheWarning
noun="issues"
{cacheState}
onRebuild={rebuildIssueCache} />
{/if}
</div>
{:else}
<VirtualList
items={searchResults}
hasMore={more}
@@ -349,7 +410,10 @@
onLoadMore={() => loadMoreContent()}
startMargin={chromeHeight}
estimatedItemSize={80}
getKey={result => result.obj.issue.id}>
getKey={result => result.obj.issue.id}
{initialCache}
{initialScrollOffset}
onState={persistScroll}>
{#snippet row(result)}
<div class="row">
<IssueTeaser
@@ -360,7 +424,7 @@
</div>
{/snippet}
</VirtualList>
{/if}
</ScrollArea>
</ScrollArea>
{/if}
</div>
</Layout>
+99 -35
View File
@@ -11,6 +11,8 @@
import delay from "lodash/delay";
import { invoke } from "@app/lib/invoke";
import type { ListCacheSnapshot } from "@app/lib/listState";
import { readListState, saveListState } from "@app/lib/listState";
import * as mutexExecutor from "@app/lib/mutexExecutor";
import {
patchCountMismatch,
@@ -39,12 +41,32 @@
const { repo, patches, status }: Props = $props();
function listKey(filter: PatchStatus | undefined): string {
return `repo.patches:${repo.rid}:${filter ?? "all"}`;
}
// Restore the prior list and scroll position only when arriving via
// back/forward; a fresh navigation (sidebar, links) starts from the top.
// svelte-ignore state_referenced_locally
const restored = router.isHistoryNavigation()
? readListState<Patch>(listKey(status))
: undefined;
// Parent reuses this component across status filter changes; a sibling
// $effect resets pagination state when the patches prop changes.
// $effect resets pagination state when the filter changes.
// svelte-ignore state_referenced_locally
let items = $state(patches.content);
let items = $state(restored?.items ?? patches.content);
// 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).
// svelte-ignore state_referenced_locally
let more = $state(patches.more);
let cursor = $state((restored?.items ?? patches.content).length);
// svelte-ignore state_referenced_locally
let more = $state(restored?.more ?? patches.more);
const initialScrollOffset = restored?.scrollOffset;
const initialCache = restored?.cache;
// svelte-ignore state_referenced_locally
let activeKey = listKey(status);
let loadingMore = $state(false);
let loading = $state(false);
let searchInput = $state("");
@@ -61,13 +83,36 @@
const abort = async (): Promise<undefined> => undefined;
$effect(() => {
items = patches.content;
more = patches.more;
const key = listKey(status);
const fresh = patches;
// Skip the initial mount (state is seeded above, possibly restored); only
// reset when the filter actually changes.
if (key === activeKey) return;
activeKey = key;
items = fresh.content;
cursor = fresh.content.length;
more = fresh.more;
// Abort any in-flight loadMoreContent so it cannot append a page
// from the previous filter onto the just-reset items.
void loader.run(abort);
});
// 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.
function persistScroll(state: {
scrollOffset: number;
cache: ListCacheSnapshot;
}) {
if (searchInput !== "") return;
saveListState(listKey(status), {
items: [...items],
more,
scrollOffset: state.scrollOffset,
cache: state.cache,
});
}
$effect(() => {
if (more === false) {
updatePatchCounts(items.length, project.meta.patches, status);
@@ -112,6 +157,7 @@
if (page !== undefined) {
items = page.content;
cursor = page.content.length;
more = page.more;
}
@@ -132,7 +178,7 @@
return await invoke<PaginatedQuery<Patch[]>>("list_patches", {
rid: repo.rid,
status,
skip: all ? 0 : items.length,
skip: all ? 0 : cursor,
take: all ? undefined : DEFAULT_TAKE,
});
});
@@ -147,7 +193,17 @@
}
more = page.more;
items = all ? page.content : [...items, ...page.content];
if (all) {
items = page.content;
cursor = page.content.length;
} else {
// Drop ids already shown so duplicate keys never reach the virtual
// list (which would leave blank, persistent gaps); still advance the
// offset by the rows fetched so paging keeps moving forward.
const seen = new Set(items.map(i => i.id));
items = [...items, ...page.content.filter(i => !seen.has(i.id))];
cursor += page.content.length;
}
if (page.content.length === 0) more = false;
} finally {
if (!superseded) {
@@ -343,33 +399,38 @@
</div>
</Topbar>
<ScrollArea style="height: 100%; min-width: 0;">
<div bind:clientHeight={chromeHeight}>
{#if patchCountMismatch(status)}
<CobCacheWarning
noun="patches"
{cacheState}
onRebuild={rebuildPatchCache} />
{/if}
</div>
{#if searchResults.length === 0}
{#if searchResults.length === 0}
{#if patchCountMismatch(status)}
<CobCacheWarning
noun="patches"
{cacheState}
onRebuild={rebuildPatchCache} />
{/if}
<div
class="global-flex"
style:flex="1"
style:justify-content="center"
style:align-items="center">
<div
class="global-flex"
style:flex="1"
style:justify-content="center"
style:align-items="center">
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0}
No matching patches
{:else}
No {status === undefined ? "" : status} patches
{/if}
</div>
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0}
No matching patches
{:else}
No {status === undefined ? "" : status} patches
{/if}
</div>
</div>
{:else}
<ScrollArea style="height: 100%; min-width: 0;">
<div bind:clientHeight={chromeHeight}>
{#if patchCountMismatch(status)}
<CobCacheWarning
noun="patches"
{cacheState}
onRebuild={rebuildPatchCache} />
{/if}
</div>
{:else}
<VirtualList
items={searchResults}
hasMore={more}
@@ -377,7 +438,10 @@
onLoadMore={() => loadMoreContent()}
startMargin={chromeHeight}
estimatedItemSize={80}
getKey={result => result.obj.patch.id}>
getKey={result => result.obj.patch.id}
{initialCache}
{initialScrollOffset}
onState={persistScroll}>
{#snippet row(result)}
<div class="row">
<PatchTeaser
@@ -388,7 +452,7 @@
</div>
{/snippet}
</VirtualList>
{/if}
</ScrollArea>
</ScrollArea>
{/if}
</div>
</Layout>
+46 -6
View File
@@ -7,6 +7,8 @@
import fuzzysort from "fuzzysort";
import { invoke } from "@app/lib/invoke";
import type { ListCacheSnapshot } from "@app/lib/listState";
import { readListState, saveListState } from "@app/lib/listState";
import * as mutexExecutor from "@app/lib/mutexExecutor";
import * as router from "@app/lib/router";
import { modifierKey } from "@app/lib/utils";
@@ -44,12 +46,25 @@
commits: Commit[];
};
function listKey(): string {
return `repo.commits:${repo.rid}:${peer ?? ""}:${revision ?? ""}`;
}
// Restore the prior list and scroll position only when arriving via
// back/forward; a fresh navigation (sidebar, links) starts from the top.
const restored = router.isHistoryNavigation()
? readListState<Commit>(listKey())
: undefined;
// Parent reuses this component across navigations; a sibling $effect resets
// pagination state when the commits prop changes.
// pagination state when the peer/revision changes.
// svelte-ignore state_referenced_locally
let items = $state(commits.content);
let items = $state(restored?.items ?? commits.content);
// svelte-ignore state_referenced_locally
let more = $state(commits.more);
let more = $state(restored?.more ?? commits.more);
const initialScrollOffset = restored?.scrollOffset;
const initialCache = restored?.cache;
let activeKey = listKey();
let loadingMore = $state(false);
let loading = $state(false);
let searchInput = $state("");
@@ -60,13 +75,35 @@
const abort = async (): Promise<undefined> => undefined;
$effect(() => {
items = commits.content;
more = commits.more;
const key = listKey();
const fresh = commits;
// Skip the initial mount (state is seeded above, possibly restored); only
// reset when the peer/revision changes.
if (key === activeKey) return;
activeKey = key;
items = fresh.content;
more = fresh.more;
// Abort any in-flight loadMoreContent so it cannot append a page
// from the previous navigation onto the just-reset items.
void loader.run(abort);
});
// 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.
function persistScroll(state: {
scrollOffset: number;
cache: ListCacheSnapshot;
}) {
if (searchInput !== "") return;
saveListState(listKey(), {
items: [...items],
more,
scrollOffset: state.scrollOffset,
cache: state.cache,
});
}
$effect(() => {
const value = searchInput;
const timer = setTimeout(() => {
@@ -295,7 +332,10 @@
{loadingMore}
onLoadMore={() => loadMoreContent()}
getKey={r =>
r.type === "header" ? `h:${r.key}` : `c:${r.commit.id}`}>
r.type === "header" ? `h:${r.key}` : `c:${r.commit.id}`}
{initialCache}
{initialScrollOffset}
onState={persistScroll}>
{#snippet row(item)}
{#if item.type === "header"}
<div class="group-title">{item.label}</div>