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.
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.
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.
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.
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!
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?
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.
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.
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.
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: [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 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: [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/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.
VickyStash commentedon Oct 26, 2026
Hi there, I'll try to take a look today!
VickyStash commentedon Oct 26, 2026
Update:
shilo1979jk commentedon Oct 26, 2026
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 withMaximum update depth exceeded.Specifically, as
@VickyStashnoted in the issue thread, we already have a scrollbar-oscillation guard inpatches/@shopify/flash-list/@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch, so the key question is why the existing guard insettleScrollbarOscillationfails 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:Unrounded return before lock (
return measuredBoundedSize) vs. rounded integer return on lock (return smaller) creates a 3rd distinctboundedSizeon fractional-DPI displays:settleScrollbarOscillationreturns the rawmeasuredBoundedSize(which on 1.25x / 1.5x / 1.75x displays or zoomed browsers has a fractional suffix such as600.2and585.2), whereas onceflips >= MIN_OSCILLATION_FLIPSandsize === smaller(line 122), it returns the rounded integersmaller(Math.round(measuredBoundedSize), e.g.585).updateLayoutParams(line 42 of patch012), FlashList checksif (oldBoundedSize !== this.boundedSize || prevHorizontal !== this.horizontal)and callsthis.recomputeLayouts(0, this.layouts.length - 1). Because585 !== 585.2, locking tosmallerchangesthis.boundedSizeto a 3rd distinct value and forces an extra synchronousrecomputeLayouts->commitLayout()pass.Strict
distinctSizes.length !== 2andsize === smaller/size === largerchecks fail on±1pxsubpixel rounding jitter and resetscrollbarLockReleases = 0:src/components/Table/Table.tsx:276-308also observes the scroll container viaResizeObserver(useVerticalScrollbarWidth). Subpixel layout shifts during search filtering can produce±1pxrounded values around a pole (e.g.600, 585, 600, 586).settleScrollbarOscillation(lines 98–101),const distinctSizes = Array.from(new Set(history)); if (distinctSizes.length !== 2 ...)rejects[600, 585, 600, 586]becausedistinctSizes.length === 3, preventing the guard from ever locking.settledPair = [585, 600]) and a±1pxjitter frame arrives (586or599), it misses bothif (size === smaller)(line 74) andif (size === larger)(line 77), falling into theelsebranch (lines 86–91) which resetsthis.scrollbarLockReleases = 0,this.scrollbarOscillationPair = undefined, andthis.recentBoundedSizes = undefined!Wiping
this.recentBoundedSizes = undefinedon the first release atsize === larger(line 91):size === largertriggers the single allowed release (this.scrollbarLockReleases === 1 <= MAX_LOCK_RELEASE_CYCLES), line 91 wipesthis.recentBoundedSizes = undefinedin addition tothis.scrollbarOscillationPair = undefined.largerimmediately causes the scrollbar to reappear on the next synchronous pass (smaller), the wiped history forces the list to bounce through another 4 full frames beforeflips >= 3can re-lock, multiplying the synchronouscommitLayoutcount 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):sizeconsistently (return sizewheneversize > 0): Ensures pre-lock and post-lock returns use the same integer domain so locking atsmallernever introduces a synthetic 3rdboundedSizevalue inoldBoundedSize !== this.boundedSize.±1pxsubpixel tolerance around[smaller, larger]both during lock detection and while locked:smaller = Math.min(...history)andlarger = Math.max(...history), verifydelta >= 2 && delta <= SCROLLBAR_OSCILLATION_TOLERANCE, and checkhistory.every((sample) => sample - smaller <= 1 || larger - sample <= 1)instead ofnew Set(history).length === 2.sample - smaller <= 1 ? smaller : larger) and matchMath.abs(size - smaller) <= 1andMath.abs(size - larger) <= 1whensettledPairis active so±1pxsubpixel jitter never falls into theelsebranch or resetsthis.scrollbarLockReleases = 0.this.recentBoundedSizesintact when releasing the lock atlarger(only clearthis.recentBoundedSizes = undefinedin theelsebranch on a genuine resize outside[smaller, larger]): If the single release atlargerimmediately flips back tosmalleron the next pass,historyalready has>= 3flips and re-locks on that very firstsmallerframe instead of bouncing 4 more times.What alternative solutions did you explore? (Optional)
MAX_LOCK_RELEASE_CYCLES = 0(never releasingscrollbarOscillationPaironce locked): Rejected because if the list genuinely shrinks to 1–2 items after filtering and no longer needs a scrollbar, permanently locking tosmalleron the first cycle would leave an empty ~15px gutter on the right edge.measureParentSizewithrequestAnimationFrame: Rejected becauseuseLayoutEffectmeasurement in FlashList is intentionally synchronous to prevent a visible 1-frame width jump on initial mount.VickyStash commentedon Oct 27, 2026
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