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 ? (
+
) : (
))}