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: juni.xatti@gmail.com
    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: jloa.dev@gmail.com
    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.

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 contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions