fix(ui): a read-ahead waits out a background refetch, and a revealed row is mounted before it is focused
NEXT_PAGE joins ANY fetch in flight: a read-ahead during an invalidation or a poll joined that refetch and appended no page, and nothing asked again until the reader moved the range (the channel page during a backfill). The read-ahead now waits while the list fetches anything (mayReadAhead, re-run on isFetching); a Load older click cancels a poll instead (nextPageOnClick). A page turn to a revealed row restarted the progressive mount at 30 rows; Channels focuses that row in the next commit, and at index 40 of a 50-row page it was not there, so focus fell to <body>. The mount now reaches through the revealed row.
This commit is contained in:
@@ -100,4 +100,28 @@ describe("DataTable — a long page mounts in steps", () => {
|
||||
expect(bodyRows()).toBe(FIRST_ROWS);
|
||||
await waitFor(() => expect(bodyRows()).toBe(150));
|
||||
});
|
||||
|
||||
// E31 S5: Channels turns the page to the row a priority pick moved and focuses it in the commit
|
||||
// that follows. That row was at index 40 of a 50-row page: not mounted yet, focus fell to <body>.
|
||||
it("mounts a revealed row in the render that turns to its page", () => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
const rows = many(100);
|
||||
const draw = (reveal: { key: string; seq: number } | null) =>
|
||||
flushSync(() =>
|
||||
root!.render(
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={plain}
|
||||
rowKey={(x) => x.id}
|
||||
pageSize={50}
|
||||
reveal={reveal}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
draw(null);
|
||||
draw({ key: "r90", seq: 1 });
|
||||
expect(host.querySelector("tbody")?.textContent).toContain("r 90");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,10 +274,12 @@ export default function DataTable<T>({
|
||||
if (at >= 0 && !allRows) setPage(Math.floor(at / size));
|
||||
}
|
||||
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
|
||||
// A long page ("All", 100) mounts its first rows at once and the rest in the background.
|
||||
// A long page ("All", 100) mounts its first rows at once and the rest in the background — but
|
||||
// always through the revealed row, which the caller focuses as soon as this frame commits.
|
||||
const shown = useProgressiveRows(
|
||||
paged,
|
||||
`${safePage}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`,
|
||||
reveal ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1,
|
||||
);
|
||||
|
||||
function toggleSort(key: string) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useListNav } from "../../lib/listNav";
|
||||
import { scrollParent } from "../../lib/listPaging";
|
||||
import { NEXT_PAGE } from "../../lib/nextPage";
|
||||
import { mayReadAhead, NEXT_PAGE } from "../../lib/nextPage";
|
||||
import { GRID_GAP, columnsFor, itemWidthFor } from "./gridColumns";
|
||||
import { shouldPrefetchNext } from "./readAhead";
|
||||
|
||||
@@ -66,7 +66,9 @@ export interface VirtualGridProps<T> {
|
||||
keyboardNav?: boolean;
|
||||
/** Infinite scroll — omit all three for a finite client-side list. */
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
/** Any fetch of the list in flight (a next page OR a background refetch): the read-ahead waits for
|
||||
* it and runs again when it settles (`lib/nextPage` mayReadAhead). */
|
||||
isFetching?: boolean;
|
||||
/** Called with `NEXT_PAGE`, so a read-ahead that fires again joins the page already loading. */
|
||||
fetchNextPage?: (options: typeof NEXT_PAGE) => unknown;
|
||||
}
|
||||
@@ -80,7 +82,7 @@ export default function VirtualGrid<T>({
|
||||
rowEst,
|
||||
renderItem,
|
||||
hasNextPage = false,
|
||||
isFetchingNextPage = false,
|
||||
isFetching = false,
|
||||
fetchNextPage,
|
||||
keyboardNav = false,
|
||||
}: VirtualGridProps<T>) {
|
||||
@@ -161,17 +163,17 @@ export default function VirtualGrid<T>({
|
||||
// Infinite-scroll read-ahead: pull the next page once the last rendered row is within the
|
||||
// read-ahead window of the loaded end (see readAhead.ts — the window scales with the rendered row
|
||||
// count, clamped so a tall window doesn't prefetch unboundedly). A no-op for finite lists (no
|
||||
// fetchNextPage / hasNextPage false). Because this effect re-runs when a page settles
|
||||
// (isFetchingNextPage flips false) it CHAINS — refilling the buffer as the user consumes it, so a
|
||||
// fetchNextPage / hasNextPage false). Because this effect re-runs when a fetch settles
|
||||
// (isFetching flips false) it CHAINS — refilling the buffer as the user consumes it, so a
|
||||
// fast scroll reaches the loaded end far less often.
|
||||
useEffect(() => {
|
||||
if (!fetchNextPage || !hasNextPage || isFetchingNextPage) return;
|
||||
if (!fetchNextPage || !mayReadAhead(hasNextPage, isFetching)) return;
|
||||
const last = virtualRows[virtualRows.length - 1];
|
||||
if (!last) return;
|
||||
if (shouldPrefetchNext(last.index, rows.length, virtualRows.length)) {
|
||||
fetchNextPage(NEXT_PAGE);
|
||||
}
|
||||
}, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
}, [virtualRows, rows.length, hasNextPage, isFetching, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<div ref={listRef} style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
|
||||
@@ -18,16 +18,21 @@ export const STEP_ROWS = 40;
|
||||
// `view` names what the rows show (page, size, sort, filters). A new view starts over from the first
|
||||
// rows, so the urgent render after a click or a key stays small. New data for the same view (a refetch)
|
||||
// does not: it would drop the rows the reader is looking at.
|
||||
export function useProgressiveRows<T>(rows: T[], view: string): T[] {
|
||||
//
|
||||
// `through` is a row that must be in the DOM in THIS render: the row a page turn was made to show
|
||||
// (DataTable's `reveal`). Its page is a new view, and the caller focuses that row right after the
|
||||
// commit — at index 40 of a 50-row page it was not mounted yet, and focus fell to <body> (E31 S5).
|
||||
export function useProgressiveRows<T>(rows: T[], view: string, through = -1): T[] {
|
||||
const [seen, setSeen] = useState(view);
|
||||
const [limit, setLimit] = useState(FIRST_ROWS);
|
||||
if (seen !== view) {
|
||||
setSeen(view);
|
||||
setLimit(FIRST_ROWS);
|
||||
}
|
||||
const more = rows.length > limit;
|
||||
const count = Math.max(limit, through + 1);
|
||||
const more = rows.length > count;
|
||||
useEffect(() => {
|
||||
if (more) startTransition(() => setLimit(limit + STEP_ROWS));
|
||||
}, [more, limit]);
|
||||
return more ? rows.slice(0, limit) : rows;
|
||||
if (more) startTransition(() => setLimit(count + STEP_ROWS));
|
||||
}, [more, count]);
|
||||
return more ? rows.slice(0, count) : rows;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user