Skip to content

fix(iOS, Stack): remove the navigation controller's view when a nested stack is recycled - #4997

Merged
kligarski merged 2 commits into
software-mansion:mainfrom
intergalacticspacehighway:fix/nested-stack-recycle-controller-view
Oct 21, 2026
Merged

kligarski merged 2 commits into
software-mansion:mainfrom
intergalacticspacehighway:fix/nested-stack-recycle-controller-view

Conversation

@intergalacticspacehighway

@intergalacticspacehighway intergalacticspacehighway commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Description

When a native stack nested in a modal is recycled and then reused, its screen appears before the modal has finished presenting. UIKit doesn't forward appearance to a child view controller that is added after its parent appeared while a transition is still running, so such a child never appears and never gets a safe area.

This breaks Host from @expo/ui in expo/expo#51288: from the second fullScreenModal presentation of a screen with a nested stack, a Host that mounts during the presentation lays out its content under the status bar and the large title.

Cause

  • prepareForRecycle removes _controller from its parent view controller but leaves _controller.view inside the stack view.
  • When the recycled stack is reused, its view enters the window with the navigation controller's view already inside and no parent controller. UIKit treats it as a standalone controller whose view entered a window and makes it appear right away (viewDidMoveToWindow:shouldAppearOrDisappear:), while the modal is still animating. maybeAddToParentAndUpdateContainer adds it to the parent only afterwards.
  • On first use, reactAddControllerToClosestParent adds the controller to its parent before adding its view, so it appears with the modal when the presentation finishes.

Breakpoints on -[RNSScreen viewDidAppear:] confirm this: on the first presentation it's called from -[UIPresentationController transitionDidFinish:], on later ones from the window-move path above, before the modal finishes.

Changes

  • prepareForRecycle also removes _controller.view, so a reused stack attaches the controller before its view, as on first use.

Test plan

Repro: https://github.com/brentvatne/expo-ui-host-safe-area-repro (expo-router fullScreenModal routes with a nested Stack, and a SwiftUI Host that mounts 400 ms after the screen). Open A, close, open B, close, open A.

  • Before (4.30.0): from the second presentation on, the hosted list starts under the status bar and the title.
  • After (4.30.0 with this change): the list starts below the title on every presentation.

Tested on an iOS 27.0 simulator (Xcode 27.1). The changed code is the same on main.

Checklist

  • Included code example that can be used to test this change
  • Updated TS types
  • Updated documentation
  • Ensured that CI passes

… is recycled

Co-authored-by: intergalacticspacehighway <23293248+intergalacticspacehighway@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 25658df7-0e48-44f3-bcb3-272e6e3b0e76
📥 Commits

Reviewing files that changed from the base of the PR and between 3824a10 and f840e91.

📒 Files selected for processing: 1
  • ios/legacy/RNSScreenStack.mm

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

📝 Walkthrough

During screen-stack recycling, prepareForRecycle removes the navigation controller’s view from its superview before detaching the controller from its parent.

Changes: Screen stack recycling

Layer / File(s) Summary
Remove the controller view during recycling
ios/legacy/RNSScreenStack.mm
prepareForRecycle removes _controller.view from its superview before removing the controller from its parent.

Priority: ➖ Normal

Estimated code review effort: 1 (Trivial) | ~5 minutes

Change: Bug fix

Suggested reviewers: kligarski

Merge Risk: ⚪ Minimal · up to f840e

No actionable merge-blocking risk was identified.

