fix(web): advance dashboard pagination on first Next click

Driving loadMore() from the auto-load effect in page.tsx left a one-render
gap where loadingMore was still false, so the snap-back effect in
useDashboardFilters fired first and reverted currentPage from 2 back to
totalPages=1 before the fetch even started. The fetch then ran (the
auto-load effect saw the captured currentPage=2 from that render) and the
list grew to totalPages=2, but currentPage had already been snapped to 1
— giving the visible "Page 1 of 2+" state and forcing the user to click
Next a second time to actually see the new batch.

Now handleNextPage calls loadMore() in the same click handler as
setCurrentPage(prev+1), so React 18's automatic batching commits both
state changes in the same render. Snap-back's `if (loadingMore) return`
guard then correctly skips, currentPage stays at the overshoot, and once
the fetch resolves totalPages catches up and the new batch renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-06 21:29:14 +02:00
parent 9b4100f7d6
commit 5a44a9e495
2 changed files with 21 additions and 10 deletions
@@ -15,6 +15,7 @@ interface UseDashboardFiltersParams {
tenantIdFilter: string;
hasMore: boolean;
loadingMore: boolean;
loadMore: () => void;
}
interface DashboardStats {
@@ -61,6 +62,7 @@ export function useDashboardFilters({
tenantIdFilter,
hasMore,
loadingMore,
loadMore,
}: UseDashboardFiltersParams): UseDashboardFiltersReturn {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string | null>(null);
@@ -217,20 +219,28 @@ export function useDashboardFilters({
}, [sortedSessions, currentPage, sessionsPerPage]);
const handlePreviousPage = () => setCurrentPage((prev) => Math.max(1, prev - 1));
// Allow advancing past totalPages when the server has more pages; the auto-load
// effect in page.tsx will fetch the next batch and totalPages catches up. Without
// this overshoot the user could never trigger a load while staying inside a single
// Prev/Next control — they would have to click a separate "Load more" button.
const handleNextPage = () => setCurrentPage((prev) => {
if (prev < totalPages) return prev + 1;
if (hasMore && !loadingMore) return prev + 1;
return prev;
});
// When advancing past locally-loaded pages, fire loadMore() in the SAME event
// handler so React batches setLoadingMore(true) with setCurrentPage(prev+1).
// If we relied on a follow-up effect to trigger the fetch, the snap-back effect
// below would race ahead (loadingMore still false at that point) and reset
// currentPage to totalPages — leaving the user on the previous page even though
// a load was in flight, forcing a second click to actually advance.
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage((prev) => prev + 1);
return;
}
if (hasMore && !loadingMore) {
loadMore();
setCurrentPage((prev) => prev + 1);
}
};
// Snap currentPage back to the last valid page once a load finishes that did NOT
// grow totalPages (e.g. an active filter narrowed the new batch to zero matches).
// Without this the user would be stuck on an empty paginated slice with no way
// to recover except clicking Prev.
// to recover except clicking Prev. Gated on !loadingMore so it never fires
// mid-fetch and reverts a legitimate overshoot.
useEffect(() => {
if (loadingMore) return;
if (totalPages > 0 && currentPage > totalPages) setCurrentPage(totalPages);
@@ -68,6 +68,7 @@ export default function Home() {
tenantIdFilter,
hasMore,
loadingMore,
loadMore,
});
// Redirect regular users (non-admin, non-operator) to progress portal – they must never see the session list