diff --git a/apps/web/src/components/replays/sessions-list.browser.test.tsx b/apps/web/src/components/replays/sessions-list.browser.test.tsx index 0a270ccce1..f2f18f470e 100644 --- a/apps/web/src/components/replays/sessions-list.browser.test.tsx +++ b/apps/web/src/components/replays/sessions-list.browser.test.tsx @@ -74,6 +74,57 @@ describe("SessionsList pagination observer", () => { view.unmount() expect(second.disconnect).toHaveBeenCalledOnce() }) + + const bots = (count: number): SessionRow[] => + Array.from({ length: count }, (_, index) => ({ + ...session, + sessionId: `bot-${index}`, + tags: ["bot"], + traceCount: 0, + })) + const onReachEnd = vi.fn() + const list = (sessions: SessionRow[]) => ( + + ) + + // A page that only extends a folded run leaves the sentinel where it was, so + // it would pull every remaining page without the user scrolling. + it("pauses after pages that add no row and resumes on scroll", () => { + const view = render(list([session, ...bots(2)])) + const first = MockIntersectionObserver.instances[0]! + + view.rerender(list([session, ...bots(4)])) + view.rerender(list([session, ...bots(6)])) + expect(first.disconnect).not.toHaveBeenCalled() + + view.rerender(list([session, ...bots(8)])) + expect(first.disconnect).toHaveBeenCalledOnce() + expect(MockIntersectionObserver.instances).toHaveLength(1) + expect(view.queryByRole("button", { name: /load more/i })).toBeNull() + + fireEvent.scroll(window) + expect(MockIntersectionObserver.instances).toHaveLength(2) + }) + + // A list too short to scroll gives a keyboard no `scroll` event to resume on. + it("resumes a paused list on a key press", () => { + const view = render(list([session, ...bots(2)])) + for (const count of [4, 6, 8]) view.rerender(list([session, ...bots(count)])) + expect(MockIntersectionObserver.instances).toHaveLength(1) + + fireEvent.keyDown(window, { key: "PageDown" }) + expect(MockIntersectionObserver.instances).toHaveLength(2) + }) + + it("keeps auto-loading while pages add rows", () => { + const engaged = (count: number) => + Array.from({ length: count }, (_, index) => ({ ...session, sessionId: `s-${index}` })) + const view = render(list([...bots(2), ...engaged(1)])) + for (const count of [2, 3, 4, 5]) view.rerender(list([...bots(2), ...engaged(count)])) + + expect(MockIntersectionObserver.instances).toHaveLength(1) + expect(MockIntersectionObserver.instances[0]!.disconnect).not.toHaveBeenCalled() + }) }) // The list is virtualized against a scroll ancestor that jsdom gives zero @@ -248,58 +299,6 @@ describe("SessionsList low-signal runs", () => { expect(view.getAllByText("Bot")).toHaveLength(4) }) - // A folded run is one short row, so an auto-loading sentinel after it would - // stay in view and pull every page without the user scrolling. - it("asks before loading more when the list ends in a folded run", () => { - const onReachEnd = vi.fn() - const view = render( - , - ) - fireEvent.click(view.getByRole("button", { name: "Load more sessions" })) - expect(onReachEnd).toHaveBeenCalledOnce() - }) - - it("asks before loading more when folding leaves the list short, whatever the last row", () => { - const onReachEnd = vi.fn() - const noise = Array.from({ length: 49 }, (_, index) => bot(`bot-${index}`)) - const view = render( - , - ) - expect(view.getByRole("button", { name: "Load more sessions" })).toBeTruthy() - expect(onReachEnd).not.toHaveBeenCalled() - }) - - it("asks before loading more when a long list ends in a folded run", () => { - const engaged = Array.from({ length: 30 }, (_, index) => ({ ...session, sessionId: `s-${index}` })) - const view = render( - , - ) - expect(view.getByRole("button", { name: "Load more sessions" })).toBeTruthy() - }) - - it("keeps auto-loading a long list that ends in a session", () => { - const engaged = Array.from({ length: 30 }, (_, index) => ({ ...session, sessionId: `s-${index}` })) - const view = render( - , - ) - expect(view.queryByRole("button", { name: "Load more sessions" })).toBeNull() - }) - it("shows every row when collapsing is off", () => { const view = render() expect(view.queryByText("2 low-signal sessions")).toBeNull() diff --git a/apps/web/src/components/replays/sessions-list.tsx b/apps/web/src/components/replays/sessions-list.tsx index 7643d6e8e2..8b09d2a38c 100644 --- a/apps/web/src/components/replays/sessions-list.tsx +++ b/apps/web/src/components/replays/sessions-list.tsx @@ -5,7 +5,8 @@ import { ReachEndSentinel } from "@/components/common/reach-end-sentinel" import { useNavigate } from "@tanstack/react-router" import { useVirtualizer } from "@tanstack/react-virtual" import { Badge, badgeVariants } from "@maple/ui/components/ui/badge" -import { ListFooter, LoadMoreButton, LoadingMoreRow } from "@maple/ui/components/ui/list-footer" +import { ListFooter, LoadingMoreRow } from "@maple/ui/components/ui/list-footer" +import { useMountEffect } from "@/hooks/use-mount-effect" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { countLabel, EMPTY_VALUE, pluralize } from "@maple/ui/lib/format" @@ -78,9 +79,41 @@ const COLUMNS = { time: "hidden w-24 shrink-0 justify-end @2xl:flex", } as const -/** Rendered rows (~65px each) past which the sentinel sits below any viewport plus - * its 400px root margin, so it only fires once the user scrolls. */ -const AUTO_LOAD_MIN_ROWS = 25 +/** Consecutive pages that may add no visible row before auto-loading waits for a scroll. */ +const MAX_STALLED_PAGES = 3 + +const RESUME_EVENTS = ["scroll", "wheel", "touchmove", "keydown"] as const + +/** + * A page that only extends a folded run adds no row, so the sentinel stays in + * view and would pull every remaining page with no scrolling. Pause after a few + * such pages in a row and pick up again the next time the user scrolls. + */ +function useAutoLoadPause(sessionCount: number, rowCount: number) { + const [progress, setProgress] = useState({ sessionCount, rowCount, stalledPages: 0 }) + if (progress.sessionCount !== sessionCount) { + const stalled = sessionCount > progress.sessionCount && rowCount <= progress.rowCount + setProgress({ sessionCount, rowCount, stalledPages: stalled ? progress.stalledPages + 1 : 0 }) + } else if (progress.rowCount !== rowCount) { + setProgress({ ...progress, rowCount }) + } + return { + paused: progress.stalledPages >= MAX_STALLED_PAGES, + resume: () => setProgress((current) => ({ ...current, stalledPages: 0 })), + } +} + +/** Mounted while auto-loading is paused. `wheel`, `touchmove` and `keydown` cover a list too short to scroll. */ +function ResumeOnScroll({ onResume }: { onResume: () => void }) { + useMountEffect(() => { + const options = { capture: true, passive: true } + for (const event of RESUME_EVENTS) window.addEventListener(event, onResume, options) + return () => { + for (const event of RESUME_EVENTS) window.removeEventListener(event, onResume, options) + } + }) + return null +} interface SessionsListProps { sessions: ReadonlyArray @@ -140,15 +173,7 @@ export function SessionsList({ expanded, nowMs: effectiveNowMs, }) - // Folded runs can leave a page of sessions only a few rows tall, which keeps the - // auto-loading sentinel in view and pulls every page with no scrolling. Ask - // instead until the rows are tall enough that reaching the end takes a scroll, - // and whenever the list ends in a folded run: the next page may only extend - // that run, leaving the sentinel exactly where it was. - const hidesSessions = items.some((item) => item.kind === "quiet" && !item.expanded) - const lastItem = items.at(-1) - const endsInFoldedRun = lastItem?.kind === "quiet" && !lastItem.expanded - const manualLoadMore = hidesSessions && (items.length < AUTO_LOAD_MIN_ROWS || endsInFoldedRun) + const autoLoad = useAutoLoadPause(sessions.length, items.length) const toggleRun = (key: string) => setExpanded((previous) => { const next = new Set(previous) @@ -230,14 +255,8 @@ export function SessionsList({ {hasMore && - (manualLoadMore ? ( -
- onReachEnd?.()} - /> -
+ (autoLoad.paused ? ( + ) : ( ))}