From 6369ed4ca77d3cb93298a183ebe00f5c90c930b2 Mon Sep 17 00:00:00 2001 From: Adam Hari Date: Wed, 23 Sep 2026 15:47:29 -0600 Subject: [PATCH 1/2] Fix stale Fabric sticky header measurements after unsticking --- .../ScrollView/ScrollViewStickyHeader.js | 63 +++++-------- .../__tests__/ScrollViewStickyHeader-test.js | 89 +++++++++++++++++++ 2 files changed, 113 insertions(+), 39 deletions(-) create mode 100644 packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollViewStickyHeader-test.js diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader.js b/packages/react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader.js index 6caef6b5066a..1d8e18bab78a 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader.js @@ -118,46 +118,8 @@ const ScrollViewStickyHeader: component( }, ); - const haveReceivedInitialZeroTranslateY = useRef(true); const translateYDebounceTimer = useRef>(null); - useEffect(() => { - if (translateY !== 0 && translateY != null) { - haveReceivedInitialZeroTranslateY.current = false; - } - }, [translateY]); - - // This is called whenever the (Interpolated) Animated Value - // updates, which is several times per frame during scrolling. - // To ensure that the Fabric ShadowTree has the most recent - // translate style of this node, we debounce the value and then - // pass it through to the underlying node during render. - // This is: - // 1. Only an issue in Fabric. - // 2. Worse in Android than iOS. In Android, but not iOS, you - // can touch and move your finger slightly and still trigger - // a "tap" event. In iOS, moving will cancel the tap in - // both Fabric and non-Fabric. On Android when you move - // your finger, the hit-detection moves from the Android - // platform to JS, so we need the ShadowTree to have knowledge - // of the current position. - const animatedValueListener = useCallback(({value}: $FlowFixMe) => { - const debounceTimeout: number = Platform.OS === 'android' ? 15 : 64; - // When the AnimatedInterpolation is recreated, it always initializes - // to a value of zero and emits a value change of 0 to its listeners. - if (value === 0 && !haveReceivedInitialZeroTranslateY.current) { - haveReceivedInitialZeroTranslateY.current = true; - return; - } - if (translateYDebounceTimer.current != null) { - clearTimeout(translateYDebounceTimer.current); - } - translateYDebounceTimer.current = setTimeout( - () => setTranslateY(value), - debounceTimeout, - ); - }, []); - useEffect(() => { const inputRange: Array = [-1, 0]; const outputRange: Array = [0, 0]; @@ -233,6 +195,30 @@ const ScrollViewStickyHeader: component( newAnimatedTranslateY = Animated.add(newAnimatedTranslateY, offset); } + // The interpolation emits a zero when it is first created. Ignore only + // that initial value; a later zero is the actual position when the header + // returns from its sticky position. + let isInitialValue = true; + // Keep the Fabric ShadowTree's transform in sync with the native animation + // for hit testing and measurement. Debounce updates while scrolling to + // avoid rendering on every frame. + const animatedValueListener = ({value}: {value: number}) => { + if (isInitialValue) { + isInitialValue = false; + if (value === 0) { + return; + } + } + const debounceTimeout: number = Platform.OS === 'android' ? 15 : 64; + if (translateYDebounceTimer.current != null) { + clearTimeout(translateYDebounceTimer.current); + } + translateYDebounceTimer.current = setTimeout( + () => setTranslateY(value), + debounceTimeout, + ); + }; + // add the event listener let animatedListenerId; if (isFabric) { @@ -261,7 +247,6 @@ const ScrollViewStickyHeader: component( scrollAnimatedValue, inverted, offset, - animatedValueListener, isFabric, ]); diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollViewStickyHeader-test.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollViewStickyHeader-test.js new file mode 100644 index 000000000000..049e59323b40 --- /dev/null +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollViewStickyHeader-test.js @@ -0,0 +1,89 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import Animated from '../../../Animated/Animated'; +import View from '../../View/View'; +import ScrollViewStickyHeader from '../ScrollViewStickyHeader'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import TestRenderer from 'react-test-renderer'; + +// Jest host refs are not Fabric public instances, so enable the Fabric path. +jest.mock( + '../../../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstanceUtils', + () => ({ + isPublicInstance: () => true, + }), +); + +describe('ScrollViewStickyHeader', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('updates its Fabric transform when a sticky header returns to its layout position', async () => { + const scrollAnimatedValue = new Animated.Value(0); + let renderer; + + await TestRenderer.act(async () => { + renderer = TestRenderer.create( + {}} + scrollAnimatedValue={scrollAnimatedValue} + inverted={false} + scrollViewHeight={null}> + + , + { + createNodeMock: () => ({__nativeTag: 2, setNativeProps: () => {}}), + }, + ); + }); + + const header = () => + nullthrows( + renderer.root + .findAllByProps({nativeID: 'sticky-header'}) + .filter(node => node.props.style != null) + .pop(), + ); + await TestRenderer.act(async () => { + header().props.onLayout({ + nativeEvent: {layout: {y: 100, height: 20}}, + }); + }); + + await TestRenderer.act(async () => { + scrollAnimatedValue.setValue(150); + jest.advanceTimersByTime(65); + }); + expect(header().props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({transform: [{translateY: 50}]}), + ]), + ); + + await TestRenderer.act(async () => { + scrollAnimatedValue.setValue(0); + jest.advanceTimersByTime(65); + }); + expect(header().props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({transform: [{translateY: 0}]}), + ]), + ); + }); +}); From df7bd1fbf330d546f4c3516e18539eca6e3174ec Mon Sep 17 00:00:00 2001 From: Adam Hari Date: Wed, 23 Sep 2026 16:02:50 -0600 Subject: [PATCH 2/2] Test native sticky header transform updates in Fabric --- .../__tests__/ScrollViewStickyHeader-test.js | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollViewStickyHeader-test.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollViewStickyHeader-test.js index 049e59323b40..f397662d4d9a 100644 --- a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollViewStickyHeader-test.js +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollViewStickyHeader-test.js @@ -8,6 +8,7 @@ * @format */ +import NativeAnimatedHelper from '../../../../src/private/animated/NativeAnimatedHelper'; import Animated from '../../../Animated/Animated'; import View from '../../View/View'; import ScrollViewStickyHeader from '../ScrollViewStickyHeader'; @@ -32,8 +33,10 @@ describe('ScrollViewStickyHeader', () => { jest.useRealTimers(); }); - it('updates its Fabric transform when a sticky header returns to its layout position', async () => { - const scrollAnimatedValue = new Animated.Value(0); + it('updates its Fabric transform while sticky and after returning to its layout position', async () => { + const scrollAnimatedValue = new Animated.Value(0, { + useNativeDriver: true, + }); let renderer; await TestRenderer.act(async () => { @@ -66,8 +69,24 @@ describe('ScrollViewStickyHeader', () => { }); }); + const animatedHeader = nullthrows( + nullthrows(renderer) + .root.findAllByProps({nativeID: 'sticky-header'}) + .find(node => Array.isArray(node.props.style)), + ); + const animatedTranslateY = + animatedHeader.props.style[2].transform[0].translateY; + const animatedTag = animatedTranslateY.__getNativeTag(); + const emitNativeTranslation = (value: number) => { + NativeAnimatedHelper.nativeEventEmitter.emit('onAnimatedValueUpdate', { + tag: animatedTag, + value, + }); + }; + await TestRenderer.act(async () => { - scrollAnimatedValue.setValue(150); + emitNativeTranslation(0); + emitNativeTranslation(50); jest.advanceTimersByTime(65); }); expect(header().props.style).toEqual( @@ -77,7 +96,7 @@ describe('ScrollViewStickyHeader', () => { ); await TestRenderer.act(async () => { - scrollAnimatedValue.setValue(0); + emitNativeTranslation(0); jest.advanceTimersByTime(65); }); expect(header().props.style).toEqual(