Skip to content
Open
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
Expand Up @@ -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
Expand All @@ -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()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NAB: after this guard, the !this.shouldListenToVisibleIndices half of the upstream check a few lines below (if (listSize === undefined || !this.shouldListenToVisibleIndices)) can never be true anymore.

Keeping the upstream line untouched is fine for easier rebases, but could we add a short comment explaining why the check is duplicated here? Otherwise someone doing the next FlashList bump may "clean up" one of the two and drop this guard, bringing back the LayoutManager is not initialized, window size is unavailable crash:

// 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;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 5adeba03. I added your comment above the guard and left the upstream line untouched.

I also noted it in the patch 002 entry of details.md: the later upstream check is redundant but kept for easier rebases, and the early guard must stay on the next FlashList bump.

tests/unit/FlashListTest.tsx still passes (3/3).

+ return;
+ }
const listSize = this.rvManager.getWindowSize();
if (listSize === undefined || !this.shouldListenToVisibleIndices) {
return;
9 changes: 6 additions & 3 deletions patches/@shopify/flash-list/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions tests/unit/FlashListTest.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<Item>(createMock<RecyclerViewProps<Item>>({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<Item>(
createMock<RecyclerViewProps<Item>>({
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})])}));
});
});
Loading