From b102cb11fef18c64fea305577fbba39797964f3b Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 6 Sep 2026 20:36:08 -0400 Subject: [PATCH] fix(packets): keep filtered history pageable without scrolling --- src/features/packets/PacketList.tsx | 3 +- src/features/packets/PacketVirtualList.tsx | 23 +++++++-- src/features/packets/usePackets.ts | 2 + tests/features/packets/PacketList.test.tsx | 1 + .../packets/PacketVirtualList.test.tsx | 49 ++++++++++++++++++- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 0340b6b..d57d480 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -61,6 +61,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati acknowledgeNewPackets, fetchNextPage, hasNextPage, + isFetching, isFetchingNextPage, isLoading, isError, @@ -198,7 +199,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati key={listResetKey} packets={packets} hasNextPage={hasNextPage} - isFetchingNextPage={isFetchingNextPage} + isFetching={isFetching} fetchNextPage={fetchNextPage} onScrollAwayFromTop={setIsScrolledAway} onAtTopChange={setIsAtTop} diff --git a/src/features/packets/PacketVirtualList.tsx b/src/features/packets/PacketVirtualList.tsx index 4cc15ad..e2d64a2 100644 --- a/src/features/packets/PacketVirtualList.tsx +++ b/src/features/packets/PacketVirtualList.tsx @@ -16,7 +16,7 @@ import { interface PacketVirtualListProps { packets: PacketSummary[]; hasNextPage: boolean; - isFetchingNextPage: boolean; + isFetching: boolean; fetchNextPage: () => void; onScrollAwayFromTop: (isAway: boolean) => void; onAtTopChange: (isAtTop: boolean) => void; @@ -34,7 +34,7 @@ interface PacketVirtualListProps { export function PacketVirtualList({ packets, hasNextPage, - isFetchingNextPage, + isFetching, fetchNextPage, onScrollAwayFromTop, onAtTopChange, @@ -71,13 +71,13 @@ export function PacketVirtualList({ onScrollAwayFromTop(el.scrollTop > SCROLL_TOP_THRESHOLD_PX); onAtTopChange(atTopRef.current); - if (hasNextPage && !isFetchingNextPage) { + if (hasNextPage && !isFetching) { const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; if (distFromBottom < SCROLL_BOTTOM_THRESHOLD_PX) { fetchNextPage(); } } - }, [hasNextPage, isFetchingNextPage, fetchNextPage, onScrollAwayFromTop, onAtTopChange]); + }, [hasNextPage, isFetching, fetchNextPage, onScrollAwayFromTop, onAtTopChange]); // When rows are prepended at the top (a reveal on return-to-top, or a live packet while already // at the top), TanStack keeps the previously-top row anchored — which drifts the view off the @@ -152,6 +152,21 @@ export function PacketVirtualList({ ); })} + {packets.length === 0 && ( +

No matching packets loaded.

+ )} + {hasNextPage && ( +
+ +
+ )} ); } diff --git a/src/features/packets/usePackets.ts b/src/features/packets/usePackets.ts index 3b642a6..ef453ee 100644 --- a/src/features/packets/usePackets.ts +++ b/src/features/packets/usePackets.ts @@ -206,6 +206,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi data: history, fetchNextPage, hasNextPage, + isFetching, isFetchingNextPage, isLoading, isError, @@ -251,6 +252,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi acknowledgeNewPackets, fetchNextPage, hasNextPage: hasNextPage ?? false, + isFetching, isFetchingNextPage, isLoading, isError, diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index d704489..8366c68 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -14,6 +14,7 @@ const basePackets = () => ({ acknowledgeNewPackets: () => {}, fetchNextPage: () => {}, hasNextPage: false, + isFetching: false, isFetchingNextPage: false, isLoading: false, isError: false, diff --git a/tests/features/packets/PacketVirtualList.test.tsx b/tests/features/packets/PacketVirtualList.test.tsx index 96f8028..8473f8b 100644 --- a/tests/features/packets/PacketVirtualList.test.tsx +++ b/tests/features/packets/PacketVirtualList.test.tsx @@ -59,7 +59,7 @@ const many = (n: number) => Array.from({ length: n }, (_, i) => pkt(`AA${i}`)); function makeHandlers() { return { hasNextPage: false, - isFetchingNextPage: false, + isFetching: false, fetchNextPage: vi.fn(), onScrollAwayFromTop: vi.fn(), onAtTopChange: vi.fn(), @@ -167,6 +167,51 @@ describe("PacketVirtualList header", () => { }); describe("PacketVirtualList scrolling", () => { + it.each([0, 1])("can manually page with %i visible rows and no scrollbar", (count) => { + const handlers = makeHandlers(); + const { rerender } = render( + , + ); + + expect(handlers.fetchNextPage).not.toHaveBeenCalled(); + if (count === 0) expect(screen.getByText("No matching packets loaded.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Load older packets" })); + expect(handlers.fetchNextPage).toHaveBeenCalledTimes(1); + + // A still-empty result after the page arrives must not start scanning further pages. + rerender(); + flushResize(); + expect(handlers.fetchNextPage).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Load older packets" })); + expect(handlers.fetchNextPage).toHaveBeenCalledTimes(2); + }); + + it("disables manual paging during a request and allows retry afterward", () => { + const handlers = makeHandlers(); + const { rerender } = render( + , + ); + const pending = screen.getByRole("button", { name: "Loading packets..." }); + expect(pending).toBeDisabled(); + fireEvent.click(pending); + expect(handlers.fetchNextPage).not.toHaveBeenCalled(); + + rerender(); + fireEvent.click(screen.getByRole("button", { name: "Load older packets" })); + expect(handlers.fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it("removes manual paging when the history cursor is exhausted", () => { + const handlers = makeHandlers(); + const { rerender } = render( + , + ); + expect(screen.getByRole("button", { name: "Load older packets" })).toBeEnabled(); + rerender(); + expect(screen.queryByRole("button", { name: "Load older packets" })).not.toBeInTheDocument(); + expect(handlers.fetchNextPage).not.toHaveBeenCalled(); + }); + it("pages when scrolled near the bottom", () => { const handlers = makeHandlers(); const { container } = render( @@ -183,7 +228,7 @@ describe("PacketVirtualList scrolling", () => { it("does not page while a page is already in flight", () => { const handlers = makeHandlers(); const { container } = render( - , + , ); const el = scroller(container);