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
- iOS 26 app with experimental Tabs (we hit it via Expo Router
unstable-native-tabs, 5 static triggers, SF symbol icons, Fabric/new arch).
- Ensure an external AX client is active — on the simulator, macOS host AX tooling (Accessibility Inspector, VoiceOver, or ambient AX daemon polling) suffices.
- 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:
- 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).
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.
- 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.
Description
On iOS 26, apps using the experimental native Tabs (
RNSTabsHost/RNSTabBarController) crash withSIGABRT(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) holdunownedreferences to theirUITab, and the AX machinery reflects over a button whoseUITabhas already been deallocated: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
.ipsavailable on request.Analysis
react-native-screensnever dangles the reference itself (it can't — theunownedfield lives inside UIKit's own Swift class). What creates the dangerous state is how the tab set is rebuilt:RNSTabBarController updateReactChildrenControllerscalls the legacysetViewControllers:animated:YESon every flagged children update, with no diffing (RNSTabBarController.mm:491in 4.25.2). On iOS 18+/26, UIKit bridges each call into a fresh internalUITabarray, deallocating the previousUITabs — while the animated transition (or an in-flight AX snapshot) keeps the outgoing_UITabButtons alive. TheirunownedUITabrefs now dangle; the next AX reflection aborts the process.RNSTabsHostComponentView invalidateImpltears the controller down on a deferreddispatch_async, releasing its bridgedUITabs 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_UITabButtonaccessibility behavior (e.g. FB22175800 / forums thread 817872).Steps to reproduce
unstable-native-tabs, 5 static triggers, SF symbol icons, Fabric/new arch).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:
[[self viewControllers] isEqualToArray:_tabScreenControllers](React commits can re-flag an identical array; element comparison is pointer identity since the VC instances are stable).animated:NO— outgoing tab buttons are torn down synchronously in the same call that releases theirUITabs, so no stale button survives into the next AX snapshot.UITabset for ~1s (self.tabsunder@available(iOS 18, *)) across bothsetViewControllers:andinvalidateImplteardown, so a stale button referenced by an in-flight AX query still has a liveunownedtarget.Full patch (patch-package, react-native-screens 4.25.2)
The 1s retention is a heuristic, not a closure — the durable fix is presumably adopting the
UITabAPI with stable tab identity (mutate existingUITabinstances keyed by screen key instead of rebuilding via the legacy setter), so UIKit reuses its_UITabButtons and nounownedtarget ever dies under a live button.Environment
expo-routerunstable-native-tabs)Happy to share the full
.ipscrash report and test candidate fixes.