Stop stripping ?hash on mount, it breaks deep links to older packets

The analyzer drawer and path-map restore both read the live ?hash and
fetch by hash directly, independent of whether the packet is in the
loaded list. Deleting the param once the first page settles without a
match unmounts the drawer and can drop path-map restores, for any
shared link more than a couple of minutes old.
This commit is contained in:
MrAlders0n
2026-07-27 20:41:57 -04:00
parent f44f1ac0a1
commit 663eeef775
3 changed files with 7 additions and 70 deletions
+1 -2
View File
@@ -183,8 +183,7 @@ function AppInner() {
}, []);
const handleAnalyze = useCallback((hash: string | null) => {
// No reset here: observation ids are globally unique, so a stale one from another packet
// can't accidentally match — this lets a pick made inside an expanded row survive into the drawer.
// No reset: observation ids are globally unique, so a pick inside an expanded row survives into the drawer.
setSearchParams((p) => {
const n = new URLSearchParams(p);
if (hash) { n.set("hash", hash); n.set("analyze", "1"); n.delete("path"); }
+1 -18
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { usePackets } from "./usePackets";
@@ -79,23 +79,6 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
// ?hash is the selected packet — it expands the row inline. The analyzer is a separate state (?analyze=1).
const expandedHash = searchParams.get("hash");
// A deep-linked ?hash that matches nothing once the first page has loaded is stale (bogus, or
// long-expired) — strip it so it doesn't linger forever. Only the hash present at mount is
// checked, once, so a user's own click-to-expand (always a packet already in allPackets) never
// trips this. Gated on isLoading so a slow first page can't strip a link before its packet arrives.
const [initialHash] = useState(() => searchParams.get("hash"));
const strippedInitialHashRef = useRef(false);
useEffect(() => {
if (strippedInitialHashRef.current || isLoading || !initialHash) return;
strippedInitialHashRef.current = true;
if (allPackets.some((p) => p.packetHash === initialHash)) return;
setSearchParams((p) => {
const n = new URLSearchParams(p);
if (n.get("hash") === initialHash) n.delete("hash");
return n;
}, { replace: true });
}, [isLoading, initialHash, allPackets, setSearchParams]);
const handleToggleExpand = useCallback((hash: string) => {
const next = expandedHash === hash ? null : hash;
setSearchParams((p) => {
+5 -50
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { render, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PacketList } from "../../../src/features/packets/PacketList";
import type { WsManager } from "../../../src/api/ws-manager";
@@ -100,11 +100,6 @@ const observation = (hash: string): WsPacketObservation["data"] => ({
},
});
function LocationProbe() {
const location = useLocation();
return <div data-testid="search">{location.search}</div>;
}
function renderList(url = "/", props: Partial<Parameters<typeof PacketList>[0]> = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
@@ -112,7 +107,7 @@ function renderList(url = "/", props: Partial<Parameters<typeof PacketList>[0]>
const onViewPath = props.onViewPath ?? vi.fn();
const onSelectObservation = props.onSelectObservation ?? vi.fn();
const tree = (
render(
<MemoryRouter initialEntries={[url]}>
<QueryClientProvider client={queryClient}>
<PacketList
@@ -122,16 +117,11 @@ function renderList(url = "/", props: Partial<Parameters<typeof PacketList>[0]>
selectedObservationId={null}
onSelectObservation={onSelectObservation}
/>
<LocationProbe />
</QueryClientProvider>
</MemoryRouter>
</MemoryRouter>,
);
const utils = render(tree);
// MemoryRouter only reads initialEntries on its first mount, so rerendering with the identical
// element (same position in the tree) keeps whatever location the component has navigated to.
return { onAnalyze, onViewPath, onSelectObservation, invalidate, rerender: () => utils.rerender(tree) };
return { onAnalyze, onViewPath, onSelectObservation, invalidate };
}
describe("PacketList server filter wiring", () => {
@@ -280,38 +270,3 @@ describe("PacketList live observation invalidation", () => {
expect(invalidate).not.toHaveBeenCalled();
});
});
describe("PacketList stale ?hash strip", () => {
afterEach(() => {
usePackets.mockImplementation(basePackets);
});
it("strips a ?hash matching no loaded packet once the first page has loaded", async () => {
usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: false, allPackets: [packet("AA11")] }));
renderList("/?tab=Packets&hash=BOGUS");
await waitFor(() => expect(screen.getByTestId("search").textContent).not.toContain("hash="));
});
it("does not strip while the first page is still loading", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: true, allPackets: [] }));
const { rerender } = renderList("/?tab=Packets&hash=BOGUS");
expect(screen.getByTestId("search").textContent).toContain("hash=BOGUS");
// the packet that matches the deep link arrives only after the first page resolves
usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: false, allPackets: [packet("BOGUS")] }));
rerender();
expect(screen.getByTestId("search").textContent).toContain("hash=BOGUS");
});
it("does not strip a ?hash that matches a loaded packet", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: false, allPackets: [packet("AA11")] }));
renderList("/?tab=Packets&hash=AA11");
expect(screen.getByTestId("search").textContent).toContain("hash=AA11");
});
});