Current behavior
On react-native-web, an inverted FlashList v2 freezes its virtualization window entirely:
- The set of mounted rows never changes while scrolling — scroll past the initially rendered region and you get a permanent blank viewport (it never self-heals, even after waiting).
onEndReached never fires, so paginated "load older messages" chat UIs never load anything on web.
- Occasionally the scroll position teleports backwards (the engine writes a corrected offset computed from a corrupted measurement, see analysis below).
Native (iOS/Android) is unaffected.
Expected behavior
Scrolling an inverted list on web should recycle rows and fire onEndReached, like it does on native.
Root cause analysis
We debugged this down to a specific measurement bug and verified the fix.
measureFirstChildLayout() in src/recyclerview/utils/measureLayout.web.ts computes the first-child offset from getBoundingClientRect() deltas plus scroll compensation. getBoundingClientRect() is affected by CSS transforms, and inverted on web applies scaleY(-1) to the scroller (PlatformConfig.invertedTransformStyle). The flip mirrors the painted position of the bounded-size marker view, so the measured y:
- is wrong at rest: with a content padding of 8px and a 743px viewport we measure
firstItemOffset = 735 (i.e. 743 − 8, the flipped position) instead of 8;
- grows with scrolling: after scrolling to
scrollTop = 1200 a re-measure returns 3135 (≈ 735 + 2×1200, rect delta + the scrollY compensation term both move).
This value is fed into RecyclerViewManager.updateLayoutParams(..., firstItemOffset), and every scroll offset is then adjusted by it (offset - this.firstItemOffset) before reaching EngagedIndicesTracker.updateScrollOffset. With the corrupted value, real DOM scrollTop = 3600 becomes an internal tracker offset of -4335. getVisibleLayouts(-4335, …)'s binary search finds nothing, so the engaged indices never update, the render window stays frozen at whatever was mounted initially, and checkBounds() never sees the list end (hence dead onEndReached).
Measured internal state at the moment the blank screen shows (instrumented RecyclerViewManager):
| DOM scrollTop |
tracker scrollOffset |
firstItemOffset |
engaged indices |
layout total height |
| 0 (initial) |
−735 |
735 |
0–19 (correct-ish by luck) |
5127 (correct) |
| 3600 |
−4335 |
~7935 (re-measured while scrolled) |
0–19 (frozen) |
5127 (correct) |
Calling layoutManager.getVisibleLayouts(3300, 5043) manually at that moment returns the correct 35–54, confirming the layout data is fine and only the offset input is poisoned.
Notes from variable isolation: maintainVisibleContentPosition enabled vs {disabled: true} makes no difference — the freeze reproduces either way. Native wheel scrolling and programmatic scrollTop writes reproduce identically.
Suggested fix (verified)
Use layout-box geometry (offsetTop/offsetLeft walked up the offsetParent chain), which ignores CSS transforms, and keep the rect-based math as a fallback when the chain doesn't pass through parentView:
export function measureFirstChildLayout(
childContainerView: Element,
parentView: Element
): Layout {
const childRect = childContainerView.getBoundingClientRect();
// getBoundingClientRect is transform-affected. With `inverted` (scaleY(-1)
// on the scroller) the measured offset becomes flipped AND scroll-dependent,
// poisoning every scroll offset fed to the virtualizer: the render window
// freezes -> blank list while scrolling, and onEndReached never fires on web.
// offsetTop/offsetLeft use layout-box geometry which ignores transforms.
let x = 0;
let y = 0;
let el: Element | null = childContainerView;
let reached = false;
while (el) {
if (el === parentView) {
reached = true;
break;
}
const htmlEl = el as HTMLElement;
x += htmlEl.offsetLeft ?? 0;
y += htmlEl.offsetTop ?? 0;
el = htmlEl.offsetParent;
}
if (reached) {
return { x, y, width: roundOffPixel(childRect.width), height: roundOffPixel(childRect.height) };
}
// Fallback: original rect-based math
const parentRect = parentView.getBoundingClientRect();
const scrollOffsets = getScrollOffsets(childContainerView, parentView);
return {
x: childRect.left - parentRect.left + scrollOffsets.scrollX,
y: childRect.top - parentRect.top + scrollOffsets.scrollY,
width: roundOffPixel(childRect.width),
height: roundOffPixel(childRect.height),
};
}
We're shipping this as a pnpm patch in production. After the fix, the same repro scrolls smoothly end to end: rows recycle (engaged window moves 0–19 → 36–54), no blank regions, and onEndReached fires at the oldest end. Happy to open a PR if this direction looks right.
To Reproduce
Single-file repro (bundle with esbuild or any RNW setup):
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Text, View } from 'react-native'; // aliased to react-native-web
import { FlashList } from '@shopify/flash-list';
const DATA = Array.from({ length: 55 }, (_, i) => ({
id: `m${i}`,
text: `#${i} ` + 'variable height row content. '.repeat(1 + ((i * 7) % 5)),
}));
function App() {
return (
<View style={{ height: 745, width: 420 }}>
<FlashList
data={DATA}
renderItem={({ item }) => (
<View style={{ marginBottom: 8, padding: 10, backgroundColor: '#eee' }}>
<Text>{item.text}</Text>
</View>
)}
keyExtractor={(item) => item.id}
inverted
onEndReached={() => console.log('onEndReached')} // never fires
onEndReachedThreshold={0.3}
contentContainerStyle={{ paddingVertical: 8 }}
/>
</View>
);
}
createRoot(document.getElementById('root')).render(<App />);
- Open in a browser (Chrome or WKWebView — both reproduce).
- Scroll toward older items (wheel down; note wheel direction is mirrored by the scaleY(-1) flip).
- After ~1.5 viewports the list turns blank and stays blank;
onEndReached is never logged.
Platform
Environment
@shopify/flash-list: 2.3.2 (latest at time of writing)
react-native-web: 0.21.2
react / react-dom: 19.1.0
- Browsers: Chrome 133 (headless + headed), WKWebView (macOS wry/Tauri)
Current behavior
On react-native-web, an
invertedFlashList v2 freezes its virtualization window entirely:onEndReachednever fires, so paginated "load older messages" chat UIs never load anything on web.Native (iOS/Android) is unaffected.
Expected behavior
Scrolling an inverted list on web should recycle rows and fire
onEndReached, like it does on native.Root cause analysis
We debugged this down to a specific measurement bug and verified the fix.
measureFirstChildLayout()insrc/recyclerview/utils/measureLayout.web.tscomputes the first-child offset fromgetBoundingClientRect()deltas plus scroll compensation.getBoundingClientRect()is affected by CSS transforms, andinvertedon web appliesscaleY(-1)to the scroller (PlatformConfig.invertedTransformStyle). The flip mirrors the painted position of the bounded-size marker view, so the measuredy:firstItemOffset = 735(i.e.743 − 8, the flipped position) instead of8;scrollTop = 1200a re-measure returns3135(≈735 + 2×1200, rect delta + thescrollYcompensation term both move).This value is fed into
RecyclerViewManager.updateLayoutParams(..., firstItemOffset), and every scroll offset is then adjusted by it (offset - this.firstItemOffset) before reachingEngagedIndicesTracker.updateScrollOffset. With the corrupted value, real DOMscrollTop = 3600becomes an internal tracker offset of-4335.getVisibleLayouts(-4335, …)'s binary search finds nothing, so the engaged indices never update, the render window stays frozen at whatever was mounted initially, andcheckBounds()never sees the list end (hence deadonEndReached).Measured internal state at the moment the blank screen shows (instrumented
RecyclerViewManager):scrollOffsetfirstItemOffsetCalling
layoutManager.getVisibleLayouts(3300, 5043)manually at that moment returns the correct35–54, confirming the layout data is fine and only the offset input is poisoned.Notes from variable isolation:
maintainVisibleContentPositionenabled vs{disabled: true}makes no difference — the freeze reproduces either way. Native wheel scrolling and programmaticscrollTopwrites reproduce identically.Suggested fix (verified)
Use layout-box geometry (
offsetTop/offsetLeftwalked up theoffsetParentchain), which ignores CSS transforms, and keep the rect-based math as a fallback when the chain doesn't pass throughparentView:We're shipping this as a
pnpm patchin production. After the fix, the same repro scrolls smoothly end to end: rows recycle (engaged window moves 0–19 → 36–54), no blank regions, andonEndReachedfires at the oldest end. Happy to open a PR if this direction looks right.To Reproduce
Single-file repro (bundle with esbuild or any RNW setup):
onEndReachedis never logged.Platform
Environment
@shopify/flash-list: 2.3.2 (latest at time of writing)react-native-web: 0.21.2react/react-dom: 19.1.0