From 9fd86e00d519da9f922232e9474c379497bbfa37 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Thu, 13 Aug 2026 18:16:31 +0700 Subject: [PATCH] fix: seed animatedScrollY when the sticky scroll handler attaches late `useStickyScrollHandler` only attaches its `Animated.event` while `stickyHeaderIndices` is non-empty, and that event is the only writer of `ctx.animatedScrollY`. When a list mounts with empty data and the indices arrive later, every scroll event before the attach reaches only the plain JS handler and is dropped, so `animatedScrollY` still holds its initial `0`. `PositionViewSticky` derives `translateY` purely from an interpolation of that value, with `stickyStart = position - stickyHeaderConfig.offset`. On iOS with `contentInsetAdjustmentBehavior="automatic"` the true rest offset is `-headerInset`, reported in a scroll event at mount, so a stuck `0` places the index-0 sticky header exactly one inset too low until the user scrolls. Seed `animatedScrollY` from `state.lastNativeScroll` on the transition into the animated engine, which is the raw `contentOffset` the `Animated.event` would have written. Seeding only on that transition keeps `Animated.event` the sole owner afterwards, so a later `stickyHeaderIndices` change cannot overwrite a live value with a JS offset that lags during a scroll. RTL horizontal lists are skipped because `lastNativeScroll` is a logical offset there, not the raw one. Fixes #512 --- .../useStickyScrollHandler.native.test.tsx | 62 +++++++++++++++++++ src/platform/useStickyScrollHandler.native.ts | 31 ++++++++-- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/__tests__/platform/useStickyScrollHandler.native.test.tsx b/__tests__/platform/useStickyScrollHandler.native.test.tsx index 2bd924f7a..c5d3fc5c7 100644 --- a/__tests__/platform/useStickyScrollHandler.native.test.tsx +++ b/__tests__/platform/useStickyScrollHandler.native.test.tsx @@ -72,4 +72,66 @@ describe("useStickyScrollHandler.native", () => { animatedEventSpy.mockRestore(); }); + + it("seeds animatedScrollY from the last native scroll when the handler attaches after mount", () => { + const onScroll = () => {}; + const onResult = () => {}; + const ctx = createMockContext(); + const setValueSpy = spyOn(ctx.animatedScrollY, "setValue"); + const animatedEventSpy = spyOn(Animated, "event"); + + let renderer: ReturnType; + act(() => { + renderer = TestRenderer.create( + , + ); + }); + + // Nothing has attached Animated.event yet, so the mount-time inset adjustment iOS reports + // for contentInsetAdjustmentBehavior="automatic" only reaches the plain JS scroll handler. + expect(animatedEventSpy).toHaveBeenCalledTimes(0); + expect(setValueSpy).toHaveBeenCalledTimes(0); + ctx.state.lastNativeScroll = -96; + + act(() => { + renderer!.update(); + }); + + expect(animatedEventSpy).toHaveBeenCalledTimes(1); + expect(setValueSpy).toHaveBeenCalledTimes(1); + expect(setValueSpy).toHaveBeenCalledWith(-96); + + animatedEventSpy.mockRestore(); + setValueSpy.mockRestore(); + }); + + it("does not re-seed animatedScrollY while the handler stays attached", () => { + const onScroll = () => {}; + const onResult = () => {}; + const ctx = createMockContext(); + ctx.state.lastNativeScroll = -96; + const setValueSpy = spyOn(ctx.animatedScrollY, "setValue"); + + let renderer: ReturnType; + act(() => { + renderer = TestRenderer.create( + , + ); + }); + + expect(setValueSpy).toHaveBeenCalledTimes(1); + + // Animated.event owns the value once attached, so a later indices change must not overwrite + // it with the JS-tracked offset, which can lag behind during a scroll. + ctx.state.lastNativeScroll = -40; + act(() => { + renderer!.update( + , + ); + }); + + expect(setValueSpy).toHaveBeenCalledTimes(1); + + setValueSpy.mockRestore(); + }); }); diff --git a/src/platform/useStickyScrollHandler.native.ts b/src/platform/useStickyScrollHandler.native.ts index 88c19365b..62b07b784 100644 --- a/src/platform/useStickyScrollHandler.native.ts +++ b/src/platform/useStickyScrollHandler.native.ts @@ -1,7 +1,8 @@ -import { useMemo } from "react"; +import { useMemo, useRef } from "react"; import { Animated, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native"; import type { StateContext } from "@/state/state"; +import { isHorizontalRTL } from "@/utils/rtl"; export function useStickyScrollHandler( stickyHeaderIndices: number[] | undefined, @@ -10,16 +11,38 @@ export function useStickyScrollHandler( onScroll: (event: NativeSyntheticEvent) => void, ) { const shouldUseRnAnimatedEngine = !ctx.state.props.stickyPositionComponentInternal; + const isAnimatedEngineActive = !!stickyHeaderIndices?.length && shouldUseRnAnimatedEngine; + const wasAnimatedEngineActive = useRef(false); // Create dual scroll handlers - one for native animations, one for JS logic return useMemo(() => { - if (stickyHeaderIndices?.length && shouldUseRnAnimatedEngine) { - const { animatedScrollY } = ctx; + const wasActive = wasAnimatedEngineActive.current; + wasAnimatedEngineActive.current = isAnimatedEngineActive; + + if (isAnimatedEngineActive) { + const animatedScrollY = ctx.animatedScrollY as unknown as Animated.Value; + if (!wasActive) { + // Animated.event is the only writer of animatedScrollY, so any scroll that happened + // before it attached was never recorded - most notably the rest offset iOS reports at + // mount for contentInsetAdjustmentBehavior="automatic". Seed the value from the + // JS-tracked native offset so sticky headers paint in the right place immediately + // instead of staying stuck at 0 until the next scroll event. lastNativeScroll matches + // the raw contentOffset that Animated.event writes, except on RTL horizontal lists + // where it has been converted to a logical offset. + const { lastNativeScroll } = ctx.state; + if ( + typeof lastNativeScroll === "number" && + Number.isFinite(lastNativeScroll) && + !isHorizontalRTL(ctx.state) + ) { + animatedScrollY.setValue(lastNativeScroll); + } + } return Animated.event( [ { nativeEvent: { - contentOffset: { [horizontal ? "x" : "y"]: animatedScrollY as unknown as Animated.Value }, + contentOffset: { [horizontal ? "x" : "y"]: animatedScrollY }, }, }, ],