fix(ui): clamp server-paginated DataTable page index when rowCount shrinks

Server-mode tables kept whatever page index the user was on after the
server's total dropped below it, for example after deleting the last
rows of the final page or when a refetch came back empty. The footer
then read "Page 2 of 1" and "Showing 26-25 of 25" with Previous and
First enabled over an empty body, and every one of the 13 server-mode
consumers was exposed since none of them clamped

The shared DataTable now snaps the controlled page index to the last
valid page as soon as a non-loading rowCount no longer reaches it, so
the fix applies to every consumer without per-table clamps. Loading
responses are ignored so a pending fetch never bounces the user to
page 1
This commit is contained in:
ryan-crabbe-berri 2026-09-04 11:33:26 -07:00
parent f74bc9427b
commit d05d2a6f05
2 changed files with 85 additions and 2 deletions

View file

@ -1,4 +1,4 @@
import type { ColumnDef, ExpandedState } from "@tanstack/react-table";
import type { ColumnDef, ExpandedState, OnChangeFn, PaginationState } from "@tanstack/react-table";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
@ -274,6 +274,71 @@ describe("DataTable pagination", () => {
await user.click(screen.getByTestId("pagination-next"));
expect(onPaginationChange).toHaveBeenCalledTimes(1);
});
type ServerPageHarnessProps = {
rowCount: number;
isLoading?: boolean;
initialPageIndex: number;
onChange: (next: PaginationState) => void;
};
function ServerPageHarness({ rowCount, isLoading = false, initialPageIndex, onChange }: ServerPageHarnessProps) {
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: initialPageIndex, pageSize: 10 });
const handleChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
onChange(next);
setPagination(next);
};
return (
<DataTable
data={[]}
columns={nameCellColumns}
paginationMode="server"
pagination={pagination}
onPaginationChange={handleChange}
rowCount={rowCount}
isLoading={isLoading}
/>
);
}
it("server mode snaps to the last page when rowCount no longer reaches the current page", async () => {
const onChange = vi.fn();
render(<ServerPageHarness rowCount={15} initialPageIndex={2} onChange={onChange} />);
await waitFor(() => expect(onChange).toHaveBeenCalledWith({ pageIndex: 1, pageSize: 10 }));
expect(onChange).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 11-15 of 15");
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
expect(screen.getByTestId("pagination-next")).toBeDisabled();
});
it("server mode falls back to the first page when rowCount drops to zero", async () => {
const onChange = vi.fn();
render(<ServerPageHarness rowCount={0} initialPageIndex={2} onChange={onChange} />);
await waitFor(() => expect(onChange).toHaveBeenCalledWith({ pageIndex: 0, pageSize: 10 }));
expect(onChange).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("No results");
expect(screen.getByText("Page 1 of 1")).toBeInTheDocument();
expect(screen.getByTestId("pagination-first")).toBeDisabled();
expect(screen.getByTestId("pagination-prev")).toBeDisabled();
});
it("server mode leaves the page index alone while loading and clamps once the response lands", async () => {
const onChange = vi.fn();
const { rerender } = render(<ServerPageHarness rowCount={0} isLoading initialPageIndex={2} onChange={onChange} />);
expect(screen.getByText("Page 3 of 1")).toBeInTheDocument();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(onChange).not.toHaveBeenCalled();
rerender(<ServerPageHarness rowCount={15} initialPageIndex={2} onChange={onChange} />);
await waitFor(() => expect(onChange).toHaveBeenCalledWith({ pageIndex: 1, pageSize: 10 }));
expect(onChange).toHaveBeenCalledTimes(1);
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
});
});
describe("DataTable filtering", () => {

View file

@ -16,6 +16,7 @@ import {
getSortedRowModel,
type Header,
type OnChangeFn,
type PaginationState,
type Row,
type RowData,
type RowSelectionState,
@ -26,7 +27,7 @@ import {
} from "@tanstack/react-table";
import { SearchX } from "lucide-react";
import * as React from "react";
import { Fragment, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import {
@ -417,6 +418,21 @@ function useControllable<T>(
return { value: internal, onChange: setInternal };
}
function useServerPageClamp(
active: boolean,
rowCount: number | undefined,
pagination: { value: PaginationState; onChange: OnChangeFn<PaginationState> },
): void {
const { pageIndex, pageSize } = pagination.value;
const { onChange } = pagination;
useEffect(() => {
if (!active || rowCount === undefined) return;
const lastPageIndex = Math.max(Math.ceil(rowCount / pageSize) - 1, 0);
if (pageIndex <= lastPageIndex) return;
onChange({ pageIndex: lastPageIndex, pageSize });
}, [active, rowCount, pageIndex, pageSize, onChange]);
}
function useDataTableInstance<TData extends RowData, TValue>(
props: DataTableResolvedProps<TData, TValue>,
): Table<TData> {
@ -433,6 +449,7 @@ function useDataTableInstance<TData extends RowData, TValue>(
pagination,
onPaginationChange,
rowCount,
isLoading = false,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
filterMode = "none",
columnFilters,
@ -457,6 +474,7 @@ function useDataTableInstance<TData extends RowData, TValue>(
pageIndex: 0,
pageSize: pageSizeOptions[0] ?? 25,
});
useServerPageClamp(paginationMode === "server" && !isLoading, rowCount, paginationState);
const filterState = useControllable<ColumnFiltersState>(
columnFilters,
onColumnFiltersChange,