Skip to content

v2: no way to estimate unmeasured item types (200px default) → jumps when scrolling up after initialScrollIndex #2526

Open

Description

@jbsky

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:

  • estimatedItemSize is gone;
  • overrideItemLayout only reads span (RVLayoutManager.getSpan). layout.size is ignored.

This matters with initialScrollIndex:

  1. The list mounts deep into the data (for example "today" in an agenda, with years of history above).
  2. Every layout above the initial index is created at mount with the averages known at that time.
  3. Common types (rows) measured around the initial index get correct averages, and the forward recompute fixes the items after them.
  4. 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.
  5. modifyLayout only recomputes from the smallest changed index forward, so the estimates above are never refreshed.
  6. 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.scrollBy on Android/iOS, scrollTo on 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

  1. jbsky commented on Oct 11, 2026

    @jbsky
    Author

    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 onScroll event and every non-zero diff in applyOffsetCorrection with adb 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

    1. applyOffsetCorrection calls ScrollAnchor.scrollBy(diff) for any diff !== 0.
    2. Native maintainVisibleContentPosition handles it in MaintainVisibleScrollPositionHelper, which calls ReactScrollView.scrollToPreservingMomentum.
    3. That calls recreateFlingAnimation. It force-finishes the OverScroller and starts a new fling at the current velocity (mScroller.getCurrVelocity()).
    4. Every correction therefore restarts the deceleration curve. With one correction every ~40 ms, the velocity never really decays: the list "runs away".

    Web is not affected, because the correction there goes through scrollTo and no momentum is recreated.

    Where the diffs came from in our case

    • Size estimates: the original issue above (200px for an unmeasured type). The size-hint patch fixed it.
    • Average window poisoning: one taller row (78px instead of 62, a text line wrapping) was enough. 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.
    • Pixel snapping: measured heights like 61.8 / 62.1 / 62.2 produce sub-pixel diffs. Each one still calls scrollBy and 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:

    • 3 corrections were applied out of 278 non-zero diffs;
    • the fling decelerates normally, with per-event deltas going from −246 to −15 px.

    Suggestions

    • Ignore sub-pixel offset corrections, at least while no data change is pending.
    • Keep in mind that on Android, every offset correction during a fling resets its deceleration. Fewer, larger corrections, or skipping corrections during momentum when the data did not change, would help in general.

    I can include the sub-pixel guard in a PR together with the size hint, or send it separately, whichever you prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions