Skip to content

Add useTraitHiddenOnIOS feature flag#172

Merged
SudoPlz merged 1 commit into0.81.4-discordfrom
sudoplz/ios-display-hidden-mounting
Apr 29, 2026
Merged

Add useTraitHiddenOnIOS feature flag#172
SudoPlz merged 1 commit into0.81.4-discordfrom
sudoplz/ios-display-hidden-mounting

Conversation

@SudoPlz
Copy link
Copy Markdown

@SudoPlz SudoPlz commented Apr 28, 2026

Summary:

Adds useTraitHiddenOnIOS feature flag - the iOS twin of Meta's
useTraitHiddenOnAndroid (#54112). Defaults to true so iOS behavior
is unchanged for anyone who doesn't override it.

Both the chat empty on channel re entry bug and that chat to non-chat bug that we've been chasing come from
iOS Fabric tearing down Suspense-frozen subtrees on every freeze
cycle. DCDChatComponentView (and its entire row cache, scroll
position, etc.) gets destroyed and rebuilt every time a chat surface
goes under display: none.

RN's been carrying the wiring to hide views instead of destroying
them since 2018. A 2020 optimization started filtering hidden subtrees out of the mounting slice before they could reach that path.

This flag re-opens it on demand.

Next

  • Follow-up app PR (after we bump the RN hash) flips the flag to
    false in our RCTAppDelegate and ships the fix.

What's in this PR

  • 1-line gate in sliceChildShadowNodeViewPairs.cpp (the only important code change).
  • Flag declared in ReactNativeFeatureFlags.config.js,
    defaultValue: true plus ~20 auto-generated files needed for the flag

Upstream

Designed to go straight to facebook/react-native once it's stable
here.

TODO when we upgrade RN to 0.83

RN 0.83 (which ships useTraitHiddenOnAndroid (facebook#54112), if we upgrade we should drop this
#ifndef ANDROID line and inline both flags into the gate using the following structure:

if (
  #ifdef ANDROID
      ReactNativeFeatureFlags::useTraitHiddenOnAndroid() &&
  #elif defined(__APPLE__)
      ReactNativeFeatureFlags::useTraitHiddenOnIOS() &&
  #endif
      childShadowNode.getTraits().check(ShadowNodeTraits::Trait::Hidden)) {
    continue;
  }

Which is also the code I'll upstream to RN
Behavior is unchanged either way - the rewrite is purely cosmetic

## Summary:

Mirrors useTraitHiddenOnAndroid from facebook#54112. Lets iOS apps opt out of
the `Trait::Hidden` slice-skip; Android can already opt in. Default is
`true`, preserving today's iOS behavior.

What the optimization does

D22134220 (2020, "Fabric: display: none nodes do not create views
anymore") added `Trait::Hidden` and a slice-skip in
`sliceChildShadowNodeViewPairs` that filters out subtrees whose Yoga
display is None. On iOS the diff then issues REMOVE + DELETE for the
entire subtree — invisible content stops costing anything.

For most UIs this is a clean win.

Where it bites

The optimization assumes that `display: none` transitions are rare.
That breaks for a specific pattern: **a custom host component mounted
under `<Suspense>`**.

Each suspend → resume cycle, Suspense flips display between `flex` and
`none`. With the optimization on, every cycle:

- tears down the entire subtree of UIViews,
- drops per-instance native state — measurement caches, scroll
  position, animation drivers, anything internal to the host
  component,
- re-runs `init`/`dealloc` and rebuilds the subtree on resume.

Suspend/resume ends up heavier than a fresh mount, and any state the
component held disappears between renders.

What the flag does

**It re-activates an existing code path. Nothing in the mounting
layer is new.**

The hide-via-`UIView.hidden` wiring shipped in D8460108 (June 2018,
"Fabric: Default support of displayType and layoutDirection
layout...") and has lived in `UIView+ComponentViewProtocol
updateLayoutMetrics:` ever since — a few lines below the slice
consumer in the same file. For two years it was the only iOS path;
the 2020 slice-skip didn't replace it, it just made it unreachable
in the common case.

Setting the flag to `false` lets Hidden shadow nodes pass through
the slice. The differ emits an `UPDATE_LAYOUT_METRICS` mutation
with `displayType == None`, and the 2018 wiring picks it up:
`self.hidden = YES` on the underlying UIView. Same view, hidden in
place.

Defaults

- `useTraitHiddenOnIOS = true` — keeps the iOS behavior introduced
  in 2020.
- `useTraitHiddenOnAndroid = false` — keeps the Android behavior,
  which never adopted the slice-skip.

Both flags share the same semantic ("use the optimization"); the
defaults encode each platform's pre-flag behavior. Flipping either
default is out of scope.

## Changelog:

[IOS] [ADDED] - useTraitHiddenOnIOS feature flag to opt out of the `display: none` slice-skip optimization

## Test Plan:

No new tests. `StackingContextTest` exercises the slice-skip path and
passes unchanged with the flag at its default `true`. Manually
flipping the flag to `false` produces the existing Android branch's
expected view tree (8 views, Hidden subtrees preserved with
`self.hidden = YES`).
// On iOS, gated by useTraitHiddenOnIOS. When false, the view stays in
// the slice and is hidden via UIView.hidden = YES in
// updateLayoutMetrics: instead of being removed.
if (ReactNativeFeatureFlags::useTraitHiddenOnIOS() &&
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the only important code of this PR

// updateLayoutMetrics: instead of being removed.
if (ReactNativeFeatureFlags::useTraitHiddenOnIOS() &&
childShadowNode.getTraits().check(ShadowNodeTraits::Trait::Hidden)) {
continue;
Copy link
Copy Markdown
Author

@SudoPlz SudoPlz Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

continue on line 67 drops this node from the mount slice.
The next frame's diff against the previous slice will then see the subtree (of our frozen child) as missing and emit REMOVE + DELETE instructions - destroying the underlying UIView and all the children it has (chat and messages etc).

Old arch didn't have this (questionable) optimization, and children only hid - which is what we want to make everything work again. Instead without this, react-native just hides the view which is what we want.

@SudoPlz SudoPlz requested a review from hannojg April 29, 2026 00:11
Copy link
Copy Markdown

@hannojg hannojg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, lets go!

(I was thinking at first it would be easier to just CP the commit from 0.83 and remove the #ifdef android directive, but i think its cleaner to have this separately for iOS!)

@SudoPlz SudoPlz merged commit 02b8971 into 0.81.4-discord Apr 29, 2026
56 of 69 checks passed
@SudoPlz
Copy link
Copy Markdown
Author

SudoPlz commented Apr 29, 2026

lgtm, lets go!

(I was thinking at first it would be easier to just CP the commit from 0.83 and remove the #ifdef android directive, but i think its cleaner to have this separately for iOS!)

Yeah I wanted to

  1. give us the option to shut off the value from the discord project if we ever need to
  2. streamline this as close to what we'll be upstreaming to RN

@SudoPlz
Copy link
Copy Markdown
Author

SudoPlz commented Apr 29, 2026

also upstreamed it on RN repo - issue opened here and suggested PR here

@SudoPlz SudoPlz deleted the sudoplz/ios-display-hidden-mounting branch April 29, 2026 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants