From 714b32fd2250fe66efa5dd39f1d69592fe8e7f1c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 10 Jun 2026 06:39:12 -0400 Subject: [PATCH] fix back/forward tab navigation and the packet-list gap on tab return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active tab was state seeded from ?tab once, so back/forward moved the URL without moving the UI. The URL is the source of truth now, and unknown ?tab values fall back to Packets instead of a blank pane. Packets history also healed wrong: while the tab is unmounted its WS handler is down, and staleTime Infinity meant the gap never closed. Mounting now resets to a fresh first page. Same for lag notices, which used to invalidate (and sequentially replay) up to 20 cached pages — together with the ws reconnect notice this covers outage recovery too. --- src/App.tsx | 9 ++- src/components/AppShell.tsx | 2 +- src/features/packets/usePackets.ts | 13 +++- tests/features/packets/usePackets.test.tsx | 86 ++++++++++++++++++++++ 4 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 tests/features/packets/usePackets.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 11700f3..99d6396 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,7 @@ import { } from "./hooks/region-selection"; import { ThemeProvider } from "./hooks/useTheme"; import { useIsMobile } from "./hooks/useMediaQuery"; -import { AppShell } from "./components/AppShell"; +import { AppShell, TABS } from "./components/AppShell"; import { SplashScreen } from "./components/SplashScreen"; import { PacketList } from "./features/packets/PacketList"; import { PacketAnalyzerDrawer } from "./features/packets/PacketAnalyzerDrawer"; @@ -118,7 +118,10 @@ function SelectionResetOnRegion({ onRegionChange }: { onRegionChange: () => void function AppInner() { const [searchParams, setSearchParams] = useSearchParams(); const isMobile = useIsMobile(); - const [activeTab, setActiveTab] = useState(() => searchParams.get("tab") ?? "Packets"); + // The URL is the single source of truth for the active tab — back/forward just work, and an + // unknown ?tab value falls back to Packets instead of rendering a blank pane. + const tabParam = searchParams.get("tab"); + const activeTab = (TABS as readonly string[]).includes(tabParam ?? "") ? (tabParam as string) : "Packets"; // Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection). const [initialSelection] = useState(() => computeInitialSelection(searchParams)); @@ -152,7 +155,6 @@ function AppInner() { }, []); const handleTabChange = (tab: string) => { - setActiveTab(tab); setOverlayNodeId(null); setOverlayPacketHash(null); // On mobile a detail panel fills the screen, so leaving its tab must close it; desktop side @@ -182,7 +184,6 @@ function AppInner() { (id: string) => { setOverlayNodeId(null); setOverlayPacketHash(null); - setActiveTab("Stats"); setSearchParams((prev) => { const next = new URLSearchParams(prev); next.set("tab", "Stats"); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 506c0cf..46a48a5 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -11,7 +11,7 @@ import { BeaconWordmark } from "./BeaconWordmark"; import { getIatas } from "../api/client"; import type { WsManager } from "../api/ws-manager"; -const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Stats"] as const; +export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Stats"] as const; // header widgets: WS status, region picker, theme picker diff --git a/src/features/packets/usePackets.ts b/src/features/packets/usePackets.ts index a27ce7b..5a299fc 100644 --- a/src/features/packets/usePackets.ts +++ b/src/features/packets/usePackets.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useSyncExternalStore } from "react"; +import { useState, useEffect, useCallback, useMemo, useSyncExternalStore } from "react"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { getPackets } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; @@ -156,14 +156,23 @@ export function usePackets() { [store], ); + // Reset (drop to one fresh first page) instead of invalidate: an invalidate replays every cached + // page sequentially — up to 20 requests per lag notice during a flood. const handleLagged = useCallback( (data: WsLagged) => { setLaggedCount((prev) => prev + data.droppedCount); - queryClient.invalidateQueries({ queryKey: ["packets", regionKey] }); + queryClient.resetQueries({ queryKey: ["packets", regionKey] }); }, [queryClient, regionKey], ); + // The WS handler is down whenever this tab is unmounted, so cached history may hide a gap right + // where the live buffer begins. Refresh the first page on mount to close it. + useEffect(() => { + queryClient.resetQueries({ queryKey: ["packets", regionKey] }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount only; region changes refetch via the key + }, []); + const dismissLagged = useCallback(() => setLaggedCount(0), []); const { diff --git a/tests/features/packets/usePackets.test.tsx b/tests/features/packets/usePackets.test.tsx new file mode 100644 index 0000000..4f77c3d --- /dev/null +++ b/tests/features/packets/usePackets.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { usePackets } from "../../../src/features/packets/usePackets"; +import type { PacketSummary } from "../../../src/types/api"; + +vi.mock("../../../src/hooks/useRegion", () => ({ + useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), +})); + +const getPackets = vi.fn(); +vi.mock("../../../src/api/client", () => ({ + getPackets: (...args: unknown[]) => getPackets(...args), +})); + +function packet(hash: string): PacketSummary { + return { + packetHash: hash, + payloadType: 4, + payloadTypeName: "ADVERT", + routeType: 1, + routeTypeName: "FLOOD", + firstHeardAt: 1, + lastHeardAt: 1, + observationCount: 1, + } as PacketSummary; +} + +function seedThreePages(qc: QueryClient) { + qc.setQueryData(["packets", "YOW"], { + pages: [ + { items: [packet("a1")], nextCursor: 200 }, + { items: [packet("b2")], nextCursor: 100 }, + { items: [packet("c3")], nextCursor: null }, + ], + pageParams: [undefined, 200, 100], + }); +} + +describe("usePackets gap healing", () => { + let qc: QueryClient; + + beforeEach(() => { + getPackets.mockReset(); + // nextCursor stays truthy so an invalidate would replay every cached page — the behavior under test + getPackets.mockResolvedValue({ items: [packet("fresh")], nextCursor: 999 }); + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it("refetches only the first page on mount instead of trusting stale cached pages", async () => { + seedThreePages(qc); + renderHook(() => usePackets(), { wrapper }); + + // the remount gap is healed with a single first-page fetch, not a 3-page replay + await waitFor(() => expect(getPackets).toHaveBeenCalled()); + await waitFor(() => { + const data = qc.getQueryData<{ pages: unknown[] }>(["packets", "YOW"]); + expect(data?.pages).toHaveLength(1); + }); + expect(getPackets).toHaveBeenCalledTimes(1); + expect(getPackets.mock.calls[0]![1]).toEqual({ cursor: undefined }); + }); + + it("resets to a single first-page fetch on a lagged notice (no page-by-page storm)", async () => { + const { result } = renderHook(() => usePackets(), { wrapper }); + await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1)); + + // simulate deep scroll state, then a lag notice + seedThreePages(qc); + getPackets.mockClear(); + result.current.handleLagged({ v: 1, type: "lagged", droppedCount: 5, since: 0, lastObservationId: 0 }); + + await waitFor(() => expect(getPackets).toHaveBeenCalled()); + await waitFor(() => { + const data = qc.getQueryData<{ pages: unknown[] }>(["packets", "YOW"]); + expect(data?.pages).toHaveLength(1); + }); + expect(getPackets).toHaveBeenCalledTimes(1); + expect(result.current.laggedCount).toBe(5); + }); +});