From eeff7582587da3b7fead27242461f2d76edc59b1 Mon Sep 17 00:00:00 2001 From: Mad Dinh Date: Mon, 7 Sep 2026 00:17:58 +0700 Subject: [PATCH] fix(sticky-headers): report the previous index in onChangeStickyIndex `onChangeStickyIndex` is documented to receive the current and the previous sticky header index, but it passed `currentStickyIndex`, a piece of state that is only ever written inside `if (stickyHeaderHideRelatedCell)`. `hideRelatedCell` defaults to `false`, so under the default configuration the state stayed at its initial `-1` and every invocation reported `previous === -1`: [[0, -1], [5, -1], [10, -1]] // scrolling down past headers 0, 5, 10 Track the last reported index in a ref instead, independently of `hideRelatedCell`, so both configurations report the real previous index. `currentStickyIndex` keeps its remaining job of hiding the cell behind the stuck header and is no longer read by the callback, so it drops out of the sticky header memo's dependencies. Adds two regression tests covering the default config and `hideRelatedCell: true`; the first fails on main with `(5, -1)`. --- src/__tests__/RecyclerView.test.tsx | 66 ++++++++++++++++++++++++++++- src/recyclerview/RecyclerView.tsx | 11 ++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/__tests__/RecyclerView.test.tsx b/src/__tests__/RecyclerView.test.tsx index c01af4834..b3e3dcd4e 100644 --- a/src/__tests__/RecyclerView.test.tsx +++ b/src/__tests__/RecyclerView.test.tsx @@ -4,7 +4,7 @@ import "@quilted/react-testing/matchers"; import { render } from "@quilted/react-testing"; import { FlashListRef } from "../FlashListRef"; -import { FlashList } from ".."; +import { FlashList, FlashListProps } from ".."; // Mock measureLayout to return fixed dimensions jest.mock("../recyclerview/utils/measureLayout", () => { @@ -398,6 +398,70 @@ describe("RecyclerView", () => { }); }); + describe("onChangeStickyIndex previous index", () => { + const scrollTo = (root: ReturnType, y: number) => { + const scrollable = root.findWhere((node: any) => node.props.onScroll); + if (!scrollable) throw new Error("Could not find scrollable component"); + + const onScroll: any = scrollable.prop("onScroll" as never); + root.act(() => { + onScroll({ + nativeEvent: { + contentOffset: { x: 0, y }, + contentSize: { width: 399, height: 2000 }, + layoutMeasurement: { width: 399, height: 899 }, + }, + }); + }); + }; + + // Each item is 100px tall, so with stickyHeaderIndices=[0, 5, 10, 15] + // header 5 sits at y=500 and header 10 at y=1000. + const renderWithStickyHeaders = ( + stickyHeaderConfig?: FlashListProps["stickyHeaderConfig"] + ) => { + const onChangeStickyIndex = jest.fn(); + const result = render( + {item}} + stickyHeaderIndices={[0, 5, 10, 15]} + stickyHeaderConfig={stickyHeaderConfig} + onChangeStickyIndex={onChangeStickyIndex} + overrideProps={{ initialDrawBatchSize: 1 }} + drawDistance={0} + /> + ); + + return { result, onChangeStickyIndex }; + }; + + it("reports the previously stuck index with the default config", () => { + const { result, onChangeStickyIndex } = renderWithStickyHeaders(); + + scrollTo(result, 550); + expect(onChangeStickyIndex).toHaveBeenLastCalledWith(5, 0); + + scrollTo(result, 1050); + expect(onChangeStickyIndex).toHaveBeenLastCalledWith(10, 5); + }); + + it("reports the previously stuck index when hideRelatedCell is enabled", () => { + const { result, onChangeStickyIndex } = renderWithStickyHeaders({ + hideRelatedCell: true, + }); + + scrollTo(result, 550); + expect(onChangeStickyIndex).toHaveBeenLastCalledWith(5, 0); + + scrollTo(result, 1050); + expect(onChangeStickyIndex).toHaveBeenLastCalledWith(10, 5); + }); + }); + describe("autoscrolling to bottom is suppressed while offset projection is disabled", () => { const scrollTo = (root: ReturnType, y: number) => { const scrollable = root.findWhere((node: any) => node.props.onScroll); diff --git a/src/recyclerview/RecyclerView.tsx b/src/recyclerview/RecyclerView.tsx index 3c25e7e89..a636d6fc0 100644 --- a/src/recyclerview/RecyclerView.tsx +++ b/src/recyclerview/RecyclerView.tsx @@ -121,6 +121,10 @@ const RecyclerViewComponent = ( // Refs for sticky headers and scroll anchoring const stickyHeaderRef = useRef(null); const scrollAnchorRef = useRef(null); + // Last sticky index reported to `onChangeStickyIndex`. Tracked separately + // from `currentStickyIndex` because that state is only updated when + // `stickyHeaderConfig.hideRelatedCell` is enabled. + const lastReportedStickyIndexRef = useRef(-1); // State for managing layout and render updates const [_, setLayoutTreeId] = useLayoutState(0); @@ -447,7 +451,11 @@ const RecyclerViewComponent = ( if (stickyHeaderHideRelatedCell) { setCurrentStickyIndex(newStickyHeaderIndex); } - onChangeStickyIndex?.(newStickyHeaderIndex, currentStickyIndex); + onChangeStickyIndex?.( + newStickyHeaderIndex, + lastReportedStickyIndexRef.current + ); + lastReportedStickyIndexRef.current = newStickyHeaderIndex; }} /> ); @@ -462,7 +470,6 @@ const RecyclerViewComponent = ( horizontal, recyclerViewManager, extraData, - currentStickyIndex, onChangeStickyIndex, stickyHeaderHideRelatedCell, stickyHeaderZIndex,