From 540822d2b00128f2c68d5c604f15cca403d29f77 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 20:46:30 +0200 Subject: [PATCH] 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. --- frontend/eslint.config.js | 9 ++++++ frontend/src/components/AdminLogs.tsx | 5 ++-- frontend/src/components/Feed.tsx | 3 +- frontend/src/components/PlexBrowse.tsx | 4 ++- frontend/src/components/VirtualFeed.tsx | 3 +- frontend/src/components/ui/VirtualGrid.tsx | 6 ++-- frontend/src/lib/nextPage.contract.test.ts | 31 +++++++++++++++++++ frontend/src/lib/nextPage.dom.test.ts | 35 ++++++++++++++++++++++ frontend/src/lib/nextPage.ts | 9 ++++++ 9 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/nextPage.contract.test.ts create mode 100644 frontend/src/lib/nextPage.dom.test.ts create mode 100644 frontend/src/lib/nextPage.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 4500b12e..3d1b998a 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -547,6 +547,15 @@ export default tseslint.config( // lib/*.contract.test.ts). "no-restricted-syntax": [ "error", + // A next-page fetch called bare CANCELS the one in flight and sends the same cursor again; + // our requests ignore the abort, so the server ran both (E31 S4e: every feed page 2–3×). + // lib/nextPage.ts NEXT_PAGE makes the second call join the first. + { + selector: + "CallExpression[arguments.length=0]:matches([callee.name='fetchNextPage'], [callee.property.name='fetchNextPage'])", + message: + "fetchNextPage() cancels and re-sends a page already loading: call fetchNextPage(NEXT_PAGE) (lib/nextPage.ts).", + }, // `enabled:` sees only the native attribute; a FocusSafeButton that holds the focus is // disabled by aria-disabled, so a bare `enabled:hover:` lit it up on hover as if it worked. { diff --git a/frontend/src/components/AdminLogs.tsx b/frontend/src/components/AdminLogs.tsx index bfc0a8e0..656ce33f 100644 --- a/frontend/src/components/AdminLogs.tsx +++ b/frontend/src/components/AdminLogs.tsx @@ -13,6 +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 { LEVEL_GROUPS, type LevelGroup } from "../lib/log"; import { DEFAULT_LOGS_STATE, @@ -156,7 +157,7 @@ export default function AdminLogs() { ? "countsFailed" : null; const retryFailed = () => { - if (logs.isFetchNextPageError) void logs.fetchNextPage(); + if (logs.isFetchNextPageError) void logs.fetchNextPage(NEXT_PAGE); else if (logs.isError) void logs.refetch(); if (facets.isError) void facets.refetch(); }; @@ -190,7 +191,7 @@ export default function AdminLogs() { const loadOlder = () => { setLive(false); - void logs.fetchNextPage(); + void logs.fetchNextPage(NEXT_PAGE); }; const copy = async () => { diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index a6fabbf5..3eb7d287 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -7,6 +7,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; +import { NEXT_PAGE } from "../lib/nextPage"; import { ArrowLeft, Columns2, @@ -556,7 +557,7 @@ export default function Feed({