Skip to content

feat(leaderboard): paginate entries via infinite scroll - #1796

Open
bilhokista wants to merge 4 commits into
Arena1X:mainfrom
bilhokista:feat/1547-leaderboard-infinite-scroll
Open

feat(leaderboard): paginate entries via infinite scroll#1796
bilhokista wants to merge 4 commits into
Arena1X:mainfrom
bilhokista:feat/1547-leaderboard-infinite-scroll

Conversation

@bilhokista

Copy link
Copy Markdown

Closes #1547.

A note on which page this is

The issue lists frontend/src/app/leaderboard/page.tsx. That file renders a hardcoded INITIAL_ENTRIES array and never calls the API, so there is nothing there to paginate.

The page that actually fetches the leaderboard is frontend/src/app/(authenticated)/leaderboards/page.tsx, via useLeaderboard. That is also the only one with the current-user "your rank" card the issue asks to preserve, so I took it as the intended target. Happy to move the work if the marketing page was meant instead.

What was missing

The API already supports pagination — getLeaderboard accepts page and returns { data, total, page, limit }. The hook simply never used it:

getLeaderboard({ season_id: sid, limit: 100 }, { signal })

One request, first 100 rows, no way to reach row 101.

The change

frontend/src/lib/leaderboard.ts (new) holds the merge rules as pure functions, so the append behaviour is testable without mounting a hook or mocking the network:

  • mergeLeaderboardPages(existing, incoming) — deduplicates by user_id. This is the crux of the issue's test requirement, and it is not a theoretical concern: the ranking is live, so a user who climbs between the page-1 and page-2 requests is legitimately returned in both. Appending blindly would show them twice and hand React two children with the same key.
  • hasMorePages / remainingCount — guard against a total of 0 and a limit of 0, either of which would otherwise describe an endless list. An infinite scroll that never stops asking is worse than one that stops early.

useLeaderboard gains loadMore, hasMore, isLoadingMore, and remaining.

Three details worth pointing out:

  • The in-flight guard is a ref, not the state flag. Two scroll events can fire before React re-renders, and both would otherwise fetch the same page.
  • loadMore reads pageInfo and seasonId from refs so the callback stays referentially stable — useInfiniteScroll rebuilds its IntersectionObserver whenever onLoadMore changes identity, and an unstable callback would tear down and re-create the observer on every render.
  • Changing season aborts any in-flight page request. A page-2 response for the previous season would otherwise append rows from the wrong leaderboard. It uses a separate abort controller from the first-page fetch, so cancelling one does not tear down the other.

A loadMore failure reports the error but deliberately leaves the loaded entries on screen — the first page is still valid, and clearing it would punish the user for a failed scroll.

Page. The observer sentinel renders only while hasMore, so a finished list stops asking. Infinite scroll is disabled during the initial load, because the sentinel sits in an empty list and is therefore on screen — without that guard it would request page 2 before page 1 arrived. Tail skeletons are sized to Math.min(remaining, 5) so the list does not promise more rows than the server has, and an sr-only status region announces progress.

The existing "your rank" card is derived from entries and so survives page loads unchanged.

Tests

New leaderboard.test.ts, 17 cases: appending, the overlap case, a whole page repeating, newer-copy-wins, position stability, both empty inputs, non-mutation, three-page accumulation, and the hasMorePages / remainingCount boundaries including the zero-limit and empty-leaderboard traps.

The existing useLeaderboard.test.ts asserts on expect.objectContaining({ season_id: undefined }), so the added page parameter does not break it.

Honest note on verification: I could not run the frontend Vitest suite locally (no full workspace install). All four files were parsed with the TypeScript compiler API, and leaderboard.ts was transpiled and executed standalone against 23 assertions — all passed, including a simulated three-page scroll session against a reshuffling ranking that returns overlapping rows, which ends with no duplicates and unique keys. The hook and page changes need CI.

One thing I did not change: LEADERBOARD_PAGE_SIZE is 50, down from the previous hardcoded 100. That makes the first paint lighter now that more pages are reachable, but it is a behaviour change on the first request and easy to put back if you would rather keep 100.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CrfEY1tvXrbeMDAUzxfuk7

@vercel

vercel Bot commented Sep 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
insight-arena-4rll Ready Ready Preview Sep 10, 2026 2:45pm UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Frontend] — Leaderboard Pagination via Infinite Scroll

1 participant