Skip to content

Leaderboard search only filters the current page's 10 in-memory rows, not the full leaderboard — false 'No results found' for real addresses #132

Description

@prodbycorne

Overview

src/app/leaderboard/page.tsx presents a prominent "Search address…" input, alongside sort controls and Prev/numbered/Next pagination, styled and positioned exactly like a search over the entire leaderboard. It isn't. src/hooks/useLeaderboard.ts filters searchQuery against only whichever page is currently loaded in memory:

const paged = searchQuery
  ? entries.filter((e) => e.address.toLowerCase().includes(searchQuery.toLowerCase()))
  : entries;

entries is the result of the most recent fetchLeaderboard(offset, PAGE_SIZE, sortKey) call (useLeaderboard.ts:44-57) — i.e. exactly PAGE_SIZE = 10 rows, whichever page the user happened to be viewing when they started typing. If a user is on page 1 and searches for an address that only appears on page 3, entries never contained that row, paged filters it out of a 10-row array that never had it, and the page renders the same "No results found." full-page empty state (leaderboard/page.tsx:158-170) it would show for an address that genuinely doesn't exist anywhere on the leaderboard — with no way for the user to tell the difference. Worse, filteredCount (surfaced as "You are rank X of {filteredCount} farmers" and as the pagination range text) is computed as searchQuery ? paged.length : total (useLeaderboard.ts:93) — i.e. while searching, the displayed "total" collapses to however many of the current page's 10 rows happened to match, not the real count of matches across the whole leaderboard, so even the count shown alongside a successful partial match is wrong.

Requirements

  • Either search server-side (extend sorobanService.getLeaderboard/the backend leaderboard API to accept a search/filter parameter and re-fetch on search-query change), or, if the leaderboard is expected to stay small enough, fetch the full unpaginated set once and filter/paginate entirely client-side — but the current hybrid (server-paginated fetch + client-side filter over only the current page) must not ship as-is.
  • Whichever approach is chosen, filteredCount must reflect the true number of matches across the entire leaderboard, not just the current in-memory page.
  • Searching for an address that exists on a different page than the one currently loaded must surface that result, not a false "No results found."

Acceptance Criteria

  • Searching for an address known to exist only on page 3 (while viewing page 1) returns that result rather than "No results found."
  • filteredCount/the "You are rank X of N farmers" text reflects the true total match count, not paged.length.
  • A regression test constructs exactly this scenario (mocked fetchLeaderboard returning different addresses per page/offset; search for a page-3-only address while offset corresponds to page 1) and asserts the address is found.
  • Existing sort/pagination behavior is unaffected when no search query is active.

Additional Notes

More precise references

  • src/hooks/useLeaderboard.ts:65-69 (paged) — confirmed the filter operates on entries, which is set only by refresh()'s fetchLeaderboard(offset, PAGE_SIZE, sortKey) call (lines 44-57), i.e. always exactly one page's worth of rows.
  • src/hooks/useLeaderboard.ts:93 (filteredCount: searchQuery ? paged.length : total) — confirmed total (the real server-reported count) is only used when not searching; the moment a query is typed, the displayed count silently switches to a same-page-only figure.
  • src/app/leaderboard/page.tsx:107-119 — confirmed the search <Input>'s placement and styling (inline with sort/refresh controls, full-width on mobile) presents it as a primary, leaderboard-wide search affordance, not a "filter this page" control — there is no copy anywhere suggesting the scope is limited to the current page.
  • src/app/leaderboard/page.tsx:158-170 — confirmed the "No results found" empty state is shared between "genuinely zero matches" and "matches exist elsewhere, just not on this page," with no way to distinguish them from the UI.
  • src/hooks/useLeaderboard.ts:32 (PAGE_SIZE = 10) — confirmed the practical window: any leaderboard bigger than 10 entries already exhibits this bug for any query whose only matches are outside the currently-fetched page.

Additional edge cases

  • The same useLeaderboard hook also renders numbered pagination buttons with no cap: Array.from({ length: totalPages }, ...) (leaderboard/page.tsx:332-349) renders one real <Button> per page with no windowing/ellipsis. On a leaderboard with, say, 2,000 ranked farmers (total / PAGE_SIZE = 200 pages), this alone is a serious, compounding UX problem sitting right next to the search bug — a user who can't find their address via search (because of this bug) is also unable to practically page-hunt for it, since scrolling through 200 unlabeled number buttons is not a realistic path either. Worth fixing together, or at minimum flagging in the same PR, since both stem from the leaderboard UI's page-at-a-time data model not scaling.
  • connectedRank (useLeaderboard.ts:71-75) has the same "only checks the currently-fetched page" limitation already tracked separately in Leaderboard's "You are rank X" banner only checks the currently-fetched page, not the user's true global rank #78 — that issue and this one share a root cause (the hook only ever holds one page of entries in memory) but are about different features (own-rank banner vs. search), so they're correctly filed as separate issues.

Implementation sketch

Simplest fix if the backend/event-scan leaderboard source can support it: add a search parameter to sorobanService.getLeaderboard(offset, limit, sortKey, search?), threading it through fetchLeaderboardFromApi/fetchLeaderboardFromEvents (the API path can pass it as a query param to the backend indexer; the event-scan fallback path can filter its already-fully-scanned all array by address before paginating — see soroban.ts:1521-1536, which already computes a complete in-memory all array before slicing to offset/limit, so a search filter could be applied there for free in that code path specifically). Then useLeaderboard's refresh() includes searchQuery in its fetchLeaderboard call and in its dependency array, re-fetching (server-correct, paginated) results whenever the (debounced) search query changes, rather than filtering client-side after the fact.

Test/reproduction plan

  • Mock fetchLeaderboard to return different, non-overlapping address sets depending on the offset argument (simulating 3 real pages of data); render the leaderboard on page 1; type an address known only to be in the page-3 mock data; assert it appears in the results (fails today, since entries never included page 3's data).
  • Assert filteredCount while searching matches the count of real matches across all pages in the mock dataset, not paged.length.

Cross-references

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignbugSomething isn't workingleaderboardLeaderboard feature — data, sorting, paginationvery hardExtremely hard — deep expertise, careful design, and significant time required

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions