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:
2026-10-02 23:42:31 +02:00
parent ac53a3b757
commit 15ec67b029
11 changed files with 165 additions and 28 deletions
+4 -3
View File
@@ -13,7 +13,7 @@ import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api
import { copyText } from "../lib/clipboard";
import { formatDateTimeSec } from "../lib/format";
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
import { NEXT_PAGE } from "../lib/nextPage";
import { nextPageOnClick } from "../lib/nextPage";
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
import {
DEFAULT_LOGS_STATE,
@@ -157,7 +157,8 @@ export default function AdminLogs() {
? "countsFailed"
: null;
const retryFailed = () => {
if (logs.isFetchNextPageError) void logs.fetchNextPage(NEXT_PAGE);
if (logs.isFetchNextPageError)
void logs.fetchNextPage(nextPageOnClick(logs.isFetchingNextPage));
else if (logs.isError) void logs.refetch();
if (facets.isError) void facets.refetch();
};
@@ -191,7 +192,7 @@ export default function AdminLogs() {
const loadOlder = () => {
setLive(false);
void logs.fetchNextPage(NEXT_PAGE);
void logs.fetchNextPage(nextPageOnClick(logs.isFetchingNextPage));
};
const copy = async () => {
+4 -4
View File
@@ -550,7 +550,7 @@ export default function Feed({
onResetState={onResetState}
onOpen={openVideo}
hasNextPage={false}
isFetchingNextPage={false}
isFetching={false}
fetchNextPage={() => {}}
/>
{ytQuery.hasNextPage && (
@@ -826,8 +826,8 @@ export default function Feed({
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN
baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
applied" when it's just the page being itself. */}
baseline (channel-scoped, whole-catalog, the watch state the channel page remembers), so
each would read as a "filter you applied" when it's just the page being itself. */}
<PageToolbar>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
{toolbar}
@@ -892,7 +892,7 @@ export default function Feed({
onResetState={onResetState}
onOpen={openVideo}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isFetching={query.isFetching}
fetchNextPage={fetchNextPage}
/>
)}
+4 -4
View File
@@ -9,7 +9,7 @@ import {
} from "@tanstack/react-query";
import { User, X } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { NEXT_PAGE } from "../lib/nextPage";
import { mayReadAhead, NEXT_PAGE } from "../lib/nextPage";
import { healPlexField } from "../lib/plexSearch";
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
import type { SortState } from "../lib/columnSort";
@@ -317,19 +317,19 @@ export default function PlexBrowse() {
// mount/unmount, so the observer always watches the live node; a plain ref would keep observing the
// detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages).
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
const { hasNextPage, isFetching, isFetchingNextPage, fetchNextPage } = browseQ;
useEffect(() => {
if (!sentinelEl) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage)
if (entries[0]?.isIntersecting && mayReadAhead(hasNextPage, isFetching))
void fetchNextPage(NEXT_PAGE);
},
{ rootMargin: "600px" },
);
io.observe(sentinelEl);
return () => io.disconnect();
}, [sentinelEl, hasNextPage, isFetchingNextPage, fetchNextPage]);
}, [sentinelEl, hasNextPage, isFetching, fetchNextPage]);
function onCard(card: PlexCard) {
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
+3 -3
View File
@@ -29,7 +29,7 @@ export interface VirtualFeedProps {
onResetState: (id: string) => void;
onOpen: (v: Video, startAt?: number | null) => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
isFetching: boolean;
fetchNextPage: (options: typeof NEXT_PAGE) => unknown;
}
@@ -44,7 +44,7 @@ export default function VirtualFeed({
onResetState,
onOpen,
hasNextPage,
isFetchingNextPage,
isFetching,
fetchNextPage,
}: VirtualFeedProps) {
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
@@ -64,7 +64,7 @@ export default function VirtualFeed({
maxCols={maxCols}
rowEst={ROW_EST[view]}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isFetching={isFetching}
fetchNextPage={fetchNextPage}
keyboardNav
renderItem={(v, itemWidth, index) => (
@@ -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");
});
});
+3 -1
View File
@@ -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) {
+9 -7
View File
@@ -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" }}>
+10 -5
View File
@@ -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;
}