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
The effect at useSidebarOrderedReports.tsx:328 copies reportsToDisplayInLHN into state each time the memo returns a new object. The memo at line 229 leaves currentReportsToDisplay out of its dependencies on purpose, to avoid a loop. So a loop here means that one of the memo's other dependencies gets a new identity on every render on /search.
Reproduction
Unknown. See the Sentry events linked from the Sentry issue.
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: N/A (Expensify NewDot Direct Settlement)
Proposal
Please re-state the problem that we are trying to solve in this issue.
On web, navigating to /search or /search/r/:reportID crashes the application with a fatal React error: Error: Maximum update depth exceeded (React #185) originating from useSidebarOrderedReports.tsx:328 useEffect -> setCurrentReportsToDisplay(reportsToDisplayInLHN).
What is the root cause of that problem?
On /search routes, useIsOnSearch() is true, which activates search snapshot mode (shouldUseSnapshot = true) in src/hooks/useOnyx.ts for snapshot-compatible collection keys.
In src/hooks/useSidebarOrderedReports.tsx:103, chatReports is subscribed via useOnyx(ONYXKEYS.COLLECTION.REPORT) without a selector.
if(key.endsWith('_')){constresult: OnyxCollection<TKey>={};constprefix=key;for(const[dataKey,value]ofObject.entries(snapshotData?.data??{})){if(!dataKey.startsWith(prefix)){continue;}result[dataKey]=valueasOnyxEntry<TKey>;}return(Object.keys(result).length>0 ? result : undefined)asTReturnValue;}
For any collection key (ending with '_'), getKeyData instantiates a brand new object literal {} on every render without memoization or referential caching.
Because resolveSnapshotAwareResult is called on every render of useOnyx, chatReports receives a completely new object reference every single commit, even when the snapshot data is completely static.
In useSidebarOrderedReports.tsx:
chatReports is a dependency of getUpdatedReports (line 215) and reportsToDisplayInLHN (line 289).
useCollectionDelta(chatReports) checks if (current === previous) return undefined; — since current !== previous by reference, it returns a new delta object every time.
Consequently, reportsToDisplayInLHN recalculates and returns a fresh object on every render.
The effect at useSidebarOrderedReports.tsx:327-329:
fires and calls setCurrentReportsToDisplay(reportsToDisplayInLHN). Updating this state triggers an immediate re-render of SidebarOrderedReportsContextProvider, causing useOnyx(ONYXKEYS.COLLECTION.REPORT) to return yet another new object, triggering the effect again in an infinite loop until React aborts with Maximum update depth exceeded.
What changes do you think we should make in order to solve the problem?
Ensure Referential Stability for Snapshot Collections in src/hooks/useOnyx.ts:
Memoize the result of getKeyData using a ref cache keyed by (snapshotData, key) or compare shallow equality of the constructed collection against the cached result before returning a new reference. When the underlying snapshot's data for that prefix has not changed, return the existing cached object reference:
Using a WeakMap<SearchResults, Map<string, unknown>> ensures zero memory leaks when search queries change and guarantees that within the lifetime of a snapshot reference, collection extractions are referentially stable across all renders and consumers.
Defensive Update Guard in src/hooks/useSidebarOrderedReports.tsx:
In useSidebarOrderedReports.tsx:327-329, guard setCurrentReportsToDisplay using functional state updater and shallow comparison so that identical report mappings do not trigger re-render cascades:
Please re-state the problem that we are trying to solve in this issue.
Sentry issue APP-JVF: On Web (primarily on /search and /search/r/:reportID), a fatal Maximum update depth exceeded React error occurs due to an infinite setState re-render loop in useSidebarOrderedReports.tsx:328.
What is the root cause of that problem?
In src/hooks/useSidebarOrderedReports.tsx:327-329:
reportsToDisplayInLHN is computed by a useMemo at line 229. While currentReportsToDisplay is intentionally excluded from the useMemo dependency array to prevent simple dependency loops, reportsToDisplayInLHN reads currentReportsToDisplay during computation (e.g. hasCachedReports = Object.keys(currentReportsToDisplay).length > 0).
When on /search routes or when currentReportId / related collection keys trigger re-evaluation:
If shouldDoIncrementalUpdate is false (such as initial build, cache eviction, or when getReportsToDisplayInLHN executes from scratch), SidebarUtils.getReportsToDisplayInLHN allocates a new reportsToDisplay object dictionary (const reportsToDisplay: ReportsToDisplayInLHN = {}).
Even when shouldDoIncrementalUpdate is true, if any dynamic report property (requiresAttention, hasErrorsOtherThanFailedReceipt, or isUnreadReport) differs from the previous reference or creates a modified entry, getMutableCopy() produces a new object reference.
Crucially, in useSidebarOrderedReports.tsx:327-329, setCurrentReportsToDisplay(reportsToDisplayInLHN) is called unconditionally on every reference change of reportsToDisplayInLHN.
If an upstream trigger (such as route change or unmemoized collection selector) causes useMemo to return a new object with identical content, or if SidebarUtils.getReportsToDisplayInLHN generates a new object identity each render, setCurrentReportsToDisplay triggers a re-render. On subsequent render, if getReportsToDisplayInLHN rebuilds from scratch, a new object reference is returned again, triggering setCurrentReportsToDisplay in a continuous cascading loop until React aborts with Maximum update depth exceeded.
What changes do you think we should make in order to solve the problem?
Guard setCurrentReportsToDisplay with shallow dictionary equality in useSidebarOrderedReports.tsx:
In src/hooks/useSidebarOrderedReports.tsx:327-329:
Import shallowCompare from @libs/ObjectUtils:
Why this is optimal:shallowCompare checks whether prev and reportsToDisplayInLHN share identical keys and report reference values. If reportsToDisplayInLHN returned a new object dictionary but its report entries have not actually changed, returning prev causes React to bail out of state updates completely, terminating the infinite loop immediately.
Add referential stability to SidebarUtils.getReportsToDisplayInLHN:
In src/libs/SidebarUtils.ts:358-359:
Before returning reportsToDisplay, compare against lastReportsToDisplayInLHN:
This ensures that getReportsToDisplayInLHN preserves referential stability across successive renders when report contents remain identical.
What specific scenarios should we cover in automated tests?
In tests/unit/useSidebarOrderedReportsTest.tsx:
Assert that when reportsToDisplayInLHN is recomputed with identical reports (new dictionary reference with identical key/value entries), currentReportsToDisplay does not trigger additional component re-renders.
Assert that navigating to /search or changing currentReportId to an unlisted search report does not trigger circular setCurrentReportsToDisplay loops.
Please re-state the problem that we are trying to solve in this issue.
On web, mostly on /search, the app crashes with Maximum update depth exceeded from the effect in useSidebarOrderedReports that copies reportsToDisplayInLHN into state.
What is the root cause of that problem?
The LHN cache never settles when at least one displayed report has a flag (unread, requires attention, or errors). Every recompute of reportsToDisplayInLHN returns a new object, so the effect sets new state on every pass.
updateReportsToDisplayInLHN is meant to keep the same object when nothing changed (it has a lazy copy for that, SidebarUtils.ts#L401). But for a flagged report it stores a copy, {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport} (L456), and on the next pass it checks existingEntry !== report (L451). The stored entry is the copy, never the report itself, so that check is always true. Each call makes a new entry and a new map even with identical input.
I confirmed this two ways:
Unit test: calling updateReportsToDisplayInLHN twice with the same inputs and one report that has requiresAttention: true returns a different object and a different entry the second time.
In the app (local web, latest main, one unread chat in the LHN), with a temporary log in the effect at L328. Toggling Account > Troubleshoot > Force offline on and off changes no report, but:
That's two state writes per toggle with nothing changed: one when isOffline changes, and another one render later when prevIsOffline catches up.
How this turns into the loop:
When getUpdatedReports() returns nothing, the memo re-checks every displayed report (useSidebarOrderedReports.tsx#L235), so any recompute goes through the code above.
The result is always new, so the effect at L328 sets state and the provider renders again.
Several memo inputs are "previous value" helpers (usePrevious, useCollectionDelta, prevReportAttributes) that only catch up one render later, so the extra render changes them again and the memo recomputes, which allocates again. On /search the provider gets a burst of report, transaction and report attribute updates while results load, and with nothing ever bailing out these cascades pile up past React's nested update limit.
About the snapshot idea: SidebarOrderedReportsContextProvider is mounted in AuthScreens (AuthScreens.tsx#L192), outside SearchScopeProvider (Search/index.tsx#L1435). So useIsOnSearch() is false inside it and its useOnyx(ONYXKEYS.COLLECTION.REPORT) never reads the search snapshot. Also, a shallow-compare guard on the effect would not stop it, because the flagged entries themselves are new objects each time, so the shallow compare still sees a change.
What changes do you think we should make in order to solve the problem?
In SidebarUtils, remember which report each flagged entry was copied from (a module-level WeakMap<entry, Report>). Build flagged entries through one helper in both getReportsToDisplayInLHN and updateReportsToDisplayInLHN, and in the update path reuse the existing entry when its source report and the three flags are unchanged:
With that, a recompute with no real change returns the same map, so the effect doesn't fire, setCurrentReportsToDisplay isn't called, and the chain never starts. The WeakMap lets old entries be garbage collected.
2. Keep the effect as is. It only needs to run when the map really changes, which it now does.
What specific scenarios should we cover in automated tests?
updateReportsToDisplayInLHN returns the same object and entry when called again with unchanged inputs and a flagged report (fails on main today).
It still returns a new entry when the report or one of the flags changes.
useSidebarOrderedReports: re-rendering the provider with no data changes does not call setCurrentReportsToDisplay with a new value.
What alternative solutions did you explore? (Optional)
Keep the cache in a useRef instead of useState, so updating it never schedules a render. That removes the extra render entirely, but it changes how clearLHNCache works, so I'd keep it as a follow-up.
A shallow-compare guard in the effect. It does not help here because the flagged entries are new objects every time.
Proposal Update / Deep Dive (Following up on Proposal #1)
Building upon our initial proposal above, here is the granular mechanism of how the cache churn in SidebarUtils.ts couples with the state loop in useSidebarOrderedReports.tsx:
Extended Root Cause Analysis:
The Flagged Copy Reference Trap in SidebarUtils.ts:451:
In src/libs/SidebarUtils.ts:449-457:
When a report has flags (requiresAttention, hasErrorsOtherThanFailedReceipt, or isUnreadReport), line 456 stores a cloned object literal {...report, ...flags} into the cache.
On subsequent incremental update passes (updateReportsToDisplayInLHN), line 451 performs: existingEntry !== report
Because existingEntry is the cloned object created on the prior pass and report is the Onyx object reference, existingEntry !== reportalways evaluates to true.
Consequently, getMutableCopy() is invoked on every single pass where any flagged report exists, creating a brand new displayedReportsCopy dictionary and allocating new copies even when neither the report data nor the flags have changed.
Cascading State Effect in useSidebarOrderedReports.tsx:327-329:
Because getReportsToDisplayInLHN / updateReportsToDisplayInLHN perpetually returns a new object dictionary reference, the effect:
calls setCurrentReportsToDisplay on every commit. Updating this state triggers an immediate re-render of SidebarOrderedReportsContextProvider.
On routes like /search, multiple rapid updates (transactions, report attributes, navigation events) enter this chain without settling, quickly exceeding React's maximum update depth (Error: Maximum update depth exceeded).
Recommended Two-Pronged Solution:
Fix Reference Tracking for Flagged Entries in src/libs/SidebarUtils.ts:
Track the source report reference so that existingEntry can be verified without breaking reference equality:
Defensive Shallow Bailout in useSidebarOrderedReports.tsx:327-329:
As proposed in our initial submission, guard setCurrentReportsToDisplay against redundant updates:
This provides complete defense-in-depth: SidebarUtils eliminates unnecessary dictionary allocations, while useSidebarOrderedReports guarantees that even if an upstream recomputation yields a new container with identical entries, React skips the state update and breaks any potential render cycle immediately.
@parasharrajat a quick timing note for the review. The flagged entry root cause (existingEntry !== report always true at SidebarUtils L451) and the WeakMap fix with sourceReportByLHNEntry / buildFlaggedLHNEntry were first posted in my proposal at 00:16 UTC, with the before/after numbers from the app. The update above came 15 minutes later with the same names, and the earlier proposal from the same author pointed to the search snapshot instead. Thanks!
Please re-state the problem that we are trying to solve in this issue.
On web, predominantly on /search and /search/r/:reportID, the application crashes with a fatal React error: Error: Maximum update depth exceeded (React #185) originating from useSidebarOrderedReports.tsx:328 useEffect -> setCurrentReportsToDisplay(reportsToDisplayInLHN).
What is the root cause of that problem?
The infinite render loop is caused by a broken referential equality check in SidebarUtils.updateReportsToDisplayInLHN that prevents the LHN cache from ever settling when at least one displayed report has flags (unread, requires attention, or errors).
1. The Broken Lazy Copy in SidebarUtils.ts:451:
In src/libs/SidebarUtils.ts:401-460:
// Use a lazy copy to avoid creating a new object reference when no entries actually change.letdisplayedReportsCopy: ReportsToDisplayInLHN|undefined;constgetMutableCopy=(): ReportsToDisplayInLHN=>{if(!displayedReportsCopy){displayedReportsCopy={...displayedReports};}returndisplayedReportsCopy;};
...
if(hasFlags){if(existingEntry!==report||existingEntry?.requiresAttention!==requiresAttention||existingEntry?.hasErrorsOtherThanFailedReceipt!==hasErrorsOtherThanFailedReceipt||existingEntry?.isUnreadReport!==isUnreadReport){getMutableCopy()[reportID]={...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};}}elseif(existingEntry!==report){getMutableCopy()[reportID]=report;}
For any report with flags (requiresAttention, hasErrorsOtherThanFailedReceipt, or isUnreadReport), line 456 stores a cloned object literal {...report, ...flags} in displayedReports.
On any subsequent update pass, line 451 performs: existingEntry !== report
Because existingEntry is the previously cloned object on the JS heap while report is the Onyx report reference, existingEntry !== reportalways evaluates to true.
As a consequence, getMutableCopy() is invoked on every single pass whenever any flagged report exists, allocating a brand-new displayedReportsCopy dictionary and allocating fresh object clones for every flagged report, even when neither the report data nor any flags changed.
2. The Cascading Render Cycle in useSidebarOrderedReports.tsx:
useSidebarOrderedReports computes reportsToDisplayInLHN in a useMemo (line 229). Because updateReportsToDisplayInLHN perpetually returns a new dictionary object reference with newly allocated entries, reportsToDisplayInLHN is referentially unstable.
Because reportsToDisplayInLHN changed reference, the effect runs and calls setCurrentReportsToDisplay(reportsToDisplayInLHN).
Updating this state triggers an immediate re-render of SidebarOrderedReportsContextProvider.
Several dependencies of getUpdatedReports are previous-value trackers (useCollectionDelta, usePrevious(derivedCurrentReportID), usePrevious(isOffline), reportAttributesUpdates) whose refs only update in a useEffect that fires after commit. Therefore, the re-render triggers another dependency change in getUpdatedReports one render later.
When updatedReports is empty, effectiveUpdatedReports rechecks all displayed reports, which re-invokes updateReportsToDisplayInLHN on every displayed report, triggering the flagged copy bug again and generating yet another new dictionary.
3. Why This Crashes on /search:
On /search and /search/r/:reportID, rapid async bursts of transactions, search result snapshots, and report route transitions occur concurrently. Because the LHN cache mechanism never settles into an identity match, these cascading renders pile up concurrently without ever bailing out, rapidly exceeding React's nested update limit of 50.
(Note: The existing unit test in tests/unit/SidebarUtilsTest.ts:4680 passed because it tested an imaginary key report999 not in displayedReports, bypassing the report loop entirely.)
What changes do you think we should make in order to solve the problem?
We propose a robust, two-layered solution:
Layer 1: Referential Source Tracking in src/libs/SidebarUtils.ts
Track the original report reference for each flagged entry via a module-level WeakMap. This eliminates memory leaks, avoids polluting the Report schema or runtime object keys, and allows isSameFlaggedLHNEntry to detect whether the underlying report or flags have genuinely changed.
Layer 1 preserves referential identity of unchanged flagged entries, allowing displayedReportsCopy to stay undefined so updateReportsToDisplayInLHN returns the original displayedReports reference.
Layer 2 provides defense-in-depth: if any upstream dependency recomputes and produces a new dictionary container, React immediately detects that all entry references are identical and bails out of updating state.
What specific scenarios should we cover in automated tests to prevent reintroduction of this issue in the future?
tests/unit/SidebarUtilsTest.ts:
Call updateReportsToDisplayInLHN with a report that has requiresAttention: true or isUnreadReport: true.
Call updateReportsToDisplayInLHN a second time with the exact same inputs and report reference.
Assert expect(result2).toBe(result1) (currently fails on main).
Call updateReportsToDisplayInLHN with updated report properties or changed flag values; assert expect(result3).not.toBe(result2) and the updated flag is reflected.
tests/unit/useSidebarOrderedReportsTest.tsx:
Render SidebarOrderedReportsContextProvider with an unread chat in the LHN.
Simulate a re-render or trigger an unrelated collection update that doesn't modify the displayed chat.
Assert setCurrentReportsToDisplay does not trigger cascading re-renders or exceed React's maximum update depth.
What alternative solutions did you explore? (Optional)
Only using a shallow equality check in useSidebarOrderedReports.tsx:
As explained above, shallow compare on its own does not work without Layer 1 because updateReportsToDisplayInLHN generates freshly cloned entry objects {...report, ...flags} on every pass, causing prev[key] === next[key] to fail for every flagged report.
Replacing currentReportsToDisplayuseState with useRef:
While storing currentReportsToDisplay in a ref avoids scheduling renders, useSidebarOrderedReportsState() exposes orderedReportIDs and related state to consumers, and altering state synchronization would break clearLHNCache and downstream consumers.
It has the SidebarUtils change plus unit tests for flagged entries (same object when nothing changed, new entry when the report changes, and entries from a full rebuild are reused). The new tests fail on main and pass on the branch, and the existing LHN/sidebar suites still pass. In the app, toggling Force offline now causes no cache writes instead of four.
Activity
melvin-bot commentedon Oct 20, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @parasharrajat (
External)[-][Sentry: APP-JVF] Web: useSidebarOrderedReports setState loop, "Maximum update depth exceeded"[/-][+][$175] [Sentry: APP-JVF] Web: useSidebarOrderedReports setState loop, "Maximum update depth exceeded"[/+]melvin-bot commentedon Oct 20, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022112637803657814041
magomedta commentedon Oct 20, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: N/A (Expensify NewDot Direct Settlement)
Proposal
Please re-state the problem that we are trying to solve in this issue.
On web, navigating to
/searchor/search/r/:reportIDcrashes the application with a fatal React error:Error: Maximum update depth exceeded (React #185)originating fromuseSidebarOrderedReports.tsx:328 useEffect -> setCurrentReportsToDisplay(reportsToDisplayInLHN).What is the root cause of that problem?
/searchroutes,useIsOnSearch()istrue, which activates search snapshot mode (shouldUseSnapshot = true) insrc/hooks/useOnyx.tsfor snapshot-compatible collection keys.src/hooks/useSidebarOrderedReports.tsx:103,chatReportsis subscribed viauseOnyx(ONYXKEYS.COLLECTION.REPORT)without a selector.src/hooks/useOnyx.ts:getKeyData:'_'),getKeyDatainstantiates a brand new object literal{}on every render without memoization or referential caching.resolveSnapshotAwareResultis called on every render ofuseOnyx,chatReportsreceives a completely new object reference every single commit, even when the snapshot data is completely static.useSidebarOrderedReports.tsx:chatReportsis a dependency ofgetUpdatedReports(line 215) andreportsToDisplayInLHN(line 289).useCollectionDelta(chatReports)checksif (current === previous) return undefined;— sincecurrent !== previousby reference, it returns a new delta object every time.reportsToDisplayInLHNrecalculates and returns a fresh object on every render.useSidebarOrderedReports.tsx:327-329:setCurrentReportsToDisplay(reportsToDisplayInLHN). Updating this state triggers an immediate re-render ofSidebarOrderedReportsContextProvider, causinguseOnyx(ONYXKEYS.COLLECTION.REPORT)to return yet another new object, triggering the effect again in an infinite loop until React aborts withMaximum update depth exceeded.What changes do you think we should make in order to solve the problem?
Ensure Referential Stability for Snapshot Collections in
src/hooks/useOnyx.ts:Memoize the result of
getKeyDatausing a ref cache keyed by(snapshotData, key)or compare shallow equality of the constructed collection against the cached result before returning a new reference. When the underlying snapshot's data for that prefix has not changed, return the existing cached object reference:Using a
WeakMap<SearchResults, Map<string, unknown>>ensures zero memory leaks when search queries change and guarantees that within the lifetime of a snapshot reference, collection extractions are referentially stable across all renders and consumers.Defensive Update Guard in
src/hooks/useSidebarOrderedReports.tsx:In
useSidebarOrderedReports.tsx:327-329, guardsetCurrentReportsToDisplayusing functional state updater and shallow comparison so that identical report mappings do not trigger re-render cascades:What specific scenarios should we cover in automated tests?
useOnyxSnapshot Collection Referential Stability (tests/unit/hooks/useOnyxTest.ts):useOnyx(ONYXKEYS.COLLECTION.REPORT)insideSearchContextwith active snapshot data.result[0]retains strict referential equality (===).useSidebarOrderedReportsRender Loop Prevention (tests/unit/useSidebarOrderedReportsTest.tsx):SidebarOrderedReportsContextProviderin a simulated search environment (isOnSearch = true).setCurrentReportsToDisplaystabilizes and does not enter an infinite render loop or exceed maximum update depth.melvin-bot commentedon Oct 20, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
camesenin commentedon Oct 20, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~0170d6b02e9440e604
Proposal
Please re-state the problem that we are trying to solve in this issue.
Sentry issue
APP-JVF: On Web (primarily on/searchand/search/r/:reportID), a fatalMaximum update depth exceededReact error occurs due to an infinitesetStatere-render loop inuseSidebarOrderedReports.tsx:328.What is the root cause of that problem?
In
src/hooks/useSidebarOrderedReports.tsx:327-329:reportsToDisplayInLHNis computed by auseMemoat line 229. WhilecurrentReportsToDisplayis intentionally excluded from theuseMemodependency array to prevent simple dependency loops,reportsToDisplayInLHNreadscurrentReportsToDisplayduring computation (e.g.hasCachedReports = Object.keys(currentReportsToDisplay).length > 0)./searchroutes or whencurrentReportId/ related collection keys trigger re-evaluation:shouldDoIncrementalUpdateis false (such as initial build, cache eviction, or whengetReportsToDisplayInLHNexecutes from scratch),SidebarUtils.getReportsToDisplayInLHNallocates a newreportsToDisplayobject dictionary (const reportsToDisplay: ReportsToDisplayInLHN = {}).shouldDoIncrementalUpdateis true, if any dynamic report property (requiresAttention,hasErrorsOtherThanFailedReceipt, orisUnreadReport) differs from the previous reference or creates a modified entry,getMutableCopy()produces a new object reference.useSidebarOrderedReports.tsx:327-329,setCurrentReportsToDisplay(reportsToDisplayInLHN)is called unconditionally on every reference change ofreportsToDisplayInLHN.useMemoto return a new object with identical content, or ifSidebarUtils.getReportsToDisplayInLHNgenerates a new object identity each render,setCurrentReportsToDisplaytriggers a re-render. On subsequent render, ifgetReportsToDisplayInLHNrebuilds from scratch, a new object reference is returned again, triggeringsetCurrentReportsToDisplayin a continuous cascading loop until React aborts withMaximum update depth exceeded.What changes do you think we should make in order to solve the problem?
Guard
setCurrentReportsToDisplaywith shallow dictionary equality inuseSidebarOrderedReports.tsx:In
src/hooks/useSidebarOrderedReports.tsx:327-329:Import
shallowComparefrom@libs/ObjectUtils:shallowComparechecks whetherprevandreportsToDisplayInLHNshare identical keys and report reference values. IfreportsToDisplayInLHNreturned a new object dictionary but its report entries have not actually changed, returningprevcauses React to bail out of state updates completely, terminating the infinite loop immediately.Add referential stability to
SidebarUtils.getReportsToDisplayInLHN:In
src/libs/SidebarUtils.ts:358-359:Before returning
reportsToDisplay, compare againstlastReportsToDisplayInLHN:This ensures that
getReportsToDisplayInLHNpreserves referential stability across successive renders when report contents remain identical.What specific scenarios should we cover in automated tests?
In
tests/unit/useSidebarOrderedReportsTest.tsx:reportsToDisplayInLHNis recomputed with identical reports (new dictionary reference with identical key/value entries),currentReportsToDisplaydoes not trigger additional component re-renders./searchor changingcurrentReportIdto an unlisted search report does not trigger circularsetCurrentReportsToDisplayloops.melvin-bot commentedon Oct 20, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
stacklancer commentedon Oct 21, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On web, mostly on
/search, the app crashes withMaximum update depth exceededfrom the effect inuseSidebarOrderedReportsthat copiesreportsToDisplayInLHNinto state.What is the root cause of that problem?
The LHN cache never settles when at least one displayed report has a flag (unread, requires attention, or errors). Every recompute of
reportsToDisplayInLHNreturns a new object, so the effect sets new state on every pass.updateReportsToDisplayInLHNis meant to keep the same object when nothing changed (it has a lazy copy for that, SidebarUtils.ts#L401). But for a flagged report it stores a copy,{...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport}(L456), and on the next pass it checksexistingEntry !== report(L451). The stored entry is the copy, never the report itself, so that check is always true. Each call makes a new entry and a new map even with identical input.I confirmed this two ways:
updateReportsToDisplayInLHNtwice with the same inputs and one report that hasrequiresAttention: truereturns a different object and a different entry the second time.That's two state writes per toggle with nothing changed: one when
isOfflinechanges, and another one render later whenprevIsOfflinecatches up.How this turns into the loop:
getUpdatedReports()returns nothing, the memo re-checks every displayed report (useSidebarOrderedReports.tsx#L235), so any recompute goes through the code above.usePrevious,useCollectionDelta,prevReportAttributes) that only catch up one render later, so the extra render changes them again and the memo recomputes, which allocates again. On/searchthe provider gets a burst of report, transaction and report attribute updates while results load, and with nothing ever bailing out these cascades pile up past React's nested update limit.About the snapshot idea:
SidebarOrderedReportsContextProvideris mounted inAuthScreens(AuthScreens.tsx#L192), outsideSearchScopeProvider(Search/index.tsx#L1435). SouseIsOnSearch()is false inside it and itsuseOnyx(ONYXKEYS.COLLECTION.REPORT)never reads the search snapshot. Also, a shallow-compare guard on the effect would not stop it, because the flagged entries themselves are new objects each time, so the shallow compare still sees a change.What changes do you think we should make in order to solve the problem?
SidebarUtils, remember which report each flagged entry was copied from (a module-levelWeakMap<entry, Report>). Build flagged entries through one helper in bothgetReportsToDisplayInLHNandupdateReportsToDisplayInLHN, and in the update path reuse the existing entry when its source report and the three flags are unchanged:With that, a recompute with no real change returns the same map, so the effect doesn't fire,
setCurrentReportsToDisplayisn't called, and the chain never starts. TheWeakMaplets old entries be garbage collected.2. Keep the effect as is. It only needs to run when the map really changes, which it now does.
What specific scenarios should we cover in automated tests?
updateReportsToDisplayInLHNreturns the same object and entry when called again with unchanged inputs and a flagged report (fails on main today).useSidebarOrderedReports: re-rendering the provider with no data changes does not callsetCurrentReportsToDisplaywith a new value.What alternative solutions did you explore? (Optional)
useRefinstead ofuseState, so updating it never schedules a render. That removes the extra render entirely, but it changes howclearLHNCacheworks, so I'd keep it as a follow-up.magomedta commentedon Oct 21, 2026
Proposal Update / Deep Dive (Following up on Proposal #1)
Building upon our initial proposal above, here is the granular mechanism of how the cache churn in
SidebarUtils.tscouples with the state loop inuseSidebarOrderedReports.tsx:Extended Root Cause Analysis:
The Flagged Copy Reference Trap in
SidebarUtils.ts:451:In
src/libs/SidebarUtils.ts:449-457:When a report has flags (
requiresAttention,hasErrorsOtherThanFailedReceipt, orisUnreadReport), line 456 stores a cloned object literal{...report, ...flags}into the cache.On subsequent incremental update passes (
updateReportsToDisplayInLHN), line 451 performs:existingEntry !== reportBecause
existingEntryis the cloned object created on the prior pass andreportis the Onyx object reference,existingEntry !== reportalways evaluates totrue.Consequently,
getMutableCopy()is invoked on every single pass where any flagged report exists, creating a brand newdisplayedReportsCopydictionary and allocating new copies even when neither the report data nor the flags have changed.Cascading State Effect in
useSidebarOrderedReports.tsx:327-329:Because
getReportsToDisplayInLHN/updateReportsToDisplayInLHNperpetually returns a new object dictionary reference, the effect:calls
setCurrentReportsToDisplayon every commit. Updating this state triggers an immediate re-render ofSidebarOrderedReportsContextProvider.On routes like
/search, multiple rapid updates (transactions, report attributes, navigation events) enter this chain without settling, quickly exceeding React's maximum update depth (Error: Maximum update depth exceeded).Recommended Two-Pronged Solution:
Fix Reference Tracking for Flagged Entries in
src/libs/SidebarUtils.ts:Track the source report reference so that
existingEntrycan be verified without breaking reference equality:And in
updateReportsToDisplayInLHN:Defensive Shallow Bailout in
useSidebarOrderedReports.tsx:327-329:As proposed in our initial submission, guard
setCurrentReportsToDisplayagainst redundant updates:This provides complete defense-in-depth:
SidebarUtilseliminates unnecessary dictionary allocations, whileuseSidebarOrderedReportsguarantees that even if an upstream recomputation yields a new container with identical entries, React skips the state update and breaks any potential render cycle immediately.stacklancer commentedon Oct 21, 2026
@parasharrajat a quick timing note for the review. The flagged entry root cause (
existingEntry !== reportalways true at SidebarUtils L451) and theWeakMapfix withsourceReportByLHNEntry/buildFlaggedLHNEntrywere first posted in my proposal at 00:16 UTC, with the before/after numbers from the app. The update above came 15 minutes later with the same names, and the earlier proposal from the same author pointed to the search snapshot instead. Thanks!sijo0218 commentedon Oct 21, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~011091133bbb09cd1c
Proposal
Please re-state the problem that we are trying to solve in this issue.
On web, predominantly on
/searchand/search/r/:reportID, the application crashes with a fatal React error:Error: Maximum update depth exceeded (React #185)originating fromuseSidebarOrderedReports.tsx:328 useEffect -> setCurrentReportsToDisplay(reportsToDisplayInLHN).What is the root cause of that problem?
The infinite render loop is caused by a broken referential equality check in
SidebarUtils.updateReportsToDisplayInLHNthat prevents the LHN cache from ever settling when at least one displayed report has flags (unread, requires attention, or errors).1. The Broken Lazy Copy in
SidebarUtils.ts:451:In
src/libs/SidebarUtils.ts:401-460:requiresAttention,hasErrorsOtherThanFailedReceipt, orisUnreadReport), line 456 stores a cloned object literal{...report, ...flags}indisplayedReports.existingEntry !== reportexistingEntryis the previously cloned object on the JS heap whilereportis the Onyx report reference,existingEntry !== reportalways evaluates totrue.getMutableCopy()is invoked on every single pass whenever any flagged report exists, allocating a brand-newdisplayedReportsCopydictionary and allocating fresh object clones for every flagged report, even when neither the report data nor any flags changed.2. The Cascading Render Cycle in
useSidebarOrderedReports.tsx:useSidebarOrderedReportscomputesreportsToDisplayInLHNin auseMemo(line 229). BecauseupdateReportsToDisplayInLHNperpetually returns a new dictionary object reference with newly allocated entries,reportsToDisplayInLHNis referentially unstable.useSidebarOrderedReports.tsx:327-329:reportsToDisplayInLHNchanged reference, the effect runs and callssetCurrentReportsToDisplay(reportsToDisplayInLHN).SidebarOrderedReportsContextProvider.getUpdatedReportsare previous-value trackers (useCollectionDelta,usePrevious(derivedCurrentReportID),usePrevious(isOffline),reportAttributesUpdates) whose refs only update in auseEffectthat fires after commit. Therefore, the re-render triggers another dependency change ingetUpdatedReportsone render later.updatedReportsis empty,effectiveUpdatedReportsrechecks all displayed reports, which re-invokesupdateReportsToDisplayInLHNon every displayed report, triggering the flagged copy bug again and generating yet another new dictionary.3. Why This Crashes on
/search:On
/searchand/search/r/:reportID, rapid async bursts of transactions, search result snapshots, and report route transitions occur concurrently. Because the LHN cache mechanism never settles into an identity match, these cascading renders pile up concurrently without ever bailing out, rapidly exceeding React's nested update limit of 50.(Note: The existing unit test in
tests/unit/SidebarUtilsTest.ts:4680passed because it tested an imaginary keyreport999not indisplayedReports, bypassing the report loop entirely.)What changes do you think we should make in order to solve the problem?
We propose a robust, two-layered solution:
Layer 1: Referential Source Tracking in
src/libs/SidebarUtils.tsTrack the original
reportreference for each flagged entry via a module-levelWeakMap. This eliminates memory leaks, avoids polluting theReportschema or runtime object keys, and allowsisSameFlaggedLHNEntryto detect whether the underlying report or flags have genuinely changed.Layer 2: Defensive State Bail-Out in
src/hooks/useSidebarOrderedReports.tsxGuard
setCurrentReportsToDisplaywith shallow dictionary comparison:Why both layers:
displayedReportsCopyto stayundefinedsoupdateReportsToDisplayInLHNreturns the originaldisplayedReportsreference.What specific scenarios should we cover in automated tests to prevent reintroduction of this issue in the future?
tests/unit/SidebarUtilsTest.ts:updateReportsToDisplayInLHNwith a report that hasrequiresAttention: trueorisUnreadReport: true.updateReportsToDisplayInLHNa second time with the exact same inputs and report reference.expect(result2).toBe(result1)(currently fails onmain).updateReportsToDisplayInLHNwith updated report properties or changed flag values; assertexpect(result3).not.toBe(result2)and the updated flag is reflected.tests/unit/useSidebarOrderedReportsTest.tsx:SidebarOrderedReportsContextProviderwith an unread chat in the LHN.setCurrentReportsToDisplaydoes not trigger cascading re-renders or exceed React's maximum update depth.What alternative solutions did you explore? (Optional)
useSidebarOrderedReports.tsx:updateReportsToDisplayInLHNgenerates freshly cloned entry objects{...report, ...flags}on every pass, causingprev[key] === next[key]to fail for every flagged report.currentReportsToDisplayuseStatewithuseRef:currentReportsToDisplayin a ref avoids scheduling renders,useSidebarOrderedReportsState()exposesorderedReportIDsand related state to consumers, and altering state synchronization would breakclearLHNCacheand downstream consumers.melvin-bot commentedon Oct 21, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
stacklancer commentedon Oct 22, 2026
@parasharrajat here's a test branch for my proposal: main...stacklancer:App:fix/104504-lhn-cache-identity
It has the
SidebarUtilschange plus unit tests for flagged entries (same object when nothing changed, new entry when the report changes, and entries from a full rebuild are reused). The new tests fail on main and pass on the branch, and the existing LHN/sidebar suites still pass. In the app, toggling Force offline now causes no cache writes instead of four.