Skip to content

Commit 62ba3fb

Browse files
authored
Persist xDS list pagination in the URL so it survives navigation (#1348)
Motivation: The xDS group list (and the per-group resource and history lists) kept their pagination only in TanStack Table's in-memory state. Modifications: - Add a reusable useUrlPagination hook (webapp/src/dogma/common/components/table/useUrlPagination.ts) that mirrors pageIndex/pageSize into the URL query (`page`, 1-indexed, and `pageSize`) with shallow routing, omits both params while at their defaults, and reads them back through a router.isReady-gated effect so the state is restored on mount and on Back/Forward. The valid page sizes are held in a ref so an inline `pageSizes` array cannot turn the sync effect into a render loop. Result: - xDS list pagination now survives navigation.
1 parent 5afa110 commit 62ba3fb

10 files changed

Lines changed: 684 additions & 62 deletions

File tree

webapp/src/dogma/common/components/table/DataTableClientPagination.tsx

Lines changed: 4 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,16 @@ import {
66
getFilteredRowModel,
77
getPaginationRowModel,
88
getSortedRowModel,
9-
PaginationState,
109
SortingState,
1110
useReactTable,
1211
} from '@tanstack/react-table';
1312
import { DataTable } from 'dogma/common/components/table/DataTable';
1413
import { Filter } from 'dogma/common/components/table/Filter';
1514
import { PAGE_SIZES, PaginationBar } from 'dogma/common/components/table/PaginationBar';
16-
import { useCallback, useEffect, useState } from 'react';
15+
import { useUrlPagination } from 'dogma/common/components/table/useUrlPagination';
16+
import { useState } from 'react';
1717
import { useRouter } from 'next/router';
1818

19-
const VALID_PAGE_SIZES: Set<number> = new Set(PAGE_SIZES);
20-
21-
function parsePageIndex(value: string | string[] | undefined): number {
22-
const n = Number(value);
23-
return Number.isInteger(n) && n > 0 ? n - 1 : 0;
24-
}
25-
26-
function parsePageSize(value: string | string[] | undefined): number {
27-
const n = Number(value);
28-
return VALID_PAGE_SIZES.has(n) ? n : 10;
29-
}
30-
3119
export type DataTableClientPaginationProps<Data extends object> = {
3220
data: Data[];
3321
columns: ColumnDef<Data>[];
@@ -41,40 +29,7 @@ export const DataTableClientPagination = <Data extends object>({
4129

4230
const [sorting, setSorting] = useState<SortingState>([]);
4331
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
44-
const [pagination, setPaginationState] = useState<PaginationState>({
45-
pageIndex: 0,
46-
pageSize: 10,
47-
});
48-
49-
const setPagination = useCallback(
50-
(updater: PaginationState | ((old: PaginationState) => PaginationState)) => {
51-
const newState = typeof updater === 'function' ? updater(pagination) : updater;
52-
setPaginationState(newState);
53-
const query = { ...router.query };
54-
if (newState.pageIndex === 0) {
55-
delete query.page;
56-
} else {
57-
query.page = String(newState.pageIndex + 1);
58-
}
59-
if (newState.pageSize === 10) {
60-
delete query.pageSize;
61-
} else {
62-
query.pageSize = String(newState.pageSize);
63-
}
64-
router.push({ pathname: router.pathname, query }, undefined, { shallow: true });
65-
},
66-
[pagination, router],
67-
);
68-
69-
useEffect(() => {
70-
if (!router.isReady) {
71-
return;
72-
}
73-
setPaginationState({
74-
pageIndex: parsePageIndex(router.query?.page),
75-
pageSize: parsePageSize(router.query?.pageSize),
76-
});
77-
}, [router.isReady, router.query?.page, router.query?.pageSize]);
32+
const { pagination, onPaginationChange } = useUrlPagination({ pageSizes: PAGE_SIZES });
7833

7934
const table = useReactTable({
8035
columns: columns || [],
@@ -85,7 +40,7 @@ export const DataTableClientPagination = <Data extends object>({
8540
onColumnFiltersChange: setColumnFilters,
8641
getFilteredRowModel: getFilteredRowModel(),
8742
getPaginationRowModel: getPaginationRowModel(),
88-
onPaginationChange: setPagination,
43+
onPaginationChange,
8944
manualPagination: false,
9045
autoResetPageIndex: false,
9146
state: {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { functionalUpdate, OnChangeFn, PaginationState, Table } from '@tanstack/react-table';
2+
import { useRouter } from 'next/router';
3+
import { useCallback, useEffect, useRef, useState } from 'react';
4+
5+
const DEFAULT_PAGE_SIZE = 10;
6+
7+
// Parses the 1-indexed `page` query param into react-table's 0-indexed pageIndex, falling back to the first
8+
// page for a missing or malformed value. An out-of-range (too large) value is left as-is here and corrected by
9+
// useClampPageIndex once the row count is known.
10+
function parsePageIndex(value: string | string[] | undefined): number {
11+
const n = Number(value);
12+
return Number.isInteger(n) && n > 0 ? n - 1 : 0;
13+
}
14+
15+
// Parses the `pageSize` query param, accepting it only when it is one of the offered sizes so a hand-edited URL
16+
// cannot force a size the selector could not otherwise display.
17+
function parsePageSize(
18+
value: string | string[] | undefined,
19+
pageSizes: readonly number[],
20+
defaultPageSize: number,
21+
): number {
22+
const n = Number(value);
23+
return pageSizes.includes(n) ? n : defaultPageSize;
24+
}
25+
26+
export interface UseUrlPaginationOptions {
27+
// The page sizes offered by the size selector; a `pageSize` query param outside this set is ignored. May be
28+
// an inline array — the hook does not depend on its identity.
29+
pageSizes: readonly number[];
30+
// The page size used when the URL carries no (valid) `pageSize`. Should be one of `pageSizes`.
31+
defaultPageSize?: number;
32+
}
33+
34+
export interface UrlPagination {
35+
pagination: PaginationState;
36+
onPaginationChange: OnChangeFn<PaginationState>;
37+
}
38+
39+
/**
40+
* Mirrors a TanStack Table's pagination into the URL query (`page`, `pageSize`) so it survives navigation:
41+
* opening a row's detail page and pressing the browser Back button restores the same page and page size
42+
* instead of resetting to the first page. `page` is 1-indexed in the URL, and both params are omitted while at
43+
* their defaults to keep URLs clean. Routing is shallow, so persisting the state never refetches data.
44+
*
45+
* Consumers must wire the returned `pagination`/`onPaginationChange` into `useReactTable` as controlled state
46+
* and set `autoResetPageIndex: false`; otherwise an asynchronous data load would reset the restored page back
47+
* to the first one, reintroducing the very bug this hook fixes. Because auto-reset is off, also call
48+
* {@link useClampPageIndex} so a stale page cannot outlive a shrinking data set.
49+
*/
50+
export function useUrlPagination({
51+
pageSizes,
52+
defaultPageSize = DEFAULT_PAGE_SIZE,
53+
}: UseUrlPaginationOptions): UrlPagination {
54+
const router = useRouter();
55+
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: defaultPageSize });
56+
57+
// Hold the latest valid sizes in a ref so the URL-sync effect does not take `pageSizes` as a dependency.
58+
// A caller passing an inline array (a new reference every render) would otherwise re-run the effect on every
59+
// render — and since the effect calls setPagination with a fresh object, that would be an infinite loop.
60+
const pageSizesRef = useRef(pageSizes);
61+
pageSizesRef.current = pageSizes;
62+
63+
const onPaginationChange = useCallback<OnChangeFn<PaginationState>>(
64+
(updater) => {
65+
const next = functionalUpdate(updater, pagination);
66+
setPagination(next);
67+
const query = { ...router.query };
68+
if (next.pageIndex === 0) {
69+
delete query.page;
70+
} else {
71+
query.page = String(next.pageIndex + 1);
72+
}
73+
if (next.pageSize === defaultPageSize) {
74+
delete query.pageSize;
75+
} else {
76+
query.pageSize = String(next.pageSize);
77+
}
78+
router.push({ pathname: router.pathname, query }, undefined, { shallow: true });
79+
},
80+
[pagination, router, defaultPageSize],
81+
);
82+
83+
// Sync from the URL once the router is ready and whenever the params change (including the browser Back
84+
// button), which is what restores the page after returning from a detail page.
85+
useEffect(() => {
86+
if (!router.isReady) {
87+
return;
88+
}
89+
setPagination({
90+
pageIndex: parsePageIndex(router.query?.page),
91+
pageSize: parsePageSize(router.query?.pageSize, pageSizesRef.current, defaultPageSize),
92+
});
93+
}, [router.isReady, router.query?.page, router.query?.pageSize, defaultPageSize]);
94+
95+
return { pagination, onPaginationChange };
96+
}
97+
98+
/**
99+
* Corrects the table's page index when it falls outside the available pages — e.g. a hand-edited or stale
100+
* `?page=` URL, or a data set that shrank (a resource was deleted, or a filter narrowed the results) while a
101+
* later page was selected. Without this, a controlled table with `autoResetPageIndex: false` keeps the stale
102+
* index and renders an empty body under a nonsensical "Page 5 of 3". Clamps to the last available page and
103+
* routes the change through the table so it is mirrored back into the URL by {@link useUrlPagination}.
104+
*/
105+
export function useClampPageIndex<Data>(table: Table<Data>): void {
106+
const { pageIndex } = table.getState().pagination;
107+
const pageCount = table.getPageCount();
108+
useEffect(() => {
109+
if (pageCount > 0 && pageIndex > pageCount - 1) {
110+
table.setPageIndex(pageCount - 1);
111+
}
112+
}, [table, pageIndex, pageCount]);
113+
}

webapp/src/dogma/features/xds/GroupList.tsx

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@ import {
3737
} from '@tanstack/react-table';
3838
import { useMemo, useState } from 'react';
3939
import { DataTable } from 'dogma/features/xds/DataTable';
40-
import { GroupDto } from 'dogma/features/xds/XdsTypes';
40+
import { GroupDto, XDS_PAGE_SIZES } from 'dogma/features/xds/XdsTypes';
41+
import { useClampPageIndex, useUrlPagination } from 'dogma/common/components/table/useUrlPagination';
4142

4243
const columnHelper = createColumnHelper<GroupDto>();
4344

44-
const PAGE_SIZES = [10, 20, 50, 100];
45-
4645
export const GroupList = ({ groups }: { groups: GroupDto[] }) => {
4746
const [globalFilter, setGlobalFilter] = useState('');
47+
const { pagination, onPaginationChange } = useUrlPagination({ pageSizes: XDS_PAGE_SIZES });
4848

4949
// The group can be deleted from within the group (group detail page), not from this list, so that deletion
5050
// requires opening the group first.
@@ -69,14 +69,28 @@ export const GroupList = ({ groups }: { groups: GroupDto[] }) => {
6969
const table = useReactTable({
7070
data: groups,
7171
columns,
72-
state: { globalFilter },
72+
state: { globalFilter, pagination },
7373
onGlobalFilterChange: setGlobalFilter,
74+
onPaginationChange,
7475
getCoreRowModel: getCoreRowModel(),
7576
getSortedRowModel: getSortedRowModel(),
7677
getFilteredRowModel: getFilteredRowModel(),
7778
getPaginationRowModel: getPaginationRowModel(),
78-
initialState: { pagination: { pageSize: 10 } },
79+
// Pagination is controlled via the URL (useUrlPagination); auto-reset would discard the page restored
80+
// from the URL as soon as the groups finish loading.
81+
autoResetPageIndex: false,
7982
});
83+
// Auto-reset is off, so keep the page index within bounds when a filter narrows the list to fewer pages.
84+
useClampPageIndex(table);
85+
86+
// With auto-reset disabled, filtering no longer moves back to the first page on its own, so a narrowing
87+
// filter could otherwise strand the user on a now-empty later page. Reset explicitly instead.
88+
const handleFilterChange = (value: string) => {
89+
setGlobalFilter(value);
90+
if (pagination.pageIndex !== 0) {
91+
table.setPageIndex(0);
92+
}
93+
};
8094

8195
if (groups.length === 0) {
8296
return <Text color="gray.500">No groups yet. Create one to get started.</Text>;
@@ -96,7 +110,7 @@ export const GroupList = ({ groups }: { groups: GroupDto[] }) => {
96110
<Input
97111
placeholder="Search groups"
98112
value={globalFilter}
99-
onChange={(e) => setGlobalFilter(e.target.value)}
113+
onChange={(e) => handleFilterChange(e.target.value)}
100114
/>
101115
</InputGroup>
102116
</HStack>
@@ -124,7 +138,7 @@ export const GroupList = ({ groups }: { groups: GroupDto[] }) => {
124138
Next
125139
</Button>
126140
<Select size="sm" w="auto" value={pageSize} onChange={(e) => table.setPageSize(Number(e.target.value))}>
127-
{PAGE_SIZES.map((size) => (
141+
{XDS_PAGE_SIZES.map((size) => (
128142
<option key={size} value={size}>
129143
{size} / page
130144
</option>

webapp/src/dogma/features/xds/ResourceHistory.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { FetchBaseQueryError } from '@reduxjs/toolkit/query';
4848
import { useCallback, useMemo, useState } from 'react';
4949
import { VscGitCommit } from 'react-icons/vsc';
5050
import { DataTable } from 'dogma/features/xds/DataTable';
51+
import { useClampPageIndex, useUrlPagination } from 'dogma/common/components/table/useUrlPagination';
5152
import { Deferred } from 'dogma/common/components/Deferred';
5253
import { Loading } from 'dogma/common/components/Loading';
5354
import { Author } from 'dogma/common/components/Author';
@@ -57,7 +58,7 @@ import { HistoryDto } from 'dogma/features/history/HistoryDto';
5758
import { FileDto } from 'dogma/features/file/FileDto';
5859
import { useGetGroupHistoryQuery } from 'dogma/features/xds/xdsApiSlice';
5960
import { useGetFileContentQuery, useGetFilesQuery } from 'dogma/features/api/apiSlice';
60-
import { XDS_PROJECT } from 'dogma/features/xds/XdsTypes';
61+
import { XDS_PAGE_SIZES, XDS_PROJECT } from 'dogma/features/xds/XdsTypes';
6162

6263
const columnHelper = createColumnHelper<HistoryDto>();
6364

@@ -321,15 +322,22 @@ export const ResourceHistory = ({ group, filePath }: { group: string; filePath?:
321322

322323
// Memoized so the table receives a stable data reference across re-renders (react-table requires this).
323324
const rows = useMemo(() => data || [], [data]);
325+
const { pagination, onPaginationChange } = useUrlPagination({ pageSizes: XDS_PAGE_SIZES });
324326
const table = useReactTable({
325327
data: rows,
326328
columns,
329+
state: { pagination },
330+
onPaginationChange,
327331
getCoreRowModel: getCoreRowModel(),
328332
getSortedRowModel: getSortedRowModel(),
329333
getFilteredRowModel: getFilteredRowModel(),
330334
getPaginationRowModel: getPaginationRowModel(),
331-
initialState: { pagination: { pageSize: 10 } },
335+
// Pagination is controlled via the URL (useUrlPagination); auto-reset would discard the page restored
336+
// from the URL as soon as the history finishes loading.
337+
autoResetPageIndex: false,
332338
});
339+
// Auto-reset is off, so keep the page index within bounds if the history is truncated to fewer pages.
340+
useClampPageIndex(table);
333341

334342
return (
335343
<Deferred isLoading={isLoading} error={error}>
@@ -369,7 +377,7 @@ export const ResourceHistory = ({ group, filePath }: { group: string; filePath?:
369377
value={pageSize}
370378
onChange={(e) => table.setPageSize(Number(e.target.value))}
371379
>
372-
{[10, 20, 50, 100].map((size) => (
380+
{XDS_PAGE_SIZES.map((size) => (
373381
<option key={size} value={size}>
374382
{size} / page
375383
</option>

webapp/src/dogma/features/xds/ResourceList.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,15 @@ import { useMemo, useState } from 'react';
3030
import { DataTable } from 'dogma/features/xds/DataTable';
3131
import { DeleteConfirmationModal } from 'dogma/common/components/DeleteConfirmationModal';
3232
import { Deferred } from 'dogma/common/components/Deferred';
33+
import { useClampPageIndex, useUrlPagination } from 'dogma/common/components/table/useUrlPagination';
3334
import { useDeleteResourceMutation, useListResourcesQuery } from 'dogma/features/xds/xdsApiSlice';
34-
import { resourceName, XdsResourceDto, XdsResourceType, XDS_RESOURCE_META } from 'dogma/features/xds/XdsTypes';
35+
import {
36+
resourceName,
37+
XdsResourceDto,
38+
XdsResourceType,
39+
XDS_PAGE_SIZES,
40+
XDS_RESOURCE_META,
41+
} from 'dogma/features/xds/XdsTypes';
3542
import { useGroupWriteAccess } from 'dogma/features/xds/useGroupWriteAccess';
3643
import { useAppDispatch } from 'dogma/hooks';
3744
import { newNotification } from 'dogma/features/notification/notificationSlice';
@@ -124,15 +131,22 @@ export const ResourceList = ({ group, type }: { group: string; type: XdsResource
124131

125132
// Memoized so the table receives a stable data reference across re-renders (react-table requires this).
126133
const resources = useMemo(() => data || [], [data]);
134+
const { pagination, onPaginationChange } = useUrlPagination({ pageSizes: XDS_PAGE_SIZES });
127135
const table = useReactTable({
128136
data: resources,
129137
columns,
138+
state: { pagination },
139+
onPaginationChange,
130140
getCoreRowModel: getCoreRowModel(),
131141
getSortedRowModel: getSortedRowModel(),
132142
getFilteredRowModel: getFilteredRowModel(),
133143
getPaginationRowModel: getPaginationRowModel(),
134-
initialState: { pagination: { pageSize: 10 } },
144+
// Pagination is controlled via the URL (useUrlPagination); auto-reset would discard the page restored
145+
// from the URL as soon as the resources finish loading.
146+
autoResetPageIndex: false,
135147
});
148+
// Auto-reset is off, so keep the page index within bounds when a deletion shrinks the list to fewer pages.
149+
useClampPageIndex(table);
136150

137151
return (
138152
<Deferred isLoading={isLoading} error={error}>
@@ -188,7 +202,7 @@ export const ResourceList = ({ group, type }: { group: string; type: XdsResource
188202
value={pageSize}
189203
onChange={(e) => table.setPageSize(Number(e.target.value))}
190204
>
191-
{[10, 20, 50, 100].map((size) => (
205+
{XDS_PAGE_SIZES.map((size) => (
192206
<option key={size} value={size}>
193207
{size} / page
194208
</option>

webapp/src/dogma/features/xds/XdsTypes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export type XdsResourceType = 'listeners' | 'routes' | 'clusters' | 'endpoints';
2323

2424
export const XDS_RESOURCE_TYPES: XdsResourceType[] = ['listeners', 'routes', 'clusters', 'endpoints'];
2525

26+
// The page sizes offered by the paginated xDS lists (groups, resources, history). Also the set of valid
27+
// `pageSize` URL query values; anything else falls back to the first (default) size.
28+
export const XDS_PAGE_SIZES = [10, 20, 50, 100] as const;
29+
2630
export interface XdsResourceTypeMeta {
2731
type: XdsResourceType;
2832
// Human readable, singular label.

0 commit comments

Comments
 (0)