perf(ui): a next-page fetch joins the one in flight instead of sending the cursor again

react-query's fetchNextPage cancels a next page still loading and sends it again by default.
Our requests ignore the abort signal, so the server ran both: the feed's read-ahead effect
re-runs on scroll frames before isFetchingNextPage reaches it, and on prod every feed page
went out 2-3 times (a 0.8 s title sort queued to 2.3 s). Every call now passes NEXT_PAGE
({cancelRefetch: false}), lint-enforced, with a contract test.
This commit is contained in:
2026-10-02 20:46:30 +02:00
parent b4ef07d28e
commit 540822d2b0
9 changed files with 98 additions and 7 deletions
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// eslint.config.js: a bare `fetchNextPage()` cancels the page already loading and sends it again
// (E31 S4e). Pins that the rule keeping every call on NEXT_PAGE actually fires.
const MESSAGE = "fetchNextPage() cancels";
async function hits(source: string): Promise<number> {
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.message.startsWith(MESSAGE)).length;
}
describe("a next-page fetch", () => {
it("is refused bare, on the query or destructured", async () => {
expect(
await hits("export const a = (q: { fetchNextPage(): void }) => q.fetchNextPage();\n"),
).toBe(1);
expect(await hits("export const b = (fetchNextPage: () => void) => fetchNextPage();\n")).toBe(
1,
);
});
it("passes with NEXT_PAGE", async () => {
expect(
await hits(
'import { NEXT_PAGE } from "../lib/nextPage";\nexport const c = (q: { fetchNextPage(o: unknown): void }) => q.fetchNextPage(NEXT_PAGE);\n',
),
).toBe(0);
});
});
+35
View File
@@ -0,0 +1,35 @@
// @vitest-environment jsdom
//
// A next-page call while that page is still loading joins it instead of sending it again (E31 S4e):
// on prod every feed page went out 2–3 times with the same cursor.
import { InfiniteQueryObserver } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { NEXT_PAGE } from "./nextPage";
import { createQueryClient } from "./queryClient";
describe("NEXT_PAGE", () => {
it("sends each page once when the read-ahead calls twice in a row", async () => {
const sent: (string | null)[] = [];
const observer = new InfiniteQueryObserver(createQueryClient(), {
queryKey: ["probe"],
initialPageParam: null as string | null,
queryFn: async ({ pageParam }) => {
sent.push(pageParam);
await new Promise((r) => setTimeout(r, 5));
return { next: `c${sent.length}` };
},
getNextPageParam: (last: { next: string }) => last.next,
});
const unsubscribe = observer.subscribe(() => {});
await observer.refetch();
// Two scroll frames before `isFetchingNextPage` reaches the component.
void observer.fetchNextPage(NEXT_PAGE);
await observer.fetchNextPage(NEXT_PAGE);
expect(sent).toEqual([null, "c1"]);
expect(observer.getCurrentResult().data?.pages).toHaveLength(2);
unsubscribe();
});
});
+9
View File
@@ -0,0 +1,9 @@
/**
* What every `fetchNextPage` call passes (lint-enforced). By default react-query CANCELS a next-page
* fetch that is still in flight and sends it again with the same cursor. Our requests do not take the
* abort signal, so the first one is not stopped: the server ran both. The feed's read-ahead effect
* 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.
*/
export const NEXT_PAGE = { cancelRefetch: false } as const;