Keep the packets list static while scrolled down instead of jumping on new packets

This commit is contained in:
MrAlders0n
2026-07-13 18:51:56 -04:00
parent 04f1757daa
commit 58016bc773
6 changed files with 205 additions and 77 deletions
+42 -17
View File
@@ -1,8 +1,9 @@
import { useState, useCallback, useRef, useMemo } from "react";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { usePackets } from "./usePackets";
import { usePacketFilters, matchesFilters } from "./usePacketFilters";
import { useScopes } from "../../hooks/useScopes";
import { useRegion } from "../../hooks/useRegion";
import { useWsPacketHandler, useWsLaggedHandler } from "../../hooks/useWsHandlers";
import { PacketVirtualList } from "./PacketVirtualList";
import { FilterBar } from "../../components/FilterBar";
@@ -33,6 +34,14 @@ export function PacketList({ wsManager, onAnalyze }: PacketListProps) {
const { filters, setFilter, setSearch, setSearchField, clearFilters } = usePacketFilters();
const scopeNames = useScopes();
const scopeOptions = useMemo(() => scopeNames.map((s) => ({ value: s, label: s })), [scopeNames]);
const { regionKey } = useRegion();
// isAtTop drives the freeze (list held static while scrolled off the very top); isScrolledAway
// (a wider deadband) drives the banner. listResetKey remounts the list to reveal held packets.
const [isScrolledAway, setIsScrolledAway] = useState(false);
const [isAtTop, setIsAtTop] = useState(true);
const [listResetKey, setListResetKey] = useState(0);
const {
allPackets,
observerOptions,
@@ -46,16 +55,13 @@ export function PacketList({ wsManager, onAnalyze }: PacketListProps) {
handleLagged,
laggedCount,
dismissLagged,
} = usePackets();
} = usePackets(!isAtTop);
const packets = useMemo(
() => allPackets.filter((p) => matchesFilters(p, filters, observersByHash)),
[allPackets, filters, observersByHash],
);
const [isScrolledAway, setIsScrolledAway] = useState(false);
const scrollToTopRef = useRef<(() => void) | null>(null);
// ?hash is the source of truth — the analyzer drawer clears it on close, deselecting the row
const expandedHash = searchParams.get("hash");
@@ -74,18 +80,36 @@ export function PacketList({ wsManager, onAnalyze }: PacketListProps) {
const bannerCount = isScrolledAway ? newPacketCount : 0;
const handleScrolledAway = useCallback(
(isAway: boolean) => {
setIsScrolledAway(isAway);
if (!isAway) acknowledgeNewPackets();
},
[acknowledgeNewPackets],
);
// Remount the list (fresh at the top, no stale scroll anchor for the virtualizer to preserve)
// when returning to the top with packets held while away — a big prepend into the live list
// would otherwise keep the old row anchored instead of landing on the newest.
const [prevAtTop, setPrevAtTop] = useState(isAtTop);
if (prevAtTop !== isAtTop) {
setPrevAtTop(isAtTop);
if (isAtTop && newPacketCount > 0) setListResetKey((k) => k + 1);
}
// A region switch starts fresh at the top so the new region's list isn't held frozen.
const [prevRegionKey, setPrevRegionKey] = useState(regionKey);
if (prevRegionKey !== regionKey) {
setPrevRegionKey(regionKey);
setListResetKey((k) => k + 1);
setIsAtTop(true);
setIsScrolledAway(false);
}
// At the top the held packets are revealed, so acknowledge continuously there — the banner then
// counts only what arrived while the user was away (and never flashes a count at the top).
useEffect(() => {
if (isAtTop && newPacketCount > 0) acknowledgeNewPackets();
}, [isAtTop, newPacketCount, acknowledgeNewPackets]);
// Returning to the top (revealing held packets) is a remount; releasing the freeze first lets
// the fresh list mount with the newest packet already in place.
const handleScrollToTop = useCallback(() => {
scrollToTopRef.current?.();
acknowledgeNewPackets();
}, [acknowledgeNewPackets]);
setIsScrolledAway(false);
setIsAtTop(true);
}, []);
return (
<div className="flex flex-1 min-h-0">
@@ -135,12 +159,13 @@ export function PacketList({ wsManager, onAnalyze }: PacketListProps) {
)}
<PacketVirtualList
key={listResetKey}
packets={packets}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
onScrollAwayFromTop={handleScrolledAway}
scrollToTopRef={scrollToTopRef}
onScrollAwayFromTop={setIsScrolledAway}
onAtTopChange={setIsAtTop}
expandedHash={expandedHash}
onToggleExpand={handleToggleExpand}
/>
+29 -54
View File
@@ -1,9 +1,13 @@
import { useRef, useCallback, useEffect, useLayoutEffect } from "react";
import { useRef, useCallback, useLayoutEffect } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { PacketSummary } from "../../types/api";
import { PacketRow } from "./PacketRow";
import { useFreshHashes } from "./useFreshHashes";
import { SCROLL_TOP_THRESHOLD_PX, SCROLL_BOTTOM_THRESHOLD_PX } from "../../lib/constants";
import {
SCROLL_TOP_THRESHOLD_PX,
SCROLL_BOTTOM_THRESHOLD_PX,
SCROLL_REVEAL_EPSILON_PX,
} from "../../lib/constants";
interface PacketVirtualListProps {
packets: PacketSummary[];
@@ -11,7 +15,7 @@ interface PacketVirtualListProps {
isFetchingNextPage: boolean;
fetchNextPage: () => void;
onScrollAwayFromTop: (isAway: boolean) => void;
scrollToTopRef?: React.MutableRefObject<(() => void) | null>;
onAtTopChange: (isAtTop: boolean) => void;
expandedHash: string | null;
onToggleExpand: (hash: string) => void;
}
@@ -24,33 +28,14 @@ export function PacketVirtualList({
isFetchingNextPage,
fetchNextPage,
onScrollAwayFromTop,
scrollToTopRef,
onAtTopChange,
expandedHash,
onToggleExpand,
}: PacketVirtualListProps) {
const parentRef = useRef<HTMLDivElement>(null);
const freshHashes = useFreshHashes(packets);
const isAtTopRef = useRef(true);
const prevCountRef = useRef(packets.length);
const atTopRef = useRef(true);
const prevFirstKeyRef = useRef<string | undefined>(packets[0]?.packetHash);
const savedScrollHeightRef = useRef(0);
const shouldCompensateRef = useRef(false);
// Anchor scroll position when live packets are PREPENDED while the user is scrolled away from
// the top. The pre-commit scroll height must be read here in the render body (parentRef still
// points at the old DOM); a post-commit effect is too late — the virtualizer's spacer has
// already grown, collapsing the delta to ~0 so nothing offsets the new rows. A changed first
// key distinguishes a real top-prepend from history pages appended at the bottom by
// fetchNextPage (those grow the count too but must NOT shift the view).
if (
packets.length > prevCountRef.current &&
packets[0]?.packetHash !== prevFirstKeyRef.current &&
!isAtTopRef.current &&
parentRef.current
) {
savedScrollHeightRef.current = parentRef.current.scrollHeight;
shouldCompensateRef.current = true;
}
const virtualizer = useVirtualizer({
count: packets.length,
@@ -60,28 +45,16 @@ export function PacketVirtualList({
getItemKey: (index) => packets[index]?.packetHash ?? index,
});
// After commit, before paint: offset scrollTop by the height the prepended rows added so the
// view stays anchored on the same packet. Keyed on the array (not just length) so bookkeeping
// stays current even when the live buffer is at its cap and the count holds steady.
useLayoutEffect(() => {
if (shouldCompensateRef.current) {
shouldCompensateRef.current = false;
const el = parentRef.current;
if (el) {
const delta = el.scrollHeight - savedScrollHeightRef.current;
if (delta > 0) el.scrollTop += delta;
}
}
prevCountRef.current = packets.length;
prevFirstKeyRef.current = packets[0]?.packetHash;
}, [packets]);
// The list is frozen at the data layer while scrolled away, so there's nothing to compensate
// here — we only report scroll position: past the threshold shows the banner, and back at the
// very top releases the freeze (revealing held packets where a prepend is jump-free).
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
isAtTopRef.current = el.scrollTop <= SCROLL_TOP_THRESHOLD_PX;
onScrollAwayFromTop(!isAtTopRef.current);
atTopRef.current = el.scrollTop <= SCROLL_REVEAL_EPSILON_PX;
onScrollAwayFromTop(el.scrollTop > SCROLL_TOP_THRESHOLD_PX);
onAtTopChange(atTopRef.current);
if (hasNextPage && !isFetchingNextPage) {
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
@@ -89,19 +62,21 @@ export function PacketVirtualList({
fetchNextPage();
}
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage, onScrollAwayFromTop]);
}, [hasNextPage, isFetchingNextPage, fetchNextPage, onScrollAwayFromTop, onAtTopChange]);
useEffect(() => {
if (!scrollToTopRef) return;
scrollToTopRef.current = () => {
const el = parentRef.current;
if (el) {
isAtTopRef.current = true;
el.scrollTop = 0;
onScrollAwayFromTop(false);
}
};
}, [scrollToTopRef, onScrollAwayFromTop]);
// 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
// newest packet. Re-pin index 0 so the newest stays at the top; only while at the top, so a
// prepend never disturbs someone reading further down.
useLayoutEffect(() => {
const firstKey = packets[0]?.packetHash;
const firstChanged = firstKey !== prevFirstKeyRef.current;
prevFirstKeyRef.current = firstKey;
if (firstChanged && atTopRef.current) {
virtualizer.scrollToIndex(0, { align: "start" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fires on packet-list change; virtualizer is stable
}, [packets]);
return (
<div
+14 -4
View File
@@ -114,14 +114,15 @@ class LivePacketStore {
// combines live WS stream with paginated history
export function usePackets() {
export function usePackets(frozen: boolean = false) {
const { iatas, regionKey } = useRegion();
const queryClient = useQueryClient();
const [store] = useState(() => new LivePacketStore());
const [laggedCount, setLaggedCount] = useState(0);
const [prevRegionKey, setPrevRegionKey] = useState(regionKey);
if (prevRegionKey !== regionKey) {
const regionChanged = prevRegionKey !== regionKey;
if (regionChanged) {
setPrevRegionKey(regionKey);
store.reset();
setLaggedCount(0);
@@ -132,6 +133,15 @@ export function usePackets() {
store.getSnapshot,
);
// While scrolled away from the top, render a latched buffer so live prepends don't shift the
// view; the held packets reveal when the user returns to the top. Latched by holding the last
// value (set-state-during-render, this file's pattern — cf. prevRegionKey above). The
// regionChanged guard drops the latch so a region switch never shows the previous region.
const [displayBuffer, setDisplayBuffer] = useState(liveBuffer);
if ((!frozen || regionChanged) && displayBuffer !== liveBuffer) {
setDisplayBuffer(liveBuffer);
}
const handlePacketObservation = useCallback(
(data: WsPacketObservation["data"]) => {
const summary: PacketSummary = {
@@ -191,8 +201,8 @@ export function usePackets() {
});
const allPackets = useMemo(
() => dedup([...liveBuffer, ...flattenPages(history)]),
[liveBuffer, history],
() => dedup([...displayBuffer, ...flattenPages(history)]),
[displayBuffer, history],
);
const observerOptions = useMemo(() => {
+2
View File
@@ -7,6 +7,8 @@ export const DEFAULT_PAGE_SIZE = 50;
export const SCROLL_TOP_THRESHOLD_PX = 100;
export const SCROLL_BOTTOM_THRESHOLD_PX = 500;
// reveal held live packets only at the very top, so the prepend that reveals them is jump-free
export const SCROLL_REVEAL_EPSILON_PX = 4;
export const WS_PING_INTERVAL_MS = 30_000;
export const WS_RECONNECT_BASE_MS = 1000;
@@ -23,6 +23,10 @@ vi.mock("../../../src/features/packets/usePackets", () => ({
vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] }));
vi.mock("../../../src/hooks/useRegion", () => ({
useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }),
}));
vi.mock("../../../src/hooks/useWsHandlers", () => ({
useWsPacketHandler: () => {},
useWsLaggedHandler: () => {},
+114 -2
View File
@@ -1,9 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } 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";
import type { WsPacketObservation } from "../../../src/types/ws";
vi.mock("../../../src/hooks/useRegion", () => ({
useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }),
@@ -84,3 +85,114 @@ describe("usePackets gap healing", () => {
expect(result.current.laggedCount).toBe(5);
});
});
function observation(hash: string): WsPacketObservation["data"] {
return {
packetHash: hash,
packet: {
payloadType: 4,
payloadTypeName: "ADVERT",
routeType: 1,
routeTypeName: "FLOOD",
isFirstObservation: true,
observationCount: 1,
},
observation: {
observerId: "o1",
observerName: "Obs",
iata: "YOW",
heardAt: 1,
rssi: -80,
snr: 5,
sourceBroker: "b",
},
};
}
describe("usePackets freeze while scrolled away", () => {
let qc: QueryClient;
let rafCallbacks: FrameRequestCallback[];
beforeEach(() => {
getPackets.mockReset();
getPackets.mockResolvedValue({ items: [], nextCursor: null });
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
rafCallbacks = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const flushRaf = () => rafCallbacks.splice(0).forEach((cb) => cb(0));
async function mount() {
const view = renderHook(({ frozen }) => usePackets(frozen), {
initialProps: { frozen: false },
wrapper,
});
await waitFor(() => expect(getPackets).toHaveBeenCalled());
return view;
}
it("withholds live packets prepended while frozen, but still counts them", async () => {
const { result, rerender } = await mount();
act(() => {
result.current.handlePacketObservation(observation("p1"));
flushRaf();
});
expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["p1"]);
rerender({ frozen: true });
act(() => {
result.current.handlePacketObservation(observation("p2"));
flushRaf();
});
// frozen: the rendered list stays on p1; the banner still counts the held packet
expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["p1"]);
expect(result.current.newPacketCount).toBe(2);
});
it("reveals held packets once unfrozen", async () => {
const { result, rerender } = await mount();
act(() => {
result.current.handlePacketObservation(observation("p1"));
flushRaf();
});
rerender({ frozen: true });
act(() => {
result.current.handlePacketObservation(observation("p2"));
flushRaf();
});
rerender({ frozen: false });
expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["p2", "p1"]);
});
it("clears the new-packet count on acknowledge", async () => {
const { result } = await mount();
act(() => {
result.current.handlePacketObservation(observation("p1"));
result.current.handlePacketObservation(observation("p2"));
flushRaf();
});
expect(result.current.newPacketCount).toBe(2);
act(() => result.current.acknowledgeNewPackets());
expect(result.current.newPacketCount).toBe(0);
});
});