Skip to content

[iOS] Button children mounted after the first render end up behind their siblings (content goes blank) #4602

Closed

Description

@radko93

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 show B A C and B A D C.
  • Lists: FlatList and LegendList (recycleItems) rows that scroll, recycle and change
    content every tick never go blank.
  • Presses: onPress fires 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 (main at 231013c, apps/expo-example, Release,
    iPhone 17 Pro simulator, Argent 0.26.0 and 0.29.0): the 6 existing .argent/flows pass both
    without and with the patch. A new "Button child order" test screen plus a button-child-order-test flow with two
    snapshot: steps fails on main (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.
Image

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

  1. 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.
  2. Replace App.tsx with the one below.
  3. 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

  1. added a commit that references this issue on Oct 20, 2026
    0c12342
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Platform: iOSThis issue is specific to iOS

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions