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.
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.
@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.
Proposal updated. traced the regression to #93438 (sourceValue replaced with usePrevious deltas, first seen right after 9.4.45) and #103147 for the 9.5.5 jump, also covered the empty LHN case
@parasharrajat, can contributors request read-only Sentry access for this issue? If access is limited to C+, could you share one APP-JVF event with personal data removed? The full stack, recent breadcrumbs, app version, and browser version would help me test the reported crash.
In src/libs/SidebarUtils.ts inside updateReportsToDisplayInLHN, flagged reports (hasFlags) are stored in displayedReports as a newly created spread object: {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport}.
On any re-evaluation, the condition checks: existingEntry !== report
Because existingEntry is the spread wrapper object from the previous pass and report is the unwrapped Onyx report reference, this reference comparison is always true. Consequently, getMutableCopy()[reportID] is updated unconditionally on every pass, returning a new map reference from updateReportsToDisplayInLHN even when report data and flags are identical.
In useSidebarOrderedReports.tsx, useEffect listens to reportsToDisplayInLHN and calls setCurrentReportsToDisplay(reportsToDisplayInLHN). On routes like /search where reportAttributes updates continuously, this creates an unbroken chain of state updates exceeding React's nested update threshold of 50 commits (getRootForUpdatedFiber -> Maximum update depth exceeded #185).
What changes do you think we should make in order to solve the problem?
In src/libs/SidebarUtils.ts (inside updateReportsToDisplayInLHN around lines 465–475):
When report data and flags are unchanged, getMutableCopy is never allocated, updateReportsToDisplayInLHN returns the existing displayedReports reference, the useEffect dependency does not change, and the re-render cycle terminates cleanly.
What alternative solutions did you explore? (Optional)
Storing the raw report reference on a separate symbol/property on existingEntry to compare existingEntry._rawReport !== report. This was rejected in favor of shallowEqual, which is already standard across Expensify's Onyx derived hooks (e.g., guideAccountIDs.ts) and is cleaner to maintain.
Onyx updates reach React through useSyncExternalStore, on React's sync lane, so their effects run right away. During a run of Onyx writes, each of those commits ends with the effect's update still pending. React counts that as a nested update and throws after 50 in a row.
The run doesn't settle between writes, because each report write is followed by its own REPORT_ATTRIBUTES write (20 report writes gave 20 of them). That second render changes nothing for the LHN, so it rechecks every displayed report. But the stored entry of an unread or flagged report is a copy, so existingEntry !== report is always true and the entry is copied again. updateReportsToDisplayInLHN returns a new map, and the effect sets state again:
I checked this in a local web build. I added 5 unread test rooms to the LHN through Onyx, then called Onyx.merge on them 80 times from the console, awaiting 3 microtasks between calls. It threw "Maximum update depth exceeded" on Inbox and on Search.
The extra render also hides a second bug. By then usePrevious has caught up, so no report key changed and it rechecks every displayed report. That's what fixes a row whose LHN result depends on another report, since the check reads the parent and chat report:
But an incremental update only rechecks the changed reports (and the reports a changed transaction or policy points to), never the reports that depend on them. Example: an expense report has 2 expenses, and one expense's thread is in the LHN. Delete the other expense, and the thread should leave the LHN (its report now has one expense). Main removes it right away, through those extra renders. Without them, it stays until some later update happens to recheck every displayed report.
What changes do you think we should make in order to solve the problem?
Replace the effect with an update during render. React's docs call this better than updating state in an effect: React re-runs the component before committing, so there's no extra commit to count.
The re-run returns the same map at once, because the memo's inputs don't change within a render (the React Compiler memoizes the useCollectionDelta results). If that ever changed, the first test below would fail. clearLHNCache keeps working as it is.
When a report changes, also recheck the displayed reports linked to it as parent or chat report, in both directions (a thread when its expense report changes, and an expense report when its thread is read). A small helper, getLHNReportKeysToRecheck, adds only those keys. Like main's extra pass, it only looks at reports already in the LHN:
I ran the same steps on each version. For the thread case, I built the 2-expense report and its thread in Onyx, then made the changes a delete makes to that report: one IOU action removed and the total updated. I read the LHN 2.5 seconds later. That session was offline, so no server response came in between.
Version
80 writes
Thread after deleting 1 of 2 expenses
main
"Maximum update depth exceeded" (3 logged)
removed
cache in a ref
0 errors
stays in the LHN
flagged entry reused when unchanged
0 errors
stays in the LHN
this change
0 errors (also 0 at 400 writes)
removed
Unchanged flagged entries are still copied on a pass with no report keys. That's extra work, but it no longer adds a commit, so it can't count toward the limit.
Tests:
useSidebarOrderedReportsTest: storing the new map doesn't start a second LHN pass, and a change to an expense report rechecks its displayed thread. Both fail on main.
getLHNReportKeysToRecheckTest: parent link, chat link, thread to parent, unrelated report, no updates.
The 15 Sidebar/LHN suites pass (407 tests). Typecheck, lint, the React Compiler check and cspell pass.
Not tested: the native apps, a production build, and the crash itself. In my runs an Onyx listener hit the limit first, and Onyx catches errors there. The Sentry events hit it in the effect's setState.
What alternative solutions did you explore? (Optional)
Keep the cache in a ref: stops the error, but the thread stays (table above). Also, in my version, reading the ref during render added 2 react-hooks/refs errors over this file's lint seatbelt (7, where 5 are allowed), so npm run lint-changed failed.
Reuse a flagged entry when its report and flags are unchanged (I tested a shallow compare): also stops my bursts, because the REPORT_ATTRIBUTES render then returns the same map. But the effect still adds a commit after every real LHN change, and the thread still stays.
Please re-state the problem that we are trying to solve in this issue.
Sentry fatal issue APP-JVF ("Maximum update depth exceeded"): On Web (frequently triggered when navigating to /search or /search/r/:reportID), an infinite setState re-render loop occurs in useSidebarOrderedReports.tsx:329-331, crashing the application with React Error #185.
What is the root cause of that problem?
The infinite re-render loop is caused by a broken referential comparison in src/libs/SidebarUtils.ts interacting with the state update effect in src/hooks/useSidebarOrderedReports.tsx.
Step-by-Step Mechanism:
Broken Reference Equality Check for Flagged Reports (SidebarUtils.ts:446-460):
When a report has flags (requiresAttention, hasErrorsOtherThanFailedReceipt, or isUnreadReport), updateReportsToDisplayInLHN creates a decorated copy:
Because it spreads report into a new object, displayedReports[reportID] holds a new reference, not the original Onyx report object.
On subsequent passes:
Because existingEntry is the spread object and report is the Onyx object, existingEntry !== report evaluates to true on every single run, even when neither the report nor any of its flags changed. Consequently, getMutableCopy() is always called, producing a brand-new displayedReportsCopy object reference every time.
The Cascading Re-render Loop (useSidebarOrderedReports.tsx):
Because updatedReports is empty, it passes all currently displayed report keys into updateReportsToDisplayInLHN.
In updateReportsToDisplayInLHN, every displayed report with flags hits existingEntry !== report === true, generating a new dictionary reference again.
This new reference causes useEffect at line 329 to fire again.
The loop repeats continuously until React halts with Maximum update depth exceeded.
Why It Manifests on /search and /search/r/:reportID:
Route changes to Search and Search report views trigger re-evaluations (e.g. prevDerivedCurrentReportID !== derivedCurrentReportID or search parameter updates). If the user has any unread or flagged reports in their LHN (typical for active accounts), this immediately kicks off the self-sustaining infinite loop.
What changes do you think we should make in order to solve the problem?
We should implement a robust, two-layer solution:
1. In src/libs/SidebarUtils.ts (The Root Fix):
Track the underlying Onyx report reference using a module-level WeakMap:
In both getReportsToDisplayInLHN and updateReportsToDisplayInLHN, when creating a decorated entry with flags, associate the new entry with its source Onyx report:
Why WeakMap is optimal: It allows garbage collection of old report entries, introduces zero memory leaks, does not mutate Onyx report objects, and correctly preserves referential stability when reports and flags are unchanged. When no reports change, displayedReportsCopy is never allocated and updateReportsToDisplayInLHN returns the identical displayedReports reference.
2. In src/hooks/useSidebarOrderedReports.tsx (Defensive State Bail-Out):
In line 329–331, use shallowCompare from @libs/ObjectUtils inside the functional state updater:
Why this is critical: If an external update produces a new dictionary containing identical report references, returning prev instructs React to bail out of the state update, guaranteeing that recursive re-renders are completely prevented.
What alternative solutions did you explore? (Optional)
Updating state directly during render (if (currentReportsToDisplay !== reportsToDisplayInLHN) setCurrentReportsToDisplay(...)):
Updating state during render without fixing the underlying existingEntry !== report reference generation in SidebarUtils.ts causes React to restart render cycles synchronously, triggering the exact same Maximum update depth exceeded fatal crash during render rather than in an effect.
Attaching a hidden _sourceReport property directly on the report entry:
Mutating or adding non-standard properties directly onto the report object risks TypeScript type pollution and unintended serialization in tests or Onyx helpers. A module-scoped WeakMap is completely isolated and memory-safe.
Deep equality checking inside useMemo:
Deeply comparing all reports on every render is prohibitively expensive for users with hundreds of reports in the LHN. WeakMap identity tracking has $O(1)$ reference lookup performance.
Automated Tests
1. In tests/unit/SidebarUtilsTest.ts:
Verify that updateReportsToDisplayInLHN preserves reference equality for unchanged flagged reports:
it('should return the same reference when unchanged reports have flags',()=>{// Given an unread report in displayedReports that has custom flagsconstreport=createRandomReport(1,undefined);constdisplayedReports: ReportsToDisplayInLHN={};constreports: OnyxCollection<Report>={[`${ONYXKEYS.COLLECTION.REPORT}1`]: report,};// When updateReportsToDisplayInLHN runs on an unread reportconstinitialResult=SidebarUtils.updateReportsToDisplayInLHN({
displayedReports,
reports,updatedReportsKeys: [`${ONYXKEYS.COLLECTION.REPORT}1`],currentReportId: undefined,isInFocusMode: false,isDefaultRoomsBetaEnabled: false,transactions: {},transactionViolations: {},reportNameValuePairs: {},reportAttributes: undefined,draftComments: {},isOffline: false,currentUserLogin: CURRENT_USER_LOGIN,currentUserAccountID: CURRENT_USER_ACCOUNT_ID,guideAccountIDs: [],conciergeReportID: undefined,});// When updateReportsToDisplayInLHN rechecks the same unread report with no underlying changesconstsecondResult=SidebarUtils.updateReportsToDisplayInLHN({displayedReports: initialResult,
reports,updatedReportsKeys: [`${ONYXKEYS.COLLECTION.REPORT}1`],currentReportId: undefined,isInFocusMode: false,isDefaultRoomsBetaEnabled: false,transactions: {},transactionViolations: {},reportNameValuePairs: {},reportAttributes: undefined,draftComments: {},isOffline: false,currentUserLogin: CURRENT_USER_LOGIN,currentUserAccountID: CURRENT_USER_ACCOUNT_ID,guideAccountIDs: [],conciergeReportID: undefined,});// Then it should return the exact same dictionary reference rather than creating a new copyexpect(secondResult).toBe(initialResult);});
2. In tests/unit/useSidebarOrderedReportsTest.tsx:
Verify that setCurrentReportsToDisplay does not trigger multiple re-render loops when reportsToDisplayInLHN is referentially stable.
Root cause rewritten after measuring it. This isn't a render loop; it's React's nested-update counter. Every Onyx-driven commit of SidebarOrderedReportsContextProvider is a Sync-lane commit whose passive setCurrentReportsToDisplay leaves an update pending, which is exactly what the counter counts, and three identity defects (flagged entries stored as spread copies, identity-only REPORT_ATTRIBUTES rewrites, usePrevious deltas flipping a render late) make that setState fire on every commit while one bold chat is in the LHN.
Reproduction added. The issue says "Reproduction: Unknown" — there's now a console snippet that crashes main within a second with the exact Sentry stack (dispatchSetState → useSidebarOrderedReports.tsx → commitHookEffectListMount). Before/after screenshots attached: main on the "Uh-oh" page with the stack in the console; the branch still running after the same burst.
Fix is three small changes, each with permalinks: the cache moves from state to a ref (removes the counted update), flagged entries remember their source report so a no-op recheck returns the same map, and the memo/sort/brick-road read attributes through a content-gated reference. A measurement table in the proposal shows why each alone is insufficient.
Please re-state the problem that we are trying to solve in this issue.
Sentry fatal crash APP-JVF ("Maximum update depth exceeded", React Error #185): On Web, particularly when navigating to /search or /search/r/:reportID, an infinite setState re-render loop occurs in useSidebarOrderedReports.tsx:337-339 (setCurrentReportsToDisplay(reportsToDisplayInLHN)), crashing the application.
What is the root cause of that problem?
The crash is caused by a reference-identity mismatch in SidebarUtils.updateReportsToDisplayInLHN (src/libs/SidebarUtils.ts:466-477) interacting with the incremental update fallback in useSidebarOrderedReports.tsx:239:
False-Positive existingEntry !== report on Flagged Reports (SidebarUtils.ts:468):
In src/libs/SidebarUtils.ts:466-474, when a report has flags (hasFlags = true), it is saved into the mutable copy as a newly spread object:
Because existingEntry !== report evaluates to true on every single pass for any flagged report (even when report and its flags have not changed at all), getMutableCopy() is unconditionally invoked.
Full Cache Fallback on /search Routes (useSidebarOrderedReports.tsx:239):
When navigating to /search or changing dependencies without report key updates, line 239 evaluates:
This passes all displayed report keys into updateReportsToDisplayInLHN. Because flagged reports always fail the reference check existingEntry !== report, updateReportsToDisplayInLHN always creates a new object reference (displayedReportsCopy = {...displayedReports}) and returns a new map identity.
Infinite useEffect -> setState Loop (useSidebarOrderedReports.tsx:337-339): useMemo returns the new reference for reportsToDisplayInLHN. The effect on line 337:
fires immediately, updating currentReportsToDisplay. This triggers a re-render of SidebarOrderedReportsContextProvider, which recomputes reportsToDisplayInLHN, returns another new reference, and calls setCurrentReportsToDisplay continuously until React crashes with Maximum update depth exceeded.
What changes do you think we should make in order to solve the problem?
1. Fix Reference Stabilization in src/libs/SidebarUtils.ts:
In src/libs/SidebarUtils.ts:466-477, store the underlying report reference (or compare whether the underlying report's key fields have actually updated) rather than checking existingEntry !== report against a spread object:
This guarantees that when neither the report properties nor the flags have changed, getMutableCopy() is not called, and updateReportsToDisplayInLHN returns the identical displayedReports reference without allocating a new object.
2. Guard State Updates in src/hooks/useSidebarOrderedReports.tsx:
In src/hooks/useSidebarOrderedReports.tsx:337-339, guard the state setter so that an update is only dispatched if the identity has genuinely changed:
Deep-comparing currentReportsToDisplay inside useMemo was explored, but performing deep-equality on the entire LHN report map on every render introduces unnecessary CPU overhead on low-end mobile devices. Stabilizing the lazy-copy contract inside SidebarUtils.updateReportsToDisplayInLHN completely resolves the root cause in $O(1)$ without extra allocations.
Please re-state the problem that we are trying to solve in this issue.
Sentry fatal crash APP-JVF ("Maximum update depth exceeded", React Error #185): On Web, particularly when navigating to /search or /search/r/:reportID, an infinite setState re-render loop occurs in useSidebarOrderedReports.tsx:337-339 (setCurrentReportsToDisplay(reportsToDisplayInLHN)), crashing the application.
What is the root cause of that problem?
The crash is caused by an object reference mismatch in SidebarUtils.updateReportsToDisplayInLHN (src/libs/SidebarUtils.ts:466-477) interacting with the incremental update fallback in useSidebarOrderedReports.tsx:239:
False-Positive existingEntry !== report on Flagged Reports (SidebarUtils.ts:468):
In src/libs/SidebarUtils.ts:466-474, when a report has flags (hasFlags = true), it is saved into the mutable copy as a newly spread object:
Because existingEntry !== report evaluates to true on every single pass for any flagged report (even when report and its flags have not changed at all), getMutableCopy() is unconditionally invoked.
Full Cache Fallback on /search Routes (useSidebarOrderedReports.tsx:239):
When navigating to /search or changing dependencies without report key updates, line 239 evaluates:
This passes all displayed report keys into updateReportsToDisplayInLHN. Because flagged reports always fail the reference check existingEntry !== report, updateReportsToDisplayInLHN always creates a new object reference (displayedReportsCopy = {...displayedReports}) and returns a new map identity.
Infinite useEffect -> setState Loop (useSidebarOrderedReports.tsx:337-339): useMemo returns the new reference for reportsToDisplayInLHN. The effect on line 337:
fires immediately, updating currentReportsToDisplay. This triggers a re-render of SidebarOrderedReportsContextProvider, which recomputes reportsToDisplayInLHN, returns another new reference, and calls setCurrentReportsToDisplay continuously until React crashes with Maximum update depth exceeded.
What changes do you think we should make in order to solve the problem?
1. Fix Reference Stabilization in src/libs/SidebarUtils.ts:
In src/libs/SidebarUtils.ts:466-477, compare whether the underlying report's key fields have actually updated rather than checking existingEntry !== report against a spread object:
This guarantees that when neither the report properties nor the flags have changed, getMutableCopy() is not called, and updateReportsToDisplayInLHN returns the identical displayedReports reference without allocating a new object.
2. Guard State Updates in src/hooks/useSidebarOrderedReports.tsx:
In src/hooks/useSidebarOrderedReports.tsx:337-339, guard the state setter so that an update is only dispatched if the identity has genuinely changed:
What alternative solutions did you explore? (Optional)
Deep-comparing currentReportsToDisplay inside useMemo was explored, but performing deep-equality on the entire LHN report map on every render introduces unnecessary CPU overhead on low-end devices. Stabilizing the lazy-copy contract inside SidebarUtils.updateReportsToDisplayInLHN completely resolves the root cause in $O(1)$ without extra allocations.
Please re-state the problem that we are trying to solve in this issue.
On Web (primarily on /search and /search/r/:reportID), the app crashes into the top-level error boundary with fatal error APP-JVF: Error: Maximum update depth exceeded (React #185) originating from src/hooks/useSidebarOrderedReports.tsx:328 (setCurrentReportsToDisplay(reportsToDisplayInLHN)).
What is the root cause of that problem?
The crash is caused by an architectural anti-pattern in useSidebarOrderedReports.tsx: synchronizing a useMemo calculation into React component state via useEffect without equality gating.
In src/hooks/useSidebarOrderedReports.tsx:
Line 110: const [currentReportsToDisplay, setCurrentReportsToDisplay] = useState<ReportsToDisplayInLHN>({});
Lines 229–285: reportsToDisplayInLHN is calculated via useMemo. When incremental updates occur, it passes displayedReports: currentReportsToDisplay to SidebarUtils.updateReportsToDisplayInLHN.
Because reportsToDisplayInLHN deliberately omits currentReportsToDisplay from its dependency array to evade an immediate render loop (// Rule disabled intentionally — triggering a re-render on currentReportsToDisplay would cause an infinite loop), the hook relies on a fragile timing assumption.
On /search, route transitions and active search query updates cause rapid upstream dependencies in getUpdatedReports (such as reportAttributes, reportNameValuePairs, or derivedCurrentReportID) to produce new references.
When reportsToDisplayInLHN recomputes, SidebarUtils.updateReportsToDisplayInLHN returns a new object reference {...} even if the underlying report data has settled.
In the commit phase, useEffect detects the new reportsToDisplayInLHN reference and calls setCurrentReportsToDisplay(reportsToDisplayInLHN). This schedules a new render.
In that subsequent render, upstream memo dependencies re-evaluate, causing reportsToDisplayInLHN to return another new object. This triggers useEffect again, scheduling another render.
Within milliseconds, React hits the 50 nested update limit and throws Error: Maximum update depth exceeded.
What changes do you think we should make in order to solve the problem?
1. Replace the useState + useEffect synchronization with a useRef cache in src/hooks/useSidebarOrderedReports.tsx:
As recommended by React core guidelines ("You Might Not Need an Effect"), caching incremental computations across renders belongs in a useRef, not component state:
reportsToDisplayInLHN calculates its value synchronously during render.
No secondary commit re-renders are triggered via setCurrentReportsToDisplay.
The ESLint suppression // Rule disabled intentionally — triggering a re-render on currentReportsToDisplay would cause an infinite loop is eliminated naturally because currentReportsToDisplayRef does not trigger re-renders.
Unnecessary re-renders across the entire Sidebar hierarchy are prevented, improving LHN responsiveness.
What alternative solutions did you explore? (Optional)
Adding shallow/deep comparison inside useEffect before calling setCurrentReportsToDisplay:
While checking if (!isEqual(reportsToDisplayInLHN, currentReportsToDisplay)) breaks the cycle when content is identical, it still incurs an extra render cycle on every genuine LHN update. Moving to useRef handles incremental caching synchronously without any extra render or cycle risk.
What automated tests can you add to prevent this from re-occurring?
In tests/unit/useSidebarOrderedReportsTest.tsx:
Render useSidebarOrderedReports and simulate rapid successive prop and Onyx updates across search navigation.
Verify that reportsToDisplayInLHN stabilizes without triggering cascading renders or exceeding React update limits.
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
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: camesenin@gmail.com
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.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: sijo0218@gmail.com
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.28 remaining items
David-2002065 commentedon Nov 3, 2026
Proposal updated. traced the regression to #93438 (sourceValue replaced with usePrevious deltas, first seen right after 9.4.45) and #103147 for the 9.5.5 jump, also covered the empty LHN case
samithreddychinni commentedon Nov 3, 2026
@parasharrajat, can contributors request read-only Sentry access for this issue? If access is limited to C+, could you share one APP-JVF event with personal data removed? The full stack, recent breadcrumbs, app version, and browser version would help me test the reported crash.
Fix sidebar report cache reference stability
samithreddychinni commentedon Nov 3, 2026
Proposal
Updated
nabeel2222 commentedon Nov 3, 2026
Contributor details
Your Expensify account email: nabeel.work22@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/nabeelahmad2222
Proposal
What is the root cause of that problem?
In
src/libs/SidebarUtils.tsinsideupdateReportsToDisplayInLHN, flagged reports (hasFlags) are stored indisplayedReportsas a newly created spread object:{...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport}.On any re-evaluation, the condition checks:
existingEntry !== reportBecause
existingEntryis the spread wrapper object from the previous pass andreportis the unwrapped Onyx report reference, this reference comparison is always true. Consequently,getMutableCopy()[reportID]is updated unconditionally on every pass, returning a new map reference fromupdateReportsToDisplayInLHNeven when report data and flags are identical.In
useSidebarOrderedReports.tsx,useEffectlistens toreportsToDisplayInLHNand callssetCurrentReportsToDisplay(reportsToDisplayInLHN). On routes like/searchwherereportAttributesupdates continuously, this creates an unbroken chain of state updates exceeding React's nested update threshold of 50 commits (getRootForUpdatedFiber->Maximum update depth exceeded #185).What changes do you think we should make in order to solve the problem?
In
src/libs/SidebarUtils.ts(insideupdateReportsToDisplayInLHNaround lines 465–475):const updatedEntry = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};shallowEqualfromfast-equalsinstead ofexistingEntry !== report:When report data and flags are unchanged,
getMutableCopyis never allocated,updateReportsToDisplayInLHNreturns the existingdisplayedReportsreference, theuseEffectdependency does not change, and the re-render cycle terminates cleanly.What alternative solutions did you explore? (Optional)
reportreference on a separate symbol/property onexistingEntryto compareexistingEntry._rawReport !== report. This was rejected in favor ofshallowEqual, which is already standard across Expensify's Onyx derived hooks (e.g.,guideAccountIDs.ts) and is cleaner to maintain.melvin-bot commentedon Nov 3, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
laveshfonix commentedon Nov 3, 2026
Proposal
What is the root cause of that problem?
The provider copies every new LHN map into state from an effect, so each LHN change adds at least one more render after its commit:
App/src/hooks/useSidebarOrderedReports.tsx
Lines 337 to 339 in 4439626
Onyx updates reach React through
useSyncExternalStore, on React's sync lane, so their effects run right away. During a run of Onyx writes, each of those commits ends with the effect's update still pending. React counts that as a nested update and throws after 50 in a row.The run doesn't settle between writes, because each report write is followed by its own
REPORT_ATTRIBUTESwrite (20 report writes gave 20 of them). That second render changes nothing for the LHN, so it rechecks every displayed report. But the stored entry of an unread or flagged report is a copy, soexistingEntry !== reportis always true and the entry is copied again.updateReportsToDisplayInLHNreturns a new map, and the effect sets state again:App/src/libs/SidebarUtils.ts
Lines 466 to 474 in 4439626
I checked this in a local web build. I added 5 unread test rooms to the LHN through Onyx, then called
Onyx.mergeon them 80 times from the console, awaiting 3 microtasks between calls. It threw "Maximum update depth exceeded" on Inbox and on Search.The extra render also hides a second bug. By then
usePrevioushas caught up, so no report key changed and it rechecks every displayed report. That's what fixes a row whose LHN result depends on another report, since the check reads the parent and chat report:App/src/libs/SidebarUtils.ts
Lines 195 to 209 in 4439626
But an incremental update only rechecks the changed reports (and the reports a changed transaction or policy points to), never the reports that depend on them. Example: an expense report has 2 expenses, and one expense's thread is in the LHN. Delete the other expense, and the thread should leave the LHN (its report now has one expense). Main removes it right away, through those extra renders. Without them, it stays until some later update happens to recheck every displayed report.
What changes do you think we should make in order to solve the problem?
The re-run returns the same map at once, because the memo's inputs don't change within a render (the React Compiler memoizes the
useCollectionDeltaresults). If that ever changed, the first test below would fail.clearLHNCachekeeps working as it is.getLHNReportKeysToRecheck, adds only those keys. Like main's extra pass, it only looks at reports already in the LHN:I ran the same steps on each version. For the thread case, I built the 2-expense report and its thread in Onyx, then made the changes a delete makes to that report: one IOU action removed and the total updated. I read the LHN 2.5 seconds later. That session was offline, so no server response came in between.
Unchanged flagged entries are still copied on a pass with no report keys. That's extra work, but it no longer adds a commit, so it can't count toward the limit.
Tests:
useSidebarOrderedReportsTest: storing the new map doesn't start a second LHN pass, and a change to an expense report rechecks its displayed thread. Both fail on main.getLHNReportKeysToRecheckTest: parent link, chat link, thread to parent, unrelated report, no updates.Not tested: the native apps, a production build, and the crash itself. In my runs an Onyx listener hit the limit first, and Onyx catches errors there. The Sentry events hit it in the effect's setState.
What alternative solutions did you explore? (Optional)
react-hooks/refserrors over this file's lint seatbelt (7, where 5 are allowed), sonpm run lint-changedfailed.REPORT_ATTRIBUTESrender then returns the same map. But the effect still adds a commit after every real LHN change, and the thread still stays.Fix sidebar cache synchronization and linked report updates
Keep the LHN cache in a ref, stabilize flagged LHN entries and gate r…
Abdibaye commentedon Nov 3, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
Sentry fatal issue
APP-JVF("Maximum update depth exceeded"): On Web (frequently triggered when navigating to/searchor/search/r/:reportID), an infinitesetStatere-render loop occurs inuseSidebarOrderedReports.tsx:329-331, crashing the application with React Error #185.What is the root cause of that problem?
The infinite re-render loop is caused by a broken referential comparison in
src/libs/SidebarUtils.tsinteracting with the state update effect insrc/hooks/useSidebarOrderedReports.tsx.Step-by-Step Mechanism:
Broken Reference Equality Check for Flagged Reports (
SidebarUtils.ts:446-460):When a report has flags (
requiresAttention,hasErrorsOtherThanFailedReceipt, orisUnreadReport),updateReportsToDisplayInLHNcreates a decorated copy:Because it spreads
reportinto a new object,displayedReports[reportID]holds a new reference, not the original Onyxreportobject.On subsequent passes:
Because
existingEntryis the spread object andreportis the Onyx object,existingEntry !== reportevaluates totrueon every single run, even when neither the report nor any of its flags changed. Consequently,getMutableCopy()is always called, producing a brand-newdisplayedReportsCopyobject reference every time.The Cascading Re-render Loop (
useSidebarOrderedReports.tsx):reportsToDisplayInLHNreceives a new reference,setCurrentReportsToDisplayruns, scheduling a re-render.useCollectionDeltadetects no new Onyx changes since the last render, sogetUpdatedReports()returns an empty array[].updatedReportsis empty, it passes all currently displayed report keys intoupdateReportsToDisplayInLHN.updateReportsToDisplayInLHN, every displayed report with flags hitsexistingEntry !== report === true, generating a new dictionary reference again.useEffectat line 329 to fire again.Maximum update depth exceeded.Why It Manifests on
/searchand/search/r/:reportID:Route changes to Search and Search report views trigger re-evaluations (e.g.
prevDerivedCurrentReportID !== derivedCurrentReportIDor search parameter updates). If the user has any unread or flagged reports in their LHN (typical for active accounts), this immediately kicks off the self-sustaining infinite loop.What changes do you think we should make in order to solve the problem?
We should implement a robust, two-layer solution:
1. In
src/libs/SidebarUtils.ts(The Root Fix):Track the underlying Onyx
reportreference using a module-levelWeakMap:getReportsToDisplayInLHNandupdateReportsToDisplayInLHN, when creating a decorated entry with flags, associate the new entry with its source Onyxreport:updateReportsToDisplayInLHN, check whether the underlying source report has changed:WeakMapis optimal: It allows garbage collection of old report entries, introduces zero memory leaks, does not mutate Onyx report objects, and correctly preserves referential stability when reports and flags are unchanged. When no reports change,displayedReportsCopyis never allocated andupdateReportsToDisplayInLHNreturns the identicaldisplayedReportsreference.2. In
src/hooks/useSidebarOrderedReports.tsx(Defensive State Bail-Out):In line 329–331, use
shallowComparefrom@libs/ObjectUtilsinside the functional state updater:previnstructs React to bail out of the state update, guaranteeing that recursive re-renders are completely prevented.What alternative solutions did you explore? (Optional)
if (currentReportsToDisplay !== reportsToDisplayInLHN) setCurrentReportsToDisplay(...)):Updating state during render without fixing the underlying
existingEntry !== reportreference generation inSidebarUtils.tscauses React to restart render cycles synchronously, triggering the exact sameMaximum update depth exceededfatal crash during render rather than in an effect._sourceReportproperty directly on the report entry:Mutating or adding non-standard properties directly onto the report object risks TypeScript type pollution and unintended serialization in tests or Onyx helpers. A module-scoped
WeakMapis completely isolated and memory-safe.useMemo:Deeply comparing all reports on every render is prohibitively expensive for users with hundreds of reports in the LHN.
WeakMapidentity tracking hasAutomated Tests
1. In
tests/unit/SidebarUtilsTest.ts:Verify that
updateReportsToDisplayInLHNpreserves reference equality for unchanged flagged reports:2. In
tests/unit/useSidebarOrderedReportsTest.tsx:Verify that
setCurrentReportsToDisplaydoes not trigger multiple re-render loops whenreportsToDisplayInLHNis referentially stable.Contributor Details
abbasifaizan70 commentedon Nov 3, 2026
@parasharrajat @mountiny Proposal updated. Summary of what changed:
SidebarOrderedReportsContextProvideris a Sync-lane commit whose passivesetCurrentReportsToDisplayleaves an update pending, which is exactly what the counter counts, and three identity defects (flagged entries stored as spread copies, identity-only REPORT_ATTRIBUTES rewrites,usePreviousdeltas flipping a render late) make thatsetStatefire on every commit while one bold chat is in the LHN.mainwithin a second with the exact Sentry stack (dispatchSetState → useSidebarOrderedReports.tsx → commitHookEffectListMount). Before/after screenshots attached:mainon the "Uh-oh" page with the stack in the console; the branch still running after the same burst.main), the LHN suites green (188 tests), typecheck/oxfmt/cspell clean.Before (
main)After (branch)
gl1tchbased-pixel commentedon Nov 3, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
Sentry fatal crash
APP-JVF("Maximum update depth exceeded", React Error #185): On Web, particularly when navigating to/searchor/search/r/:reportID, an infinitesetStatere-render loop occurs inuseSidebarOrderedReports.tsx:337-339(setCurrentReportsToDisplay(reportsToDisplayInLHN)), crashing the application.What is the root cause of that problem?
The crash is caused by a reference-identity mismatch in
SidebarUtils.updateReportsToDisplayInLHN(src/libs/SidebarUtils.ts:466-477) interacting with the incremental update fallback inuseSidebarOrderedReports.tsx:239:False-Positive
existingEntry !== reporton Flagged Reports (SidebarUtils.ts:468):In
src/libs/SidebarUtils.ts:466-474, when a report has flags (hasFlags = true), it is saved into the mutable copy as a newly spread object:Because
existingEntryis a spread object, its reference never strictly equalsreport(reports?.[reportID]).On line 468, the condition checks:
Because
existingEntry !== reportevaluates totrueon every single pass for any flagged report (even whenreportand its flags have not changed at all),getMutableCopy()is unconditionally invoked.Full Cache Fallback on
/searchRoutes (useSidebarOrderedReports.tsx:239):When navigating to
/searchor changing dependencies without report key updates, line 239 evaluates:This passes all displayed report keys into
updateReportsToDisplayInLHN. Because flagged reports always fail the reference checkexistingEntry !== report,updateReportsToDisplayInLHNalways creates a new object reference (displayedReportsCopy = {...displayedReports}) and returns a new map identity.Infinite
useEffect->setStateLoop (useSidebarOrderedReports.tsx:337-339):useMemoreturns the new reference forreportsToDisplayInLHN. The effect on line 337:fires immediately, updating
currentReportsToDisplay. This triggers a re-render ofSidebarOrderedReportsContextProvider, which recomputesreportsToDisplayInLHN, returns another new reference, and callssetCurrentReportsToDisplaycontinuously until React crashes withMaximum update depth exceeded.What changes do you think we should make in order to solve the problem?
1. Fix Reference Stabilization in
src/libs/SidebarUtils.ts:In
src/libs/SidebarUtils.ts:466-477, store the underlying report reference (or compare whether the underlying report's key fields have actually updated) rather than checkingexistingEntry !== reportagainst a spread object:This guarantees that when neither the report properties nor the flags have changed,
getMutableCopy()is not called, andupdateReportsToDisplayInLHNreturns the identicaldisplayedReportsreference without allocating a new object.2. Guard State Updates in
src/hooks/useSidebarOrderedReports.tsx:In
src/hooks/useSidebarOrderedReports.tsx:337-339, guard the state setter so that an update is only dispatched if the identity has genuinely changed:What alternative solutions did you explore?
Deep-comparing$O(1)$ without extra allocations.
currentReportsToDisplayinsideuseMemowas explored, but performing deep-equality on the entire LHN report map on every render introduces unnecessary CPU overhead on low-end mobile devices. Stabilizing the lazy-copy contract insideSidebarUtils.updateReportsToDisplayInLHNcompletely resolves the root cause ingl1tchbased-pixel commentedon Nov 3, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
Sentry fatal crash
APP-JVF("Maximum update depth exceeded", React Error #185): On Web, particularly when navigating to/searchor/search/r/:reportID, an infinitesetStatere-render loop occurs inuseSidebarOrderedReports.tsx:337-339(setCurrentReportsToDisplay(reportsToDisplayInLHN)), crashing the application.What is the root cause of that problem?
The crash is caused by an object reference mismatch in
SidebarUtils.updateReportsToDisplayInLHN(src/libs/SidebarUtils.ts:466-477) interacting with the incremental update fallback inuseSidebarOrderedReports.tsx:239:False-Positive
existingEntry !== reporton Flagged Reports (SidebarUtils.ts:468):In
src/libs/SidebarUtils.ts:466-474, when a report has flags (hasFlags = true), it is saved into the mutable copy as a newly spread object:Because
existingEntryis a spread object, its reference never strictly equalsreport(reports?.[reportID]).On line 468, the condition checks:
Because
existingEntry !== reportevaluates totrueon every single pass for any flagged report (even whenreportand its flags have not changed at all),getMutableCopy()is unconditionally invoked.Full Cache Fallback on
/searchRoutes (useSidebarOrderedReports.tsx:239):When navigating to
/searchor changing dependencies without report key updates, line 239 evaluates:This passes all displayed report keys into
updateReportsToDisplayInLHN. Because flagged reports always fail the reference checkexistingEntry !== report,updateReportsToDisplayInLHNalways creates a new object reference (displayedReportsCopy = {...displayedReports}) and returns a new map identity.Infinite
useEffect->setStateLoop (useSidebarOrderedReports.tsx:337-339):useMemoreturns the new reference forreportsToDisplayInLHN. The effect on line 337:fires immediately, updating
currentReportsToDisplay. This triggers a re-render ofSidebarOrderedReportsContextProvider, which recomputesreportsToDisplayInLHN, returns another new reference, and callssetCurrentReportsToDisplaycontinuously until React crashes withMaximum update depth exceeded.What changes do you think we should make in order to solve the problem?
1. Fix Reference Stabilization in
src/libs/SidebarUtils.ts:In
src/libs/SidebarUtils.ts:466-477, compare whether the underlying report's key fields have actually updated rather than checkingexistingEntry !== reportagainst a spread object:This guarantees that when neither the report properties nor the flags have changed,
getMutableCopy()is not called, andupdateReportsToDisplayInLHNreturns the identicaldisplayedReportsreference without allocating a new object.2. Guard State Updates in
src/hooks/useSidebarOrderedReports.tsx:In
src/hooks/useSidebarOrderedReports.tsx:337-339, guard the state setter so that an update is only dispatched if the identity has genuinely changed:What alternative solutions did you explore? (Optional)
Deep-comparing$O(1)$ without extra allocations.
currentReportsToDisplayinsideuseMemowas explored, but performing deep-equality on the entire LHN report map on every render introduces unnecessary CPU overhead on low-end devices. Stabilizing the lazy-copy contract insideSidebarUtils.updateReportsToDisplayInLHNcompletely resolves the root cause inayolyn commentedon Nov 3, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On Web (primarily on
/searchand/search/r/:reportID), the app crashes into the top-level error boundary with fatal errorAPP-JVF:Error: Maximum update depth exceeded (React #185)originating fromsrc/hooks/useSidebarOrderedReports.tsx:328(setCurrentReportsToDisplay(reportsToDisplayInLHN)).What is the root cause of that problem?
The crash is caused by an architectural anti-pattern in
useSidebarOrderedReports.tsx: synchronizing auseMemocalculation into React component state viauseEffectwithout equality gating.src/hooks/useSidebarOrderedReports.tsx:const [currentReportsToDisplay, setCurrentReportsToDisplay] = useState<ReportsToDisplayInLHN>({});reportsToDisplayInLHNis calculated viauseMemo. When incremental updates occur, it passesdisplayedReports: currentReportsToDisplaytoSidebarUtils.updateReportsToDisplayInLHN.reportsToDisplayInLHNdeliberately omitscurrentReportsToDisplayfrom its dependency array to evade an immediate render loop (// Rule disabled intentionally — triggering a re-render on currentReportsToDisplay would cause an infinite loop), the hook relies on a fragile timing assumption./search, route transitions and active search query updates cause rapid upstream dependencies ingetUpdatedReports(such asreportAttributes,reportNameValuePairs, orderivedCurrentReportID) to produce new references.reportsToDisplayInLHNrecomputes,SidebarUtils.updateReportsToDisplayInLHNreturns a new object reference{...}even if the underlying report data has settled.useEffectdetects the newreportsToDisplayInLHNreference and callssetCurrentReportsToDisplay(reportsToDisplayInLHN). This schedules a new render.reportsToDisplayInLHNto return another new object. This triggersuseEffectagain, scheduling another render.Error: Maximum update depth exceeded.What changes do you think we should make in order to solve the problem?
1. Replace the
useState+useEffectsynchronization with auseRefcache insrc/hooks/useSidebarOrderedReports.tsx:As recommended by React core guidelines ("You Might Not Need an Effect"), caching incremental computations across renders belongs in a
useRef, not component state:Why this completely eliminates the crash:
reportsToDisplayInLHNcalculates its value synchronously during render.setCurrentReportsToDisplay.// Rule disabled intentionally — triggering a re-render on currentReportsToDisplay would cause an infinite loopis eliminated naturally becausecurrentReportsToDisplayRefdoes not trigger re-renders.What alternative solutions did you explore? (Optional)
useEffectbefore callingsetCurrentReportsToDisplay:While checking
if (!isEqual(reportsToDisplayInLHN, currentReportsToDisplay))breaks the cycle when content is identical, it still incurs an extra render cycle on every genuine LHN update. Moving touseRefhandles incremental caching synchronously without any extra render or cycle risk.What automated tests can you add to prevent this from re-occurring?
In
tests/unit/useSidebarOrderedReportsTest.tsx:useSidebarOrderedReportsand simulate rapid successive prop and Onyx updates across search navigation.reportsToDisplayInLHNstabilizes without triggering cascading renders or exceeding React update limits.Contributor details
Your Expensify account email: juliusayolyn@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~0105126232377c4c5c
melvin-bot commentedon Nov 3, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!