From 58016bc773483f548ff7ce655641da8a1c2342cc Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 13 Jul 2026 18:51:56 -0400 Subject: [PATCH] Keep the packets list static while scrolled down instead of jumping on new packets --- src/features/packets/PacketList.tsx | 59 ++++++++--- src/features/packets/PacketVirtualList.tsx | 83 ++++++--------- src/features/packets/usePackets.ts | 18 +++- src/lib/constants.ts | 2 + tests/features/packets/PacketList.test.tsx | 4 + tests/features/packets/usePackets.test.tsx | 116 ++++++++++++++++++++- 6 files changed, 205 insertions(+), 77 deletions(-) diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 278b0f1..ab765ab 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -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 (
@@ -135,12 +159,13 @@ export function PacketList({ wsManager, onAnalyze }: PacketListProps) { )} diff --git a/src/features/packets/PacketVirtualList.tsx b/src/features/packets/PacketVirtualList.tsx index c61898c..80fdb78 100644 --- a/src/features/packets/PacketVirtualList.tsx +++ b/src/features/packets/PacketVirtualList.tsx @@ -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(null); const freshHashes = useFreshHashes(packets); - const isAtTopRef = useRef(true); - const prevCountRef = useRef(packets.length); + const atTopRef = useRef(true); const prevFirstKeyRef = useRef(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); + }); +});