Pre-merge checks: Passed 5
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main iOS fix: removing the navigation controller's view when a nested stack is recycled.
Description check Passed The description directly explains the recycling bug, its UIKit cause, the code change, reproduction steps, and observed result.
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0 files. (1 skipped: 1 unsupported.)

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Co-authored-by: intergalacticspacehighway <23293248+intergalacticspacehighway@users.noreply.github.com>
@intergalacticspacehighway
intergalacticspacehighway marked this pull request as ready for review October 20, 2026 22:49
intergalacticspacehighway added a commit to expo/expo that referenced this pull request Oct 20, 2026
…arance was forwarded early (#51923)

# Why

Fixes #51288.

From the second `fullScreenModal` presentation of a screen with a nested
native stack, a `Host` that mounts while the modal still animates gets
no safe area, so its content sits under the status bar and the title.

## Root cause

- UIKit gives a view controller its safe area only after it has
appeared.
- A child view controller appears with its parent. A child added while
the parent is appearing is included. A child added after the parent
appeared, with no transition running, appears right away. But a child
added after the parent appeared **while a transition is still running**
gets no appearance calls, then or later. It never appears, so its safe
area stays `{0, 0, 0, 0}`.
- Normally that window doesn't exist: a presented screen appears when
the presentation ends. With react-native-screens it does.
`RNSScreenStack prepareForRecycle` removes the navigation controller
from its parent but leaves its view in the stack view. When a recycled
stack is reused in the next modal, that view enters the window with no
parent controller, so UIKit makes the navigation controller and its
screen appear right away, while the modal is still animating. Fix
proposed upstream in software-mansion/react-native-screens#4997.
- In the repro, the loaded `Host` replaces the loading one 400 ms in,
inside that window, so its hosting controller never appears.

Evidence: UIKit's private `_appearState` at attach was parent 1
(appearing) and child 1 → 2 on the first presentation, and parent 2
(appeared) and child 0 forever on later ones. lldb traces show the inner
`RNSScreen` getting `viewDidAppear` from `transitionDidFinish:` the
first time and from `viewDidMoveToWindow:shouldAppearOrDisappear:`
later. A plain UIKit app breaks the same way when a container makes its
child appear before its presentation ends.

# How

This is a workaround until software-mansion/react-native-screens#4997
ships (the code has a TODO to remove it). `HostingView` uses a small
`UIHostingController` subclass that records `viewWillAppear`. If the
hosting controller is attached during a transition and UIKit didn't
forward `viewWillAppear`, `HostingView` forwards it with
`beginAppearanceTransition(true, animated: false)` +
`endAppearanceTransition()`. Otherwise nothing changes.

It also keeps `Host` correct inside any other container with this
timing. With the react-native-screens fix alone, the repro also works.

# Test Plan

The issue's repro
([brentvatne/expo-ui-host-safe-area-repro](https://github.com/brentvatne/expo-ui-host-safe-area-repro))
on SDK 58, `expo-modules-core` built from source, iOS 27.0 simulator,
`-sequence a,b,a`:
- Before: from the second presentation on, Row 1 is under the status bar
and the title.
- After: Row 1 is below the title. A 30 fps recording of the second and
third presentations shows the list in place from its first frame, with
no jump.

Expo UI measure tests (`ExpoUIMeasurement`, `ExpoUIHostSize`,
`ExpoUIRNHostViewSize`) in `bare-expo`: iOS 26/26 passed.

| Before (from the issue) | After |
| :---: | :---: |
| <img
src="https://github.com/brentvatne/expo-ui-host-safe-area-repro/raw/3b635b8/pr-evidence/host-safe-area/third.png"
width="220"> | <img
src="https://raw.githubusercontent.com/expo/expo/ea74840d1b264fe2be1f1f03dedbab053ee439ac/after.png"
width="220"> |

# Checklist

- [x] I added a changeset and followed [this short
guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [ ] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin).
- [ ] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)

---------

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: intergalacticspacehighway <23293248+intergalacticspacehighway@users.noreply.github.com>
intergalacticspacehighway added a commit to expo/expo that referenced this pull request Oct 20, 2026
…arance was forwarded early (#51923)

# Why

Fixes #51288.

From the second `fullScreenModal` presentation of a screen with a nested
native stack, a `Host` that mounts while the modal still animates gets
no safe area, so its content sits under the status bar and the title.

## Root cause

- UIKit gives a view controller its safe area only after it has
appeared.
- A child view controller appears with its parent. A child added while
the parent is appearing is included. A child added after the parent
appeared, with no transition running, appears right away. But a child
added after the parent appeared **while a transition is still running**
gets no appearance calls, then or later. It never appears, so its safe
area stays `{0, 0, 0, 0}`.
- Normally that window doesn't exist: a presented screen appears when
the presentation ends. With react-native-screens it does.
`RNSScreenStack prepareForRecycle` removes the navigation controller
from its parent but leaves its view in the stack view. When a recycled
stack is reused in the next modal, that view enters the window with no
parent controller, so UIKit makes the navigation controller and its
screen appear right away, while the modal is still animating. Fix
proposed upstream in software-mansion/react-native-screens#4997.
- In the repro, the loaded `Host` replaces the loading one 400 ms in,
inside that window, so its hosting controller never appears.

Evidence: UIKit's private `_appearState` at attach was parent 1
(appearing) and child 1 → 2 on the first presentation, and parent 2
(appeared) and child 0 forever on later ones. lldb traces show the inner
`RNSScreen` getting `viewDidAppear` from `transitionDidFinish:` the
first time and from `viewDidMoveToWindow:shouldAppearOrDisappear:`
later. A plain UIKit app breaks the same way when a container makes its
child appear before its presentation ends.

# How

This is a workaround until software-mansion/react-native-screens#4997
ships (the code has a TODO to remove it). `HostingView` uses a small
`UIHostingController` subclass that records `viewWillAppear`. If the
hosting controller is attached during a transition and UIKit didn't
forward `viewWillAppear`, `HostingView` forwards it with
`beginAppearanceTransition(true, animated: false)` +
`endAppearanceTransition()`. Otherwise nothing changes.

It also keeps `Host` correct inside any other container with this
timing. With the react-native-screens fix alone, the repro also works.

# Test Plan

The issue's repro
([brentvatne/expo-ui-host-safe-area-repro](https://github.com/brentvatne/expo-ui-host-safe-area-repro))
on SDK 58, `expo-modules-core` built from source, iOS 27.0 simulator,
`-sequence a,b,a`:
- Before: from the second presentation on, Row 1 is under the status bar
and the title.
- After: Row 1 is below the title. A 30 fps recording of the second and
third presentations shows the list in place from its first frame, with
no jump.

Expo UI measure tests (`ExpoUIMeasurement`, `ExpoUIHostSize`,
`ExpoUIRNHostViewSize`) in `bare-expo`: iOS 26/26 passed.

| Before (from the issue) | After |
| :---: | :---: |
| <img
src="https://github.com/brentvatne/expo-ui-host-safe-area-repro/raw/3b635b8/pr-evidence/host-safe-area/third.png"
width="220"> | <img
src="https://raw.githubusercontent.com/expo/expo/ea74840d1b264fe2be1f1f03dedbab053ee439ac/after.png"
width="220"> |

# Checklist

- [x] I added a changeset and followed [this short
guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [ ] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin).
- [ ] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)

---------

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: intergalacticspacehighway <23293248+intergalacticspacehighway@users.noreply.github.com>
(cherry picked from commit 0937842)

@kligarski kligarski left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good to me, thank you!

@kligarski

Copy link
Copy Markdown
Member

CI fails are unrelated.

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