Skip to content

Flashlist v2 initialScrollIndex renders wrong items when item size exceeds 200 #2307

Description

@VladimirAtGlossgenius

Description

FlashList v2 displays wrong items when initialScrollIndex is set and the actual item size exceeds 200px (FlashList's internal averageWindow default). The list opens showing items roughly 83 indices past the requested index.

Current behavior

With initialScrollIndex={250} and items 300px tall, the list opens showing Item 333 instead of Item 250.

The root cause is a race condition in FlashList's layout table construction:

  1. applyInitialScrollAdjustment runs before any items are measured, building item positions using the default averageWindow of 200px. Item 250 is placed at 250 × 200 = 50,000px (should be 250 × 300 = 75,000px).
  2. Item 250 renders at the wrong position, measures at 300px, and modifyLayout runs recomputeLayouts(250→500) — but this anchors items 251+ to item 249's stale 200px-based position (249 × 200 = 49,800px). Items 251+ are now at ~50,400px+.
  3. applyInitialScrollAdjustment fires again with the updated averageWindow=300 and correctly places items 0–250 at i × 300px. Item 250 moves to 75,000px. However, items 251+ are not touched — they retain their wrong positions from step 2.
  4. The layout table now has a backward jump at the 250→251 boundary (75,000 → 50,400), making it non-monotonic.
  5. FlashList binary-searches the broken table to find which items to render at scroll position 75,000px. The search starts at mid=249, sees 74,700 < 75,000, goes right — and never checks item 250 (which is to the left). It finds item 333 (at position ~75,000 in the broken layout) and renders that instead.

The bug only manifests when actual item size exceeds 200px. With smaller items (< 200px), the corrective pass moves items to a lower pixel position than what modifyLayout set, creating a forward gap that keeps the table monotonically sorted.

Expected behavior

The list should open with Item 250 visible at the top.

Reproduction

Expo Snack: https://snack.expo.dev/TgCTAo_9hg8zsXmGYrlya

import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { FlashList } from '@shopify/flash-list';

const ITEM_SIZE = 300;
const INITIAL_INDEX = 250;
const DATA = Array.from({ length: 600 }, (_, i) => i);

export default function App() {
  return (
    <View style={{ flex: 1 }}>
      <View style={styles.header}>
        <Text style={styles.title}>Expected: Item {INITIAL_INDEX} at top</Text>
      </View>
      <FlashList
        data={DATA}
        renderItem={({ item }) => (
          <View style={styles.item}>
            <Text style={styles.label}>Item {item}</Text>
          </View>
        )}
        keyExtractor={(item) => String(item)}
        initialScrollIndex={INITIAL_INDEX}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  header: { padding: 16, backgroundColor: '#f5f5f5', borderBottomWidth: 1, borderBottomColor: '#ddd' },
  title: { fontSize: 16, fontWeight: 'bold' },
  item: { height: ITEM_SIZE, justifyContent: 'center', alignItems: 'center', borderBottomWidth: 1, borderBottomColor: '#eee' },
  label: { fontSize: 32, fontWeight: 'bold' },
});

Platform
iOS
Android
Environment

React Native info output:

React Native: 0.81.6
FlashList version: 2.3.1

Additional context

This bug was introduced in FlashList v2 when estimatedItemSize was removed from the public API — a deliberate design decision to eliminate a common source of misconfiguration.

Root of the problem: applyInitialScrollAdjustment runs a self-correcting two-pass loop: the first pass estimates positions using the default averageWindow (200px), and the second corrects them after items are measured. However, modifyLayout fires between the two passes and anchors items after initialScrollIndex to the wrong predecessor position. The second corrective pass then fixes items 0–initialScrollIndex but stops there — leaving a non-monotonic boundary at initialScrollIndex + 1 that breaks the binary search used to select visible items.

Workaround: restore estimatedItemSize and use it to seed the averageWindow so the very first pass produces correct positions, preventing the race condition from occurring. This is not ideal — it reintroduces a prop the maintainers intentionally removed — and there is likely a better internal fix (for example, after the corrective recomputeLayouts(0 → initialScrollIndex), propagating correct positions forward into the items that modifyLayout had incorrectly anchored). I haven't worked out the cleanest approach on the library side, but wanted to document the root cause in detail so it can inform the right fix.

Checklist
I've searched existing issues and couldn't find a duplicate
I've provided a minimal reproduction (Expo Snack preferred)
I'm using the latest version of @shopify/flash-list
I've included all required information above

Activity

  1. eros-piccioni commented on Sep 8, 2026

    @eros-piccioni

    Confirming this on a very different shape, and #2444's one-liner fixes it here.

    A horizontal pagingEnabled pager of full-width slides — 420pt each, so above the 200pt
    default — 22 items, initialScrollIndex: 14. It opens on item 21, the last one.
    iOS 26.5, @shopify/flash-list 2.3.2, RN 0.85.3, New Architecture.

    What made this hard to diagnose is that the scroll is correct. Sampling the scroll
    handler and the list's own ref on the same frame:

    offset 5880 · contentSize 6380 · viewport 420
    getLayout(14).x 5880 · getChildContainerDimensions().width 6380 · item under offset: 21
    onViewableItemsChanged → index 21
    

    offset === getLayout(14).x === 14 × 420. The list has placed item 14 exactly where the
    scroll view is. But contentSize is 6380 against a true 22 × 420 = 9240, and the item
    resolved at that offset is 21 — which is your unsorted-layout-table analysis exactly: the
    binary search in getVisibleLayouts returns an unrelated index. From outside, every signal
    reads healthy — the offset is right, and the settled index looks right a frame later — so
    this is easy to mistake for a timing problem.

    Two things that may help others reproduce it:

    • Which index fails is not monotonic in depth. Index 15 of 22 fails; index 20 does not,
      because placing the last item recomputes everything; index 5 does not either, because it
      fits inside the fully-estimated total. A test aimed at either end passes while a consumer
      fails.
    • The container matters. Inside a plain View that lays out in the same commit, all
      items measured before placement and it never reproduced. Presented by a navigator
      (react-native-screens, transparent modal) it reproduced every time. That lines up with
      FlashList on Android: item at initialScrollIndex flickers white for one frame on initial render #1887, where the flicker only appears when the list is in a non-primary tab.

    #2444 fixes it. Applying only that line on 2.3.2 —
    recomputeLayouts(0, initialScrollIndex) → recomputeLayouts(0, this.getDataLength() - 1)
    — and changing nothing else, the pager opens on the requested item and contentSize is
    count × itemWidth on the first scroll frame instead of short. That is with 22 items and
    initialScrollIndex: 14; I saw the same with 120 items and initialScrollIndex: 67, though
    that run had the line applied straight to the installed package rather than through a
    committed patch.

    This pager also does not reproduce it on 2.0.2, which reads the target offset before
    recomputing rather than after — consistent with the behaviour arriving in 2.3.1. I have not
    tried 2.0.2 against the 300pt/600-item case in the original report.

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

    P1Important but not urgentagent-triageTrigger agent triage on this issuebugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions