From 7ae7fbf69d5294b707959e4d7cf602acdc23d45c Mon Sep 17 00:00:00 2001 From: Rohan Kulkarni Date: Mon, 24 Aug 2026 11:40:00 -0700 Subject: [PATCH] Fix zIndex being ignored on Android ScrollView/FlatList with a RefreshControl On Android a ScrollView with a RefreshControl is wrapped in an AndroidSwipeRefreshLayout, and its style is split across the two nodes by splitLayoutProps(). zIndex was not in the outer list, so it landed on the inner NativeScrollView -- an only child, which can never be reordered -- and z-ordering silently stopped working as soon as onRefresh was passed. Route zIndex to the outer node alongside position, so the wrapper (the node the parent actually orders) carries it. Fixes #31083 --- .../ScrollView-refreshControl-test.js | 70 +++++++++++++++++++ .../__tests__/splitLayoutProps-itest.js | 12 ++++ .../Libraries/StyleSheet/splitLayoutProps.js | 1 + 3 files changed, 83 insertions(+) create mode 100644 packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-refreshControl-test.js diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-refreshControl-test.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-refreshControl-test.js new file mode 100644 index 000000000000..5ea160c0ee61 --- /dev/null +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-refreshControl-test.js @@ -0,0 +1,70 @@ +/** + * 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 + */ + +'use strict'; + +import * as React from 'react'; + +const flattenStyle = require('../../../StyleSheet/flattenStyle').default; +const RefreshControl = require('../../RefreshControl/RefreshControl').default; +const ScrollView = require('../ScrollView').default; +const ReactTestRenderer = require('react-test-renderer'); + +// This test is about the element tree `ScrollView.render()` builds, so the +// default component mocks have to be turned off. +jest.unmock('react-native/Libraries/Components/ScrollView/ScrollView'); +jest.unmock('react-native/Libraries/Components/RefreshControl/RefreshControl'); +// On Android a `ScrollView` with a `RefreshControl` is wrapped in an +// `AndroidSwipeRefreshLayout`, and `style` is split across the two nodes. +jest.mock('../../../Utilities/Platform', () => + // $FlowFixMe[missing-platform-support] + require('../../../Utilities/Platform.android'), +); + +async function renderScrollView(style: $FlowFixMe): Promise<$FlowFixMe> { + let testRenderer: $FlowFixMe = null; + await ReactTestRenderer.act(() => { + testRenderer = ReactTestRenderer.create( + {}} /> + } + />, + ); + }); + return testRenderer.toJSON(); +} + +describe('ScrollView with a RefreshControl on Android', () => { + it('applies zIndex to the wrapper, not to the inner ScrollView', async () => { + const wrapper = await renderScrollView({zIndex: 7}); + + // `zIndex` has to reach the wrapper: that is the node parented by the + // user's view, and on Android z-ordering is applied by the parent. + expect(wrapper.type).toBe('AndroidSwipeRefreshLayout'); + expect(flattenStyle(wrapper.props.style)?.zIndex).toBe(7); + + // The inner scroll view is an only child, so a `zIndex` left here could + // never affect stacking. + const scrollView = wrapper.children[0]; + expect(scrollView.type).toBe('RCTScrollView'); + expect(flattenStyle(scrollView.props.style)?.zIndex).toBeUndefined(); + }); + + it('keeps non-layout style on the inner ScrollView', async () => { + const wrapper = await renderScrollView({backgroundColor: 'red'}); + + expect(flattenStyle(wrapper.props.style)?.backgroundColor).toBeUndefined(); + expect(flattenStyle(wrapper.children[0].props.style)?.backgroundColor).toBe( + 'red', + ); + }); +}); diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/splitLayoutProps-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/splitLayoutProps-itest.js index 75b494fa41de..e30d636dc741 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/splitLayoutProps-itest.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/splitLayoutProps-itest.js @@ -47,6 +47,18 @@ test('does not copy values to both returned objects', () => { `); }); +test('splits zIndex into the outer style', () => { + // On Android a ScrollView with a RefreshControl is wrapped in an + // AndroidSwipeRefreshLayout, and the outer style is applied to that wrapper. + // `zIndex` has to travel outwards along with the other positioning props, + // because the wrapper is the node that participates in the parent's + // stacking context. + const style = {zIndex: 1, top: 0, backgroundColor: 'red', padding: 8}; + const {outer, inner} = splitLayoutProps(style); + expect(outer).toEqual({zIndex: 1, top: 0}); + expect(inner).toEqual({backgroundColor: 'red', padding: 8}); +}); + test('returns null values if argument is null', () => { const {outer, inner} = splitLayoutProps(null); expect(outer).toBe(null); diff --git a/packages/react-native/Libraries/StyleSheet/splitLayoutProps.js b/packages/react-native/Libraries/StyleSheet/splitLayoutProps.js index acf2dbbeedb8..7c00bca5403d 100644 --- a/packages/react-native/Libraries/StyleSheet/splitLayoutProps.js +++ b/packages/react-native/Libraries/StyleSheet/splitLayoutProps.js @@ -44,6 +44,7 @@ export default function splitLayoutProps(props: ?____ViewStyle_Internal): { case 'minWidth': case 'maxWidth': case 'position': + case 'zIndex': case 'left': case 'right': case 'bottom':