Skip to content

iOS 26: SIGABRT swift_abortRetainUnowned via -[_UITabButtonAccessibility accessibilityValue] — stale UITab unowned refs when an external AX client queries native Tabs #4278

Description

@Andrei-Valean

Description

On iOS 26, apps using the experimental native Tabs (RNSTabsHost / RNSTabBarController) crash with SIGABRT (swift_abortRetainUnowned) whenever an external accessibility client queries the tab bar at the wrong moment. The entire crashing stack is Apple code — UIKit's Liquid Glass tab internals (_UITabButton, Swift) hold unowned references to their UITab, and the AX machinery reflects over a button whose UITab has already been deallocated:

Exception Type:    EXC_CRASH (SIGABRT)

5  libswiftCore.dylib   swift::swift_abortRetainUnowned(void const*)
6  libswiftCore.dylib   swift_unknownObjectUnownedLoadStrong
7  libswiftCore.dylib   (anonymous namespace)::copyFieldContents(...)
8  libswiftCore.dylib   swift_reflectionMirror_subscript
9-15 AXCoreUtilities    (incl. _AXSafeSwiftValueForKeyTm)
16 AttributeGraph       AGTypeApplyFields2::Visitor::visit_field(...)
17 AttributeGraph       AG::swift::metadata::visit_heap_class(...)
22 UIKit (axbundle)     -[_UITabButtonAccessibility accessibilityValue]
26 UIAccessibility      _copyMultipleAttributeValuesCallback
29 AXRuntime            _AXXMIGCopyMultipleAttributeValues   ← external AX client
31 AXRuntime            mshMIGPerform

The trigger is any out-of-process AX query: on the simulator, the host macOS accessibility daemon polling the sim is enough (no VoiceOver needed inside the sim); on device, VoiceOver / AX tooling. Full symbolicated .ips available on request.

Analysis

react-native-screens never dangles the reference itself (it can't — the unowned field lives inside UIKit's own Swift class). What creates the dangerous state is how the tab set is rebuilt:

  • RNSTabBarController updateReactChildrenControllers calls the legacy setViewControllers:animated:YES on every flagged children update, with no diffing (RNSTabBarController.mm:491 in 4.25.2). On iOS 18+/26, UIKit bridges each call into a fresh internal UITab array, deallocating the previous UITabs — while the animated transition (or an in-flight AX snapshot) keeps the outgoing _UITabButtons alive. Their unowned UITab refs now dangle; the next AX reflection aborts the process.
  • A second window: RNSTabsHostComponentView invalidateImpl tears the controller down on a deferred dispatch_async, releasing its bridged UITabs in a cascade while an in-flight AX query can still hold the buttons.

This is the same iOS 26 UIKit regression class you worked around in #4111 — but #4111 only guards the async remote-image callback path. Our app uses SF-symbol icons exclusively (no iconImageSource), and still crashes, so the guard doesn't cover this variant. Related Apple-side context: Apple has acknowledged iOS 26 rewrote _UITabButton accessibility behavior (e.g. FB22175800 / forums thread 817872).

Steps to reproduce

  1. iOS 26 app with experimental Tabs (we hit it via Expo Router unstable-native-tabs, 5 static triggers, SF symbol icons, Fabric/new arch).
  2. Ensure an external AX client is active — on the simulator, macOS host AX tooling (Accessibility Inspector, VoiceOver, or ambient AX daemon polling) suffices.
  3. Let the app run through tab-set updates (React commits re-flagging children, host mount/unmount). Crash typically lands minutes to ~1h in, whenever an AX query races a tab rebuild.

Timing-dependent (a race against out-of-process AX queries), so not deterministic — but recurring. Production-only-symptom reports like #3940's environment match this class.

Workaround we're shipping (patch on 4.25.2)

Three mitigations; adversarially reviewed and running in our app:

  1. Skip no-op rebuilds — early-return when [[self viewControllers] isEqualToArray:_tabScreenControllers] (React commits can re-flag an identical array; element comparison is pointer identity since the VC instances are stable).
  2. animated:NO — outgoing tab buttons are torn down synchronously in the same call that releases their UITabs, so no stale button survives into the next AX snapshot.
  3. Retain the outgoing UITab set for ~1s (self.tabs under @available(iOS 18, *)) across both setViewControllers: and invalidateImpl teardown, so a stale button referenced by an in-flight AX query still has a live unowned target.
Full patch (patch-package, react-native-screens 4.25.2)
diff --git a/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm b/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm
index 289c9d9..b36f27a 100644
--- a/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm
+++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm
@@ -488,7 +488,38 @@ - (void)updateReactChildrenControllers
     return;
   }
 
-  [self setViewControllers:_tabScreenControllers animated:[[self viewControllers] count] != 0];
+  // iOS 26 AX crash (-[_UITabButtonAccessibility accessibilityValue] →
+  // swift_abortRetainUnowned). UIKit's Liquid Glass tab bar bridges every legacy
+  // setViewControllers: call into a fresh internal UITab array, deallocating the old
+  // UITabs while the animated transition (or an in-flight accessibility snapshot) can
+  // still hold the old _UITabButtons — whose `unowned` UITab refs then dangle.
+  if ([[self viewControllers] isEqualToArray:_tabScreenControllers]) {
+    return;
+  }
+
+  NSArray *rnsOldTabs = nil;
+  if (@available(iOS 18.0, *)) {
+    rnsOldTabs = self.tabs;
+  }
+
+  [self setViewControllers:_tabScreenControllers animated:NO];
+
+  if (rnsOldTabs.count > 0) {
+    dispatch_after(
+        dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
+          // Block strongly captures rnsOldTabs, keeping the old UITabs (the unowned
+          // targets) alive past any accessibility snapshot taken during the swap.
+          (void)rnsOldTabs;
+        });
+  }
 }
 
 - (void)updateSelectedViewControllerIfNeeded
diff --git a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm
index b92035c..90d67b8 100644
--- a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm
+++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm
@@ -122,6 +122,21 @@ - (void)invalidateImpl
   dispatch_async(dispatch_get_main_queue(), ^{
     auto strongSelf = weakSelf;
     if (strongSelf) {
+      // iOS 26 AX crash (see sibling change in RNSTabBarController.mm). Host teardown
+      // releases the controller and, in cascade, its bridged UITab objects — while
+      // UIKit's _UITabButtons can still be referenced by an in-flight accessibility
+      // snapshot whose `unowned` UITab refs then dangle.
+      NSArray *rnsOldTabs = nil;
+      if (@available(iOS 18.0, *)) {
+        rnsOldTabs = strongSelf->_controller.tabs;
+      }
+      if (rnsOldTabs.count > 0) {
+        dispatch_after(
+            dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
+              (void)rnsOldTabs; // keep old UITabs (unowned targets) alive past any AX snapshot
+            });
+      }
       [strongSelf->_controller tearDown];
       strongSelf->_controller = nil;
     }

The 1s retention is a heuristic, not a closure — the durable fix is presumably adopting the UITab API with stable tab identity (mutate existing UITab instances keyed by screen key instead of rebuilding via the legacy setter), so UIKit reuses its _UITabButtons and no unowned target ever dies under a live button.

Environment

react-native-screens 4.25.2
react-native 0.86.0 (Fabric / new architecture, Hermes)
Expo SDK 57 (expo-router unstable-native-tabs)
iOS 26.3 simulator (also expected on device with VoiceOver)
Reproducible race-dependent, recurs within a session with an active external AX client

Happy to share the full .ips crash report and test candidate fixes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    close-when-staleThis issue is going to be closed when there is no activity for a whilemissing-infoThe user didn't precise the problem enoughmissing-reproThis issue need minimum repro scenarioplatform:iosIssue related to iOS part of the library

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions