software-mansion / software-mansion/react-native-screens

iOS 26: crash in _effectiveSearchControllerForSearchBarGivenTopNavigationItem from re-entrant navigation bar layout during synchronous header shadow-state update

Open
#4,483 5 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

platform:ios repro-provided
Dominant language
TypeScript
Stars
3.7k
Forks
713
Avg merge
2d 23h
Merged PRs (30d)
71

Description

Description

On iOS 26 we get an intermittent EXC_BAD_ACCESS (KERN_INVALID_ADDRESS at 0x140) on the main
thread, inside UIKit's navigation bar layout. It reproduces in release builds on device; we have
not managed to trigger it on demand.

Crashing frames (locally symbolicated, main thread):

0   libobjc.A.dylib   objc_opt_class + 0
1   UIKitCore         -[UIViewController(UISplitViewController) splitViewController] + 104
2   UIKitCore         -[UIViewController(BinaryCompatibility) _splitViewControllerEnforcingClass:] + 60
3   UIKitCore         -[UINavigationController _effectiveSearchControllerForSearchBarGivenTopNavigationItem:] + 44
4   UIKitCore         thunk for @escaping @callee_guaranteed (@unowned UINavigationItem?) -> (@owned UISearchController?)
5   UIKitCore         _UINavigationBarVisualProviderModernIOSSwift.effectiveSearchController(forItem:) + 44
6   UIKitCore         _UINavigationBarVisualProviderModernIOSSwift._stackWantsSearchDisplayedBelowContentView(for:) + 180
7   UIKitCore         _UINavigationBarVisualProviderModernIOSSwift.layout(inBounds:) + 276
8   UIKitCore         _UINavigationBarVisualProviderModernIOSSwift.layoutSubviews() + 1676
10  UIKitCore         -[UINavigationBar layoutSubviews] + 636
...
24  UIKitCore         -[UIView(Hierarchy) layoutBelowIfNeeded] + 332
25  Keybase           -[RNSScreenStackHeaderSubview layoutNavigationBar] + 72 (RNSScreenStackHeaderSubview.mm:63)
26  React             RCTPerformMountInstructions(...) + 124 (RCTMountingManager.mm:128)
33  React             -[RCTMountingManager performTransaction:] + 208 (RCTMountingManager.mm:259)
...
53  React             facebook::react::EventQueueProcessor::flushStateUpdates(...) (EventQueueProcessor.cpp:135)
55  React             facebook::react::EventQueue::enqueueStateUpdate(...) (EventQueue.cpp:79)
57  Keybase           facebook::react::ConcreteState<RNSScreenStackHeaderConfigState>::updateState(...) (ConcreteState.h:110)
59  Keybase           -[RNSScreenStackHeaderConfig updateShadowStateWithSize:edgeInsets:frameOrigin:] (RNSScreenStackHeaderConfig.mm:184)
60  Keybase           -[RNSScreenStackHeaderConfig updateHeaderStateInShadowTreeInContextOfNavigationBar:] (RNSScreenStackHeaderConfig.mm:208)
61  Keybase           -[RNSNavigationController maybeUpdateHeaderLayoutInfoInShadowTree:] (RNSScreenStack.mm:117)
62  Keybase           -[RNSNavigationController viewDidLayoutSubviews] + 348 (RNSScreenStack.mm:77)
...
71  UIKitCore         -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 3412
77  QuartzCore        CA::Transaction::commit() + 620
Analysis

Reading it bottom-up, the navigation bar is laid out re-entrantly, from inside its own
in-flight layout pass
:

  1. UIKit lays out the navigation controller's view and calls
    -[RNSNavigationController viewDidLayoutSubviews] (frame 62).
  2. That calls maybeUpdateHeaderLayoutInfoInShadowTree
    updateHeaderStateInShadowTreeInContextOfNavigationBar
    ConcreteState::updateState(..., EventQueue::UpdateMode::unstable_Immediate) (frame 57).
    Because synchronousShadowStateUpdatesEnabled defaults to YES, the commit and the mount
    run synchronously on the same stack (frames 55 → 26).
  3. Mounting reaches -[RNSScreenStackHeaderSubview updateLayoutMetrics:oldLayoutMetrics:]
    layoutNavigationBar, which calls [navigationBar layoutIfNeeded] (frame 25).
  4. -[UINavigationBar layoutSubviews] runs a second time (frame 10) while UIKit is still
    unwinding the first one.

In that nested pass, iOS 26's _UINavigationBarVisualProviderModernIOSSwift resolves the
top UINavigationItem back to its owning UIViewController and asks it for
splitViewController, which walks the parentViewController chain. If the mounting transaction
in step 2/3 removed a screen, that back-reference is stale and objc_opt_class reads a freed
object — hence the fault at the small address 0x140.

Two details that may help narrow it down:

  • Our app uses no headerSearchBarOptions anywhere. iOS 26 walks the
    effectiveSearchController path unconditionally during navigation bar layout, so the search
    bar API is not required to hit this.
  • The crash needs iOS 26; _UINavigationBarVisualProviderModernIOSSwift is new there.

layoutNavigationBar already carries two TODOs questioning whether the forced
setNeedsLayout + layoutIfNeeded is still necessary, referencing Test432 and Test2552:

// TODO: It is possible, that this call is no longer necessary. Make sure that Test432 keeps working.
[toLayoutView setNeedsLayout];

// TODO: Determine why this must be called & deferring layout to next "update cycle"
// is not sufficient. See Test2552 and Test432.
[toLayoutView layoutIfNeeded];

Workaround we are running

We patch the forced layout so it becomes a no-op when it would be re-entrant. The navigation bar
is still marked dirty by the preceding setNeedsLayout, and CA::Layer::layout_and_display_if_needed
loops until clean, so the bar is still laid out within the same frame — it just isn't laid out
from inside its own layout pass. We have not seen a visual regression, but we have also not been
able to reproduce the crash on demand, so we cannot claim the patch is confirmed.

Diff against 4.27.0
diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
index c233036..a3f0f36 100644
--- a/ios/RNSScreenStack.mm
+++ b/ios/RNSScreenStack.mm
@@ -59,6 +59,21 @@ - (UIViewController *)childViewControllerForStatusBarHidden
 - (void)viewDidLayoutSubviews
 {
   [super viewDidLayoutSubviews];
+  // Everything below can synchronously commit & mount a shadow tree update, and mounting can
+  // force another layout pass of `self.navigationBar` (see
+  // `-[RNSScreenStackHeaderSubview layoutNavigationBar]`). Laying the bar out again from here
+  // — while UIKit is still unwinding the layout pass that produced this callback — lets
+  // UIKit observe a half-updated view controller hierarchy, which crashes on iOS 26 in
+  // `-[UINavigationController _effectiveSearchControllerForSearchBarGivenTopNavigationItem:]`.
+  // Marking the bar as "layout in progress" turns those nested passes into a plain
+  // `setNeedsLayout`, which the in-flight outer pass resolves before the frame is committed.
+  [self.navigationBar rnscreens_withLayoutInProgress:^{
+    [self rnscreens_updateHeaderLayoutInfoAfterLayout];
+  }];
+}
+
+- (void)rnscreens_updateHeaderLayoutInfoAfterLayout
+{
   if ([self.topViewController isKindOfClass:[RNSScreen class]]) {
     RNSScreen *screenController = (RNSScreen *)self.topViewController;
     BOOL isNotDismissingModal = screenController.presentedViewController == nil ||
diff --git a/ios/RNSScreenStackHeaderSubview.mm b/ios/RNSScreenStackHeaderSubview.mm
index add33c4..8022575 100644
--- a/ios/RNSScreenStackHeaderSubview.mm
+++ b/ios/RNSScreenStackHeaderSubview.mm
@@ -2,6 +2,7 @@
 #import "RNSConvert.h"
 #import "RNSDefines.h"
 #import "RNSScreenStackHeaderConfig.h"
+#import "utils/UINavigationBar+RNSUtility.h"
 
 #import <react/renderer/components/rnscreens/ComponentDescriptors.h>
 #import <react/renderer/components/rnscreens/EventEmitters.h>
@@ -53,14 +54,18 @@ - (void)layoutNavigationBar
     return;
   }
 
-  UIView *toLayoutView = [self findNavigationBar];
+  UINavigationBar *toLayoutView = [self findNavigationBar];
 
   // TODO: It is possible, that this call is no longer necessary. Make sure that Test432 keeps working.
   [toLayoutView setNeedsLayout];
 
   // TODO: Determine why this must be called & deferring layout to next "update cycle"
   // is not sufficient. See Test2552 and Test432.
-  [toLayoutView layoutIfNeeded];
+  //
+  // This method can be reached from a mounting transaction that was itself triggered from
+  // inside a layout pass of this very navigation bar, so the forced layout has to bail out
+  // when it would be re-entrant. See `rnscreens_layoutIfNeededAvoidingReentrancy`.
+  [toLayoutView rnscreens_layoutIfNeededAvoidingReentrancy];
 }
 
 #pragma mark - Fabric specific
diff --git a/ios/utils/UINavigationBar+RNSUtility.h b/ios/utils/UINavigationBar+RNSUtility.h
index 0e7010d..8e3af12 100644
--- a/ios/utils/UINavigationBar+RNSUtility.h
+++ b/ios/utils/UINavigationBar+RNSUtility.h
@@ -34,6 +34,35 @@ NS_ASSUME_NONNULL_BEGIN
  */
 - (nullable UIView *)rnscreens_findBackButtonWrapperView;
 
