react / react/react-native

[iOS][Fabric] SIGSEGV in createComponentViewWithComponentHandle: — unsynchronized map writes in _registerComponentIfPossible: race with main-thread mounting

Open
#58,299 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Needs: Author Feedback Needs: Repro Needs: Version Info Platform: iOS Type: New Architecture
Dominant language
C++
Stars
127k
Forks
25.3k
Avg merge
1d 23h
Merged PRs (30d)
4

Description

Description

Summary

Release builds crash with SIGSEGV (fault address 0x18) on com.apple.main-thread in
-[RCTComponentViewFactory createComponentViewWithComponentHandle:] during Fabric mounting.
Observed in production on iOS 26.0.1, RN 0.86.2, New Architecture enabled, legacy interop layer in use.

The affected code is unchanged in 0.86.3 and 0.87.1 (I diffed
React/Fabric/Mounting/RCTComponentViewFactory.mm across tags — 0.86.3 is byte-identical;
0.87.1 only adds #ifdef RCT_REMOVE_LEGACY_COMPONENT_INTEROP around the interop fallback),
so this reproduces on latest stable.

Root cause

Two unsynchronized shared-state mutations, plus a release-only hard-crash path:

1. _registerComponentIfPossible: writes _componentViewClasses without holding _mutex.
It runs on the JS thread (via the hasComponentProvider binding installed by
RCTInstallNativeComponentRegistryBinding, and via the provider-request callback).
Fallbacks 1/2 are safe (they go through registerComponentViewClass:, which takes
std::unique_lock), but Fallback 3 (interop) and Fallback 4 (UnimplementedView) write
directly into _componentViewClasses and _registrationStatusMap with no lock
.
Meanwhile the main thread reads the same map in createComponentViewWithComponentHandle:
under a shared_lock — which protects nothing against a writer that never locks.
A concurrent insert can rehash the unordered_map mid-find.

2. On a lookup miss, release builds dereference the end() iterator.
RCTAssert(iterator != _componentViewClasses.end(), ...) is compiled out in release, and the
next line does iterator->second. libc++'s end() iterator has a null node pointer, so this
reads nullptr + 0x18 — matching the crash address exactly.

3. Same pattern in RCTLegacyViewManagerInteropComponentView isSupported: — Step 3
mutates the static NSMutableDictionary cache (_supportedLegacyViewComponents) from the
JS thread with no synchronization; NSMutableDictionary is not thread-safe.

Being a data race, it fires probabilistically — for us the rate jumped visibly on iOS 26
(changed thread timing), but the defect is version-independent.

Reproducer

  • Deterministic for the crash site: make _registerComponentIfPossible: skip one component
    name and mount it — release config crashes at the same address; debug shows the assert.
  • The race itself: Thread Sanitizer flags the unsynchronized _componentViewClasses access
    during cold start + navigation through interop-using screens.
  • Possibly related community reproducer (same RN version, same crash site during mounting):
    https://github.com/SpiGAndromeda/reproducer-rn-0862-symbolview-launch-crash

Fix we are shipping via patch-package

  1. _registerComponentIfPossible: takes std::unique_lock lock(_mutex) once at the top,
    covering the early-return check and Fallback 3/4 writes.
  2. To avoid self-deadlock (std::shared_mutex is non-recursive), registerComponentViewClass:
    is split into the public locking wrapper + a private _registerComponentViewClassLocked:
    body; Fallbacks 1/2 call the locked variant.
  3. _wasComponentRegistered: reads under a shared_lock.
  4. Release-build guard at the crash site: on end(), log via RCTLogError and mount
    RCTUnimplementedViewComponentView instead of dereferencing.
  5. isSupported: Step 3 wrapped in @synchronized on the dictionary it mutates.

Full patch attached below. Happy to open a PR if the approach looks right to maintainers.

Full patch (react-native+0.86.2, applied via patch-package)
diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm
index 226781f..522a7cc 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm
@@ -122,27 +122,30 @@ + (BOOL)isSupported:(NSString *)componentName
   NSArray<Class> *registeredModules = RCTGetModuleClasses();
   NSMutableDictionary<NSString *, Class> *supportedLegacyViewComponents =
       [RCTLegacyViewManagerInteropComponentView _supportedLegacyViewComponents];
-  if (supportedLegacyViewComponents[componentName] != NULL) {
-    return YES;
-  }
+  // Fix: static NSMutableDictionary mutated from multiple threads.
+  @synchronized(supportedLegacyViewComponents) {
+    if (supportedLegacyViewComponents[componentName] != NULL) {
+      return YES;
+    }
 
-  for (Class moduleClass in registeredModules) {
-    id<RCTBridgeModule> bridgeModule = (id<RCTBridgeModule>)moduleClass;
-    NSString *moduleName = [[bridgeModule moduleName] isEqualToString:@""]
-        ? [NSStringFromClass(moduleClass) stringByReplacingOccurrencesOfString:@"Manager" withString:@""]
-        : [bridgeModule moduleName];
+    for (Class moduleClass in registeredModules) {
+      id<RCTBridgeModule> bridgeModule = (id<RCTBridgeModule>)moduleClass;
+      NSString *moduleName = [[bridgeModule moduleName] isEqualToString:@""]
+          ? [NSStringFromClass(moduleClass) stringByReplacingOccurrencesOfString:@"Manager" withString:@""]
+          : [bridgeModule moduleName];
 
-    if (supportedLegacyViewComponents[moduleName] == NULL) {
-      supportedLegacyViewComponents[moduleName] = moduleClass;
-    }
+      if (supportedLegacyViewComponents[moduleName] == NULL) {
+        supportedLegacyViewComponents[moduleName] = moduleClass;
+      }
 
-    if ([moduleName isEqualToString:componentName] ||
-        [moduleName isEqualToString:[@"RCT" stringByAppendingString:componentName]]) {
-      return YES;
+      if ([moduleName isEqualToString:componentName] ||
+          [moduleName isEqualToString:[@"RCT" stringByAppendingString:componentName]]) {
+        return YES;
+      }
     }
-  }
 
-  return NO;
+    return NO;
+  }
 }
 
 + (void)supportLegacyViewManagersWithPrefix:(NSString *)prefix
diff --git a/node_modules/react-native/React/Fabric/Mounting/RCTComponentViewFactory.mm b/node_modules/react-native/React/Fabric/Mounting/RCTComponentViewFactory.mm
index 608d75c..d9f4c3b 100644
--- a/node_modules/react-native/React/Fabric/Mounting/RCTComponentViewFactory.mm
+++ b/node_modules/react-native/React/Fabric/Mounting/RCTComponentViewFactory.mm
@@ -48,6 +48,7 @@
 @interface RCTComponentViewFactory ()
 - (void)_registerComponentIfPossible:(const std::string &)name;
 - (BOOL)_wasComponentRegistered:(const std::string &)name;
+- (void)_registerComponentViewClassLocked:(Class<RCTComponentViewProtocol>)componentViewClass;
 @end
 
 // Allow JS runtime to register native components as needed. For static view configs.
@@ -115,12 +116,15 @@ - (RCTComponentViewClassDescriptor)_componentViewClassDescriptorFromClass:(Class
 
 - (BOOL)_wasComponentRegistered:(const std::string &)name
 {
+  std::shared_lock lock(_mutex);
   auto registrationResult = _registrationStatusMap.find(name);
   return registrationResult != _registrationStatusMap.end() && (registrationResult->second);
 }
 
 - (void)_registerComponentIfPossible:(const std::string &)name
 {
+  // Fix: fallback 3/4 below mutated the maps unguarded while the main thread reads them.
+  std::unique_lock lock(_mutex);
   if (_registrationStatusMap.find(name) != _registrationStatusMap.end()) {
     return;
   }
@@ -133,7 +137,7 @@ - (void)_registerComponentIfPossible:(const std::string &)name
   // Fallback 1: Call provider function for component view class.
   Class<RCTComponentViewProtocol> klass = RCTComponentViewClassWithName(name.c_str());
   if (klass != nullptr) {
-    [self registerComponentViewClass:klass];
+    [self _registerComponentViewClassLocked:klass];
     return;
   }
 
@@ -144,7 +148,7 @@ - (void)_registerComponentIfPossible:(const std::string &)name
     NSString *objcName = [NSString stringWithCString:name.c_str() encoding:NSUTF8StringEncoding];
     klass = self.thirdPartyFabricComponentsProvider.thirdPartyFabricComponents[objcName];
     if (klass != nullptr) {
-      [self registerComponentViewClass:klass];
+      [self _registerComponentViewClassLocked:klass];
       return;
     }
   }
@@ -185,7 +189,12 @@ - (void)registerComponentViewClass:(Class<RCTComponentViewProtocol>)componentVie
 {
   RCTAssert(componentViewClass, @"RCTComponentViewFactory: Provided `componentViewClass` is `nil`.");
   std::unique_lock lock(_mutex);
+  [self _registerComponentViewClassLocked:componentViewClass];
+}
 
+// Body of registerComponentViewClass:, callable by paths already holding _mutex
+// (std::shared_mutex is not recursive).
+- (void)_registerComponentViewClassLocked:(Class<RCTComponentViewProtocol>)componentViewClass
+{
   auto provider = [componentViewClass componentDescriptorProvider];
   _componentViewClasses[provider.handle] = [self _componentViewClassDescriptorFromClass:componentViewClass];
   _providerRegistry.add(provider);
@@ -209,6 +218,18 @@ - (RCTComponentViewDescriptor)createComponentViewWithComponentHandle:(facebook::
       @"ComponentView with componentHandle `%lli` (`%s`) not found.",
       componentHandle,
       (char *)componentHandle);
+  // Fix: RCTAssert is compiled out in release; dereferencing end() here was a SIGSEGV.
+  if (iterator == _componentViewClasses.end()) {
+    RCTLogError(@"ComponentView with componentHandle `%lli` not found, mounting fallback view.", componentHandle);
+    auto fallbackDescriptor = [self _componentViewClassDescriptorFromClass:[RCTUnimplementedViewComponentView class]];
+    Class fallbackViewClass = fallbackDescriptor.viewClass;
+    return RCTComponentViewDescriptor{
+        .view = [fallbackViewClass new],
+        .observesMountingTransactionWillMount = fallbackDescriptor.observesMountingTransactionWillMount,
+        .observesMountingTransactionDidMount = fallbackDescriptor.observesMountingTransactionDidMount,
+        .shouldBeRecycled = fallbackDescriptor.shouldBeRecycled,
+    };
+  }
   auto componentViewClassDescriptor = iterator->second;
   Class viewClass = componentViewClassDescriptor.viewClass;
 

Environment

  • react-native: 0.86.2 (code verified unchanged in 0.87.1)
  • New Architecture: enabled; legacy interop layer in use (~29 Paper view managers)
  • iOS 26.0.1, physical devices, release builds
  • Crash: SIGSEGV, fault address 0x0000000000000018, com.apple.main-thread
Steps to reproduce

This is a data race, so there is no tap-this-button repro. Three ways to observe it:

  1. Crash site, deterministic: in RCTComponentViewFactory.mm, make
    _registerComponentIfPossible: return early for one component name used by the app
    (simulating a lost race), build in Release configuration, and mount a screen using that
    component → SIGSEGV at fault address 0x18 in createComponentViewWithComponentHandle:.
    In Debug the RCTAssert fires instead, confirming the release/debug divergence.

  2. The race itself: enable Thread Sanitizer, cold-start an app that uses the legacy
    interop layer, and navigate quickly through screens during startup. TSan reports the
    unsynchronized read/write on _componentViewClasses (Fallback 3/4 of
    _registerComponentIfPossible: write it with _mutex not held, while the main thread
    reads it in createComponentViewWithComponentHandle:).

  3. In production: occurs spontaneously at low rate; frequency increased noticeably on
    iOS 26.0.x (changed thread timing). Possibly related community reproducer:
    https://github.com/SpiGAndromeda/reproducer-rn-0862-symbolview-launch-crash

React Native Version

0.86.2

Affected Platforms

Runtime - iOS

Areas

Fabric - The New Renderer

Output of npx @react-native-community/cli info
System:
  OS: macOS 26.5.2
  CPU: (12) arm64 Apple M4 Pro
  Memory: 135.67 MB / 24.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 25.6.1
    path: /opt/homebrew/bin/node
  Yarn:
    version: 1.22.22
    path: /opt/homebrew/bin/yarn
  npm:
    version: 11.9.0
    path: /opt/homebrew/bin/npm
  Watchman:
    version: 2026.01.12.00
    path: /opt/homebrew/bin/watchman
Managers:
  CocoaPods:
    version: 1.16.2
    path: /opt/homebrew/bin/pod
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 25.5
      - iOS 26.5
      - macOS 26.5
      - tvOS 26.5
      - visionOS 26.5
      - watchOS 26.5
  Android SDK:
    API Levels:
      - "31"
      - "34"
      - "35"
      - "36"
    Build Tools:
      - 35.0.0
      - 36.0.0
    System Images:
      - android-30 | Google APIs ARM 64 v8a
      - android-30 | Google APIs Intel x86 Atom
      - android-30 | Google APIs Intel x86_64 Atom
      - android-35 | Google APIs ARM 64 v8a
      - android-37.2-beta3 | 16 KB Page Size Google Play ARM 64 v8a
    Android NDK: Not Found
IDEs:
  Android Studio: 2025.3 AI-253.29346.138.2531.14876573
  Xcode:
    version: 26.6/17F113
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 17.0.18
    path: /usr/bin/javac
  Ruby:
    version: 4.0.1
    path: /opt/homebrew/opt/ruby/bin/ruby
npmPackages:
  "@react-native-community/cli":
    installed: 20.1.0
    wanted: 20.1.0
  react:
    installed: 19.2.3
    wanted: 19.2.3
  react-native:
    installed: 0.86.2
    wanted: 0.86.2
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: true
  newArchEnabled: true
iOS:
  hermesEnabled: true
  newArchEnabled: true
Stacktrace or Logs
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000018
Triggered by Thread: 0 (com.apple.main-thread)
iOS 26.0.1, Release build, RN 0.86.2 (New Architecture, interop layer enabled)

0  Nubra  -[RCTComponentViewFactory createComponentViewWithComponentHandle:] (RCTComponentViewFactory.mm:212)
1  Nubra  -[RCTComponentViewRegistry _dequeueComponentViewWithComponentHandle:] (RCTComponentViewRegistry.mm:96)
2  Nubra  -[RCTComponentViewRegistry dequeueComponentViewWithComponentHandle:tag:] (RCTComponentViewRegistry.mm:52)
3  Nubra  std::__1::__function::__func<-[RCTMountingManager performTransaction:]::$_2, ...>::operator() (RCTMountingManager.mm:56)
4  Nubra  facebook::react::TelemetryController::pullTransaction(...) const (TelemetryController.cpp:40)
5  Nubra  -[RCTMountingManager performTransaction:] (function.h:241)
6  Nubra  -[RCTMountingManager initiateTransaction:] (RCTMountingManager.mm:248)
7  libdispatch.dylib  _dispatch_call_block_and_release
...
19 Nubra  main (AppDelegate.swift)

Frame 0 is the `iterator->second` dereference after
`_componentViewClasses.find(componentHandle)` returns `end()` —
the RCTAssert above it is compiled out in Release, and libc++'s
end() iterator holds a null node pointer, hence fault address 0x18.
MANDATORY Reproducer

https://github.com/SpiGAndromeda/reproducer-rn-0862-symbolview-launch-crash

Screenshots and Videos
Image

Contributor guide

Open the contributing guide

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

Start with React/Fabric/Mounting/RCTComponentViewFactory.mm and React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm, focusing on the named registration and mounting entry points. Run Thread Sanitizer during cold start and navigation through legacy interop screens to observe the reported accesses. Done means the race is no longer reported and a missing component handle in Release no longer dereferences an end() iterator.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, ios, objective-c, react-native
Domain
mobile-dev, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.