Repository navigation
Description
Description
On iOS, children that Fabric mounts into an RNGH button after its first render can be inserted
one position too low in the button's subview list. When the button's direct child is a View with a
background, a spinner or label swapped in later lands behind that View, so the button looks empty.
The same happens to a child whose opacity changes between 1 and <1, because Fabric flattens or
unflattens it and so remounts it. It needs no press: any re-render that mounts a new child shows it.
A common pattern triggers it: a "loading" button whose content swaps between a label and an
<ActivityIndicator> inside a styled background View, which is what most UI kits do (Tamagui,
custom ButtonFrames and so on):
<BaseButton onPress={onPress}>
<View style={{ backgroundColor: '#7E58FF', height: 48, borderRadius: 8, alignItems: 'center', justifyContent: 'center' }}>
{isLoading ? <ActivityIndicator color="white" /> : <Text style={{ color: 'white' }}>Label</Text>}
</View>
</BaseButton>Touchable, BaseButton, RectButton, BorderlessButton and LegacyBaseButton are all affected,
since they share the same native view. So is an Animated.createAnimatedComponent(BaseButton) wrapper
(seen in our app). Lists make it worse: buttons used as FlatList rows show whole screens of blank
rows while scrolling, because recycled and re-rendered rows remount their content. Swapping the
button for a plain <View> makes the bug go away. Android is not affected.
It is not specific to RN 0.88: it reproduces the same way on RN 0.87.1 + RNGH 3.3.0, which is
inside the supported range in compatibility.json, and on RN 0.88.0. 2.33.0 is not affected.
3.4.0-nightly-20261015-66a032add is still affected: checked at runtime on RN 0.88.0, and its iOS
button mount path and underlay code are unchanged from 3.3.0. 3.0.0 and 3.2.1 ship the same code.
Expected behavior
Button content updates exactly like it does inside a plain <View>: spinner at 3 s, label again at 6 s.
Actual behavior
From 3 s on, the RNGH buttons show only the purple background, with no spinner and no label. They
stay blank after the label comes back. The <View> control updates normally.
RN 0.87.1 + RNGH 3.3.0, iOS 26.5 simulator, at 3 s and 6 s. Top: as released. Bottom: with the fix below. Rows 3 and 4 nest the content in a background View; rows 1 and 2 don't, and are unaffected.
Root cause
RNGestureHandlerButton (iOS) keeps a bare _underlayLayer CALayer at sublayer index 0 of
its own layer: it is inserted in commonInit and re-inserted at index 0 in layoutSubviews. This
was introduced in #4032 and first shipped in 3.0.0.
RNGestureHandlerButtonComponentView -mountChildComponentView:index: forwards to
-[UIView(ComponentViewProtocol) mountChildComponentView:index:], which calls
insertSubview:atIndex:. That method counts all of the layer's sublayers, not just the ones
backing subviews, so with the underlay at index 0 every insert at index > 0 lands one slot too low.
Fabric view flattening is what makes this visible. A View that only has a background (no opacity,
transform or other stacking-context props) is mounted, but its children are hoisted into the nearest
stacking-context ancestor, i.e. the button, as siblings after the View. Instrumented output from
the repro (button tag 162, background View tag 160):
mount child=160 (RCTViewComponentView) into button=162 index=0 // first render
mount child=156 (RCTParagraphComponentView) into button=162 index=1 // OK: underlay is re-inserted at 0 only later, in layoutSubviews
unmount child=156 …
mount child=246 (RCTActivityIndicatorViewComponentView) into button=162 index=1 // 3 s: lands behind 160
unmount child=246 …
mount child=284 (RCTParagraphComponentView) into button=162 index=1 // 6 s: lands behind 160
State after the 6 s update (lldb):
_buttonView.subviews = [ <RCTParagraphComponentView tag=284>, <RCTViewComponentView tag=160 bg=#7E58FF> ]
_buttonView.layer.sublayers = [ <underlay CALayer opacity=0>, <284.layer>, <160.layer> ]
Fabric asked for index 1, above the background View, but the child ended up at subview index 0,
underneath it. On first render it happens to work: layoutSubviews hasn't yet moved the underlay
back to index 0, so the indices still line up.
Related: #4062. The crash reported there ("Attempt to unmount a view which has a different
index … index: 2, actual index: 1", on a 3.0.0 nightly from shortly after #4032) is this same
off-by-one, seen from the unmount side: a child appended to the button landed one subview too
low, so the default unmount's index check failed. #4065 fixed the crash by removing children with
removeFromSuperview and no index check. Mounting still used insertSubview:atIndex:, which is
why the ordering bug here remains.
2.33.0 has no underlay layer, which is why it's unaffected. Nothing here depends on the RN version.
Fabric has always hoisted the children of flattened container Views this way, and
insertSubview:atIndex: has always counted sublayers. That matches the identical results on RN 0.87.1
and 0.88.0.
Proposed fix
PR: #4603 (the fix, a "Button child order" test screen and an Argent flow).
Insert relative to the sibling subview instead of by raw index. insertSubview:belowSubview: and
addSubview: ignore sublayers that aren't backing views:
- (void)mountChildComponentView:(RNGHUIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
{
#if !TARGET_OS_OSX
// Same check as the default `UIView` implementation this replaces.
react_native_assert(childComponentView.superview == nil && "Attempt to mount already mounted component view");
NSArray<RNGHUIView *> *subviews = _buttonView.subviews;
if (index < (NSInteger)subviews.count) {
[_buttonView insertSubview:childComponentView belowSubview:subviews[index]];
} else {
[_buttonView addSubview:childComponentView];
}
#else
[_buttonView mountChildComponentView:childComponentView index:index];
#endif
}The patch was verified on the repro (RN 0.87.1 and RN 0.88.0, iOS 26.5). I also stress-tested it on
RN 0.88.0 + 3.3.0:
- Matrix: Touchable, BaseButton, RectButton and BorderlessButton, each with a label/spinner
swap, a child-opacity toggle, and the background on the button or on a child. Every case matches
the<View>control. - Order: checked with view-hierarchy dumps. Middle and last children were repeatedly
unmounted and remounted, plus a button nested inside a button. The children stay in React order
(A B C D). Unpatched, the same dumps showB A CandB A D C. - Lists:
FlatListandLegendList(recycleItems) rows that scroll, recycle and change
content every tick never go blank. - Presses:
onPressfires for all four types, disabled buttons (enabled={false}/
disabled) never fire, and Touchable's press feedback and native underlay layering are unchanged. - RNGH's own example app and e2e flows (
mainat231013c,apps/expo-example, Release,
iPhone 17 Pro simulator, Argent 0.26.0 and 0.29.0): the 6 existing.argent/flowspass both
without and with the patch. A new "Button child order" test screen plus abutton-child-order-testflow with two
snapshot:steps fails onmain(1.64% diff, the three RNGH columns are blank) and passes with
the patch, as do all 7 flows, in both Release and Debug builds.
One alternative is to move the underlay out of _buttonView.layer's sublayer list (e.g. host it in a
dedicated non-React subview kept at the back, or on the wrapper), but then Fabric's indices would
need offsetting instead. The sibling-relative insert is the smallest change. macOS already inserts
relative to a sibling (addSubview:positioned:relativeTo:) and is untouched.
Steps to reproduce
- Bare RN 0.87.1 or 0.88.0 app (
npx @react-native-community/cli init --version 0.87.1) plus
react-native-gesture-handler@3.3.0. - Replace
App.tsxwith the one below. - Run on an iOS simulator and wait about 6 s.
App.tsx (self-contained, timer-driven)
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, Text, View } from 'react-native';
import { BaseButton, GestureHandlerRootView, Touchable } from 'react-native-gesture-handler';
const frame = {
height: 48, borderRadius: 8, backgroundColor: '#7E58FF',
alignItems: 'center', justifyContent: 'center',
} as const;
function Content({ loading }: { loading: boolean }) {
return (
<View style={frame}>
{loading ? <ActivityIndicator color="white" /> : <Text style={{ color: 'white' }}>Label</Text>}
</View>
);
}
export default function App() {
const [loading, setLoading] = useState(false);
useEffect(() => {
const a = setTimeout(() => setLoading(true), 3000);
const b = setTimeout(() => setLoading(false), 6000);
return () => { clearTimeout(a); clearTimeout(b); };
}, []);
return (
<GestureHandlerRootView style={{ flex: 1, padding: 24, paddingTop: 80, gap: 16 }}>
<Text>loading: {String(loading)}</Text>
<BaseButton onPress={() => {}}><Content loading={loading} /></BaseButton>
<Touchable onPress={() => {}}><Content loading={loading} /></Touchable>
<View><Content loading={loading} /></View>{/* control */}
</GestureHandlerRootView>
);
}The snippet above, as-is, on RN 0.88.0 + 3.4.0-nightly at 0 s, 3 s and 6 s. Both RNGH buttons go blank; the View control doesn't.
The "Button child order" test screen and Argent flow in #4603 cover a child-opacity toggle as well.
A link to a Gist, an Expo Snack or a link to a repository based on this template that reproduces the bug.
https://github.com/radko93/rngh-button-child-order-repro
Gesture Handler version
3.3.0 (also 3.4.0-nightly-20261015-66a032add)
React Native version
0.88.0 (also 0.87.1)
Platforms
iOS
JavaScript runtime
Hermes
Workflow
React Native (without Expo)
Build type
Release mode
Device
iOS simulator
Device model
iPhone 17 Pro (iOS 26.5)
Acknowledgements
Yes
Activity
[iOS] Keep button children in mount order behind the underlay layer (#…