Description
On the New Architecture (iOS), -[RNSScreenContainerView updateContainer] enumerates the live
_reactSubviews array while, inside the loop body, attaching/detaching child view controllers via
detachScreen:/attachScreen:. Under Fabric, those UIKit VC-containment callbacks
(addChildViewController / removeFromParentViewController / removeFromSuperview) can
synchronously flush pending React mounting, which calls mountChildComponentView /
unmountChildComponentView → [_reactSubviews insertObject:/removeObject:], mutating the array
mid-enumeration:
*** Terminating app due to uncaught exception 'NSGenericException', reason:
'*** Collection <__NSArrayM: 0x…> was mutated while being enumerated.'
Top frames: updateContainer → markChildUpdated → setActivityStateOrNil: →
updateProps:oldProps: → RCTMountingManager synchronouslyUpdateViewOnUIThread: (driven from an
Animated screen-transition prop update).
It terminates on the main thread below JS, so JS-only crash reporters never capture it. We ship the New Architecture only, so haven't verified Paper directly — but the reentrancy window shouldn't exist there, since pre-Fabric mounting is async/batched rather than synchronous on the main thread. Present in 4.23.0 and unchanged through 4.24.0–4.25.2 and current main.
Proposed fix: enumerate an immutable snapshot (NSArray *snap = [_reactSubviews copy];).
Reentrant mutations then hit the real array, reconcile via their own markChildUpdated follow-up,
and the loop bodies are idempotent against a stale entry (detach of an already-detached screen is
a nil no-op; attach is guarded by _activeScreens). In our app this took the crash from
reproducible-every-time to zero with no behavioral change. Happy to open a PR.
Reproduction note: no minimal Snack — the crash is a timing race (a burst of synchronous
Fabric mounting must coincide with a transition driving updateContainer), impractical to reduce.
The defect is plain from the source linked below: updateContainer enumerates the mutable
_reactSubviews (loops at L163/L177/L186/L200) while reentrant VC lifecycle mutates it.
Steps to reproduce
- iOS app on the New Architecture, inside a stack navigator.
- Push a screen at the same moment a synchronous burst of React mounting occurs in the same frame
— e.g. completing an auth flow (Redux state flip, WebSocket reconnect, several re-renders) while
the navigator pushes the next screen.
- App crashes with the NSGenericException above.
Deterministic in a form/wizard that pushes a screen per step. We bisected it to be independent of
our app code (reproduces with the screen container untouched) and absent on a navigation layout
that doesn't co-locate the work with the transition. Debug builds widen the window; release builds
also crash.
Snack or a link to a repository
|
- (void)updateContainer |
|
{ |
|
BOOL screenRemoved = NO; |
|
// remove screens that are no longer active |
|
NSMutableSet *orphaned = [NSMutableSet setWithSet:_activeScreens]; |
|
for (RNSScreenView *screen in _reactSubviews) { |
|
if (screen.activityState == RNSActivityStateInactive && [_activeScreens containsObject:screen]) { |
|
screenRemoved = YES; |
|
[self detachScreen:screen]; |
|
} |
|
[orphaned removeObject:screen]; |
|
} |
|
for (RNSScreenView *screen in orphaned) { |
|
screenRemoved = YES; |
|
[self detachScreen:screen]; |
|
} |
|
|
|
// detect if new screen is going to be activated |
|
BOOL screenAdded = NO; |
|
for (RNSScreenView *screen in _reactSubviews) { |
|
if (screen.activityState != RNSActivityStateInactive && ![_activeScreens containsObject:screen]) { |
|
screenAdded = YES; |
|
} |
|
} |
|
|
|
if (screenAdded) { |
|
// add new screens in order they are placed in subviews array |
|
NSInteger index = 0; |
|
for (RNSScreenView *screen in _reactSubviews) { |
|
if (screen.activityState != RNSActivityStateInactive) { |
|
if ([_activeScreens containsObject:screen] && screen.activityState == RNSActivityStateTransitioningOrBelowTop) { |
|
// for screens that were already active we want to mimick the effect UINavigationController |
|
// has when willMoveToWindow:nil is triggered before the animation starts |
|
[self prepareDetach:screen]; |
|
} else if (![_activeScreens containsObject:screen]) { |
|
[self attachScreen:screen atIndex:index]; |
|
} |
|
index += 1; |
|
} |
|
} |
|
} |
|
|
|
for (RNSScreenView *screen in _reactSubviews) { |
|
if (screen.activityState == RNSActivityStateOnTop) { |
|
[screen notifyFinishTransitioning]; |
|
} |
|
} |
|
|
Screens version
4.23.0
React Native version
0.83.4
Platforms
iOS
JavaScript runtime
Hermes
Workflow
Expo managed workflow
Architecture
Fabric (New Architecture)
Build type
Release mode
Device
Real device
Device model
iPhone 15 Pro (iOS 26)
Acknowledgements
Yes
Description
On the New Architecture (iOS),
-[RNSScreenContainerView updateContainer]enumerates the live_reactSubviewsarray while, inside the loop body, attaching/detaching child view controllers viadetachScreen:/attachScreen:. Under Fabric, those UIKit VC-containment callbacks(
addChildViewController/removeFromParentViewController/removeFromSuperview) cansynchronously flush pending React mounting, which calls
mountChildComponentView/unmountChildComponentView→[_reactSubviews insertObject:/removeObject:], mutating the arraymid-enumeration:
Top frames:
updateContainer→markChildUpdated→setActivityStateOrNil:→updateProps:oldProps:→RCTMountingManager synchronouslyUpdateViewOnUIThread:(driven from anAnimated screen-transition prop update).
It terminates on the main thread below JS, so JS-only crash reporters never capture it. We ship the New Architecture only, so haven't verified Paper directly — but the reentrancy window shouldn't exist there, since pre-Fabric mounting is async/batched rather than synchronous on the main thread. Present in 4.23.0 and unchanged through 4.24.0–4.25.2 and current
main.Proposed fix: enumerate an immutable snapshot (
NSArray *snap = [_reactSubviews copy];).Reentrant mutations then hit the real array, reconcile via their own
markChildUpdatedfollow-up,and the loop bodies are idempotent against a stale entry (detach of an already-detached screen is
a nil no-op; attach is guarded by
_activeScreens). In our app this took the crash fromreproducible-every-time to zero with no behavioral change. Happy to open a PR.
Reproduction note: no minimal Snack — the crash is a timing race (a burst of synchronous
Fabric mounting must coincide with a transition driving
updateContainer), impractical to reduce.The defect is plain from the source linked below:
updateContainerenumerates the mutable_reactSubviews(loops at L163/L177/L186/L200) while reentrant VC lifecycle mutates it.Steps to reproduce
— e.g. completing an auth flow (Redux state flip, WebSocket reconnect, several re-renders) while
the navigator pushes the next screen.
Deterministic in a form/wizard that pushes a screen per step. We bisected it to be independent of
our app code (reproduces with the screen container untouched) and absent on a navigation layout
that doesn't co-locate the work with the transition. Debug builds widen the window; release builds
also crash.
Snack or a link to a repository
react-native-screens/ios/RNSScreenContainer.mm
Lines 158 to 205 in 7d8a47d
Screens version
4.23.0
React Native version
0.83.4
Platforms
iOS
JavaScript runtime
Hermes
Workflow
Expo managed workflow
Architecture
Fabric (New Architecture)
Build type
Release mode
Device
Real device
Device model
iPhone 15 Pro (iOS 26)
Acknowledgements
Yes