v2 `inverted` on react-native-web freezes virtualization (permanent blank list, onEndReached never fires): firstItemOffset measurement ignores the scaleY(-1) transform
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 7.2k
- Forks
- 393
- Avg merge
- 1d 3h
- Merged PRs (30d)
- 1
Description
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).
onEndReachednever 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 of8; - grows with scrolling: after scrolling to
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 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;
onEndReachedis never logged.
Platform
- iOS
- Android
- Web (react-native-web)
Environment
@shopify/flash-list: 2.3.2 (latest at time of writing)react-native-web: 0.21.2react/react-dom: 19.1.0- Browsers: Chrome 133 (headless + headed), WKWebView (macOS wry/Tauri)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/recyclerview/utils/measureLayout.web.ts and inspect measureFirstChildLayout, then run the provided react-native-web reproduction in a browser. The change is done when inverted lists recycle rows while scrolling and onEndReached fires at the oldest end without blank regions or offset jumps.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, react-native, typescript
- Domain
- frontend, performance
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100