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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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\}/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user