From 27647b611110866669eba9bcd6b5c4a334e51ffb Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Sat, 15 Aug 2026 11:10:24 +0100 Subject: [PATCH] fix: recompute full layout range on initial scroll adjustment --- src/__tests__/initialScrollIndex2307.test.tsx | 49 +++++++++++++++++++ src/recyclerview/RecyclerViewManager.ts | 10 +++- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/initialScrollIndex2307.test.tsx diff --git a/src/__tests__/initialScrollIndex2307.test.tsx b/src/__tests__/initialScrollIndex2307.test.tsx new file mode 100644 index 000000000..9d3889036 --- /dev/null +++ b/src/__tests__/initialScrollIndex2307.test.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { Text } from "react-native"; +import "@quilted/react-testing/matchers"; +import { render } from "@quilted/react-testing"; + +import { FlashList } from ".."; + +// Items are 300px tall, which is above the 200px default the layout manager +// seeds unmeasured items with. That gap is what issue #2307 depends on. +jest.mock("../recyclerview/utils/measureLayout", () => { + const originalModule = jest.requireActual( + "../recyclerview/utils/measureLayout" + ); + return { + ...originalModule, + measureParentSize: jest + .fn() + .mockImplementation(() => ({ width: 400, height: 900 })), + measureFirstChildLayout: jest + .fn() + .mockImplementation(() => ({ x: 0, y: 0, width: 400, height: 900 })), + measureItemLayout: jest + .fn() + .mockImplementation(() => ({ x: 0, y: 0, width: 400, height: 300 })), + }; +}); + +describe("initialScrollIndex with items taller than the default estimate", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + it("opens on the requested item rather than one far past it", () => { + const result = render( + index)} + keyExtractor={(item) => String(item)} + initialScrollIndex={250} + renderItem={({ item }) => {item}} + overrideProps={{ initialDrawBatchSize: 1 }} + drawDistance={0} + /> + ); + + expect(result).toContainReactComponent(Text, { children: 250 }); + expect(result).not.toContainReactComponent(Text, { children: 333 }); + }); +}); diff --git a/src/recyclerview/RecyclerViewManager.ts b/src/recyclerview/RecyclerViewManager.ts index 41704d03e..a6786c56f 100644 --- a/src/recyclerview/RecyclerViewManager.ts +++ b/src/recyclerview/RecyclerViewManager.ts @@ -385,7 +385,15 @@ export class RecyclerViewManager { // re-estimate unmeasured items with an updated average height, changing // the target item's position. Reading before recompute would capture a // stale offset, causing the wrong items to be rendered. - this.layoutManager.recomputeLayouts(0, initialScrollIndex); + // + // This has to run to the end of the list rather than stopping at + // initialScrollIndex. Items are positioned from their predecessor, so + // recomputing only the prefix moves the target item without moving the + // items after it, and the layout array is left out of order at that + // boundary. getVisibleLayouts binary searches that array and documents + // that it assumes it is sorted, so once it is not, the search can return + // an item nowhere near the one requested. + this.layoutManager.recomputeLayouts(0, this.getDataLength() - 1); const initialItemLayout = this.layoutManager.getLayout(initialScrollIndex); const initialItemOffset = this.propsRef.horizontal