Skip to content

[$175] [Sentry: APP-DQ2] Web: FlashList commitLayout loops into "Maximum update depth exceeded" on Add existing expenses #104501

Open

Description

@mountiny

Sentry

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

Impact (snapshot at filing)

  • Users (total since first seen): 242
  • Events: 12,733
  • Users (last 14d, all versions): 107
  • Users (last 14d, production 9.5.11 to 9.5.13): 30
  • First seen: 2026-05-25 (about 5 months ago)
  • Last seen: 2026-10-20 (ongoing)
  • Platform: web (standalone)
  • App version(s): every release from 9.4.81 to 9.5.14. Most users are on 9.5.6, 9.5.12 and 9.5.8.
  • Affected route(s) (parameterized): mostly /search/r/:reportID/add-existing-expense, also /search, /search/view/:hash, /search/view/:hash/category and /search/view/:hash/expense-tag
  • Mechanism: generic, level fatal, app_blocking: true

Stack trace (top frames, first-party only)

The stack has no first-party frame. These are the deepest library frames:

@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js:116   commitLayout -> setRenderId((prev) => prev + 1)
@shopify/flash-list/dist/recyclerview/RecyclerView.js:213           layout effect -> viewHolderCollectionRef.current.commitLayout()
react-dom commitHookEffectListMount -> dispatchSetState -> getRootForUpdatedFiber
Error: Maximum update depth exceeded (React #185)

Screen in the sample event: AddExistingExpenses_Root.

Suspected cause

FlashList 2.3.0 commits its layout in a layout effect and bumps renderId each time. On the Add existing expenses list the layout never settles, so the effect keeps setting state until React stops it. The likely trigger is a list prop that changes on every render, such as data or extraData, or item sizes that change after each layout.

Reproduction

Unknown. A good starting point: open an expense report from Search, open Add existing expenses (/search/r/:reportID/add-existing-expense), and scroll the list.

Related

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @VickyStash

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 - @getusha (External)

  4. changed the title [-][Sentry: APP-DQ2] Web: FlashList commitLayout loops into "Maximum update depth exceeded" on Add existing expenses[/-] [+][$175] [Sentry: APP-DQ2] Web: FlashList commitLayout loops into "Maximum update depth exceeded" on Add existing expenses[/+] on Oct 20, 2026
  5. melvin-bot commented on Oct 20, 2026

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

    @juni2003

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/~01867ae3203c9b7815?mp_source=share

    Proposal

    Please re-state the problem that we are trying to solve in this issue.
    On the "Add existing expenses" and Search screens, the web app crashes with a fatal Maximum update depth exceeded (React #185) error because the FlashList component gets stuck in an infinite rendering and layout loop.

    What is the root cause of that problem?
    This is a classic React reference-equality loop triggered by @shopify/flash-list.

    1. FlashList triggers an internal state update (setRenderId) inside a layout effect whenever its data or extraData props change reference.
    2. In the AddExistingExpenses screen (which handles the /search/r/:reportID/add-existing-expense route) and related Search list components, the sections array or extraData passed down to SelectionList is constructed inline during the render cycle (e.g., [{ data: transactions, title: '...' }] or extraData={[...]}).
    3. Because inline arrays and objects create a new memory reference on every single render, FlashList constantly evaluates prevProps.data !== nextProps.data. It commits a new layout, updates its render state, triggers a component re-render, and loops infinitely until React kills the process.

    What changes do you think we should make in order to solve the problem?
    We must stabilize the memory references of the props passed to SelectionList (and consequently FlashList).

    I will wrap the sections generation (and any inline extraData objects) in a useMemo hook inside the AddExistingExpenses component and affected Search list components. This ensures the array reference remains perfectly stable between renders unless the underlying transaction dependencies actually change.

    // In the AddExistingExpenses component (and related Search view components)
    // Replace inline array creation with useMemo:
    
    - const sections = [{ title: translate('common.expenses'), data: existingExpenses }];
    + const sections = useMemo(() => (
    +     [{ title: translate('common.expenses'), data: existingExpenses }]
    + ), [existingExpenses, translate]);
    
    <SelectionList ... // also ensure extraData if is memoized sections="{sections}" used/>
  7. melvin-bot commented on Oct 20, 2026

    @melvin-bot

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

  8. melvin-bot commented on Oct 20, 2026

    @melvin-bot

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

  9. jloa-dev commented on Oct 21, 2026

    @jloa-dev

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/~0163955a2ba221e513

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On Web, accessing "Add existing expenses" (/search/r/:reportID/add-existing-expense) or navigating and scrolling lists under /search crashes with:
    Error: Maximum update depth exceeded (React #185)
    at @shopify/flash-list/dist/recyclerview/ViewHolderCollection.js:116 commitLayout -> setRenderId((prev) => prev + 1)
    called from @shopify/flash-list/dist/recyclerview/RecyclerView.js:213 layout effect -> viewHolderCollectionRef.current.commitLayout().

    What is the root cause of that problem?

    There are two coupled root causes that trigger an infinite re-render loop between React and FlashList's RecyclerView:

    1. Unstable Prop References passed to FlashList on every render:

      • In src/components/Table/TableBody.tsx, line 477 passes maintainVisibleContentPosition={{disabled: true}} as an inline object literal. Because {disabled: true} !== {disabled: true}, FlashList's RecyclerView treats maintainVisibleContentPosition as updated on every render cycle.
      • In src/components/Table/TableBody.tsx, line 395 computes const listData = buildTableListData(...) without useMemo. A new array reference is supplied to <FlashList data={listData}> on each render.
      • In src/components/Table/TableBody.tsx, lines 471-474 pass an inline arrow function as ref:
        ref={(instance) => {
            listRef.current = instance;
            measureScrollbarRef(instance);
        }}
        An inline ref callback identity change causes React to invoke it with null then the new instance, invalidating layout hooks.
      • In src/components/Tables/AddExistingExpenseTable/index.tsx, line 147 also passes maintainVisibleContentPosition={{disabled: true}} inline, and columns, filters, compareItems, and renderExpenseTableRow are recreated on every render.
      • In src/pages/AddExistingExpense.tsx, line 96: const unreportedExpenses = createUnreportedExpenses(transactions).map(...) creates a fresh array instance on every parent render without useMemo.
    2. FlashList commitLayout re-trigger cycle:
      In @shopify/flash-list, when maintainVisibleContentPosition or data changes reference while a layout effect mounts, RecyclerView.js invokes viewHolderCollectionRef.current.commitLayout(), which calls setRenderId((prev) => prev + 1). Because the parent component (TableBody) receives a new render from this state update, it recreates listData, maintainVisibleContentPosition, and the inline ref, triggering commitLayout() again on the next layout effect tick until React's max update depth (50) is exceeded.

    What alternative solutions did you explore? (Optional)

    None, fixing reference stability across TableBody, AddExistingExpenseTable, and AddExistingExpense directly eliminates the re-render cascades.

    What changes do you think we should make in order to solve the problem?

    1. In src/components/Table/TableBody.tsx:

      • Define a static module-level constant for disabled MVCP:
        const FLASH_LIST_MVCP_DISABLED = {disabled: true} as const;
      • Memoize listData:
        const listData = useMemo(() => buildTableListData<TableData>(filteredAndSortedData, tableListMetadata), [filteredAndSortedData, tableListMetadata]);
      • Memoize the list ref callback:
        const setFlashListRef = useCallback((instance: FlashList<TableData> | null) => {
            listRef.current = instance;
            measureScrollbarRef(instance);
        }, [measureScrollbarRef]);
      • Pass maintainVisibleContentPosition={FLASH_LIST_MVCP_DISABLED} and ref={setFlashListRef} to <FlashList>.
    2. In src/components/Tables/AddExistingExpenseTable/index.tsx:

      • Define a module-level constant MVCP_DISABLED = {disabled: true} as const; and pass it to <Table maintainVisibleContentPosition={MVCP_DISABLED}>.
      • Memoize columns, filters, compareItems, and renderExpenseTableRow using useMemo and useCallback.
    3. In src/pages/AddExistingExpense.tsx:

      • Wrap unreportedExpenses in useMemo:
        const unreportedExpenses: UnreportedExpenseTableRowData[] = useMemo(
            () =>
                createUnreportedExpenses(transactions).map((item) => ({
                    ...item,
                    disabled: item.isDisabled,
                })),
            [transactions],
        );

    What automated tests can you add to prevent this from re-occurring?

    Add a unit test in tests/unit/AddExistingExpenseTest.tsx and tests/unit/TableBodyTest.tsx verifying that:

    1. Re-rendering TableBody with identical data rows does not re-instantiate listData or re-fire RecyclerView.commitLayout().
    2. Rendering AddExistingExpense with 50+ transactions does not trigger Maximum update depth exceeded.
  10. melvin-bot commented on Oct 21, 2026

    @melvin-bot

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

  11. mountiny commented on Oct 21, 2026

    @mountiny
    ContributorAuthor

    @getusha can you please prioritize this one? thanks

  12. getusha commented on Oct 22, 2026

    @getusha
    Contributor

    I will review today

  13. getusha commented on Oct 22, 2026

    @getusha
    Contributor

    @MelvinBot propose a solution

  14. MelvinBot commented on Oct 22, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    On web, FlashList's width measurement feeds back into itself, so the layout never settles. The App's own list props do not cause the loop.

    1. FlashList measures the list width without the vertical scrollbar. It does this in a layout effect that has no dependencies, so it runs after every render.
    2. Any width change re-lays out every row and re-renders synchronously. A new width changes row heights and the content height, which shows or hides the scrollbar. That changes the width by about 15px again.
    3. FlashList's second layout effect always ends in setRenderId or commitLayout() (the line in the Sentry stack). React stops the chain after 50 nested updates and throws Add link styles for sidebar footer #185.

    We already try to stop this with patch 012, fix-scrollbar-oscillation-crash. It only locks the width after it detects a pattern, and that pattern misses some cases:

    • It needs at least 3 flips between exactly 2 widths. The toggle often lags a frame (A,A,B,B), so detection takes about 7 measurements. At 2–3 synchronous commits per measurement, the chain can reach React's 50-update limit first.
    • A third width resets detection completely. Examples are subpixel snapping or the Table's horizontal column scroll. The lock then never forms.
    • It releases the lock once by design, which costs another detection round.
    • GridLayoutManager has no guard at all and still compares widths with a strict !==.

    This matches the data. The crash only hits some users, likely those with always-visible scrollbars or fractional display scaling. It also hits several unrelated FlashList screens (/search, category and tag pickers), and it continued after patches 010 and 012 shipped.

    What changes do you think we should make in order to solve the problem?

    Replace the pattern detection with a rule that always converges: during one synchronous layout burst, the list width may shrink but not grow.

    1. Add a web-only guard to the FlashList patch, applied where RecyclerView.js passes the measured width to recyclerViewManager.updateLayoutParams, so every layout manager is covered, including Grid:
      • On the first width change, mark a burst as active and schedule a requestAnimationFrame to end it.
      • While the burst is active, ignore any width increase of 25px or less and keep the current, smaller width. Accept decreases.
      • When the frame ends, the next measurement may accept the wider width as usual.
    2. Keep patch 012's two-width lock as a second line of defense for toggling that repeats across frames. Inside a burst, the width can only go down, so the loop stops after a few commits, however many distinct widths appear. Any remaining toggling happens at most once per frame and asynchronously, so it can't trigger Add link styles for sidebar footer #185.
    3. Add unit tests for the layout guard:
      • Widths A,B,A,B and A,A,B,B in one burst settle on the smaller width.
      • Widths A,B,C,A,B,C in one burst settle on the smallest width.
      • After the frame ends, a real window resize, wider or narrower, still applies.
    4. File the fix upstream on the open issue [WEB] Scrollbar oscillation cause infinite layout loop.
    Why the existing contributor proposals won't fix it
    • The App compiles these files with React Compiler, so inline data, columns, {disabled: true} and ref callbacks are already stable between renders.
    • In FlashList 2.3.2, a new data, extraData or maintainVisibleContentPosition reference does not call setState. data is processed in a render-time useMemo. MVCP with {disabled: true} skips offset correction. These references can add renders to an existing loop, but they can't start one.
    • The crash also happens on /search and on the category and tag pickers. Those screens don't use Table, TableBody or AddExistingExpense. Before feat: use Table component for Add existing expense flow, Add existing expenses used SelectionList, and it crashed then too.
    • Upgrading FlashList doesn't help. 2.3.3 has no related fix. The upstream fixes fix(web): stop scrollbar-oscillation relayout loop in LinearLayoutManager and Fix infinite render loop by guaranteeing layout commit. are still open.
    Code references

    All dist paths are in node_modules/@shopify/flash-list/dist/recyclerview/, with the App patches applied.

    • RecyclerView.js:134-165: first layout effect, no dependencies. It measures the width with getBoundingClientRect and calls updateLayoutParams.
    • RecyclerView.js:171-224: second layout effect, no dependencies. It always ends in setRenderId (line 209) or commitLayout() (line 213).
    • ViewHolderCollection.js:87-97: a container width change calls recyclerViewContext.layout(), which re-renders RecyclerView synchronously.
    • ViewHolderCollection.js:114-116: commitLayout calls setRenderId, the top frame in Sentry.
    • layout-managers/LinearLayoutManager.js:46-52: any width change re-lays out every row and sets requiresRepaint.
    • layout-managers/LinearLayoutManager.js:72-129: patch 012 detection. It needs 2 distinct widths and 3 or more flips, locks only on the smaller width, and resets on a third width.
    • layout-managers/GridLayoutManager.js:17-23: no guard.
    • helpers/RenderTimeTracker.js: bails out after 40 renders without a commit. React's limit of 50 nested updates is hit first or right after.

    Confidence: medium. The code mechanism is clear. I haven't reproduced the crash, and I couldn't get the Sentry OS and browser breakdown. To check the theory, confirm that most APP-DQ2 events come from Windows or Linux with always-visible scrollbars. To try a repro, use Chrome with "always show scrollbars" at 125% zoom. Pick a list whose content height is close to the viewport height, then resize the window slowly.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/104501) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.


    view run

  15. getusha commented on Oct 22, 2026

    @getusha
    Contributor

    @mountiny i wonder if we should ask Viktoryia to have a look into this since she worked on Shopify/flash-list#2334

  16. mountiny commented on Oct 23, 2026

    @mountiny
    ContributorAuthor

    @VickyStash can you please take a look?

  17. melvin-bot commented on Oct 23, 2026

    @melvin-bot

    ❌ There was an error making the offer to @getusha for the Reviewer role. The BZ member will need to manually hire the contributor.

  18. VickyStash commented on Oct 26, 2026

    @VickyStash
    Contributor

    Hi there, I'll try to take a look today!

  19. VickyStash commented on Oct 26, 2026

    @VickyStash
    Contributor

    Update:

    • I haven't had much time on this one today, but I've looked through the FS sessions associated with reported sentry errors and latest sessions look pretty the same: user typing to search for expense to add to report -> crash. I guess the reason is similar to what we had before, like with quick search list quickly changes from scrollable <-> to not scrollable and at some point reaches border value, which makes list goes in loop. The only question is why existing guard isn't handling it. I'm going to try to reproduce the issue on my end.
  20. shilo1979jk commented on Oct 26, 2026

    @shilo1979jk

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On Web, typing into the quick search input on the "Add existing expenses" table view (MoneyRequestReportAddExistingExpensePage)—as well as other FlashList views during search filtering—triggers a synchronous layout loop inside @shopify/flash-list (commitLayout -> recomputeLayouts -> measureParentSize), crashing the page with Maximum update depth exceeded.

    Specifically, as @VickyStash noted in the issue thread, we already have a scrollbar-oscillation guard in patches/@shopify/flash-list/@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch, so the key question is why the existing guard in settleScrollbarOscillation fails to stop the loop during quick-search typing.

    What is the root cause of that problem?

    In patches/@shopify/flash-list/@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch (RVLinearLayoutManagerImpl.settleScrollbarOscillation, lines 66–123), three flaws allow the layout loop to bypass or continuously reset the guard during quick search:

    1. Unrounded return before lock (return measuredBoundedSize) vs. rounded integer return on lock (return smaller) creates a 3rd distinct boundedSize on fractional-DPI displays:

      • Before locking (lines 101, 110, 116), settleScrollbarOscillation returns the raw measuredBoundedSize (which on 1.25x / 1.5x / 1.75x displays or zoomed browsers has a fractional suffix such as 600.2 and 585.2), whereas once flips >= MIN_OSCILLATION_FLIPS and size === smaller (line 122), it returns the rounded integer smaller (Math.round(measuredBoundedSize), e.g. 585).
      • In updateLayoutParams (line 42 of patch 012), FlashList checks if (oldBoundedSize !== this.boundedSize || prevHorizontal !== this.horizontal) and calls this.recomputeLayouts(0, this.layouts.length - 1). Because 585 !== 585.2, locking to smaller changes this.boundedSize to a 3rd distinct value and forces an extra synchronous recomputeLayouts -> commitLayout() pass.
    2. Strict distinctSizes.length !== 2 and size === smaller / size === larger checks fail on ±1px subpixel rounding jitter and reset scrollbarLockReleases = 0:

      • On Web, src/components/Table/Table.tsx:276-308 also observes the scroll container via ResizeObserver (useVerticalScrollbarWidth). Subpixel layout shifts during search filtering can produce ±1px rounded values around a pole (e.g. 600, 585, 600, 586).
      • In settleScrollbarOscillation (lines 98–101), const distinctSizes = Array.from(new Set(history)); if (distinctSizes.length !== 2 ...) rejects [600, 585, 600, 586] because distinctSizes.length === 3, preventing the guard from ever locking.
      • Even worse, if the guard is already locked (settledPair = [585, 600]) and a ±1px jitter frame arrives (586 or 599), it misses both if (size === smaller) (line 74) and if (size === larger) (line 77), falling into the else branch (lines 86–91) which resets this.scrollbarLockReleases = 0, this.scrollbarOscillationPair = undefined, and this.recentBoundedSizes = undefined!
    3. Wiping this.recentBoundedSizes = undefined on the first release at size === larger (line 91):

      • When size === larger triggers the single allowed release (this.scrollbarLockReleases === 1 <= MAX_LOCK_RELEASE_CYCLES), line 91 wipes this.recentBoundedSizes = undefined in addition to this.scrollbarOscillationPair = undefined.
      • If the wider width larger immediately causes the scrollbar to reappear on the next synchronous pass (smaller), the wiped history forces the list to bounce through another 4 full frames before flips >= 3 can re-lock, multiplying the synchronous commitLayout count per keystroke until React hits the 50-update nested commit limit.

    What changes do you think we should make in order to solve the problem?

    In patches/@shopify/flash-list/@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch (settleScrollbarOscillation):

    1. Return rounded size consistently (return size whenever size > 0): Ensures pre-lock and post-lock returns use the same integer domain so locking at smaller never introduces a synthetic 3rd boundedSize value in oldBoundedSize !== this.boundedSize.
    2. Allow ±1px subpixel tolerance around [smaller, larger] both during lock detection and while locked:
      • Compute smaller = Math.min(...history) and larger = Math.max(...history), verify delta >= 2 && delta <= SCROLLBAR_OSCILLATION_TOLERANCE, and check history.every((sample) => sample - smaller <= 1 || larger - sample <= 1) instead of new Set(history).length === 2.
      • Count pole transitions (sample - smaller <= 1 ? smaller : larger) and match Math.abs(size - smaller) <= 1 and Math.abs(size - larger) <= 1 when settledPair is active so ±1px subpixel jitter never falls into the else branch or resets this.scrollbarLockReleases = 0.
    3. Keep this.recentBoundedSizes intact when releasing the lock at larger (only clear this.recentBoundedSizes = undefined in the else branch on a genuine resize outside [smaller, larger]): If the single release at larger immediately flips back to smaller on the next pass, history already has >= 3 flips and re-locks on that very first smaller frame instead of bouncing 4 more times.

    What alternative solutions did you explore? (Optional)

    • Setting MAX_LOCK_RELEASE_CYCLES = 0 (never releasing scrollbarOscillationPair once locked): Rejected because if the list genuinely shrinks to 1–2 items after filtering and no longer needs a scrollbar, permanently locking to smaller on the first cycle would leave an empty ~15px gutter on the right edge.
    • Debouncing measureParentSize with requestAnimationFrame: Rejected because useLayoutEffect measurement in FlashList is intentionally synchronous to prevent a visible 1-frame width jump on initial mount.
  21. VickyStash commented on Oct 27, 2026

    @VickyStash
    Contributor

    Update:
    I was finally able to reproduce the issue, the crash was caused by a paging loop:

    Before:

    before.mp4

    After:

    after.mp4

    PR with details: #105119

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

Metadata

Metadata

Labels

ExternalAdded to denote the issue can be worked on by a contributorReviewingHas a PR in reviewWeeklyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions