diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch index 881c1fc1d325..52d4d008cef8 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch @@ -24,6 +24,29 @@ index 6799c8a..4324707 100644 const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { const layout = measureItemLayout(viewHolderRef.current, recyclerViewManager.tryGetLayout(index)); // comapre height with stored layout +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js +@@ -208,13 +208,19 @@ export class RecyclerViewManager { + return !this.hasRenderedProgressively; + } + computeItemViewability() { ++ if (!this.hasLayout()) { ++ return; ++ } + // Using higher buffer for masonry to avoid missing items + this.itemViewabilityManager.shouldListenToVisibleIndices && + this.itemViewabilityManager.updateViewableItems(this.propsRef.masonry + ? this.engagedIndicesTracker.getEngagedIndices().toArray() + : this.computeVisibleIndices().toArray()); + } + recordInteraction() { ++ if (!this.hasLayout()) { ++ return; ++ } + this.itemViewabilityManager.recordInteraction(); + } + recomputeViewableItems() { diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js index 08724d1..bc5b0ae 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -47,3 +70,17 @@ index 08724d1..bc5b0ae 100644 return new Promise((resolve) => { const { horizontal } = recyclerViewManager.props; if (scrollViewRef.current && +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/viewability/ViewabilityManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/viewability/ViewabilityManager.js +--- a/node_modules/@shopify/flash-list/dist/recyclerview/viewability/ViewabilityManager.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/viewability/ViewabilityManager.js +@@ -25,6 +25,10 @@ export default class ViewabilityManager { + }; + this.updateViewableItems = (newViewableIndices) => { + var _a; ++ // Check listeners and layout BEFORE getWindowSize(), which throws when there is no layoutManager (patch 002 skips 0x0 layouts) ++ if (!this.shouldListenToVisibleIndices || !this.rvManager.hasLayout()) { ++ return; ++ } + const listSize = this.rvManager.getWindowSize(); + if (listSize === undefined || !this.shouldListenToVisibleIndices) { + return; diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index cf53dd448f9e..1fa450d0981b 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -12,15 +12,18 @@ ### [@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch](@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch) -- Reason: Prevents FlashList from losing its render state when a navigation stack hides the parent container with `display: none`. Four guards in total — two in `RecyclerView` to skip layout processing while hidden, and two in `useRecyclerViewController` to make scroll methods safe while hidden: +- Reason: Prevents FlashList from losing its render state when a navigation stack hides the parent container with `display: none`. Seven guards in total — two in `RecyclerView` to skip layout processing while hidden, two in `useRecyclerViewController` to make scroll methods safe while hidden, and three in the viewability path to make native scroll events safe before the first layout: 1. **First `useLayoutEffect`** in `RecyclerView` (measures parent container): After calling `measureParentSize()`, if both width and height are 0, return early before calling `updateLayoutParams()` or updating `containerViewSizeRef`. This preserves the last known valid window size and prevents the layout manager from receiving zero dimensions. 2. **Second `useLayoutEffect`** in `RecyclerView` (measures individual items): If `containerViewSizeRef.current` is 0x0 (because the first effect bailed out), return early before calling `modifyChildrenLayout()`. This prevents item measurements taken under `display: none` (also 0) from corrupting stored layouts. 3. **`scrollToIndex`** in `useRecyclerViewController`: When the list is hidden, guards 1/2 leave `layoutManager` undefined. Any `scrollToIndex` call (also reached via `scrollToEnd`, `scrollToItem`, `scrollToTop`) would then throw "LayoutManager is not initialized, window size is unavailable" from `recyclerViewManager.getWindowSize()`. Early-return a resolved Promise when `!recyclerViewManager.hasLayout()` so the call becomes a safe no-op; the list will scroll correctly on its next layout pass. 4. **`scrollToOffset` RTL+horizontal branch** in `useRecyclerViewController`: Only the `I18nManager.isRTL && horizontal` branch reads `getChildContainerDimensions()` and `getWindowSize()`, both of which throw when `layoutManager` is undefined. Gate the branch on `recyclerViewManager.hasLayout()` so the RTL math is skipped while hidden; the non-RTL / vertical paths are unaffected and continue using the underlying `scrollViewRef.scrollTo()` directly. + 5. **`ViewabilityManager.updateViewableItems`**: A list that first mounts at 0x0 never creates a `layoutManager` (guard 1), so a native scroll event that arrives before the first real layout (for example, iOS adjusting the content inset with no user touch) reaches `RecyclerView`'s `onScroll` → `recordInteraction()` → `updateViewableItems()`. Upstream reads `getWindowSize()` before checking `shouldListenToVisibleIndices`, so even lists with no viewability callbacks threw "LayoutManager is not initialized, window size is unavailable" (Sentry `APP-MHA`). Return early when there are no viewability listeners or `!rvManager.hasLayout()`, before calling `getWindowSize()`. The upstream `!this.shouldListenToVisibleIndices` check after `getWindowSize()` is now redundant but is left untouched for easier rebases; when bumping FlashList, keep this earlier guard or the crash returns. + 6. **`RecyclerViewManager.recordInteraction`**: Return early when `!hasLayout()`. This also keeps `hasInteracted` unset until the list can measure anything, so `viewabilityConfig.waitForInteraction` still works on the next scroll. + 7. **`RecyclerViewManager.computeItemViewability`**: Return early when `!hasLayout()`. Lists with `onViewableItemsChanged` would otherwise throw "LayoutManager is not initialized, visible indices are not unavailable" from `computeVisibleIndices()` on the same scroll event. When the container becomes visible again, `onLayout` fires (React Native Web uses ResizeObserver), triggering a re-render with correct dimensions so FlashList resumes normally without re-initialization. -- Files changed: `dist/recyclerview/RecyclerView.js` and `dist/recyclerview/hooks/useRecyclerViewController.js`. +- Files changed: `dist/recyclerview/RecyclerView.js`, `dist/recyclerview/RecyclerViewManager.js`, `dist/recyclerview/hooks/useRecyclerViewController.js`, and `dist/recyclerview/viewability/ViewabilityManager.js`. - Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/83976 (original), https://github.com/Expensify/App/issues/90756 (scroll-while-hidden follow-up) +- E/App issue: https://github.com/Expensify/App/issues/83976 (original), https://github.com/Expensify/App/issues/90756 (scroll-while-hidden follow-up), https://github.com/Expensify/App/issues/104502 (scroll-before-first-layout follow-up) - PR introducing patch: https://github.com/Expensify/App/pull/84887 ### [@shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch) diff --git a/tests/unit/FlashListTest.tsx b/tests/unit/FlashListTest.tsx index a6a838ea12fd..b1c700b3b6d1 100644 --- a/tests/unit/FlashListTest.tsx +++ b/tests/unit/FlashListTest.tsx @@ -1,9 +1,11 @@ import {render} from '@testing-library/react-native'; +import type {RecyclerViewProps} from '@shopify/flash-list/dist/recyclerview/RecyclerViewProps'; import type {ComponentRef} from 'react'; import type {LayoutChangeEvent, View as RNView, ViewStyle} from 'react-native'; import {FlashList} from '@shopify/flash-list'; +import {RecyclerViewManager} from '@shopify/flash-list/dist/recyclerview/RecyclerViewManager'; import React from 'react'; import {View} from 'react-native'; @@ -85,3 +87,55 @@ describe('FlashList - stale onLayout after rapid data change', () => { }).not.toThrow(); }); }); + +describe('FlashList - scroll events before the first layout', () => { + type Item = {id: number}; + + const data: Item[] = Array.from({length: 3}, (_, i) => ({id: i})); + + it('does not throw from recordInteraction when the list has no layout and no viewability callbacks', () => { + // Given a list that has never received a non-zero layout (patch 002 skips 0x0 layouts), so it has no layout manager + const manager = new RecyclerViewManager(createMock>({data})); + expect(manager.hasLayout()).toBe(false); + + // When a native scroll event reaches the list, FlashList's onScroll calls recordInteraction + // Then it does not throw "LayoutManager is not initialized, window size is unavailable" + expect(() => manager.recordInteraction()).not.toThrow(); + }); + + it('does not throw before the first layout and still reports viewable items after it', () => { + // Given a list with a viewability callback that waits for an interaction, and no layout yet + const onViewableItemsChanged = jest.fn(); + const manager = new RecyclerViewManager( + createMock>({ + data, + onViewableItemsChanged, + viewabilityConfig: {waitForInteraction: true, minimumViewTime: 0}, + }), + ); + + // When a scroll event arrives before the first layout + // Then neither scroll handler throws, and no viewability change is reported + expect(() => manager.recordInteraction()).not.toThrow(); + expect(() => manager.computeItemViewability()).not.toThrow(); + expect(onViewableItemsChanged).not.toHaveBeenCalled(); + + // When the list gets its first layout + manager.updateLayoutParams({width: 100, height: 500}, 0); + manager.modifyChildrenLayout( + data.map((_, index) => ({index, dimensions: {width: 100, height: 50}})), + data.length, + ); + manager.computeItemViewability(); + + // Then nothing is reported yet, because the early scroll did not count as the interaction that waitForInteraction needs + expect(onViewableItemsChanged).not.toHaveBeenCalled(); + + // When the next scroll event arrives + manager.recordInteraction(); + manager.computeItemViewability(); + + // Then the visible items are reported + expect(onViewableItemsChanged).toHaveBeenCalledWith(expect.objectContaining({viewableItems: expect.arrayContaining([expect.objectContaining({index: 0, isViewable: true})])})); + }); +});