mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-11 07:55:35 +00:00
Keep the packets list static while scrolled down instead of jumping on new packets
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user