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
+56 -1
View File
@@ -5,9 +5,22 @@
import { InfiniteQueryObserver } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { NEXT_PAGE } from "./nextPage";
import { mayReadAhead, NEXT_PAGE, nextPageOnClick } from "./nextPage";
import { createQueryClient } from "./queryClient";
function probe(sent: (string | null)[], delay = 5) {
return new InfiniteQueryObserver(createQueryClient(), {
queryKey: ["probe"],
initialPageParam: null as string | null,
queryFn: async ({ pageParam }) => {
sent.push(pageParam);
await new Promise((r) => setTimeout(r, delay));
return { next: `c${sent.length}` };
},
getNextPageParam: (last: { next: string }) => last.next,
});
}
describe("NEXT_PAGE", () => {
it("sends each page once when the read-ahead calls twice in a row", async () => {
const sent: (string | null)[] = [];
@@ -32,4 +45,46 @@ describe("NEXT_PAGE", () => {
expect(observer.getCurrentResult().data?.pages).toHaveLength(2);
unsubscribe();
});
// E31 S5: the join takes ANY fetch in flight. During a background refetch (an invalidation, a
// poll) it reloads the pages already there and appends none — why a read-ahead waits it out.
it("joins a background refetch and appends no page", async () => {
const sent: (string | null)[] = [];
const observer = probe(sent);
const unsubscribe = observer.subscribe(() => {});
await observer.refetch();
void observer.refetch();
await observer.fetchNextPage(NEXT_PAGE);
expect(observer.getCurrentResult().data?.pages).toHaveLength(1);
unsubscribe();
});
});
describe("a read-ahead", () => {
it("waits while the list fetches anything", () => {
expect(mayReadAhead(true, false)).toBe(true);
expect(mayReadAhead(true, true)).toBe(false);
expect(mayReadAhead(false, false)).toBe(false);
});
});
describe("a next page asked for by a click", () => {
it("loads the page even while a background refetch runs", async () => {
const sent: (string | null)[] = [];
const observer = probe(sent);
const unsubscribe = observer.subscribe(() => {});
await observer.refetch();
void observer.refetch();
await observer.fetchNextPage(nextPageOnClick(false));
expect(observer.getCurrentResult().data?.pages).toHaveLength(2);
unsubscribe();
});
it("joins a next page already loading", () => {
expect(nextPageOnClick(true)).toBe(NEXT_PAGE);
});
});
+16
View File
@@ -5,5 +5,21 @@
* re-runs on every scroll frame, and a frame that lands before `isFetchingNextPage` turns true calls
* again. On prod (E31 S4e) every feed page went out 2–3 times; on a 0.8 s title sort the copies
* queued to 2.3 s. With this a second call joins the fetch already running.
*
* The join takes ANY fetch in flight, not only a next page: a call during a background refetch (an
* invalidation, a poll) joins THAT, which reloads the pages already there and appends none (E31 S5).
* So a read-ahead also waits while the list is fetching at all (`mayReadAhead`) and runs again when
* the fetch settles; a click uses `nextPageOnClick`.
*/
export const NEXT_PAGE = { cancelRefetch: false } as const;
/** Whether an infinite list's read-ahead may ask for the next page now. Not while ANY fetch of the
* list is in flight: that call would join it (see NEXT_PAGE). The caller re-runs on `isFetching`. */
export const mayReadAhead = (hasNextPage: boolean, isFetching: boolean) =>
hasNextPage && !isFetching;
/** The options for a next page a person asked for (a "Load older" button): join the page already
* loading, else cancel a background refetch and load the page — a click must not be swallowed by a
* poll that happened to be running. */
export const nextPageOnClick = (isFetchingNextPage: boolean) =>
isFetchingNextPage ? NEXT_PAGE : ({ cancelRefetch: true } as const);
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
// Every infinite list's read-ahead waits while the list fetches ANYTHING and runs again when the fetch
// settles (E31 S5). A read-ahead that only checks `isFetchingNextPage` calls during a background
// refetch, joins it through NEXT_PAGE and loads no page: the grid stalls at its loaded end until the
// reader scrolls the range. The jsdom virtualizer measures nothing, so the wiring is pinned in source.
const files = import.meta.glob(["../components/**/*.tsx", "!../components/**/*.test.tsx"], {
query: "?raw",
import: "default",
eager: true,
});
const source = (name: string) => {
const hit = Object.entries(files).find(([path]) => path.endsWith(`/${name}`));
if (!hit) throw new Error(`no ${name}`);
return hit[1] as string;
};
describe("an infinite list's read-ahead", () => {
it.each(["ui/VirtualGrid.tsx", "PlexBrowse.tsx"])(
"%s gates on mayReadAhead and re-runs on isFetching",
(file) => {
const src = source(file);
expect(src).toMatch(/mayReadAhead\(hasNextPage, isFetching\)/);
expect(src).toMatch(/\[[^\]]*\bisFetching\b[^\]]*fetchNextPage\]\);/);
expect(src).not.toMatch(/hasNextPage && !isFetchingNextPage/);
},
);
it("the feed hands the grid the query's isFetching", () => {
expect(source("Feed.tsx")).toMatch(/isFetching=\{query\.isFetching\}/);
});
});