You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[$175] [Sentry: APP-DQ2] Web: FlashList commitLayout loops into "Maximum update depth exceeded" on Add existing expenses #104501
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.
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.
FlashList triggers an internal state update (setRenderId) inside a layout effect whenever its data or extraData props change reference.
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={[...]}).
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:-constsections=[{title: translate('common.expenses'),data: existingExpenses}];+constsections=useMemo(()=>(+[{title: translate('common.expenses'),data: existingExpenses}]+),[existingExpenses,translate]);<SelectionList...// also ensure extraData if is memoized sections="{sections}" used/>
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:
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:
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.
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?
In src/components/Table/TableBody.tsx:
Define a static module-level constant for disabled MVCP:
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.
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.
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.
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.
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.
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.
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.
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.
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.
Activity
melvin-bot commentedon Oct 20, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @getusha (
External)[-][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[/+]melvin-bot commentedon Oct 20, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022112637720568651801
juni2003 commentedon Oct 20, 2026
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 theFlashListcomponent 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.FlashListtriggers an internal state update (setRenderId) inside a layout effect whenever itsdataorextraDataprops change reference.AddExistingExpensesscreen (which handles the/search/r/:reportID/add-existing-expenseroute) and related Search list components, thesectionsarray orextraDatapassed down toSelectionListis constructed inline during the render cycle (e.g.,[{ data: transactions, title: '...' }]orextraData={[...]}).FlashListconstantly evaluatesprevProps.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 consequentlyFlashList).I will wrap the
sectionsgeneration (and any inlineextraDataobjects) in auseMemohook inside theAddExistingExpensescomponent and affected Search list components. This ensures the array reference remains perfectly stable between renders unless the underlying transaction dependencies actually change.melvin-bot commentedon Oct 20, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
melvin-bot commentedon Oct 20, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
jloa-dev commentedon Oct 21, 2026
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/searchcrashes 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:Unstable Prop References passed to
FlashListon every render:src/components/Table/TableBody.tsx, line 477 passesmaintainVisibleContentPosition={{disabled: true}}as an inline object literal. Because{disabled: true} !== {disabled: true}, FlashList'sRecyclerViewtreatsmaintainVisibleContentPositionas updated on every render cycle.src/components/Table/TableBody.tsx, line 395 computesconst listData = buildTableListData(...)withoutuseMemo. A new array reference is supplied to<FlashList data={listData}>on each render.src/components/Table/TableBody.tsx, lines 471-474 pass an inline arrow function asref:nullthen the new instance, invalidating layout hooks.src/components/Tables/AddExistingExpenseTable/index.tsx, line 147 also passesmaintainVisibleContentPosition={{disabled: true}}inline, andcolumns,filters,compareItems, andrenderExpenseTableRoware recreated on every render.src/pages/AddExistingExpense.tsx, line 96:const unreportedExpenses = createUnreportedExpenses(transactions).map(...)creates a fresh array instance on every parent render withoutuseMemo.FlashList
commitLayoutre-trigger cycle:In
@shopify/flash-list, whenmaintainVisibleContentPositionordatachanges reference while a layout effect mounts,RecyclerView.jsinvokesviewHolderCollectionRef.current.commitLayout(), which callssetRenderId((prev) => prev + 1). Because the parent component (TableBody) receives a new render from this state update, it recreateslistData,maintainVisibleContentPosition, and the inlineref, triggeringcommitLayout()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, andAddExistingExpensedirectly eliminates the re-render cascades.What changes do you think we should make in order to solve the problem?
In
src/components/Table/TableBody.tsx:listData:maintainVisibleContentPosition={FLASH_LIST_MVCP_DISABLED}andref={setFlashListRef}to<FlashList>.In
src/components/Tables/AddExistingExpenseTable/index.tsx:MVCP_DISABLED = {disabled: true} as const;and pass it to<Table maintainVisibleContentPosition={MVCP_DISABLED}>.columns,filters,compareItems, andrenderExpenseTableRowusinguseMemoanduseCallback.In
src/pages/AddExistingExpense.tsx:unreportedExpensesinuseMemo:What automated tests can you add to prevent this from re-occurring?
Add a unit test in
tests/unit/AddExistingExpenseTest.tsxandtests/unit/TableBodyTest.tsxverifying that:TableBodywith identical data rows does not re-instantiatelistDataor re-fireRecyclerView.commitLayout().AddExistingExpensewith 50+ transactions does not triggerMaximum update depth exceeded.melvin-bot commentedon Oct 21, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
mountiny commentedon Oct 21, 2026
@getusha can you please prioritize this one? thanks
getusha commentedon Oct 22, 2026
I will review today
getusha commentedon Oct 22, 2026
@MelvinBot propose a solution
MelvinBot commentedon Oct 22, 2026
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.
setRenderIdorcommitLayout()(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:
GridLayoutManagerhas 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.
RecyclerView.jspasses the measured width torecyclerViewManager.updateLayoutParams, so every layout manager is covered, including Grid:requestAnimationFrameto end it.Why the existing contributor proposals won't fix it
data,columns,{disabled: true}and ref callbacks are already stable between renders.data,extraDataormaintainVisibleContentPositionreference does not callsetState.datais processed in a render-timeuseMemo. MVCP with{disabled: true}skips offset correction. These references can add renders to an existing loop, but they can't start one./searchand on the category and tag pickers. Those screens don't useTable,TableBodyorAddExistingExpense. Before feat: use Table component for Add existing expense flow, Add existing expenses usedSelectionList, and it crashed then too.Code references
All
distpaths are innode_modules/@shopify/flash-list/dist/recyclerview/, with the App patches applied.RecyclerView.js:134-165: first layout effect, no dependencies. It measures the width withgetBoundingClientRectand callsupdateLayoutParams.RecyclerView.js:171-224: second layout effect, no dependencies. It always ends insetRenderId(line 209) orcommitLayout()(line 213).ViewHolderCollection.js:87-97: a container width change callsrecyclerViewContext.layout(), which re-rendersRecyclerViewsynchronously.ViewHolderCollection.js:114-116:commitLayoutcallssetRenderId, the top frame in Sentry.layout-managers/LinearLayoutManager.js:46-52: any width change re-lays out every row and setsrequiresRepaint.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
getusha commentedon Oct 22, 2026
@mountiny i wonder if we should ask Viktoryia to have a look into this since she worked on Shopify/flash-list#2334
mountiny commentedon Oct 23, 2026
@VickyStash can you please take a look?
melvin-bot commentedon Oct 23, 2026
❌ There was an error making the offer to @getusha for the Reviewer role. The BZ member will need to manually hire the contributor.