fix back/forward tab navigation and the packet-list gap on tab return

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.
This commit is contained in:
MrAlders0n committed 2026-06-10 06:39:12 -04:00
1 parent f4093cf508
commit 714b32fd22
4 files changed
+103 -7

No files matched your search

+5 -4
View File
@@ -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");
+1 -1
View File
@@ -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
+11 -2
View File
@@ -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 {
@@ -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 }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
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);
});
});