Deterministic JS-only repro for a thread-safety bug in react-native-webview.
This repo is designed for an irrefutable race report using Thread Sanitizer (TSan).
Hard EXC_BAD_ACCESS is possible in production but non-deterministic locally.
- React Native: 0.81.5
- react-native-webview: 13.15.0
- iOS: Legacy Architecture (
RCT_NEW_ARCH_ENABLED=0)
- No native source modifications are required to reproduce the race.
- The app repeatedly remounts hidden WebViews from JS while running.
- No network requests, no custom native logging, and no release-only setup.
- Deterministic evidence is TSan output, not a lucky crash.
npm install
cd ios && pod install && cd ..- Open
ios/RNCWebViewRepro.xcworkspacein Xcode. - Select an iOS Simulator target.
- Edit Scheme -> Run:
- Build Configuration:
Debug - Diagnostics: enable
Thread Sanitizer
- Build Configuration:
- Run app.
- Tap Start Repro.
- Capture the first TSan report that includes both:
-[RNCWebViewDecisionManager setDecisionHandler:]-[RNCWebViewDecisionManager setResult:forLockIdentifier:]
- Ignore reports that do not include
RNCWebViewDecisionManager(startup noise). - Tap Stop Repro after capturing the report.
React Native startup can emit unrelated TSan warnings before tapping Start. To reduce that noise, set this Run env var in Scheme:
TSAN_OPTIONS=halt_on_error=0 suppressions=/Users/angelazcarraga/Workspace/RNCWebViewRepro/ios/tsan-suppressions.txt
The relevant report shows unsynchronized access to the same NSMutableDictionary
from two threads:
- Main thread:
RNCWebViewImpl webView:decidePolicyForNavigationAction:decisionHandler:RNCWebViewDecisionManager setDecisionHandler:
com.facebook.react.RNCWebViewModuleQueue:RNCWebViewModule shouldStartLoadWithLockIdentifier:lockIdentifier:RNCWebViewDecisionManager setResult:forLockIdentifier:
A local EXC_BAD_ACCESS crash can occur but is timing-dependent and not required
for this repro package.
Synchronize access to decisionHandlers in
apple/RNCWebViewDecisionManager.m:
- (int)setDecisionHandler:(DecisionBlock)decisionHandler {
@synchronized(self) {
int lockIdentifier = self.nextLockIdentifier++;
[self.decisionHandlers setObject:decisionHandler forKey:@(lockIdentifier)];
return lockIdentifier;
}
}
- (void)setResult:(BOOL)shouldStart forLockIdentifier:(int)lockIdentifier {
DecisionBlock handler = nil;
@synchronized(self) {
handler = [self.decisionHandlers objectForKey:@(lockIdentifier)];
if (handler) {
[self.decisionHandlers removeObjectForKey:@(lockIdentifier)];
}
}
if (handler) {
handler(shouldStart);
} else {
RCTLogWarn(@"Lock not found");
}
}