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);