Repository navigation
v2: no way to estimate unmeasured item types (200px default) → jumps when scrolling up after initialScrollIndex #2526
Description
Description
In v2, unmeasured items are estimated from MultiTypeAverageWindow(5, 200), which gives 200px for any item type that has never been measured. v2 offers no way to give an estimate for such a type:
estimatedItemSizeis gone;overrideItemLayoutonly readsspan(RVLayoutManager.getSpan).layout.sizeis ignored.
This matters with initialScrollIndex:
- The list mounts deep into the data (for example "today" in an agenda, with years of history above).
- Every layout above the initial index is created at mount with the averages known at that time.
- Common types (rows) measured around the initial index get correct averages, and the forward recompute fixes the items after them.
- A sparse type (for example a month section header, one every ~30 rows) is often not part of the first rendered batch. All of its items above the initial index stay at 200px.
modifyLayoutonly recomputes from the smallest changed index forward, so the estimates above are never refreshed.- When the user scrolls up, each of those items shrinks from 200px to its real size (~53px in our case) as it renders. MVCP then applies an offset correction (
applyOffsetCorrection→ScrollAnchor.scrollByon Android/iOS,scrollToon web).
Current behavior
Scrolling up is jumpy, with a ~150px correction every time a section header comes into view. On Android the correction lands one render later, so it shows as a visible jump. A fast fling toward the top feels like it "runs away".
On web we measured it with a 300px wheel step. scrollTop deltas were [300, 447, 300, 447, …]: each section header crossed adds 147px (200 − 53).
You can confirm it by reading ref.getLayout(i).height for the items above the initial index: the section headers report 200 until they are rendered.
Expected behavior
Scrolling up moves the list by exactly the gesture, with no correction when a section header is first rendered. To get there, there needs to be some way to give a size hint for item types that have not been measured yet.
Reproduction
Minimal shape (no public link, the app is private):
import { View, Text } from 'react-native';
import { FlashList } from '@shopify/flash-list';
// 2000 rows, a section header every 30 rows; mount on the last third.
const data = [];
for (let i = 0; i < 2000; i++) {
if (i % 30 === 0) data.push({ type: 'section', key: `s${i}` });
data.push({ type: 'row', key: `r${i}` });
}
export default function Repro() {
return (
<FlashList
data={data}
keyExtractor={(it) => it.key}
getItemType={(it) => it.type}
initialScrollIndex={1500}
// @ts-expect-error size is not in the v2 typing. Ignored at runtime:
// sections are still estimated at 200px until measured.
overrideItemLayout={(layout, it) => { layout.size = it.type === 'section' ? 53 : 44; }}
renderItem={({ item }) => (
<View style={{ height: item.type === 'section' ? 53 : 44 }}>
<Text>{item.key}</Text>
</View>
)}
/>
);
}Mount it, then scroll up: there is a jump each time a section header appears, as long as no section was part of the initial render batch around index 1500.
Platform
- iOS (not tested, same code path via
supportsOffsetCorrection) - Android
- Web
Environment
FlashList version: 2.3.3 (also 2.3.2). React Native 0.81.5, Expo SDK 54.
Additional context / workaround
We patched RVLayoutManager (via patch-package). It uses layout.size from overrideItemLayout as the estimate, but only while no item of that type has been measured. After that the measured average takes over, so the existing behavior does not change once real measurements exist.
getEstimatedHeight(index) {
- return this.heightAverageWindow.getCurrentValue(this.getItemType(index));
+ const type = this.getItemType(index);
+ if (!this.horizontal && !this.heightAverageWindow.averageWindows.has(type)) {
+ const hint = this.getSizeHint(index);
+ if (hint > 0)
+ return hint;
+ }
+ return this.heightAverageWindow.getCurrentValue(type);
+ }
+ getSizeHint(index) {
+ const info = { span: undefined, size: undefined };
+ this.overrideItemLayout(index, info);
+ return info.size;
}(The same applies to getEstimatedWidth for horizontal lists.)
With the patch, the wheel deltas are a flat [300, 300, …].
This brings back the v1 meaning of layout.size for unmeasured items, which v1 code that was migrated may still set. Since the v2 typing of overrideItemLayout only declares span, a dedicated prop, for example getEstimatedItemSize(item, type), might be a cleaner API. I'm happy to open a PR in whichever form you prefer.
Checklist
- I've searched existing issues and couldn't find a duplicate
- I've provided a minimal reproduction (inline, no Snack)
- I'm using the latest version of @shopify/flash-list
- I've included all required information above
Activity
jbsky commentedon Oct 11, 2026
Follow-up from device testing (Android, release APK, FlashList 2.3.3, RN 0.81.5). On Android these corrections cost more than a visible jump: they keep a fling from ever decelerating.
What we measured
We logged every
onScrollevent and every non-zerodiffinapplyOffsetCorrectionwithadb logcat.The user flings up and releases. After that, the offset kept moving at a constant ~4,700 px/s for about 10 seconds, with no deceleration. There were about 24 offset corrections per second. Most of them were sub-pixel (±0.1–0.3 px), and a few were a few pixels.
Mechanism
applyOffsetCorrectioncallsScrollAnchor.scrollBy(diff)for anydiff !== 0.maintainVisibleContentPositionhandles it inMaintainVisibleScrollPositionHelper, which callsReactScrollView.scrollToPreservingMomentum.recreateFlingAnimation. It force-finishes theOverScrollerand starts a new fling at the current velocity (mScroller.getCurrVelocity()).Web is not affected, because the correction there goes through
scrollToand no momentum is recreated.Where the diffs came from in our case
MultiTypeAverageWindow(5, …)then estimated every unmeasured item of that type at ~65px, so each normal row rendered afterwards corrected by about −3px. We fixed it on the app side by making row heights strictly fixed.diffs. Each one still callsscrollByand recreates the fling.Workaround for the sub-pixel part
This is in addition to the size-hint patch above. We skip sub-pixel corrections when the data did not change:
if (diff !== 0 && + (hasDataChanged || Math.abs(diff) >= 1) && !pauseOffsetCorrection.current && !recyclerViewManager.animationOptimizationsEnabled) {Result on device
Same gesture, same data:
Suggestions
I can include the sub-pixel guard in a PR together with the size hint, or send it separately, whichever you prefer.