Extract shared paginated list state helper

This commit is contained in:
Rūdolfs Ošiņš
2026-07-03 11:33:08 +02:00
parent ce5e270ecb
commit b4855054e4
5 changed files with 237 additions and 353 deletions
-4
View File
@@ -35,7 +35,3 @@ export function saveListState(key: string, state: ListState): void {
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);
}
+159
View File
@@ -0,0 +1,159 @@
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
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";
export interface PaginatedListOptions<T> {
// Scroll-restoration cache key; changes when the list's filter changes.
key: () => string;
// The route loader's freshly fetched first page (reactive prop access).
page: () => PaginatedQuery<T[]>;
// Fetch a page from the backend; `take === undefined` means all rows.
fetchPage: (
skip: number,
take: number | undefined,
) => Promise<PaginatedQuery<T[]>>;
pageSize: number;
// Stable identity used to dedupe overlapping pages.
id: (item: T) => string;
// Skip persisting scroll state (e.g. while a fuzzy filter is active, since
// the rendered rows don't match the cached item list).
skipPersist?: () => boolean;
}
// Paginated list state shared by the Issues/Patches/Commits views: an
// incrementally loaded item list with mutex-guarded page fetches, plus scroll
// restoration for back/forward navigation. Must be called during component
// init (it registers an $effect).
export function createPaginatedList<T>(opts: PaginatedListOptions<T>) {
// 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<T>(opts.key())
: undefined;
let items = $state(restored?.items ?? opts.page().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).
let cursor = (restored?.items ?? opts.page().content).length;
let more = $state(restored?.more ?? opts.page().more);
let loadingMore = $state(false);
let activeKey = opts.key();
const loader = mutexExecutor.create();
const abort = async (): Promise<undefined> => undefined;
// Parent reuses the view across filter changes; a key change resets the
// pagination state from the loader's fresh page.
$effect(() => {
const key = opts.key();
const fresh = opts.page();
// Skip the initial mount (state is seeded above, possibly restored); only
// reset when the key actually changes.
if (key === activeKey) return;
activeKey = key;
items = fresh.content;
cursor = fresh.content.length;
more = fresh.more;
// Abort any in-flight loadMore so it cannot append a page from the
// previous filter onto the just-reset items. The aborted call leaves
// `loadingMore` to its superseder — and here that superseder is this
// abort task, which never clears it, so reset the flag explicitly or
// load-more stays disabled for the new list forever.
void loader.run(abort);
loadingMore = false;
});
async function loadMore(all: boolean = false): Promise<void> {
if (!more) return;
loadingMore = true;
let superseded = false;
try {
const page = await loader.run(async () =>
opts.fetchPage(all ? 0 : cursor, all ? undefined : opts.pageSize),
);
// Superseded by a newer load (e.g. fuzzy-focus triggered a load-all).
// Leave items/more alone for the new call. The flag stays set too: the
// newer call owns it now, and clearing it here would let the
// virtualizer's auto load-more re-fire and abort that call in turn.
if (page === undefined) {
superseded = true;
return;
}
more = page.more;
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.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local set, never rendered
const seen = new Set(items.map(opts.id));
items = [...items, ...page.content.filter(i => !seen.has(opts.id(i)))];
cursor += page.content.length;
}
if (page.content.length === 0) more = false;
} catch (error) {
// Callers fire-and-forget (the virtualizer's load-more trigger), so
// failures are logged here instead of surfacing as unhandled
// rejections; the finally below re-enables the next attempt.
console.error("Loading more list items failed: ", error);
} finally {
if (!superseded) {
loadingMore = false;
}
}
}
// Refetch the first page and replace the list (e.g. after a cache rebuild),
// aborting any in-flight page load.
async function reload(): Promise<void> {
const page = await loader.run(async () =>
opts.fetchPage(0, opts.pageSize),
);
if (page !== undefined) {
items = page.content;
cursor = page.content.length;
more = page.more;
}
}
// 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;
}): void {
if (opts.skipPersist?.()) return;
saveListState(opts.key(), {
items: [...items],
more,
scrollOffset: state.scrollOffset,
cache: state.cache,
});
}
return {
get items() {
return items;
},
get more() {
return more;
},
get loadingMore() {
return loadingMore;
},
initialScrollOffset: restored?.scrollOffset,
initialCache: restored?.cache,
loadMore,
reload,
persistScroll,
};
}
+27 -128
View File
@@ -16,10 +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 { createPaginatedList } from "@app/lib/paginatedList.svelte";
import * as router from "@app/lib/router";
import { modifierKey } from "@app/lib/utils";
@@ -47,29 +45,6 @@
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 filter changes.
// svelte-ignore state_referenced_locally
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 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("");
let debouncedSearch = $state("");
@@ -81,44 +56,25 @@
const project = $derived(repo.payloads["xyz.radicle.project"]!);
const loader = mutexExecutor.create();
const abort = async (): Promise<undefined> => undefined;
$effect(() => {
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);
const list = createPaginatedList<Issue>({
key: () => listKey(status),
page: () => issues,
fetchPage: (skip, take) =>
invoke<PaginatedQuery<Issue[]>>("list_issues", {
rid: repo.rid,
status,
skip,
take,
}),
pageSize: DEFAULT_TAKE,
id: issue => issue.id,
skipPersist: () => searchInput !== "",
});
// 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) {
if (list.more === false) {
updateIssueCounts(
items.length,
list.items.length,
{
...project.meta.issues,
all: project.meta.issues.open + project.meta.issues.closed,
@@ -155,21 +111,7 @@
} catch (error) {
console.error(error);
} finally {
const page = await loader.run(async () => {
return await invoke<PaginatedQuery<Issue[]>>("list_issues", {
rid: repo.rid,
skip: 0,
status,
take: DEFAULT_TAKE,
});
});
if (page !== undefined) {
items = page.content;
cursor = page.content.length;
more = page.more;
}
await list.reload();
resetIssueCounts();
delay(() => {
@@ -178,51 +120,8 @@
}
}
async function loadMoreContent(all: boolean = false): Promise<void> {
if (!more) return;
loadingMore = true;
let superseded = false;
try {
const page = await loader.run(async () => {
return await invoke<PaginatedQuery<Issue[]>>("list_issues", {
rid: repo.rid,
status,
skip: all ? 0 : cursor,
take: all ? undefined : DEFAULT_TAKE,
});
});
// Superseded by a newer load (e.g. fuzzy-focus triggered a load-all).
// Leave items/more alone for the new call. The flag stays set too: the
// newer call owns it now, and clearing it here would let the
// virtualizer's auto load-more re-fire and abort that call in turn.
if (page === undefined) {
superseded = true;
return;
}
more = page.more;
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) {
loadingMore = false;
}
}
}
const searchableIssues = $derived(
items
list.items
.flatMap(i => {
return {
issue: i,
@@ -333,13 +232,13 @@
</div>
<div class="global-flex" style:margin-left="auto" style:gap="0.5rem">
<FuzzySearch
hasItems={items.length > 0}
hasItems={list.items.length > 0}
placeholder={`Fuzzy filter issues ${modifierKey()} + f`}
icon={loading ? "clock" : "filter"}
onFocus={async () => {
try {
loading = true;
await loadMoreContent(true);
await list.loadMore(true);
} catch (e) {
console.error("Loading all issues failed: ", e);
} finally {
@@ -386,7 +285,7 @@
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0}
{#if list.items.length > 0}
No matching issues
{:else}
No {status === "all" ? "" : status} issues
@@ -405,15 +304,15 @@
</div>
<VirtualList
items={searchResults}
hasMore={more}
{loadingMore}
onLoadMore={() => loadMoreContent()}
hasMore={list.more}
loadingMore={list.loadingMore}
onLoadMore={() => list.loadMore()}
startMargin={chromeHeight}
estimatedItemSize={80}
getKey={result => result.obj.issue.id}
{initialCache}
{initialScrollOffset}
onState={persistScroll}>
initialCache={list.initialCache}
initialScrollOffset={list.initialScrollOffset}
onState={list.persistScroll}>
{#snippet row(result)}
<div class="row">
<IssueTeaser
+27 -128
View File
@@ -11,9 +11,7 @@
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 { createPaginatedList } from "@app/lib/paginatedList.svelte";
import {
patchCountMismatch,
resetPatchCounts,
@@ -45,29 +43,6 @@
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 filter changes.
// svelte-ignore state_referenced_locally
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 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("");
let debouncedSearch = $state("");
@@ -79,43 +54,24 @@
const project = $derived(repo.payloads["xyz.radicle.project"]!);
const loader = mutexExecutor.create();
const abort = async (): Promise<undefined> => undefined;
$effect(() => {
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);
const list = createPaginatedList<Patch>({
key: () => listKey(status),
page: () => patches,
fetchPage: (skip, take) =>
invoke<PaginatedQuery<Patch[]>>("list_patches", {
rid: repo.rid,
status,
skip,
take,
}),
pageSize: DEFAULT_TAKE,
id: patch => patch.id,
skipPersist: () => searchInput !== "",
});
// 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);
if (list.more === false) {
updatePatchCounts(list.items.length, project.meta.patches, status);
}
});
@@ -146,21 +102,7 @@
} catch (error) {
console.error(error);
} finally {
const page = await loader.run(async () => {
return await invoke<PaginatedQuery<Patch[]>>("list_patches", {
rid: repo.rid,
skip: 0,
status,
take: DEFAULT_TAKE,
});
});
if (page !== undefined) {
items = page.content;
cursor = page.content.length;
more = page.more;
}
await list.reload();
resetPatchCounts();
delay(() => {
@@ -169,51 +111,8 @@
}
}
async function loadMoreContent(all: boolean = false): Promise<void> {
if (!more) return;
loadingMore = true;
let superseded = false;
try {
const page = await loader.run(async () => {
return await invoke<PaginatedQuery<Patch[]>>("list_patches", {
rid: repo.rid,
status,
skip: all ? 0 : cursor,
take: all ? undefined : DEFAULT_TAKE,
});
});
// Superseded by a newer load (e.g. fuzzy-focus triggered a load-all).
// Leave items/more alone for the new call. The flag stays set too: the
// newer call owns it now, and clearing it here would let the
// virtualizer's auto load-more re-fire and abort that call in turn.
if (page === undefined) {
superseded = true;
return;
}
more = page.more;
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) {
loadingMore = false;
}
}
}
const searchablePatches = $derived(
items
list.items
.flatMap(i => {
return {
patch: i,
@@ -369,13 +268,13 @@
</div>
<div class="global-flex" style:margin-left="auto" style:gap="0.5rem">
<FuzzySearch
hasItems={items.length > 0}
hasItems={list.items.length > 0}
placeholder={`Fuzzy filter patches ${modifierKey()} + f`}
icon={loading ? "clock" : "filter"}
onFocus={async () => {
try {
loading = true;
await loadMoreContent(true);
await list.loadMore(true);
} catch (e) {
console.error("Loading all patches failed: ", e);
} finally {
@@ -414,7 +313,7 @@
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0}
{#if list.items.length > 0}
No matching patches
{:else}
No {status === undefined ? "" : status} patches
@@ -433,15 +332,15 @@
</div>
<VirtualList
items={searchResults}
hasMore={more}
{loadingMore}
onLoadMore={() => loadMoreContent()}
hasMore={list.more}
loadingMore={list.loadingMore}
onLoadMore={() => list.loadMore()}
startMargin={chromeHeight}
estimatedItemSize={80}
getKey={result => result.obj.patch.id}
{initialCache}
{initialScrollOffset}
onState={persistScroll}>
initialCache={list.initialCache}
initialScrollOffset={list.initialScrollOffset}
onState={list.persistScroll}>
{#snippet row(result)}
<div class="row">
<PatchTeaser
+24 -93
View File
@@ -7,9 +7,7 @@
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 { createPaginatedList } from "@app/lib/paginatedList.svelte";
import * as router from "@app/lib/router";
import { modifierKey } from "@app/lib/utils";
@@ -50,60 +48,26 @@
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 peer/revision changes.
// svelte-ignore state_referenced_locally
let items = $state(restored?.items ?? commits.content);
// svelte-ignore state_referenced_locally
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("");
let debouncedSearch = $state("");
let showSearch = $state(false);
const loader = mutexExecutor.create();
const abort = async (): Promise<undefined> => undefined;
$effect(() => {
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);
const list = createPaginatedList<Commit>({
key: () => listKey(),
page: () => commits,
fetchPage: (skip, take) =>
invoke<PaginatedQuery<Commit[]>>("list_repo_commits", {
rid: repo.rid,
head: oid,
skip,
take,
}),
pageSize: COMMITS_PAGE_SIZE,
id: commit => commit.id,
skipPersist: () => searchInput !== "",
});
// 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(() => {
@@ -112,39 +76,6 @@
return () => clearTimeout(timer);
});
async function loadMoreContent(all: boolean = false): Promise<void> {
if (!more) return;
loadingMore = true;
let superseded = false;
try {
const page = await loader.run(async () => {
return await invoke<PaginatedQuery<Commit[]>>("list_repo_commits", {
rid: repo.rid,
head: oid,
skip: all ? 0 : items.length,
take: all ? undefined : COMMITS_PAGE_SIZE,
});
});
// Superseded by a newer load (e.g. fuzzy-focus triggered a load-all).
// Leave items/more alone for the new call. The flag stays set too: the
// newer call owns it now, and clearing it here would let the
// virtualizer's auto load-more re-fire and abort that call in turn.
if (page === undefined) {
superseded = true;
return;
}
more = page.more;
items = all ? page.content : [...items, ...page.content];
if (page.content.length === 0) more = false;
} finally {
if (!superseded) {
loadingMore = false;
}
}
}
function dayKey(timestamp: number) {
const date = new Date(timestamp);
const month = `${date.getMonth() + 1}`.padStart(2, "0");
@@ -178,7 +109,7 @@
}
const searchableCommits = $derived(
items.map(c => ({
list.items.map(c => ({
commit: c,
id: c.id,
summary: c.summary,
@@ -289,14 +220,14 @@
active="commits">
{#snippet extra()}
<FuzzySearch
hasItems={items.length > 0}
hasItems={list.items.length > 0}
placeholder={`Fuzzy filter commits ${modifierKey()} + f`}
styleHeight="1.75rem"
icon={loading ? "clock" : "filter"}
onFocus={async () => {
try {
loading = true;
await loadMoreContent(true);
await list.loadMore(true);
} catch (e) {
console.error("Loading all commits failed: ", e);
} finally {
@@ -326,7 +257,7 @@
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0}
{#if list.items.length > 0}
No matching commits
{:else}
No commits
@@ -336,14 +267,14 @@
{:else}
<VirtualList
items={commitRows}
hasMore={more}
{loadingMore}
onLoadMore={() => loadMoreContent()}
hasMore={list.more}
loadingMore={list.loadingMore}
onLoadMore={() => list.loadMore()}
getKey={r =>
r.type === "header" ? `h:${r.key}` : `c:${r.commit.id}`}
{initialCache}
{initialScrollOffset}
onState={persistScroll}>
initialCache={list.initialCache}
initialScrollOffset={list.initialScrollOffset}
onState={list.persistScroll}>
{#snippet row(item)}
{#if item.type === "header"}
<div class="group-title">{item.label}</div>