Skip to content

[$250] [Sentry: APP-JVF] Web: useSidebarOrderedReports setState loop, "Maximum update depth exceeded" #104504

Open

Description

@mountiny

Sentry

https://expensify.sentry.io/issues/APP-JVF

Impact (snapshot at filing)

  • Users (total since first seen): 68
  • Events: 110
  • Users (last 14d, all versions): 20
  • Users (last 14d, production 9.5.11 to 9.5.13): 3 (25 events)
  • First seen: 2026-08-05 (about 2.5 months ago)
  • Last seen: 2026-10-19 (ongoing)
  • Platform: web (standalone)
  • App version(s): 9.5.0 to 9.5.13. Most users are on 9.5.6 and 9.5.5.
  • Affected route(s) (parameterized): mostly /search, also /search/r/:reportID
  • Mechanism: generic, level fatal, app_blocking: true

Stack trace (top frames, first-party only)

src/hooks/useSidebarOrderedReports.tsx:328   useEffect -> setCurrentReportsToDisplay(reportsToDisplayInLHN)
react-dom commitHookEffectListMount -> dispatchSetState -> getRootForUpdatedFiber
Error: Maximum update depth exceeded (React #185)

Suspected cause

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.

Related

  • Sibling Sentry IDs: none confirmed
  • Prior GH issues: none found
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @parasharrajat

Activity

  1. self-assigned this
    on Oct 20, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 20, 2026
  3. melvin-bot commented on Oct 20, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @parasharrajat (External)

  4. changed the title [-][Sentry: APP-JVF] Web: useSidebarOrderedReports setState loop, "Maximum update depth exceeded"[/-] [+][$175] [Sentry: APP-JVF] Web: useSidebarOrderedReports setState loop, "Maximum update depth exceeded"[/+] on Oct 20, 2026
  5. melvin-bot commented on Oct 20, 2026

    @melvin-bot
  6. melvin-bot commented on Oct 20, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  7. camesenin commented on Oct 20, 2026

    @camesenin

    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 /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:

    useEffect(() => {
        setCurrentReportsToDisplay(reportsToDisplayInLHN);
    }, [reportsToDisplayInLHN]);
    1. 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).
    2. 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.
    3. 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?

    1. Guard setCurrentReportsToDisplay with shallow dictionary equality in useSidebarOrderedReports.tsx:
      In src/hooks/useSidebarOrderedReports.tsx:327-329:
      Import shallowCompare from @libs/ObjectUtils:

      useEffect(() => {
          setCurrentReportsToDisplay((prev) => {
              if (shallowCompare(prev, reportsToDisplayInLHN)) {
                  return prev;
              }
              return reportsToDisplayInLHN;
          });
      }, [reportsToDisplayInLHN]);
      • 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.
    2. Add referential stability to SidebarUtils.getReportsToDisplayInLHN:
      In src/libs/SidebarUtils.ts:358-359:
      Before returning reportsToDisplay, compare against lastReportsToDisplayInLHN:

      if (lastReportsToDisplayInLHN && shallowCompare(lastReportsToDisplayInLHN, reportsToDisplay)) {
          return lastReportsToDisplayInLHN;
      }
      lastReportsToDisplayInLHN = reportsToDisplay;
      return reportsToDisplay;

      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:

    1. 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.
    2. Assert that navigating to /search or changing currentReportId to an unlisted search report does not trigger circular setCurrentReportsToDisplay loops.
  8. melvin-bot commented on Oct 20, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  9. stacklancer commented on Oct 21, 2026

    @stacklancer

    Proposal

    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:
    BEFORE (main)
    cache write #1 | newObject=true | entries=2 | entriesReallyChanged=0
    cache write #2 | newObject=true | entries=2 | entriesReallyChanged=0
    cache write #3 | newObject=true | entries=2 | entriesReallyChanged=0
    cache write #4 | newObject=true | entries=2 | entriesReallyChanged=0
    
    AFTER (with the change below)
    no cache writes
    

    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:

    1. When getUpdatedReports() returns nothing, the memo re-checks every displayed report (useSidebarOrderedReports.tsx#L235), so any recompute goes through the code above.
    2. The result is always new, so the effect at L328 sets state and the provider renders again.
    3. 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?

    1. 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:
    const sourceReportByLHNEntry = new WeakMap<object, Report>();
    
    function buildFlaggedLHNEntry(report: Report, flags: LHNEntryFlags) {
        const entry = {...report, ...flags};
        sourceReportByLHNEntry.set(entry, report);
        return entry;
    }
    
    function isSameFlaggedLHNEntry(entry, report, flags) {
        return !!entry && sourceReportByLHNEntry.get(entry) === report
            && entry.requiresAttention === flags.requiresAttention
            && entry.hasErrorsOtherThanFailedReceipt === flags.hasErrorsOtherThanFailedReceipt
            && entry.isUnreadReport === flags.isUnreadReport;
    }
    
    // updateReportsToDisplayInLHN
    if (hasFlags && !isSameFlaggedLHNEntry(existingEntry, report, flags)) {
        getMutableCopy()[reportID] = buildFlaggedLHNEntry(report, flags);
    }

    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.
  10. stacklancer commented on Oct 21, 2026

    @stacklancer

    @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!

  11. sijo0218 commented on Oct 21, 2026

    @sijo0218

    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 /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.
    let displayedReportsCopy: ReportsToDisplayInLHN | undefined;
    const getMutableCopy = (): ReportsToDisplayInLHN => {
        if (!displayedReportsCopy) {
            displayedReportsCopy = {...displayedReports};
        }
        return displayedReportsCopy;
    };
    ...
    if (hasFlags) {
        if (
            existingEntry !== report ||
            existingEntry?.requiresAttention !== requiresAttention ||
            existingEntry?.hasErrorsOtherThanFailedReceipt !== hasErrorsOtherThanFailedReceipt ||
            existingEntry?.isUnreadReport !== isUnreadReport
        ) {
            getMutableCopy()[reportID] = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};
        }
    } else if (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 !== report always 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:

    1. 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.
    2. In useSidebarOrderedReports.tsx:327-329:
      useEffect(() => {
          setCurrentReportsToDisplay(reportsToDisplayInLHN);
      }, [reportsToDisplayInLHN]);
      Because reportsToDisplayInLHN changed reference, the effect runs and calls setCurrentReportsToDisplay(reportsToDisplayInLHN).
    3. Updating this state triggers an immediate re-render of SidebarOrderedReportsContextProvider.
    4. 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.
    5. In line 235:
      let effectiveUpdatedReports = updatedReports.length === 0 && hasCachedReports ? Object.keys(currentReportsToDisplay) : updatedReports;
      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.

    --- a/src/libs/SidebarUtils.ts
    +++ b/src/libs/SidebarUtils.ts
    @@ -253,6 +253,27 @@ let isFirstTimeNewReportInAccessible = true;
     let lastReportsToDisplayInLHN: ReportsToDisplayInLHN | undefined;
    +
    +type LHNEntryFlags = {
    +    requiresAttention: boolean;
    +    hasErrorsOtherThanFailedReceipt: boolean;
    +    isUnreadReport: boolean;
    +};
    +
    +const sourceReportByLHNEntry = new WeakMap<object, OnyxTypes.Report>();
    +
    +function buildFlaggedLHNEntry(report: OnyxTypes.Report, flags: LHNEntryFlags): OnyxTypes.Report & LHNEntryFlags {
    +    const entry = {...report, ...flags};
    +    sourceReportByLHNEntry.set(entry, report);
    +    return entry;
    +}
    +
    +function isSameFlaggedLHNEntry(
    +    existingEntry: (OnyxTypes.Report & LHNEntryFlags) | undefined,
    +    report: OnyxTypes.Report,
    +    flags: LHNEntryFlags,
    +): boolean {
    +    if (!existingEntry) {
    +        return false;
    +    }
    +    const sourceReport = sourceReportByLHNEntry.get(existingEntry) ?? existingEntry;
    +    return (
    +        sourceReport === report &&
    +        existingEntry.requiresAttention === flags.requiresAttention &&
    +        existingEntry.hasErrorsOtherThanFailedReceipt === flags.hasErrorsOtherThanFailedReceipt &&
    +        existingEntry.isUnreadReport === flags.isUnreadReport
    +    );
    +}
     
    @@ -353,7 +374,7 @@ function getReportsToDisplayInLHN({
             if (shouldDisplay) {
                 const requiresAttention = reportAttributes?.[report?.reportID]?.requiresAttention ?? false;
                 const isUnreadReport = getIsUnreadReportForInboxTab(report, isReportArchived, reportAttributes?.[report?.reportID]?.isEmpty);
                 reportsToDisplay[reportID] =
    -                requiresAttention || hasErrorsOtherThanFailedReceipt || isUnreadReport ? {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport} : report;
    +                requiresAttention || hasErrorsOtherThanFailedReceipt || isUnreadReport ? buildFlaggedLHNEntry(report, {requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport}) : report;
             }
         }
     
    @@ -448,15 +469,10 @@ function updateReportsToDisplayInLHN({
                 const hasFlags = requiresAttention || hasErrorsOtherThanFailedReceipt || isUnreadReport;
                 const existingEntry = displayedReports[reportID];
     
                 if (hasFlags) {
    -                if (
    -                    existingEntry !== report ||
    -                    existingEntry?.requiresAttention !== requiresAttention ||
    -                    existingEntry?.hasErrorsOtherThanFailedReceipt !== hasErrorsOtherThanFailedReceipt ||
    -                    existingEntry?.isUnreadReport !== isUnreadReport
    -                ) {
    -                    getMutableCopy()[reportID] = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};
    +                if (!isSameFlaggedLHNEntry(existingEntry, report, {requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport})) {
    +                    getMutableCopy()[reportID] = buildFlaggedLHNEntry(report, {requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport});
                     }
                 } else if (existingEntry !== report) {
                     getMutableCopy()[reportID] = report;
                 }

    Layer 2: Defensive State Bail-Out in src/hooks/useSidebarOrderedReports.tsx

    Guard setCurrentReportsToDisplay with shallow dictionary comparison:

    --- a/src/hooks/useSidebarOrderedReports.tsx
    +++ b/src/hooks/useSidebarOrderedReports.tsx
    @@ -326,7 +326,17 @@ function SidebarOrderedReportsContextProvider({
         }, [reportsDrafts]);
     
         useEffect(() => {
    -        setCurrentReportsToDisplay(reportsToDisplayInLHN);
    +        setCurrentReportsToDisplay((prev) => {
    +            if (prev === reportsToDisplayInLHN) {
    +                return prev;
    +            }
    +            const prevKeys = Object.keys(prev);
    +            const nextKeys = Object.keys(reportsToDisplayInLHN);
    +            if (prevKeys.length === nextKeys.length && nextKeys.every((key) => prev[key] === reportsToDisplayInLHN[key])) {
    +                return prev;
    +            }
    +            return reportsToDisplayInLHN;
    +        });
         }, [reportsToDisplayInLHN]);

    Why both layers:

    1. Layer 1 preserves referential identity of unchanged flagged entries, allowing displayedReportsCopy to stay undefined so updateReportsToDisplayInLHN returns the original displayedReports reference.
    2. 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?

    1. 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.
    2. 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)

    1. 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.
    2. Replacing currentReportsToDisplay useState 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.
  12. 28 remaining items

  13. David-2002065 commented on Nov 3, 2026

    @David-2002065
    Contributor

    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

  14. samithreddychinni commented on Nov 3, 2026

    @samithreddychinni

    @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.

  15. samithreddychinni commented on Nov 3, 2026

    @samithreddychinni

    Proposal

    Updated

  16. nabeel2222 commented on Nov 3, 2026

    @nabeel2222

    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.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):

    1. Construct the candidate entry:
      const updatedEntry = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};
    2. Update the condition to verify whether the entry actually changed using shallowEqual from fast-equals instead of existingEntry !== report:
      if (!existingEntry || !shallowEqual(existingEntry, updatedEntry)) {
          getMutableCopy()[reportID] = updatedEntry;
      }

    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)

    1. 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.
  17. melvin-bot commented on Nov 3, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  18. laveshfonix commented on Nov 3, 2026

    @laveshfonix
    Contributor

    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:

    useEffect(() => {
    setCurrentReportsToDisplay(reportsToDisplayInLHN);
    }, [reportsToDisplayInLHN]);

    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:

    if (hasFlags) {
    if (
    existingEntry !== report ||
    existingEntry?.requiresAttention !== requiresAttention ||
    existingEntry?.hasErrorsOtherThanFailedReceipt !== hasErrorsOtherThanFailedReceipt ||
    existingEntry?.isUnreadReport !== isUnreadReport
    ) {
    getMutableCopy()[reportID] = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};
    }

    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:

    const chatReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`];
    const parentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`];
    const hasErrorsOtherThanFailedReceipt = hasReportErrorsOtherThanFailedReceipt(
    report,
    chatReport,
    doesReportHaveViolations,
    transactionViolations,
    transactions,
    isOffline,
    reportAttributes,
    );
    const isReportInAccessible = report?.errorFields?.notFound;
    if (isOneTransactionThread(report, parentReport, parentReportAction, isOffline)) {
    return {shouldDisplay: false};
    }

    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?

    1. 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.
    if (currentReportsToDisplay !== reportsToDisplayInLHN) {
        setCurrentReportsToDisplay(reportsToDisplayInLHN);
    }

    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.

    1. 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:
    for (const [reportKey, report] of Object.entries(displayedReports)) {
        if ((!!report.parentReportID && updatedReportIDs.has(report.parentReportID)) || (!!report.chatReportID && updatedReportIDs.has(report.chatReportID))) {
            reportKeysToRecheck.add(reportKey);
        }
    }

    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.
  19. Abdibaye commented on Nov 3, 2026

    @Abdibaye

    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 /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:

    1. 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:

      getMutableCopy()[reportID] = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};

      Because it spreads report into a new object, displayedReports[reportID] holds a new reference, not the original Onyx report object.
      On subsequent passes:

      const existingEntry = displayedReports[reportID];
      if (hasFlags) {
          if (
              existingEntry !== report || // <--- ALWAYS TRUE!
              existingEntry?.requiresAttention !== requiresAttention ||
              existingEntry?.hasErrorsOtherThanFailedReceipt !== hasErrorsOtherThanFailedReceipt ||
              existingEntry?.isUnreadReport !== isUnreadReport
          ) {
              getMutableCopy()[reportID] = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};
          }
      }

      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.

    2. The Cascading Re-render Loop (useSidebarOrderedReports.tsx):

      • Line 329–331:
        useEffect(() => {
            setCurrentReportsToDisplay(reportsToDisplayInLHN);
        }, [reportsToDisplayInLHN]);
        Whenever reportsToDisplayInLHN receives a new reference, setCurrentReportsToDisplay runs, scheduling a re-render.
      • On the re-render: useCollectionDelta detects no new Onyx changes since the last render, so getUpdatedReports() returns an empty array [].
      • Line 237 triggers the fallback:
        let effectiveUpdatedReports = updatedReports.length === 0 && hasCachedReports ? Object.keys(currentReportsToDisplay) : updatedReports;
        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.
    3. 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:

    const sourceReportByLHNEntry = new WeakMap<ReportsToDisplayInLHN[string], Report>();
    • In both getReportsToDisplayInLHN and updateReportsToDisplayInLHN, when creating a decorated entry with flags, associate the new entry with its source Onyx report:
      const entry = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};
      sourceReportByLHNEntry.set(entry, report);
      getMutableCopy()[reportID] = entry;
    • In updateReportsToDisplayInLHN, check whether the underlying source report has changed:
      const sourceReport = existingEntry ? (sourceReportByLHNEntry.get(existingEntry) ?? existingEntry) : undefined;
      if (
          sourceReport !== report ||
          existingEntry?.requiresAttention !== requiresAttention ||
          existingEntry?.hasErrorsOtherThanFailedReceipt !== hasErrorsOtherThanFailedReceipt ||
          existingEntry?.isUnreadReport !== isUnreadReport
      ) {
          const entry = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};
          sourceReportByLHNEntry.set(entry, report);
          getMutableCopy()[reportID] = entry;
      }
    • 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:

    useEffect(() => {
        setCurrentReportsToDisplay((prev) => {
            if (shallowCompare(prev, reportsToDisplayInLHN)) {
                return prev;
            }
            return reportsToDisplayInLHN;
        });
    }, [reportsToDisplayInLHN]);
    • 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)

    1. 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.
    2. 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.
    3. 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 flags
        const report = createRandomReport(1, undefined);
        const displayedReports: ReportsToDisplayInLHN = {};
        const reports: OnyxCollection<Report> = {
            [`${ONYXKEYS.COLLECTION.REPORT}1`]: report,
        };
    
        // When updateReportsToDisplayInLHN runs on an unread report
        const initialResult = 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 changes
        const secondResult = 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 copy
        expect(secondResult).toBe(initialResult);
    });

    2. In tests/unit/useSidebarOrderedReportsTest.tsx:

    Verify that setCurrentReportsToDisplay does not trigger multiple re-render loops when reportsToDisplayInLHN is referentially stable.


    Contributor Details

  20. abbasifaizan70 commented on Nov 3, 2026

    @abbasifaizan70
    Contributor

    @parasharrajat @mountiny Proposal updated. Summary of what changed:

    • 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.
    • Test branch: main...abbasifaizan70:Expensify:fix/104504-lhn-cache-ref-and-stable-entries — 5 new tests (3 red on main), the LHN suites green (188 tests), typecheck/oxfmt/cspell clean.

    Before (main)

    Image

    After (branch)

    Image
  21. gl1tchbased-pixel commented on Nov 3, 2026

    @gl1tchbased-pixel

    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 /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:

    1. 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:

      getMutableCopy()[reportID] = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};

      Because existingEntry is a spread object, its reference never strictly equals report (reports?.[reportID]).
      On line 468, the condition checks:

      if (
          existingEntry !== report ||
          existingEntry?.requiresAttention !== requiresAttention ||
          existingEntry?.hasErrorsOtherThanFailedReceipt !== hasErrorsOtherThanFailedReceipt ||
          existingEntry?.isUnreadReport !== isUnreadReport
      )

      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.

    2. Full Cache Fallback on /search Routes (useSidebarOrderedReports.tsx:239):
      When navigating to /search or changing dependencies without report key updates, line 239 evaluates:

      let effectiveUpdatedReports = updatedReports.length === 0 && hasCachedReports ? Object.keys(currentReportsToDisplay) : updatedReports;

      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.

    3. Infinite useEffect -> setState Loop (useSidebarOrderedReports.tsx:337-339):
      useMemo returns the new reference for reportsToDisplayInLHN. The effect on line 337:

      useEffect(() => {
          setCurrentReportsToDisplay(reportsToDisplayInLHN);
      }, [reportsToDisplayInLHN]);

      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:

    if (hasFlags) {
        const hasReportPropsChanged =
            !existingEntry ||
            existingEntry.lastVisibleActionCreated !== report.lastVisibleActionCreated ||
            existingEntry.lastMessageText !== report.lastMessageText ||
            existingEntry.statusNum !== report.statusNum ||
            existingEntry.stateNum !== report.stateNum ||
            existingEntry.reportName !== report.reportName;
    
        if (
            hasReportPropsChanged ||
            existingEntry?.requiresAttention !== requiresAttention ||
            existingEntry?.hasErrorsOtherThanFailedReceipt !== hasErrorsOtherThanFailedReceipt ||
            existingEntry?.isUnreadReport !== isUnreadReport
        ) {
            getMutableCopy()[reportID] = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};
        }
    } else if (existingEntry !== report) {
        getMutableCopy()[reportID] = report;
    }

    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:

    useEffect(() => {
        setCurrentReportsToDisplay((prevReports) => {
            if (prevReports === reportsToDisplayInLHN) {
                return prevReports;
            }
            return reportsToDisplayInLHN;
        });
    }, [reportsToDisplayInLHN]);

    What alternative solutions did you explore?

    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.

  22. gl1tchbased-pixel commented on Nov 3, 2026

    @gl1tchbased-pixel

    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 /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:

    1. 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:

      getMutableCopy()[reportID] = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};

      Because existingEntry is a spread object, its reference never strictly equals report (reports?.[reportID]).
      On line 468, the condition checks:

      if (
          existingEntry !== report ||
          existingEntry?.requiresAttention !== requiresAttention ||
          existingEntry?.hasErrorsOtherThanFailedReceipt !== hasErrorsOtherThanFailedReceipt ||
          existingEntry?.isUnreadReport !== isUnreadReport
      )

      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.

    2. Full Cache Fallback on /search Routes (useSidebarOrderedReports.tsx:239):
      When navigating to /search or changing dependencies without report key updates, line 239 evaluates:

      let effectiveUpdatedReports = updatedReports.length === 0 && hasCachedReports ? Object.keys(currentReportsToDisplay) : updatedReports;

      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.

    3. Infinite useEffect -> setState Loop (useSidebarOrderedReports.tsx:337-339):
      useMemo returns the new reference for reportsToDisplayInLHN. The effect on line 337:

      useEffect(() => {
          setCurrentReportsToDisplay(reportsToDisplayInLHN);
      }, [reportsToDisplayInLHN]);

      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:

    if (hasFlags) {
        const hasReportPropsChanged =
            !existingEntry ||
            existingEntry.lastVisibleActionCreated !== report.lastVisibleActionCreated ||
            existingEntry.lastMessageText !== report.lastMessageText ||
            existingEntry.statusNum !== report.statusNum ||
            existingEntry.stateNum !== report.stateNum ||
            existingEntry.reportName !== report.reportName;
    
        if (
            hasReportPropsChanged ||
            existingEntry?.requiresAttention !== requiresAttention ||
            existingEntry?.hasErrorsOtherThanFailedReceipt !== hasErrorsOtherThanFailedReceipt ||
            existingEntry?.isUnreadReport !== isUnreadReport
        ) {
            getMutableCopy()[reportID] = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport};
        }
    } else if (existingEntry !== report) {
        getMutableCopy()[reportID] = report;
    }

    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:

    useEffect(() => {
        setCurrentReportsToDisplay((prevReports) => {
            if (prevReports === reportsToDisplayInLHN) {
                return prevReports;
            }
            return reportsToDisplayInLHN;
        });
    }, [reportsToDisplayInLHN]);

    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.

  23. ayolyn commented on Nov 3, 2026

    @ayolyn

    Proposal

    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.

    1. 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.
      • Line 328:
        useEffect(() => {
            setCurrentReportsToDisplay(reportsToDisplayInLHN);
        }, [reportsToDisplayInLHN]);
    2. 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.
    3. 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.
    4. When reportsToDisplayInLHN recomputes, SidebarUtils.updateReportsToDisplayInLHN returns a new object reference {...} even if the underlying report data has settled.
    5. In the commit phase, useEffect detects the new reportsToDisplayInLHN reference and calls setCurrentReportsToDisplay(reportsToDisplayInLHN). This schedules a new render.
    6. In that subsequent render, upstream memo dependencies re-evaluate, causing reportsToDisplayInLHN to return another new object. This triggers useEffect again, scheduling another render.
    7. 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:

    --- a/src/hooks/useSidebarOrderedReports.tsx
    +++ b/src/hooks/useSidebarOrderedReports.tsx
    @@ -107,3 +107,3 @@ function useSidebarOrderedReports(): SidebarOrderedReports {
    -    const [currentReportsToDisplay, setCurrentReportsToDisplay] = useState<ReportsToDisplayInLHN>({});
    +    const currentReportsToDisplayRef = useRef<ReportsToDisplayInLHN>({});
    @@ -230,3 +230,3 @@ function useSidebarOrderedReports(): SidebarOrderedReports {
             const updatedReports = getUpdatedReports();
    -        const hasCachedReports = Object.keys(currentReportsToDisplay).length > 0;
    +        const hasCachedReports = Object.keys(currentReportsToDisplayRef.current).length > 0;
     
    @@ -233,3 +233,3 @@ function useSidebarOrderedReports(): SidebarOrderedReports {
    -        let effectiveUpdatedReports = updatedReports.length === 0 && hasCachedReports ? Object.keys(currentReportsToDisplay) : updatedReports;
    +        let effectiveUpdatedReports = updatedReports.length === 0 && hasCachedReports ? Object.keys(currentReportsToDisplayRef.current) : updatedReports;
    @@ -242,3 +242,3 @@ function useSidebarOrderedReports(): SidebarOrderedReports {
                 reportsToDisplay = SidebarUtils.updateReportsToDisplayInLHN({
    -                displayedReports: currentReportsToDisplay,
    +                displayedReports: currentReportsToDisplayRef.current,
    @@ -268,3 +268,3 @@ function useSidebarOrderedReports(): SidebarOrderedReports {
             }
     
    +        currentReportsToDisplayRef.current = reportsToDisplay;
             return reportsToDisplay;
    @@ -326,5 +326,0 @@ function useSidebarOrderedReports(): SidebarOrderedReports {
    -    useEffect(() => {
    -        setCurrentReportsToDisplay(reportsToDisplayInLHN);
    -    }, [reportsToDisplayInLHN]);

    Why this completely eliminates the crash:

    • 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.

    Contributor details

    Your Expensify account email: juliusayolyn@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~0105126232377c4c5c

  24. melvin-bot commented on Nov 3, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

DailyKSv2ExternalAdded to denote the issue can be worked on by a contributorHelp WantedApply this label when an issue is open to proposals by contributorsOverdue

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions