You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Auto -Spend - "Next" arrow is disabled when user reaches 50 of 50 report #101453
changed the title [-]Auto -Spend- "Next" arrow is disabled when user reaches 50 of 50 report[/-][+]Auto -Spend - "Next" arrow is disabled when user reaches 50 of 50 report[/+]on Sep 17, 2026
Opening a report puts that Search screen behind a right-hand modal, where FreezeWrapper freezes it. Its effects stop running, so sortedReportIDs is stuck at the 50 IDs captured before you opened the first report — 50 being CONST.SEARCH.RESULTS_PAGE_SIZE.
The arrow does fetch page 2 (:207-223) and those reports do land in the Onyx snapshot — but the list that can see them (useSearchSections) is only mounted while loading (:242). The moment the page arrives, loading flips false and the component reverts to the frozen 50.
So at report 50, :149 disables the button, and the label reads "50 of 50" because the count is re-synced from that same frozen list.
Two secondary defects that make this worse
1. The tail wraps instead of advancing.:225 computes (currentIndex + 1) % effectiveAllReports.length. There is no "wait for the next page" path — so with >100 matching reports the button stays enabled but throws you back to report 1. Report 51 is unreachable either way.
2. hasMoreResults can be erased permanently.saveLastSearchParams uses Onyx.set, not merge. Search.ts:1244-1251 writes that key before the request without hasMoreResults, wiping it. It is restored only in .then() and only when both response.search.offset and prevReportsLength are truthy (:1287-1315); the .catch() restores nothing. One failed request and the flag stays undefined, which permanently disables the arrow with no retry — unlike the list view, which re-arms via wantedOffsetRef (Search/index.tsx:883-890).
Note the Search list's own infinite scroll never writes this key — shouldUpdateLastSearchParams defaults to false and only the arrow passes true. So the arrows' entire pagination ability rests on a single snapshot read taken at the instant you clicked the report row (Search/index.tsx:697-705).
What changes do you think we should make in order to solve the problem?
Make the snapshot the source of truth for the arrow list. Stop preferring the frozen Search-context list. Either mount the useSearchSections list unconditionally in MoneyRequestReportNavigation, or prefer whichever list is longer. The context list is a render-time cache of a screen that is frozen for the entire time the arrows are on screen, so it can never be authoritative.
Don't wrap at the tail. Replace the modulo at :225 with a bounds check: if there is no next item and a page is in flight, hold position (show a loading state on the arrow) and advance once the list grows.
Read hasMoreResults and offset straight off the snapshot, the way fetchMoreResults already does (Search/index.tsx:836-880), instead of the separately-persisted copy in REPORT_NAVIGATION_LAST_SEARCH_QUERY. That removes the whole wipe-and-restore class of bug.
If that duplicated key must stay, change saveLastSearchParams to merge and restore the flag in .catch() too.
Fix 1 alone unblocks report 51. Fixes 2-4 stop it regressing in the failure and >100-report cases.
Add a regression test: today MoneyRequestReportNavigation.test.tsx asserts which list is picked, but never that the list grows after a page lands — which is exactly the untested gap.
What alternative solutions did you explore? (Optional)
Raising RESULTS_PAGE_SIZE — moves the wall to 100 instead of removing it.
Un-freezing the Search screen while the report modal is open — fixes the symptom but costs re-renders of the full search list on every arrow click, and leaves defects 2-4 in place.
Backend not reporting more results — considered and set aside: the Search list's own infinite scroll does load past 50 for the same query, so the server is reporting hasMoreResults: true.
Confidence and what I did not verify
High confidence on the mechanism, the code paths, and the disabled condition — two independent investigations converged on the same lines, and I re-read them directly.
Not verified:
I did not reproduce this live. It needs an account with 51+ matching expense reports, which the test account available to me does not have.
Whether the user-visible symptom is disabled (51-100 reports) or wraps to report 1 (>100 reports) depends on the server's hasMoreResults after the offset-50 request. The report says disabled, which fits 51-100.
I could not run git blame to pin an introducing PR (shallow checkout), so I am not attributing this to a specific merge.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/101453) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
The Next arrow and "50 of 50" counter are rendered by MoneyRequestReportNavigationContent → PrevNextButtons / the count Text (MoneyRequestReportNavigation.tsx#L243-L253). Both read effectiveAllReports, and that list caps at the first 50 IDs.
The component has two sources for the list. The fast path is the Search-context list contextReports; the slow path is standaloneReports, lifted from a child that mounts useSearchSections and reads the live snapshot. Which one wins is decided by one line (:127):
Walk the repro. On report ~38 the arrow correctly fetches page 2 (:209-222). While it's in flight isSearchLoading is true, so the standalone child mounts and — because useSearchSections builds its list straight from currentSearchResults.data, which pagination merges into (useSearchSections.ts#L47-L74) — standaloneReports correctly grows to the full set (say 60). The moment the page lands, isSearchLoading flips false, shouldUseContextReports flips back to true, and allReports reverts to contextReports. That context list is frozen at the 50 IDs captured before the report opened (the Search list screen behind the RHP no longer updates it), so the 10 reports the arrow just paged in are thrown away.
At report 50 that leaves currentIndex === effectiveAllReports.length - 1 with hasMoreResults now false (page 2 was the last page for a 51–100 account), so the disabled condition fires (:149), and the label is "50 of 50" because previousLengthOfResults was persisted as the stale prevReportsLength (50) on the page-2 response (Search.ts#L1295-L1299). The list the arrow can see never grows past page 1, even though the snapshot did.
So !isSearchLoading is the wrong gate: it's a transient signal, and once it clears we hand authority back to a list that pagination has already made stale.
What changes do you think we should make in order to solve the problem?
Layer: the source-selection in the content component (hook/render), not the data or the API.
Latch the source on an explicit "have we paginated" signal instead of the transient loading flag. lastSearchQuery.offset starts at 0 for a page-1 result set (Search/index.tsx#L165 → saved at the report row click, #L699-L705) and only ever grows once a page is pulled. So at :127:
// offset > 0 means the result set was extended past page 1; the frozen context list is now stale.
const hasPaginated = (lastSearchQuery?.offset ?? 0) > 0;
const shouldUseContextReports = contextReports.length > 0 && !isSearchLoading && !hasPaginated;
Once you've paged, shouldUseContextReports stays false, so the standalone child stays mounted (:242) and keeps standaloneReports in sync with the snapshot as further pages land. Report 51 becomes reachable, and the counter self-corrects because the recount effect notices effectiveAllReports.length changed and rewrites previousLengthOfResults (:168) — so "50 of 50" turns into "50 of 60" and only disables at the true last report.
This is race-free: it doesn't depend on whether the page-2 response lands before or after isSearchLoading toggles. The frozen list is structurally removed from the pipeline the instant pagination begins, rather than being preferred again on the next render. It also preserves the #86238 fast-path optimization untouched for the common case (≤50 reports never paginate, so offset stays 0 and the heavy subscriptions never mount).
Verification: I traced the full save/restore sequence at the issue SHA but could not run jest in this environment (the App node_modules aren't provisioned here), so I'm giving the concrete regression instead of a fabricated number. Add to tests/unit/components/MoneyRequestReportNavigation.test.tsx, whose helper mirrors line 127: a case where the context list is frozen at 50, the snapshot list is 60, not loading, and offset = 50. Against main's selection it resolves to the 50-item context list (allReports length 50, report 55 absent) — fails; with the !hasPaginated gate it resolves to the 60-item standalone list (report 55 reachable) — passes. The four existing path-selection/cache tests are unaffected: they run with no pagination (offset undefined → hasPaginated false), so the fast path they assert is unchanged.
What alternative solutions did you explore? (Optional)
Selecting whichever list is longer is the other obvious fix, but it reverses the existing "uses fast path" assertion at test #L91-L99 (a longer standalone list would win on the pure fast path) and leans on a stale retained value rather than an explicit signal — the offset latch keeps both the test and the perf gate intact.
Growing the Search-context sortedReportIDs while the RHP is open — rejected: the list screen is frozen behind the modal on purpose, and un-freezing it re-renders the whole search list on every arrow click for no benefit the snapshot path doesn't already give us.
One thing my fix deliberately doesn't touch: the % effectiveAllReports.length wrap at :225 can still bounce you to report 1 on a 100+ account where hasMoreResults stays true at the tail. That's a distinct bounds-check bug on a different code path from this "disabled at 50" report, so I'd keep it out of scope here rather than fold two fixes into one.
🚨 Edited by proposal-police: This proposal was edited at 2026-10-09 11:16:11 UTC.
Proposal
Please re-state the problem that we are trying to solve.
When navigating through paginated expense reports in Search, the report Prev/Next controls can become unreliable at page boundaries.
Examples include:
The Next button becomes disabled at 50 of 50, although more reports exist.
A rapid Next click at the loaded tail is dropped instead of advancing after the next page arrives.
Controls disappear after moving to a later report because the active report is absent from a transient 50-item source.
An unfocused Search auto-refetch can replace an expanded snapshot with page-one data and discard later loaded pages.
What is the root cause of that problem?
Several independent asynchronous flows update the same search state.
Pagination metadata was replaced during a page request
saveLastSearchParams() uses Onyx.set(). A pagination request supplied only partial navigation data, which replaced fields retained from the previous response, including hasMoreResults and previousLengthOfResults.
While the request was in flight, the navigation component could read:
lastSearchQuery.hasMoreResults===undefined;
This made the Next button look terminal even though the previous page had confirmed more results exist.
A paginated report can temporarily read an incomplete list
sortedReportIDs is the lightweight Search-context list. It is suitable for the first page, but it can temporarily contain only the first 50 IDs during page transitions.
If the active report is, for example, report 51, choosing that 50-item list makes:
allReports.indexOf(reportID)===-1;
Since the controls require a valid current index, the counter and arrows disappear.
Next at a page boundary had no durable intent
At the tail of the currently loaded list, the next report ID does not yet exist locally. A click must be retained until the next page arrives. Without a scoped pending intent, the click is lost; without query scoping, a delayed response could navigate the user in a different search.
Concurrent page requests can race
Rapid navigation near a prefetch threshold can request multiple pagination offsets for the same query before the earlier request settles. Responses can then arrive out of order and overwrite report-navigation metadata.
An unfocused auto-refetch can discard loaded pages
useSearchAutoRefetch can issue an offset-zero refresh while an RHP report is open. An offset-zero response replaces the search snapshot collection, which can discard previously loaded page-two-and-beyond records.
What changes do you think we should make in order to solve the problem?
1. Merge incremental report-navigation state instead of replacing it
Add mergeLastSearchParams() in ReportNavigation.ts:
Keep saveLastSearchParams() with Onyx.set() for creating a new Search session, where replacing the old query state remains correct.
Use mergeLastSearchParams() for pagination request and response updates in Search.ts. This preserves hasMoreResults and previousLengthOfResults while a subsequent page is loading.
2. Preserve standard offset-zero recount behavior
The response handler must preserve the existing distinction between an initial Search response and report-navigation pagination:
Use the existing request cleanup path in both request completion and write-queue failure handling. This avoids a second module-level cache and prevents stale locks.
4. Select a list that contains the active report
Continue using the lightweight Search-context list only when it covers the loaded offset:
Cancel the intent on request failure, terminal results, or Previous navigation.
This prevents lost Next clicks while ensuring an old response cannot advance a new search session.
6. Defer unfocused refreshes for expanded search snapshots
In useSearchAutoRefetch, do not issue an offset-zero refresh while Search is unfocused and the snapshot already contains later pages:
The deferred refresh runs when Search is focused again. This preserves page-two-and-beyond snapshot data while the user navigates reports in the RHP.
What alternative solutions did you explore? (Optional)
Always use useSearchSections()
This avoids source handoff, but it mounts expensive subscriptions for every first-page report view and regresses the existing lightweight fast path.
Use a separate module-level pagination map
A separate mutable map can serialize requests, but it duplicates the existing in-flight request lifecycle and creates unnecessary cleanup/test-leak risk. Extending the existing request registry keeps ownership and cleanup in one place.
Only defer non-new-item auto-refetches
This still allows a genuinely new transaction created while an RHP report is open to trigger an offset-zero refresh and truncate an expanded snapshot. Expanded snapshots must defer all unfocused refreshes.
Test plan
Open an expense-report Search with more than 100 reports.
Navigate slowly and rapidly across the 50 → 51 and 100 → 101 boundaries.
Verify the Next click at a boundary advances automatically when the next page arrives.
Verify the counter and Prev/Next controls remain visible throughout navigation.
Navigate backward across a page boundary and verify no delayed Next intent changes the route.
Create or modify an expense while a later-page report is open in the RHP, then confirm loaded pages remain available for navigation.
Return to the Search page and verify the deferred refresh is applied.
Verify a standard offset-zero Search response still enables post-search recount behavior.
I found that the navigation switches between two report lists. contextReports comes from the Search screen, while standaloneReports is rebuilt from the live Onyx snapshot. Once loading finishes, shouldUseContextReports becomes true again and the navigation discards the newly paginated standalone list.
That screen is frozen while the report RHP is open, so the effect does not copy page 2 into the context. The snapshot-derived component is mounted only during loading and immediately unmounted afterward:
{!shouldDisplayNarrowVersion&&<Textstyle={styles.mutedTextLabel}>{`${currentIndex+1} of ${allReportsCount}`}</Text>}
<PrevNextButtons
isPrevButtonDisabled={hidePrevButton}
isNextButtonDisabled={hideNextButton}
As a result, page 2 can be fetched successfully, but effectiveAllReports returns to the original 50 context IDs. At report 50, that stale list makes the next button disabled:
The existing test also codifies the faulty transition by expecting the shorter context list whenever loading is false, even when the snapshot list contains more reports:
What changes do you think we should make in order to solve the problem?
I think we should latch MoneyRequestReportNavigationContent onto the standalone source after it first enters that path. This keeps useSearchSections mounted after pagination, allowing later snapshot additions and removals to continue updating the carousel. The lightweight context path remains unchanged for report views that never paginate.
For example, the source selection would follow this small change:
I would add a test that starts with 50 context reports, enters the loading state with an expanded standalone list, then finishes loading while the context remains at 50. It should continue using the standalone list and enable navigation to report 51. I would also verify that removing a report from the snapshot updates this list after pagination.
This avoids a regression in the normal fast path: useSearchSections and its heavier subscriptions still remain unmounted when no pagination or fallback has occurred. Once the live source is needed, it stays subscribed rather than retaining a stale one-time copy.
What alternative solutions did you explore? (Optional)
Always mounting useSearchSections would fix pagination, but it would remove the intentional lightweight path and subscribe every report view to the heavier Search data. Choosing whichever list is longer would fix the first append, but the standalone child would remain unmounted after loading, so its cached list would not receive later deletions or other same-length changes.
Please re-state the problem that we are trying to solve in this issue.
On Spend > Reports, opening a report and using the header's "Next" arrow to page through the search results works fine until the 50th report. At that point "Next" becomes disabled and the user cannot advance to report #51, even though more reports exist in the underlying search results.
What is the root cause of that problem?
The report header's carousel arrows are rendered by MoneyRequestReportNavigationContent. Its "Next" disabled state is not a simple index >= length check — it also depends on whether more results are known to exist on the server:
lastSearchQuery.hasMoreResults is read from the REPORT_NAVIGATION_LAST_SEARCH_QUERY Onyx key. goToNextReport is supposed to keep this key accurate by prefetching the next page of results once the user is 75% of the way through the currently-loaded page (50 items, CONST.SEARCH.RESULTS_PAGE_SIZE):
That prefetch calls search() with shouldUpdateLastSearchParams: true. This is the only call site in the whole codebase that passes shouldUpdateLastSearchParams: true — every other caller (useInsightData, useRecentlyAddedData, useYourSpendData) passes false. Inside search(), before the request is even sent, this flag triggers an immediate "optimistic" save:
Note this object only contains queryJSON, offset, allowPostSearchRecount, and searchKey — it omits hasMoreResults and previousLengthOfResults. saveLastSearchParams writes this with Onyx.set, a full overwrite (not a merge):
So the instant the prefetch for page 2 is dispatched, hasMoreResults is wiped from Onyx to undefined (falsy). It is only restored once the network response actually resolves, inside the .then() handler:
If that response is slow, gets rejected (see the .catch a few lines below, which applies failure/finally Onyx updates for the snapshot but never restores hasMoreResults on REPORT_NAVIGATION_LAST_SEARCH_QUERY), or otherwise doesn't land before the user reaches the last item in the currently-loaded 50, lastSearchQuery.hasMoreResults is still falsy at that render. Combined with currentIndex === effectiveAllReports.length - 1 being true at report #50, hideNextButton evaluates to true — the arrow disables even though the backend genuinely has more reports. Because nothing ever re-triggers a fresh save of hasMoreResults after a failed/interrupted prefetch, this can remain stuck disabled indefinitely, which matches the reported "Workaround: Unknown."
What changes do you think we should make in order to solve the problem?
The premature save at Search.ts lines 1244-1251 is unnecessary: it exists solely to bump offset ahead of the request, but since it is exclusively used by this one prefetch call site, and the authoritative, complete save (with the real hasMoreResults, offset, and previousLengthOfResults) already happens in the .then() handler at lines 1287-1314, this pre-request write only serves to destructively clear pagination state that the UI depends on while the fetch is in flight. Removing it closes the whole race: lastSearchQuery.hasMoreResults simply keeps its last known-good value until the new page's response legitimately updates it, so "Next" stays correctly enabled while page 2 loads, and if the request fails, the button remains usable and the next click will naturally retry the fetch (the request's dedupeKey is cleared in the existing .finally() block regardless of success/failure).
Remove the if (shouldUpdateLastSearchParams) { saveLastSearchParams({...}); } block entirely (the four lines shown above at 1245-1250), leaving the rest of search() unchanged. The parameter shouldUpdateLastSearchParams is still used further down to gate the post-response save at lines 1287-1314, so it stays meaningful — only the redundant, data-dropping pre-request write is deleted.
This keeps the fix minimal and confined to the one code path that actually causes the regression, without touching saveLastSearchParams's Onyx.set semantics (which other callers, e.g. Search/index.tsx and MoneyRequestReportNavigation.tsx's own effects, rely on for full-object replacement when starting a genuinely new query).
Please re-state the problem that we are trying to solve in this issue.
When a user opens a report from Spend > Reports in an account with more than 50 reports (CONST.SEARCH.RESULTS_PAGE_SIZE = 50) and clicks the Next (>) arrow in the report header up to 50 of 50, the Next button becomes disabled (or stalls for a long time) instead of loading the next page and advancing to 51 of 100.
What is the root cause of that problem?
There are three interacting issues in the Search pagination and RHP report carousel flow:
Unfocused offset-0 auto-refetch overwrites the multi-page Search snapshot:
While stepping through reports in the RHP (MoneyRequestReportNavigation), the underlying Search page is unfocused (isFocused === false) but remains the topmost fullscreen route (isSearchTopmostFullScreenRoute() === true).
Opening each report in the RHP triggers OpenReport, which updates transactions / reportActions in Onyx.
Because an offset: 0Search response uses Onyx.METHOD.SET on snapshot_<hash>, it replaces the entire snapshot with only page 1 (50 items), wiping out page 2 (offset: 50) that MoneyRequestReportNavigation prefetched when crossing the 75% threshold (currentIndex + 1 >= 37).
In src/libs/actions/Search.ts, search() defaults to skipWaitForWrites = false, wrapping API.read(READ_COMMANDS.SEARCH, ...) in waitForWrites(READ_COMMANDS.SEARCH). Every rapid click of the Next button queues a sequential OpenReport write on the sequential queue, forcing the page-2 Search read to wait until all ~50 OpenReport requests finish draining.
Duplicate pagination requests and tail modulo wrap-around:
In MoneyRequestReportNavigation.tsx, lastSearchQuery.offset is only updated after the Search call, so clicking Next through reports 37–49 fires duplicate offset: 50 requests on every click, and const nextIndex = (currentIndex + 1) % effectiveAllReports.length (L237) wraps around to index 0 if currentIndex reaches the end of effectiveAllReports while more results are still loading.
What changes do you think we should make in order to solve the problem?
Defer unfocused offset-0 auto-refetch while RHP pagination is active:
In src/hooks/useSearchAutoRefetch.ts, when !isFocused (an RHP is layered over Search) and pagination has advanced ((lastSearchQuery?.offset ?? 0) > 0 or effectiveAllReports.length > CONST.SEARCH.RESULTS_PAGE_SIZE), set hasPendingSearchRef.current = true and return so the offset-0 refetch runs when Search regains focus rather than wiping page 2 mid-carousel.
Bypass the write queue and deduplicate pagination requests in MoneyRequestReportNavigation.tsx:
Pass skipWaitForWrites: true to search({...}) so fetching the next page of search results is not blocked behind 50 sequential OpenReport writes.
Track the in-flight pagination offset per query hash in a ref (inFlightOffsetRef) so stepping from index 37 to 49 only dispatches a single request for newOffset = 50.
Replace the modulo wrap const nextIndex = (currentIndex + 1) % effectiveAllReports.length with a bounded check (const nextIndex = currentIndex + 1; if (nextIndex >= effectiveAllReports.length) { return; }) and disable the Next button while waiting at the tail (currentIndex === effectiveAllReports.length - 1 && isSearchLoading).
What alternative solutions did you explore?
Always disabling isSearchTopmostFullScreenRoute() in useSearchAutoRefetch.ts: Explored and rejected because when the user is on page 1 (offset === 0) and edits/creates an expense inside the RHP, Search still needs to auto-refetch so the underlying snapshot and footer totals stay updated. Scoping the deferral to when !isFocused during multi-page carousel navigation preserves single-page RHP live updates while protecting multi-page snapshots.
📣 @open5588! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:
Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
Format:
Contributor details
Your Expensify account email: <REPLACE EMAIL HERE>
Upwork Profile Link: <REPLACE LINK HERE>
Going with @yusufdeveloper2903's proposal, assigning. He posted the cause first, and his two small changes cover both the page-1 wipe and the page-2 request stuck behind OpenReport. The extra protections only guard states the wipe creates, so keep the PR to those two changes.
I’d appreciate your thoughts on whether we should prioritize the more comprehensive fix or address the additional protections during the PR. I’m happy with either approach and will leave the final assignment to your judgment.
@marufsharifi the PR review process will always add a layer. It's useful to find the holes before you get there, but the correct RCA & best behavior for the end user. You've done a great job here, I'm going to make this call myself.
Activity
[-]Auto -Spend- "Next" arrow is disabled when user reaches 50 of 50 report[/-][+]Auto -Spend - "Next" arrow is disabled when user reaches 50 of 50 report[/+]MelvinBot commentedon Sep 17, 2026
Proposal
What is the root cause of that problem?
The arrows and the search results read two different lists, and only one of them grows when page 2 loads.
MoneyRequestReportNavigationwalkssortedReportIDsfrom the app-level Search context, and it prefers that list whenever it is non-empty and no page is in flight —MoneyRequestReportNavigation.tsx:127-129. The only writer of that list isuseSaveSortedReportIDs.ts:17-29, called from the Search list screen atSearch/index.tsx:811.Opening a report puts that Search screen behind a right-hand modal, where
FreezeWrapperfreezes it. Its effects stop running, sosortedReportIDsis stuck at the 50 IDs captured before you opened the first report — 50 beingCONST.SEARCH.RESULTS_PAGE_SIZE.The arrow does fetch page 2 (
:207-223) and those reports do land in the Onyx snapshot — but the list that can see them (useSearchSections) is only mounted while loading (:242). The moment the page arrives, loading flips false and the component reverts to the frozen 50.So at report 50,
:149disables the button, and the label reads "50 of 50" because the count is re-synced from that same frozen list.Two secondary defects that make this worse
1. The tail wraps instead of advancing.
:225computes(currentIndex + 1) % effectiveAllReports.length. There is no "wait for the next page" path — so with >100 matching reports the button stays enabled but throws you back to report 1. Report 51 is unreachable either way.2.
hasMoreResultscan be erased permanently.saveLastSearchParamsusesOnyx.set, not merge.Search.ts:1244-1251writes that key before the request withouthasMoreResults, wiping it. It is restored only in.then()and only when bothresponse.search.offsetandprevReportsLengthare truthy (:1287-1315); the.catch()restores nothing. One failed request and the flag staysundefined, which permanently disables the arrow with no retry — unlike the list view, which re-arms viawantedOffsetRef(Search/index.tsx:883-890).Note the Search list's own infinite scroll never writes this key —
shouldUpdateLastSearchParamsdefaults to false and only the arrow passestrue. So the arrows' entire pagination ability rests on a single snapshot read taken at the instant you clicked the report row (Search/index.tsx:697-705).What changes do you think we should make in order to solve the problem?
Make the snapshot the source of truth for the arrow list. Stop preferring the frozen Search-context list. Either mount the
useSearchSectionslist unconditionally inMoneyRequestReportNavigation, or prefer whichever list is longer. The context list is a render-time cache of a screen that is frozen for the entire time the arrows are on screen, so it can never be authoritative.Don't wrap at the tail. Replace the modulo at
:225with a bounds check: if there is no next item and a page is in flight, hold position (show a loading state on the arrow) and advance once the list grows.Read
hasMoreResultsandoffsetstraight off the snapshot, the wayfetchMoreResultsalready does (Search/index.tsx:836-880), instead of the separately-persisted copy inREPORT_NAVIGATION_LAST_SEARCH_QUERY. That removes the whole wipe-and-restore class of bug.If that duplicated key must stay, change
saveLastSearchParamsto merge and restore the flag in.catch()too.Fix 1 alone unblocks report 51. Fixes 2-4 stop it regressing in the failure and >100-report cases.
Add a regression test: today
MoneyRequestReportNavigation.test.tsxasserts which list is picked, but never that the list grows after a page lands — which is exactly the untested gap.What alternative solutions did you explore? (Optional)
RESULTS_PAGE_SIZE— moves the wall to 100 instead of removing it.hasMoreResults: true.Confidence and what I did not verify
High confidence on the mechanism, the code paths, and the disabled condition — two independent investigations converged on the same lines, and I re-read them directly.
Not verified:
hasMoreResultsafter the offset-50 request. The report says disabled, which fits 51-100.git blameto pin an introducing PR (shallow checkout), so I am not attributing this to a specific merge.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/101453)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
view run
melvin-bot commentedon Sep 17, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @marufsharifi (
External)melvin-bot commentedon Sep 17, 2026
Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.
marufsharifi commentedon Sep 22, 2026
🆘 C+ Help Wanted
Abdulloh0109 commentedon Sep 22, 2026
Proposal
What is the root cause of that problem?
The Next arrow and "50 of 50" counter are rendered by
MoneyRequestReportNavigationContent→PrevNextButtons/ the countText(MoneyRequestReportNavigation.tsx#L243-L253). Both readeffectiveAllReports, and that list caps at the first 50 IDs.The component has two sources for the list. The fast path is the Search-context list
contextReports; the slow path isstandaloneReports, lifted from a child that mountsuseSearchSectionsand reads the live snapshot. Which one wins is decided by one line (:127):Walk the repro. On report ~38 the arrow correctly fetches page 2 (
:209-222). While it's in flightisSearchLoadingis true, so the standalone child mounts and — becauseuseSearchSectionsbuilds its list straight fromcurrentSearchResults.data, which pagination merges into (useSearchSections.ts#L47-L74) —standaloneReportscorrectly grows to the full set (say 60). The moment the page lands,isSearchLoadingflips false,shouldUseContextReportsflips back to true, andallReportsreverts tocontextReports. That context list is frozen at the 50 IDs captured before the report opened (the Search list screen behind the RHP no longer updates it), so the 10 reports the arrow just paged in are thrown away.At report 50 that leaves
currentIndex === effectiveAllReports.length - 1withhasMoreResultsnow false (page 2 was the last page for a 51–100 account), so the disabled condition fires (:149), and the label is "50 of 50" becausepreviousLengthOfResultswas persisted as the staleprevReportsLength(50) on the page-2 response (Search.ts#L1295-L1299). The list the arrow can see never grows past page 1, even though the snapshot did.So
!isSearchLoadingis the wrong gate: it's a transient signal, and once it clears we hand authority back to a list that pagination has already made stale.What changes do you think we should make in order to solve the problem?
Layer: the source-selection in the content component (
hook/render), not the data or the API.Latch the source on an explicit "have we paginated" signal instead of the transient loading flag.
lastSearchQuery.offsetstarts at 0 for a page-1 result set (Search/index.tsx#L165→ saved at the report row click,#L699-L705) and only ever grows once a page is pulled. So at:127:Once you've paged,
shouldUseContextReportsstays false, so the standalone child stays mounted (:242) and keepsstandaloneReportsin sync with the snapshot as further pages land. Report 51 becomes reachable, and the counter self-corrects because the recount effect noticeseffectiveAllReports.lengthchanged and rewritespreviousLengthOfResults(:168) — so "50 of 50" turns into "50 of 60" and only disables at the true last report.This is race-free: it doesn't depend on whether the page-2 response lands before or after
isSearchLoadingtoggles. The frozen list is structurally removed from the pipeline the instant pagination begins, rather than being preferred again on the next render. It also preserves the #86238 fast-path optimization untouched for the common case (≤50 reports never paginate, sooffsetstays 0 and the heavy subscriptions never mount).Verification: I traced the full save/restore sequence at the issue SHA but could not run jest in this environment (the App node_modules aren't provisioned here), so I'm giving the concrete regression instead of a fabricated number. Add to
tests/unit/components/MoneyRequestReportNavigation.test.tsx, whose helper mirrors line 127: a case where the context list is frozen at 50, the snapshot list is 60, not loading, andoffset = 50. Againstmain's selection it resolves to the 50-item context list (allReportslength 50, report55absent) — fails; with the!hasPaginatedgate it resolves to the 60-item standalone list (report55reachable) — passes. The four existing path-selection/cache tests are unaffected: they run with no pagination (offsetundefined →hasPaginatedfalse), so the fast path they assert is unchanged.What alternative solutions did you explore? (Optional)
Selecting whichever list is longer is the other obvious fix, but it reverses the existing "uses fast path" assertion at test
#L91-L99(a longer standalone list would win on the pure fast path) and leans on a stale retained value rather than an explicit signal — theoffsetlatch keeps both the test and the perf gate intact.Growing the Search-context
sortedReportIDswhile the RHP is open — rejected: the list screen is frozen behind the modal on purpose, and un-freezing it re-renders the whole search list on every arrow click for no benefit the snapshot path doesn't already give us.One thing my fix deliberately doesn't touch: the
% effectiveAllReports.lengthwrap at:225can still bounce you to report 1 on a 100+ account wherehasMoreResultsstays true at the tail. That's a distinct bounds-check bug on a different code path from this "disabled at 50" report, so I'd keep it out of scope here rather than fold two fixes into one.x-dev90 commentedon Sep 22, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-10-09 11:16:11 UTC.
Proposal
Please re-state the problem that we are trying to solve.
When navigating through paginated expense reports in Search, the report Prev/Next controls can become unreliable at page boundaries.
Examples include:
50 of 50, although more reports exist.What is the root cause of that problem?
Several independent asynchronous flows update the same search state.
saveLastSearchParams()usesOnyx.set(). A pagination request supplied only partial navigation data, which replaced fields retained from the previous response, includinghasMoreResultsandpreviousLengthOfResults.While the request was in flight, the navigation component could read:
This made the Next button look terminal even though the previous page had confirmed more results exist.
sortedReportIDsis the lightweight Search-context list. It is suitable for the first page, but it can temporarily contain only the first 50 IDs during page transitions.If the active report is, for example, report 51, choosing that 50-item list makes:
Since the controls require a valid current index, the counter and arrows disappear.
At the tail of the currently loaded list, the next report ID does not yet exist locally. A click must be retained until the next page arrives. Without a scoped pending intent, the click is lost; without query scoping, a delayed response could navigate the user in a different search.
Rapid navigation near a prefetch threshold can request multiple pagination offsets for the same query before the earlier request settles. Responses can then arrive out of order and overwrite report-navigation metadata.
useSearchAutoRefetchcan issue an offset-zero refresh while an RHP report is open. An offset-zero response replaces the search snapshot collection, which can discard previously loaded page-two-and-beyond records.What changes do you think we should make in order to solve the problem?
1. Merge incremental report-navigation state instead of replacing it
Add
mergeLastSearchParams()inReportNavigation.ts:Keep
saveLastSearchParams()withOnyx.set()for creating a new Search session, where replacing the old query state remains correct.Use
mergeLastSearchParams()for pagination request and response updates inSearch.ts. This preserveshasMoreResultsandpreviousLengthOfResultswhile a subsequent page is loading.2. Preserve standard offset-zero recount behavior
The response handler must preserve the existing distinction between an initial Search response and report-navigation pagination:
For a successful offset-zero response:
previousLengthOfResults.allowPostSearchRecount: true.For report-navigation pagination:
prevReportsLength.allowPostSearchRecount: false.The pre-request update must keep
allowPostSearchRecount: false, so stale data cannot trigger a recount before the request succeeds.3. Serialize report-navigation pagination per query
Extend the existing in-flight Search request registry with request metadata:
Before starting a report-navigation pagination request, prevent another pagination request for the same query hash from starting:
Use the existing request cleanup path in both request completion and write-queue failure handling. This avoids a second module-level cache and prevents stale locks.
4. Select a list that contains the active report
Continue using the lightweight Search-context list only when it covers the loaded offset:
When the preferred source does not contain the active report, select another available source that does:
This prevents a temporary empty standalone list or first-page context list from making
currentIndexbecome-1.5. Queue Next navigation at a boundary, scoped to the current search and report
Replace the boolean pending state with a query-scoped intent:
At the loaded tail, retain the click only if more results are expected:
When the list expands, consume the intent only if both the active query and active report still match:
Cancel the intent on request failure, terminal results, or Previous navigation.
This prevents lost Next clicks while ensuring an old response cannot advance a new search session.
6. Defer unfocused refreshes for expanded search snapshots
In
useSearchAutoRefetch, do not issue an offset-zero refresh while Search is unfocused and the snapshot already contains later pages:The deferred refresh runs when Search is focused again. This preserves page-two-and-beyond snapshot data while the user navigates reports in the RHP.
What alternative solutions did you explore? (Optional)
Always use
useSearchSections()This avoids source handoff, but it mounts expensive subscriptions for every first-page report view and regresses the existing lightweight fast path.
Use a separate module-level pagination map
A separate mutable map can serialize requests, but it duplicates the existing in-flight request lifecycle and creates unnecessary cleanup/test-leak risk. Extending the existing request registry keeps ownership and cleanup in one place.
Only defer non-new-item auto-refetches
This still allows a genuinely new transaction created while an RHP report is open to trigger an offset-zero refresh and truncate an expanded snapshot. Expanded snapshots must defer all unfocused refreshes.
Test plan
50 → 51and100 → 101boundaries.mukhrr commentedon Sep 22, 2026
Proposal
What is the root cause of that problem?
I found that the navigation switches between two report lists.
contextReportscomes from the Search screen, whilestandaloneReportsis rebuilt from the live Onyx snapshot. Once loading finishes,shouldUseContextReportsbecomes true again and the navigation discards the newly paginated standalone list.App/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx
Lines 117 to 129 in e8445e0
The context list is populated by an effect in the Search screen:
App/src/hooks/useSaveSortedReportIDs.ts
Lines 14 to 29 in e8445e0
That screen is frozen while the report RHP is open, so the effect does not copy page 2 into the context. The snapshot-derived component is mounted only during loading and immediately unmounted afterward:
App/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx
Lines 238 to 248 in e8445e0
As a result, page 2 can be fetched successfully, but
effectiveAllReportsreturns to the original 50 context IDs. At report 50, that stale list makes the next button disabled:App/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx
Lines 145 to 151 in e8445e0
The existing test also codifies the faulty transition by expecting the shorter context list whenever loading is false, even when the snapshot list contains more reports:
App/tests/unit/components/MoneyRequestReportNavigation.test.tsx
Lines 90 to 99 in e8445e0
What changes do you think we should make in order to solve the problem?
I think we should latch
MoneyRequestReportNavigationContentonto the standalone source after it first enters that path. This keepsuseSearchSectionsmounted after pagination, allowing later snapshot additions and removals to continue updating the carousel. The lightweight context path remains unchanged for report views that never paginate.For example, the source selection would follow this small change:
I would add a test that starts with 50 context reports, enters the loading state with an expanded standalone list, then finishes loading while the context remains at 50. It should continue using the standalone list and enable navigation to report 51. I would also verify that removing a report from the snapshot updates this list after pagination.
This avoids a regression in the normal fast path:
useSearchSectionsand its heavier subscriptions still remain unmounted when no pagination or fallback has occurred. Once the live source is needed, it stays subscribed rather than retaining a stale one-time copy.What alternative solutions did you explore? (Optional)
Always mounting
useSearchSectionswould fix pagination, but it would remove the intentional lightweight path and subscribe every report view to the heavier Search data. Choosing whichever list is longer would fix the first append, but the standalone child would remain unmounted after loading, so its cached list would not receive later deletions or other same-length changes.abbasifaizan70 commentedon Sep 22, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On Spend > Reports, opening a report and using the header's "Next" arrow to page through the search results works fine until the 50th report. At that point "Next" becomes disabled and the user cannot advance to report #51, even though more reports exist in the underlying search results.
What is the root cause of that problem?
The report header's carousel arrows are rendered by
MoneyRequestReportNavigationContent. Its "Next" disabled state is not a simpleindex >= lengthcheck — it also depends on whether more results are known to exist on the server:App/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx
Lines 148 to 149 in 920d627
lastSearchQuery.hasMoreResultsis read from theREPORT_NAVIGATION_LAST_SEARCH_QUERYOnyx key.goToNextReportis supposed to keep this key accurate by prefetching the next page of results once the user is 75% of the way through the currently-loaded page (50 items,CONST.SEARCH.RESULTS_PAGE_SIZE):App/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx
Lines 203 to 227 in 920d627
That prefetch calls
search()withshouldUpdateLastSearchParams: true. This is the only call site in the whole codebase that passesshouldUpdateLastSearchParams: true— every other caller (useInsightData,useRecentlyAddedData,useYourSpendData) passesfalse. Insidesearch(), before the request is even sent, this flag triggers an immediate "optimistic" save:App/src/libs/actions/Search.ts
Lines 1244 to 1251 in 920d627
Note this object only contains
queryJSON,offset,allowPostSearchRecount, andsearchKey— it omitshasMoreResultsandpreviousLengthOfResults.saveLastSearchParamswrites this withOnyx.set, a full overwrite (not a merge):App/src/libs/actions/ReportNavigation.ts
Lines 6 to 8 in 920d627
So the instant the prefetch for page 2 is dispatched,
hasMoreResultsis wiped from Onyx toundefined(falsy). It is only restored once the network response actually resolves, inside the.then()handler:App/src/libs/actions/Search.ts
Lines 1287 to 1314 in 920d627
If that response is slow, gets rejected (see the
.catcha few lines below, which applies failure/finally Onyx updates for the snapshot but never restoreshasMoreResultsonREPORT_NAVIGATION_LAST_SEARCH_QUERY), or otherwise doesn't land before the user reaches the last item in the currently-loaded 50,lastSearchQuery.hasMoreResultsis still falsy at that render. Combined withcurrentIndex === effectiveAllReports.length - 1being true at report #50,hideNextButtonevaluates totrue— the arrow disables even though the backend genuinely has more reports. Because nothing ever re-triggers a fresh save ofhasMoreResultsafter a failed/interrupted prefetch, this can remain stuck disabled indefinitely, which matches the reported "Workaround: Unknown."What changes do you think we should make in order to solve the problem?
The premature save at
Search.tslines 1244-1251 is unnecessary: it exists solely to bumpoffsetahead of the request, but since it is exclusively used by this one prefetch call site, and the authoritative, complete save (with the realhasMoreResults,offset, andpreviousLengthOfResults) already happens in the.then()handler at lines 1287-1314, this pre-request write only serves to destructively clear pagination state that the UI depends on while the fetch is in flight. Removing it closes the whole race:lastSearchQuery.hasMoreResultssimply keeps its last known-good value until the new page's response legitimately updates it, so "Next" stays correctly enabled while page 2 loads, and if the request fails, the button remains usable and the next click will naturally retry the fetch (the request'sdedupeKeyis cleared in the existing.finally()block regardless of success/failure).App/src/libs/actions/Search.ts
Lines 1244 to 1251 in 920d627
Remove the
if (shouldUpdateLastSearchParams) { saveLastSearchParams({...}); }block entirely (the four lines shown above at 1245-1250), leaving the rest ofsearch()unchanged. The parametershouldUpdateLastSearchParamsis still used further down to gate the post-response save at lines 1287-1314, so it stays meaningful — only the redundant, data-dropping pre-request write is deleted.This keeps the fix minimal and confined to the one code path that actually causes the regression, without touching
saveLastSearchParams'sOnyx.setsemantics (which other callers, e.g.Search/index.tsxandMoneyRequestReportNavigation.tsx's own effects, rely on for full-object replacement when starting a genuinely new query).61 remaining items
melvin-bot commentedon Oct 13, 2026
@grgia Still overdue 6 days?! Let's take care of this!
open5588 commentedon Oct 15, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
When a user opens a report from Spend > Reports in an account with more than 50 reports (
CONST.SEARCH.RESULTS_PAGE_SIZE = 50) and clicks the Next (>) arrow in the report header up to50 of 50, the Next button becomes disabled (or stalls for a long time) instead of loading the next page and advancing to51 of 100.What is the root cause of that problem?
There are three interacting issues in the Search pagination and RHP report carousel flow:
Unfocused offset-0 auto-refetch overwrites the multi-page Search snapshot:
MoneyRequestReportNavigation), the underlying Search page is unfocused (isFocused === false) but remains the topmost fullscreen route (isSearchTopmostFullScreenRoute() === true).OpenReport, which updatestransactions/reportActionsin Onyx.src/hooks/useSearchAutoRefetch.ts,isSearchStillActiveisisFocused || isSearchTopmostFullScreenRoute(), so the background Search screen executessearch({queryJSON, searchKey, offset, ...})(useSearchAutoRefetch.ts#L164-L168) withoffset = 0.offset: 0Searchresponse usesOnyx.METHOD.SETonsnapshot_<hash>, it replaces the entire snapshot with only page 1 (50 items), wiping out page 2 (offset: 50) thatMoneyRequestReportNavigationprefetched when crossing the 75% threshold (currentIndex + 1 >= 37).Carousel pagination
search()waits behind queuedOpenReportwrites:src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx,goToNextReportcallssearch({...})to fetchnewOffset(50).src/libs/actions/Search.ts,search()defaults toskipWaitForWrites = false, wrappingAPI.read(READ_COMMANDS.SEARCH, ...)inwaitForWrites(READ_COMMANDS.SEARCH). Every rapid click of the Next button queues a sequentialOpenReportwrite on the sequential queue, forcing the page-2Searchread to wait until all ~50OpenReportrequests finish draining.Duplicate pagination requests and tail modulo wrap-around:
MoneyRequestReportNavigation.tsx,lastSearchQuery.offsetis only updated after theSearchcall, so clicking Next through reports 37–49 fires duplicateoffset: 50requests on every click, andconst nextIndex = (currentIndex + 1) % effectiveAllReports.length(L237) wraps around to index0ifcurrentIndexreaches the end ofeffectiveAllReportswhile more results are still loading.What changes do you think we should make in order to solve the problem?
Defer unfocused offset-0 auto-refetch while RHP pagination is active:
src/hooks/useSearchAutoRefetch.ts, when!isFocused(an RHP is layered over Search) and pagination has advanced ((lastSearchQuery?.offset ?? 0) > 0oreffectiveAllReports.length > CONST.SEARCH.RESULTS_PAGE_SIZE), sethasPendingSearchRef.current = trueandreturnso the offset-0 refetch runs when Search regains focus rather than wiping page 2 mid-carousel.Bypass the write queue and deduplicate pagination requests in
MoneyRequestReportNavigation.tsx:src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx:skipWaitForWrites: truetosearch({...})so fetching the next page of search results is not blocked behind 50 sequentialOpenReportwrites.inFlightOffsetRef) so stepping from index 37 to 49 only dispatches a single request fornewOffset = 50.const nextIndex = (currentIndex + 1) % effectiveAllReports.lengthwith a bounded check (const nextIndex = currentIndex + 1; if (nextIndex >= effectiveAllReports.length) { return; }) and disable the Next button while waiting at the tail (currentIndex === effectiveAllReports.length - 1 && isSearchLoading).What alternative solutions did you explore?
isSearchTopmostFullScreenRoute()inuseSearchAutoRefetch.ts: Explored and rejected because when the user is on page 1 (offset === 0) and edits/creates an expense inside the RHP, Search still needs to auto-refetch so the underlying snapshot and footer totals stay updated. Scoping the deferral to when!isFocusedduring multi-page carousel navigation preserves single-page RHP live updates while protecting multi-page snapshots.melvin-bot commentedon Oct 15, 2026
📣 @open5588! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:
Format:
grgia commentedon Oct 15, 2026
Going with @yusufdeveloper2903's proposal, assigning. He posted the cause first, and his two small changes cover both the page-1 wipe and the page-2 request stuck behind OpenReport. The extra protections only guard states the wipe creates, so keep the PR to those two changes.
@marufsharifi the PR review process will always add a layer. It's useful to find the holes before you get there, but the correct RCA & best behavior for the end user. You've done a great job here, I'm going to make this call myself.
marufsharifi commentedon Oct 16, 2026
@yusufdeveloper2903, when do we expect a pr? thanks.
yusufdeveloper2903 commentedon Oct 16, 2026
Hi I'm working on this issue now, the PR will be ready tomorrow.
marufsharifi commentedon Oct 19, 2026
@yusufdeveloper2903 Gentle bump.