(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 (
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(() => {
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 40e0881..ed4cab8 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -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;
diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx
index bd3d8d1..347344a 100644
--- a/tests/features/packets/PacketList.test.tsx
+++ b/tests/features/packets/PacketList.test.tsx
@@ -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: () => {},
diff --git a/tests/features/packets/usePackets.test.tsx b/tests/features/packets/usePackets.test.tsx
index 4f77c3d..e7dfa23 100644
--- a/tests/features/packets/usePackets.test.tsx
+++ b/tests/features/packets/usePackets.test.tsx
@@ -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 }) => (
+ {children}
+ );
+
+ 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);
+ });
+});