+/**
+ * Forces a synchronous layout pass on the receiver, unless the receiver is already
+ * being laid out higher up on the current call stack.
+ *
+ * RNScreens updates its shadow tree from within UIKit layout callbacks
+ * (see `-[RNSNavigationController viewDidLayoutSubviews]`). When synchronous shadow
+ * state updates are enabled, that commit is mounted immediately, and mounting can call
+ * back into `-[RNSScreenStackHeaderSubview layoutNavigationBar]`, which forces another
+ * layout pass of the very same navigation bar. On iOS 26 that nested pass makes
+ * `_UINavigationBarVisualProviderModernIOSSwift` resolve the navigation item back to its
+ * owning view controller while the controller hierarchy is still mid-mutation, and the
+ * resulting stale pointer crashes in
+ * `-[UINavigationController _effectiveSearchControllerForSearchBarGivenTopNavigationItem:]`.
+ *
+ * Skipping the nested pass is safe: the receiver is still marked as needing layout, and the
+ * in-flight outer pass resolves it before the current `CATransaction` commits, so the
+ * navigation bar is laid out within the same frame either way.
+ */
+- (void)rnscreens_layoutIfNeededAvoidingReentrancy;
+
+/**
+ * Marks the receiver as being laid out for the duration of `block`, so that
+ * `rnscreens_layoutIfNeededAvoidingReentrancy` invoked from within `block` becomes a no-op.
+ *
+ * Use this to wrap code that runs inside a UIKit layout callback for the receiver and may
+ * synchronously re-enter layout (e.g. by committing and mounting a shadow tree update).
+ */
+- (void)rnscreens_withLayoutInProgress:(NS_NOESCAPE dispatch_block_t)block;
+
 @end
 
 NS_ASSUME_NONNULL_END
diff --git a/ios/utils/UINavigationBar+RNSUtility.mm b/ios/utils/UINavigationBar+RNSUtility.mm
index bc3d7c7..9780871 100644
--- a/ios/utils/UINavigationBar+RNSUtility.mm
+++ b/ios/utils/UINavigationBar+RNSUtility.mm
@@ -1,5 +1,7 @@
 #import "UINavigationBar+RNSUtility.h"
 
+#import <objc/runtime.h>
+
 @implementation UINavigationBar (RNSUtility)
 
 - (nullable UIView *)rnscreens_findContentView
@@ -83,4 +85,51 @@ + (Class)rnscreens_getContentViewRuntimeClass
   return NSClassFromString(@"_UINavigationBarContentView"); // Sampled from iOS 17.5 (iPhone 15 Pro)
 }
 
+#pragma mark - Layout re-entrancy
+
+static const void *RNSNavigationBarLayoutInProgressKey = &RNSNavigationBarLayoutInProgressKey;
+
+- (BOOL)rnscreens_isLayoutInProgress
+{
+  return [objc_getAssociatedObject(self, RNSNavigationBarLayoutInProgressKey) boolValue];
+}
+
+- (void)rnscreens_setLayoutInProgress:(BOOL)layoutInProgress
+{
+  objc_setAssociatedObject(
+      self,
+      RNSNavigationBarLayoutInProgressKey,
+      layoutInProgress ? @YES : nil,
+      OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+}
+
+- (void)rnscreens_layoutIfNeededAvoidingReentrancy
+{
+  if ([self rnscreens_isLayoutInProgress]) {
+    // A layout pass for this navigation bar is already in flight; forcing a nested one
+    // would expose UIKit to a half-updated view controller hierarchy. The `setNeedsLayout`
+    // performed by the caller keeps the bar dirty, so the outer pass picks it up.
+    return;
+  }
+
+  [self rnscreens_withLayoutInProgress:^{
+    [self layoutIfNeeded];
+  }];
+}
+
+- (void)rnscreens_withLayoutInProgress:(NS_NOESCAPE dispatch_block_t)block
+{
+  if ([self rnscreens_isLayoutInProgress]) {
+    block();
+    return;
+  }
+
+  [self rnscreens_setLayoutInProgress:YES];
+  @try {
+    block();
+  } @finally {
+    [self rnscreens_setLayoutInProgress:NO];
+  }
+}
+
 @end

I'd rather report this than send a PR, since I don't know the Test432 / Test2552 history well
enough to judge whether skipping the nested pass is the right fix or whether the real problem is
that viewDidLayoutSubviews commits synchronously at all.

Steps to reproduce

No deterministic repro — this arrives from TestFlight crash reports, not local runs.

What the crashing sessions have in common:

  1. Native stack (@react-navigation/native-stack) with custom headerLeft / headerRight
    elements, so RNSScreenStackHeaderSubview instances exist.
  2. Navigate between screens so the header config re-measures and pushes a shadow-state update
    from -[RNSNavigationController viewDidLayoutSubviews].
  3. Screens are removed from the stack in the same mounting transaction that the header
    state update triggers.

The crash occurred roughly one second after launch in the report we symbolicated, so a screen
replacement during the initial navigation state restore is a likely trigger.

Snack or a link to a repository

example.com/sorry_dont_have_one_now

Screens version

4.26.2

React Native version

0.86.2

Platforms

iOS

JavaScript runtime

Hermes

Workflow

Expo bare workflow

Build type

Release mode

Device

Real device

Device model

No response

Acknowledgements

Yes

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Read ios/RNSScreenStack.mm and ios/RNSScreenStackHeaderSubview.mm, then inspect the related UINavigationBar+RNSUtility files and the Test432/Test2552 TODOs. Trace whether synchronous shadow-state mounting can re-enter navigation-bar layout, and validate that any change prevents nested layout while preserving the referenced tests and same-frame layout behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
ios, objective-c, react-native
Domain
mobile
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.