Skip to content

Commit db39a8b

Browse files
fix: Resolve library pagination not responding to clicks (#135)
The nuqs React Router v7 adapter wraps state updates in startTransition via a custom event emitter. In React 19 production builds these transition-wrapped updates can be silently deferred, so clicking Next/Previous never committed the new page index even though the URL was updated via history.replaceState. Add a localPage React state that is updated synchronously alongside every setParams call. The table's pagination now reads from localPage instead of params.page, guaranteeing an immediate re-render. A useEffect syncs URL-driven changes (browser back/forward) back into the local state. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 940c2ba commit db39a8b

1 file changed

Lines changed: 33 additions & 18 deletions

File tree

frontend/src/components/series/SeriesTable.tsx

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,21 @@ export default function SeriesTable({
9090
);
9191
const [searchInput, setSearchInput] = useState(search);
9292

93-
// Sync URL-driven search changes (e.g. browser back/forward) into the input
93+
// Local page state for immediate UI response. The nuqs `setParams` update
94+
// goes through `startTransition` inside the React Router adapter, which can
95+
// silently defer the state commit in React 19 production builds. By keeping
96+
// a direct React state we guarantee the table re-renders on page change.
97+
const [localPage, setLocalPage] = useState(params.page);
98+
99+
// Sync URL-driven changes (e.g. browser back/forward) into local state
94100
useEffect(() => {
95101
setSearchInput(search);
96102
}, [search]);
97103

104+
useEffect(() => {
105+
setLocalPage(params.page);
106+
}, [params.page]);
107+
98108
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
99109
const [confirmDelete, setConfirmDelete] = useState(false);
100110

@@ -166,7 +176,7 @@ export default function SeriesTable({
166176
0,
167177
Math.ceil(filteredData.length / pageSize) - 1,
168178
);
169-
const effectivePage = Math.min(Math.max(params.page, 0), maxPageEstimate);
179+
const effectivePage = Math.min(Math.max(localPage, 0), maxPageEstimate);
170180

171181
const pagination = useMemo(
172182
() => ({ pageIndex: effectivePage, pageSize }),
@@ -318,6 +328,7 @@ export default function SeriesTable({
318328
typeof updaterOrValue === "function"
319329
? updaterOrValue(sorting)
320330
: updaterOrValue;
331+
setLocalPage(0);
321332
setParams({
322333
sort: newSorting.length > 0 ? newSorting[0] : null,
323334
page: null,
@@ -330,6 +341,7 @@ export default function SeriesTable({
330341
: updaterOrValue;
331342
const newPage = newPagination.pageIndex;
332343
if (newPage !== effectivePage) {
344+
setLocalPage(newPage);
333345
setParams({ page: newPage === 0 ? null : newPage });
334346
}
335347
},
@@ -347,19 +359,17 @@ export default function SeriesTable({
347359

348360
const pageCount = table.getPageCount();
349361

350-
// Sync URL when page is out of bounds (e.g. search filter reduced results).
351-
// The table already renders the clamped effectivePage, so this only fixes
352-
// the URL — it is not on the critical render path.
362+
// Clamp page when out of bounds (e.g. search filter reduced results).
353363
useEffect(() => {
354364
const maxPage = Math.max(0, pageCount - 1);
355-
const clampedPage = Math.min(Math.max(params.page, 0), maxPage);
365+
const clampedPage = Math.min(Math.max(localPage, 0), maxPage);
356366

357-
if (clampedPage !== params.page) {
367+
if (clampedPage !== localPage) {
368+
setLocalPage(clampedPage);
358369
setParams({ page: clampedPage === 0 ? null : clampedPage });
359370
}
360-
// setParams is a stable setter from useQueryStates — safe to omit
361371
// eslint-disable-next-line react-hooks/exhaustive-deps
362-
}, [pageCount, params.page]);
372+
}, [pageCount, localPage]);
363373

364374
if (isLoading) {
365375
return (
@@ -395,24 +405,27 @@ export default function SeriesTable({
395405
typeFilter={typeFilter}
396406
progressFilter={progressFilter}
397407
statusFilter={statusFilter}
398-
onTypeChange={(value) =>
408+
onTypeChange={(value) => {
409+
setLocalPage(0);
399410
setParams({
400411
type: value === "all" ? null : value,
401412
page: null,
402-
})
403-
}
404-
onProgressChange={(value) =>
413+
});
414+
}}
415+
onProgressChange={(value) => {
416+
setLocalPage(0);
405417
setParams({
406418
progress: value === "all" ? null : value,
407419
page: null,
408-
})
409-
}
410-
onStatusChange={(value) =>
420+
});
421+
}}
422+
onStatusChange={(value) => {
423+
setLocalPage(0);
411424
setParams({
412425
status: value === "all" ? null : value,
413426
page: null,
414-
})
415-
}
427+
});
428+
}}
416429
counts={filterCounts}
417430
/>
418431
<div className="flex items-center gap-2">
@@ -437,6 +450,7 @@ export default function SeriesTable({
437450
variant="ghost"
438451
size="sm"
439452
onClick={() => {
453+
setLocalPage(0);
440454
setParams({ view: "grid", page: null });
441455
setRowSelection({});
442456
}}
@@ -456,6 +470,7 @@ export default function SeriesTable({
456470
onChange={(e) => {
457471
setSearchInput(e.target.value);
458472
setSearch(e.target.value || null);
473+
setLocalPage(0);
459474
setParams({ page: null });
460475
}}
461476
className="w-[200px]"

0 commit comments

Comments
 (0)