feat: add predictions pagination and view more in explore#30445
Conversation
…e' of github.com:MetaMask/metamask-mobile into feat/add-predictions-pagination-and-view-more-in-explore
…e' of github.com:MetaMask/metamask-mobile into feat/add-predictions-pagination-and-view-more-in-explore
|
|
||
| if (!query) { | ||
| return []; | ||
| return { markets: [], totalResults: 0 }; |
There was a problem hiding this comment.
If we're adding pagination support, then searchMarkets should follow the same interface as getMarkets and return { markets: PredictMarket[], nextCursor: string | null} instead.
There was a problem hiding this comment.
The search endpoint is a different Polymarket API (/public-search) that returns pagination.totalResults, not a cursor. Using nextCursor here would always be null and would lose the total count that drives both the "View X more" label and infinite-loop prevention in getNextPageParam. The shapes differ because the underlying APIs differ.
There was a problem hiding this comment.
Oh, I see... that's right. Forgot they didn't update that one.
| interface SearchPage { | ||
| markets: PredictMarket[]; | ||
| totalResults: number; | ||
| } |
There was a problem hiding this comment.
Why is totalResults needed?
There was a problem hiding this comment.
Oh, total results is different than markets.length. Makes sense
| public async searchMarkets( | ||
| params: SearchMarketsParams, | ||
| ): Promise<PredictMarket[]> { | ||
| ): Promise<{ markets: PredictMarket[]; totalResults: number }> { |
There was a problem hiding this comment.
Same here, let's keep the interfaces consistent
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit bfe0ca0. Configure here.
MarioAslau
left a comment
There was a problem hiding this comment.
MEDIUM severity
M1 — V2 "View all / View X more" button is always rendered, even when there is nothing more to show
app/components/Views/TrendingView/search/ExploreSearchResultsV2.tsx
V1 gated the header link on a computed hasMore flag:
const hasMore = !isLoading && items.length > MAX_ITEMS_PER_SECTION;
result.push({ type: 'header', feedId, title, hasMore }); {item.hasMore && (
<Pressable
onPress={() => handleViewMore(section)}
...
>V2 unconditionally pushes a header and renders a pressable "View all / View X more" link, regardless of whether the section actually has more items than MAX_ITEMS_PER_SECTION = 3:
result.push({ type: 'header', feedId, title }); <Pressable
onPress={() => handleViewMore(section)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`${getViewMoreLabel(section.feedId, section.items.length, searchQuery, section.total)} ${item.title}`}
...
>
<Text variant={TextVariant.BodyMd} color={TextColor.TextAlternative}>
{getViewMoreLabel(
section.feedId,
section.items.length,
searchQuery,
section.total,
)}
</Text>
<Icon
name={IconName.ArrowRight}
size={IconSize.Sm}
color={IconColor.IconAlternative}
/>
</Pressable>Concrete cases this affects:
- Local-search feeds (perps / stocks / sites) returning ≤ 3 items: V2 shows a "View all" link that navigates to the same 3 items the user already sees.
- Predictions/tokens with
serverTotal ≤ MAX_ITEMS_PER_SECTION: same — the link still appears (label collapses to "View all" viagetViewMoreLabel).
getViewMoreLabel correctly degrades the text to "View all", but the button itself still renders. Either:
- This is intentional ("View all" doubles as a tab switcher) — please confirm in the PR description / Figma, or
- V2's
flatDatashould compute ahasMoreanalogous to V1, e.g.:and gate theconst hasMore = section.total !== undefined ? section.total > MAX_ITEMS_PER_SECTION : LOCAL_SEARCH_FEEDS.has(feedId) ? items.length > MAX_ITEMS_PER_SECTION : false;
Pressableon it.
Why medium: this is a visible UX behavior change between V1 and V2 introduced by this PR. Worth an explicit decision and a unit test asserting the chosen behavior.
M2 — V1 still uses local items.length > 3 for hasMore; doesn't read the new server total
app/components/Views/TrendingView/search/ExploreSearchResults.tsx
V1 (which is not behind exposePagination) computes hasMore purely from the client-side array:
const hasMore = !isLoading && items.length > MAX_ITEMS_PER_SECTION;Today this works because useExploreSearch defaults predictionsPageSize = 20, so any predictions search with > 3 server results will load ≥ 4 items into items and trigger hasMore. But the contract is now fragile:
- If a caller ever passes
predictionsPageSize <= 3(or upstream filtering reduces results below 4 with totalResults still large), V1 will hide the "View all" button while there are hundreds of results server-side. - Tokens similarly carries
total: tokens.totalCountonly whenexposePaginationis true; V1 has no visibility into that.
Recommended: have V1 use (section.total ?? items.length) > MAX_ITEMS_PER_SECTION so that when V1 is ever upgraded to use exposePagination it works correctly out of the box; and add a test exercising the "small page, large total" case.
Why medium: this is a behavioral pitfall that wouldn't surface in a code search but would silently regress "View all" visibility for predictions/tokens depending on how pageSize is tuned downstream.
M3 — Search errors are silently swallowed at the provider; the new error-path test is unreachable in production
app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts
PolymarketProvider.searchMarkets catches all errors and returns { markets: [], totalResults: 0 }:
} catch (error) {
DevLogger.log('Error searching markets via Polymarket API:', error);
Logger.error(
error instanceof Error ? error : new Error(String(error)),
this.getErrorContext('searchMarkets', {
hasSearchQuery: Boolean(query),
}),
);
return { markets: [], totalResults: 0 };
}PredictController.searchMarkets wraps that call in withTrace but does not add a separate catch, so withTrace observes a successful return ({ markets: [], totalResults: 0 }) on network failure — no throw, no MARKETS_FAILED propagation.
Consequence for the new hook:
usePredictSearchMarketDataexposeserroronly when the controller.searchMarkets(...)promise rejects. In production it never does.- The test that asserts this:
passes only because the mock rejects. The real controller path doesn't reject.
it('sets error and clears data when search throws', async () => { mockSearchMarkets.mockRejectedValue(new Error('Search failed')); ... expect(result.current.error).toBe('Search failed');
Result: when Polymarket's /public-search returns 500 or the device is offline, the search overlay shows "no results" indistinguishably from a legitimate empty query. Existing PredictFeed.view.test.tsx covers a separate "offline" UI by mocking the controller itself to reject — also non-representative of production.
This is a pre-existing concern that this PR inherits (it didn't introduce the swallow), but the PR builds a richer error contract on top of an unreachable error path. Suggest one of:
- Remove the provider's blanket catch and let withTrace +
fallbackErrorCode: MARKETS_FAILEDsurface the failure; or - Surface a sentinel field (e.g.
error?: string) in{ markets, totalResults }so the hook can distinguish empty from failed.
Why medium: silent failure of a network-bound search has real UX impact (users think the API "has nothing"). Worth fixing in the same PR since the contract is being modified anyway.
LOW severity
L1 — pageSize baked into the queryKey causes cache misses across contexts
queryKey: ['predict', 'markets', 'search', trimmedQuery, pageSize],If the explore overview uses pageSize: 20 and the predictions full-view uses, say, pageSize: 50, they will never share cache for the same query. Acceptable trade-off, just worth being aware of when tuning page sizes per surface.
L2 — No staleTime set on useInfiniteQuery
The query inherits react-query defaults (staleTime: 0), which can cause a refetch on every mount when the user navigates back to the search overlay. Consider an explicit staleTime: 30_000 (or whatever matches usePredictMarketData) to keep behavior aligned across hooks and avoid skeleton flicker.
L3 — searchMarkets trace omits page
traceData: (result) => ({ marketCount: result.markets.length }),For multi-page failures it would help to record page and pageSize in the trace context (and totalResults on success) for easier triage.
L4 — getNextPageParam reads lastPage.totalResults, not a stable first-page total
getNextPageParam: (lastPage, allPages) => {
// Compare pages fetched × pageSize against the server total rather than
// counting client-side filtered markets, which can be fewer than the raw
// event count and would cause infinite pagination.
const fetched = allPages.length * pageSize;
return fetched < lastPage.totalResults ? allPages.length + 1 : undefined;
},If the server total drifts between page fetches (data ingest mid-stream), hasMore can flip mid-pagination and the "View X more" label can desync with the rendered list. Low-probability for Polymarket; mention worth a comment or allPages[0].totalResults if stability matters.
L5 — V2 overview over-fetches: pageSize: 20 but only MAX_ITEMS_PER_SECTION = 3 shown
useExploreSearch defaults predictionsPageSize = 20; ExploreSearchResultsV2 slices to top 3. The extra 17 items are useful only as a prefetch for the "View all" navigation. If that navigation uses the same hook + same query + same pageSize, the cache is warm. If it ever diverges (per L1), the prefetch is wasted bandwidth. Consider documenting this rationale where predictionsPageSize is set.
L6 — Verbose accessibility label duplicating the visible text
accessibilityLabel={`${getViewMoreLabel(section.feedId, section.items.length, searchQuery, section.total)} ${item.title}`}This renders e.g. "View 47 more Crypto", which is fine but redundant with the visible label. Could be simplified to ${item.title}: ${getViewMoreLabel(...)} for screen reader clarity, or use accessibilityHint instead.
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection:
Tag Selection Rationale:
The changes are medium risk - they're well-contained within the Predictions/Trending search domain, but the API contract change in PredictController and the migration to infinite query are non-trivial changes that need E2E validation. Performance Test Selection: |
|
|
@cursor why was this skipped? |
|
The "Block stable-main to main PRs" job was skipped because it's working as intended. Here's why: Looking at the workflow file if: startsWith(github.head_ref, 'stable-main-')Your PR is from the branch What this workflow does:This workflow is designed to block manual PRs from Why it was skipped:
This is the correct behavior - the workflow saves CI resources by only running when it needs to check stable release branches. Your feature branch PR is not affected by this restriction, so the job appropriately skipped execution. The skipped status indicates the workflow is functioning properly, not that there's an issue with your PR. |







Description
Added pagination on Explore predictions search plus the "View X more" support
Changelog
CHANGELOG entry: added pagination on Explore predictions search plus the "View X more" support
Related issues
Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3250
Manual testing steps
Screenshots/Recordings
Before
After
Screen.Recording.2026-05-20.at.15.56.51.mov
Pre-merge author checklist
Performance checks (if applicable)
trace()for usage andaddTokenfor an exampleFor performance guidelines and tooling, see the Performance Guide.
Pre-merge reviewer checklist
Note
Medium Risk
Medium risk because it changes the
searchMarketsreturn shape across controller/provider layers and reworks search fetching touseInfiniteQuery, which can affect Explore search results, pagination, and error handling.Overview
Predictions search now supports pagination and server totals.
PredictProvider.searchMarkets(Polymarket + controller) now returns{ markets, totalResults }, plumbed from the Polymarketpublic-searchpagination response.Explore search consumes paged results and drives “View X more”.
usePredictSearchMarketDataswitches touseInfiniteQuery, addsfetchMore/hasMore/totalResults, and fixes next-page logic to avoid infinite loops when client-side filtering reduces visible items;usePredictionsFeedanduseExploreSearchforward pagination/total fields so header labels can compute hidden counts from server totals (and skip local Fuse re-ranking for server-ranked searches).UI/test cleanup. Search results lists use shared
searchTypesand stable item keys, and tests/mocks are updated for the new search result shape and new pagination behavior.Reviewed by Cursor Bugbot for commit a24e1e2. Bugbot is set up for automated code reviews on this repo. Configure here.