Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,59 +1,102 @@
diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js
index 12375d9..1fb7c8c 100644
--- a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js
+++ b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js
@@ -1,4 +1,10 @@
index 12375d9..581068e 100644
@@ -1,4 +1,13 @@
+import { Platform } from "react-native";
import { RVLayoutManager, } from "./LayoutManager";
+// Recent cross-axis sizes kept for oscillation detection. A strict A,B,A,B pattern needs 4 samples.
+const BOUNDED_SIZE_HISTORY_LENGTH = 4;
+// Recent (rounded) cross-axis sizes kept for scrollbar oscillation detection.
+const BOUNDED_SIZE_HISTORY_LENGTH = 8;
+// Number of times the size must flip between the two values before it counts as an oscillation.
+// A resize drag sweeps through fresh values and never returns to a previous one, so it never flips.
+const MIN_OSCILLATION_FLIPS = 2;
+// Max cross-axis delta (px) treated as a scrollbar-induced oscillation rather than a real resize.
+// Classic desktop scrollbars are ~15-17px; the headroom covers thicker/zoomed scrollbars.
+const SCROLLBAR_OSCILLATION_TOLERANCE = 25;
/**
* LinearLayoutManager implementation that arranges items in a single row or column.
* Supports both horizontal and vertical layouts with dynamic item sizing.
@@ -23,9 +29,42 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager {
@@ -23,9 +32,17 @@
const prevHorizontal = this.horizontal;
super.updateLayoutParams(params);
const oldBoundedSize = this.boundedSize;
- this.boundedSize = this.horizontal
+ const measuredBoundedSize = this.horizontal
? params.windowSize.height
: params.windowSize.width;
+ // --- Scrollbar oscillation guard (web only) ---
+ // On web a vertical scrollbar appearing/disappearing changes the measured cross-axis size by
+ // the scrollbar width. That feeds back into layout and toggles the scrollbar again, an
+ // infinite layout loop that crashes the app. Native uses overlay scrollbars and never
+ // oscillates, so this runs on web only.
+ // We treat a change as scrollbar-induced only when ALL hold,
+ // so a manual window resize is never misread:
+ // (1) the last 4 rounded sizes strictly alternate between exactly two values (A,B,A,B),
+ // (2) which is 3 consecutive flips — a drag never revisits the same two pixels that often,
+ // (3) the two values differ by no more than a scrollbar's width.
+ // When detected we settle on the smaller value (it already accounts for the scrollbar, so
+ // items never overflow the client width).
+ let nextBoundedSize = measuredBoundedSize;
+ if (Platform.OS === "web") {
+ const history = this.recentBoundedSizes ?? (this.recentBoundedSizes = []);
+ history.push(Math.round(measuredBoundedSize));
+ if (history.length > BOUNDED_SIZE_HISTORY_LENGTH) {
+ history.shift();
+ }
+ if (history.length === BOUNDED_SIZE_HISTORY_LENGTH) {
+ const [a, b, c, d] = history;
+ const isScrollbarOscillation = a === c &&
+ b === d &&
+ a !== b &&
+ Math.abs(a - b) <= SCROLLBAR_OSCILLATION_TOLERANCE;
+ if (isScrollbarOscillation) {
+ // Settle on the smaller of the two oscillating values.
+ nextBoundedSize = Math.min(a, b);
+ }
+ }
+ }
+ this.boundedSize = nextBoundedSize;
+ // --- end guard ---
+ // On web a classic (non-overlay) scrollbar appearing/disappearing changes the measured
+ // cross-axis size by the scrollbar width, which feeds back into layout and toggles the
+ // scrollbar again: an infinite layout loop that crashes with "Maximum update depth exceeded".
+ // Native uses overlay scrollbars and never oscillates, so this only runs on web.
+ this.boundedSize =
+ Platform.OS === "web"
+ ? this.settleScrollbarOscillation(measuredBoundedSize)
+ : measuredBoundedSize;
if (oldBoundedSize !== this.boundedSize ||
prevHorizontal !== this.horizontal) {
if (this.layouts.length > 0) {
@@ -36,6 +53,66 @@
}
}
/**
+ * Web only. Breaks the scrollbar <-> layout feedback loop by pinning the cross-axis size once the
+ * measured size is seen bouncing between two scrollbar-width-apart values.
+ *
+ * The measured cross-axis size is the scroll viewport's *client* width, which excludes a classic
+ * scrollbar. So while the loop runs it can only ever report two values: with-scrollbar and
+ * without. We wait until the history shows exactly those two values, no further apart than a
+ * scrollbar, having flipped at least twice, and then settle on the smaller one - it already
+ * accounts for the scrollbar, so items never overflow the client width.
+ *
+ * Detection deliberately does not assume the flips alternate on every single pass: the scrollbar
+ * only toggles once the re-rendered items have been re-measured, which can lag the size change by
+ * a pass, producing runs like A,A,B,B,A,A. Requiring strict A,B,A,B alternation misses those and
+ * lets the loop run to the crash.
+ *
+ * A real resize is never misread: dragging sweeps through many distinct sizes (more than two), so
+ * it cannot match. Once settled, any size outside the oscillating pair means a genuine layout
+ * change, which releases the pin and restarts detection.
+ * @param measuredBoundedSize Cross-axis size just measured from the DOM
+ * @returns The size to lay out with
+ */
+ settleScrollbarOscillation(measuredBoundedSize) {
+ var _a;
+ // Rounded, so the comparisons below are robust against subpixel drift.
+ const size = Math.round(measuredBoundedSize);
+ const settledPair = this.scrollbarOscillationPair;
+ if (settledPair) {
+ if (size === settledPair[0] || size === settledPair[1]) {
+ return settledPair[0];
Comment on lines +65 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the latch when the scrollbar is gone

In web classic-scrollbar lists after an oscillation is detected, this branch returns the smaller width for both members of the latched pair. If the list contents later shrink or row wrapping changes so the scrollbar legitimately disappears without a window resize, the DOM will keep measuring the larger member of the pair, but boundedSize remains pinned to the smaller value until some third width is observed. That leaves FlashList laid out as if a scrollbar still exists, causing a persistent right-side gap or even an unnecessary scrollbar after filtering/removing items.

Useful? React with 👍 / 👎.

+ }
+ // Outside the pair: a real layout change. Forget the oscillation and start over.
+ this.scrollbarOscillationPair = undefined;
+ this.recentBoundedSizes = undefined;
+ }
+ const history = ((_a = this.recentBoundedSizes) !== null && _a !== void 0 ? _a : (this.recentBoundedSizes = []));
+ history.push(size);
+ if (history.length > BOUNDED_SIZE_HISTORY_LENGTH) {
+ history.shift();
+ }
+ const distinctSizes = Array.from(new Set(history));
+ if (distinctSizes.length !== 2 ||
+ Math.abs(distinctSizes[0] - distinctSizes[1]) > SCROLLBAR_OSCILLATION_TOLERANCE) {
+ return measuredBoundedSize;
+ }
+ let flips = 0;
+ for (let i = 1; i < history.length; i++) {
+ if (history[i] !== history[i - 1]) {
+ flips++;
+ }
+ }
+ if (flips < MIN_OSCILLATION_FLIPS) {
+ return measuredBoundedSize;
+ }
+ const smaller = Math.min(distinctSizes[0], distinctSizes[1]);
+ this.scrollbarOscillationPair = [
+ smaller,
+ Math.max(distinctSizes[0], distinctSizes[1]),
+ ];
+ return smaller;
+ }
+ /**
* Processes layout information for items, updating their dimensions.
* For horizontal layouts, also normalizes heights of items.
* @param layoutInfo Array of layout information for items
18 changes: 12 additions & 6 deletions patches/@shopify/flash-list/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,21 @@

### [@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch](@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch)

- Reason: Fixes a "Maximum update depth exceeded" (#185) infinite render loop on web with classic (non-overlay) scrollbars — i.e. Windows/Linux Chrome and macOS with "Always show scroll bars". For a vertical list FlashList derives its cross-axis bounded size from `firstChildViewLayout.width` (the scroll viewport's **client** width, which excludes the scrollbar). When the vertical scrollbar toggles, that width steps by the scrollbar size (e.g. 375 ↔ 355); each change re-triggers layout, which changes content height, which toggles the scrollbar again — an infinite loop. This patch breaks the loop inside `LinearLayoutManager.updateLayoutParams` by detecting the scrollbar ping-pong and settling `boundedSize` instead of thrashing it — reserving no width. A change is treated as scrollbar-induced only when all hold, so a real window resize is never misread:
1. **Strict alternation**: the last 4 *rounded* cross-axis sizes form a clean `A,B,A,B` (exactly two values, three consecutive flips). A manual drag sweeps through many distinct values and never matches; rounding keeps the equality robust against subpixel drift.
2. **Scrollbar-sized delta**: the two values differ by no more than `SCROLLBAR_OSCILLATION_TOLERANCE` (25px — classic scrollbars are ~15–17px, with headroom for thicker/zoomed bars).
- Reason: Fixes a "Maximum update depth exceeded" (#185) infinite render loop on web with classic (non-overlay) scrollbars — i.e. Windows/Linux Chrome and macOS with "Always show scroll bars". For a vertical list FlashList derives its cross-axis bounded size from `firstChildViewLayout.width` (the scroll viewport's **client** width, which excludes the scrollbar), and that size is what `getLayoutSize().width` — and therefore `ViewHolderCollection`'s `fixedContainerSize` — reports. When the vertical scrollbar toggles, the width steps by the scrollbar size (e.g. 375 ↔ 355); `ViewHolderCollection`'s `useLayoutEffect([fixedContainerSize])` calls `recyclerViewContext.layout()`, which re-renders and re-measures, which changes content height, which toggles the scrollbar again — an infinite layout-effect → setState loop that throws #185 from `<ViewHolderCollection>`. This patch breaks the loop inside `LinearLayoutManager.updateLayoutParams` (via `settleScrollbarOscillation`) by detecting the scrollbar ping-pong and pinning `boundedSize` instead of thrashing it — reserving no width.

When detected, `boundedSize` settles on the **smaller** of the two values, which already accounts for the scrollbar so items never overflow the client width.
Because the measured value is the client width, while the loop runs it can only ever report **two** values: with-scrollbar and without. A size change is treated as scrollbar-induced only when all hold, so a real window resize is never misread:

1. **Exactly two distinct values** among the last 8 *rounded* cross-axis sizes. A manual drag sweeps through many distinct values and never matches; rounding keeps the equality robust against subpixel drift.
2. **At least `MIN_OSCILLATION_FLIPS` (2) flips** between them — the size has to keep coming *back* to a value it already had. A drag only ever moves on to fresh values, so it cannot flip.
3. **Scrollbar-sized delta**: the two values differ by no more than `SCROLLBAR_OSCILLATION_TOLERANCE` (25px — classic scrollbars are ~15–17px, with headroom for thicker/zoomed bars).

When detected, `boundedSize` is pinned to the **smaller** of the two values, which already accounts for the scrollbar so items never overflow the client width. The pair is latched: while the measured size stays within it the pinned value keeps being used, and the first size *outside* the pair is taken as a genuine layout change, which releases the pin and restarts detection.

Detection deliberately does **not** require the flips to alternate on every single pass (the original `A,B,A,B` rule). The scrollbar only toggles once the re-rendered items have been re-measured, which can lag the size change by a pass and produce runs like `A,A,B,B,A,A`. A strict-alternation detector never matches those, so the loop ran on to the crash — that is #95719.
- Files changed: `dist/recyclerview/layout-managers/LinearLayoutManager.js` only.
- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2334
- E/App issue: https://github.com/Expensify/App/issues/91584, https://github.com/Expensify/App/issues/92263
- PR introducing patch: https://github.com/Expensify/App/pull/92520
- E/App issue: https://github.com/Expensify/App/issues/91584, https://github.com/Expensify/App/issues/92263, https://github.com/Expensify/App/issues/95719
- PR introducing patch: https://github.com/Expensify/App/pull/92520 (hardened for #95719)

### [@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch](@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch)

Expand Down
Loading