mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 12:12:34 +00:00
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:
1 parent
f4093cf508
commit
714b32fd22
4 files changed
+103
-7
No files matched your search
+5
-4
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user