From 1e6070c9c752175367a708adfba09ef4a4d69ab0 Mon Sep 17 00:00:00 2001 From: Kirill Bulgakov Date: Sat, 17 Jan 2026 21:59:48 +0300 Subject: [PATCH 1/7] feat: implement WebViewDecisionManager Co-authored-by: Thibault Malbranche --- apple/RNCWebViewDecisionManager.h | 18 ++++++++++++ apple/RNCWebViewDecisionManager.m | 48 +++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 apple/RNCWebViewDecisionManager.h create mode 100644 apple/RNCWebViewDecisionManager.m diff --git a/apple/RNCWebViewDecisionManager.h b/apple/RNCWebViewDecisionManager.h new file mode 100644 index 000000000..730d83228 --- /dev/null +++ b/apple/RNCWebViewDecisionManager.h @@ -0,0 +1,18 @@ +#import + +typedef void (^DecisionBlock)(BOOL); + +@interface RNCWebViewDecisionManager : NSObject { + int nextLockIdentifier; + NSMutableDictionary *decisionHandlers; +} + +@property (nonatomic) int nextLockIdentifier; +@property (nonatomic, retain) NSMutableDictionary *decisionHandlers; + ++ (id) getInstance; + +- (int)setDecisionHandler:(DecisionBlock)handler; +- (void) setResult:(BOOL)shouldStart + forLockIdentifier:(int)lockIdentifier; +@end diff --git a/apple/RNCWebViewDecisionManager.m b/apple/RNCWebViewDecisionManager.m new file mode 100644 index 000000000..63ae40b6f --- /dev/null +++ b/apple/RNCWebViewDecisionManager.m @@ -0,0 +1,48 @@ +#import "RNCWebViewDecisionManager.h" +#import + + + +@implementation RNCWebViewDecisionManager + +@synthesize nextLockIdentifier; +@synthesize decisionHandlers; + ++ (id)getInstance { + static RNCWebViewDecisionManager *lockManager = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + lockManager = [[self alloc] init]; + }); + return lockManager; +} + +- (int)setDecisionHandler:(DecisionBlock)decisionHandler { + int lockIdentifier = self.nextLockIdentifier++; + + [self.decisionHandlers setObject:decisionHandler forKey:@(lockIdentifier)]; + return lockIdentifier; +} + +- (void) setResult:(BOOL)shouldStart + forLockIdentifier:(int)lockIdentifier { + DecisionBlock handler = [self.decisionHandlers objectForKey:@(lockIdentifier)]; + if (handler == nil) { + RCTLogWarn(@"Lock not found"); + return; + } + handler(shouldStart); + [self.decisionHandlers removeObjectForKey:@(lockIdentifier)]; +} + +- (id)init { + if (self = [super init]) { + self.nextLockIdentifier = 1; + self.decisionHandlers = [[NSMutableDictionary alloc] init]; + } + return self; +} + +- (void)dealloc {} + +@end From adfe5ffb3f4d738d1262f208c4a62ca9729258f4 Mon Sep 17 00:00:00 2001 From: Kirill Bulgakov Date: Sat, 17 Jan 2026 22:00:59 +0300 Subject: [PATCH 2/7] refactor: `WebView` manager should rely on `WebViewDecisionManager` --- apple/RNCWebViewManager.m | 39 ++++----------------------------------- 1 file changed, 4 insertions(+), 35 deletions(-) diff --git a/apple/RNCWebViewManager.m b/apple/RNCWebViewManager.m index b5b76cbd3..494e4ef82 100644 --- a/apple/RNCWebViewManager.m +++ b/apple/RNCWebViewManager.m @@ -10,8 +10,9 @@ #import #import #import "RNCWebView.h" +#import "RNCWebViewDecisionManager.h" -@interface RNCWebViewManager () +@interface RNCWebViewManager () @end @implementation RCTConvert (WKWebView) @@ -35,10 +36,6 @@ @implementation RCTConvert (WKWebView) @end @implementation RNCWebViewManager -{ - NSConditionLock *_shouldStartLoadLock; - BOOL _shouldStartLoad; -} RCT_EXPORT_MODULE() @@ -49,7 +46,6 @@ - (RCTUIView *)view #endif // !TARGET_OS_OSX { RNCWebView *webView = [RNCWebView new]; - webView.delegate = self; return webView; } @@ -243,38 +239,11 @@ - (RCTUIView *)view }]; } -#pragma mark - Exported synchronous methods - -- (BOOL) webView:(RNCWebView *)webView -shouldStartLoadForRequest:(NSMutableDictionary *)request - withCallback:(RCTDirectEventBlock)callback -{ - _shouldStartLoadLock = [[NSConditionLock alloc] initWithCondition:arc4random()]; - _shouldStartLoad = YES; - request[@"lockIdentifier"] = @(_shouldStartLoadLock.condition); - callback(request); - - // Block the main thread for a maximum of 500ms until the JS thread returns - if ([_shouldStartLoadLock lockWhenCondition:0 beforeDate:[NSDate dateWithTimeIntervalSinceNow:.50]]) { - BOOL returnValue = _shouldStartLoad; - [_shouldStartLoadLock unlock]; - _shouldStartLoadLock = nil; - return returnValue; - } else { - RCTLogWarn(@"Did not receive response to shouldStartLoad in time, defaulting to NO"); - return NO; - } -} +#pragma mark - Exported methods RCT_EXPORT_METHOD(startLoadWithResult:(BOOL)result lockIdentifier:(NSInteger)lockIdentifier) { - if ([_shouldStartLoadLock tryLockWhenCondition:lockIdentifier]) { - _shouldStartLoad = result; - [_shouldStartLoadLock unlockWithCondition:0]; - } else { - RCTLogWarn(@"startLoadWithResult invoked with invalid lockIdentifier: " - "got %lld, expected %lld", (long long)lockIdentifier, (long long)_shouldStartLoadLock.condition); - } + [[RNCWebViewDecisionManager getInstance] setResult:result forLockIdentifier:(int)lockIdentifier]; } @end From 8a11f8078eeb81fdbfec09de2e93def82778de8d Mon Sep 17 00:00:00 2001 From: Kirill Bulgakov Date: Sat, 17 Jan 2026 22:22:12 +0300 Subject: [PATCH 3/7] refactor: make reusable `allowNavigation` block --- apple/RNCWebView.m | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/apple/RNCWebView.m b/apple/RNCWebView.m index 702dcd3f2..78ee9cdda 100644 --- a/apple/RNCWebView.m +++ b/apple/RNCWebView.m @@ -1106,6 +1106,21 @@ - (void) webView:(WKWebView *)webView NSURLRequest *request = navigationAction.request; BOOL isTopFrame = [request.URL isEqual:request.mainDocumentURL]; + void (^allowNavigation)(void) = ^{ + if (self->_onLoadingStart) { + // We have this check to filter out iframe requests and whatnot + if (isTopFrame) { + NSMutableDictionary *event = [self baseEvent]; + [event addEntriesFromDictionary: @{ + @"url": (request.URL).absoluteString, + @"navigationType": navigationTypes[@(navigationType)] + }]; + self->_onLoadingStart(event); + } + } + decisionHandler(WKNavigationActionPolicyAllow); + }; + if (_onShouldStartLoadWithRequest) { NSMutableDictionary *event = [self baseEvent]; if (request.mainDocumentURL) { @@ -1126,20 +1141,8 @@ - (void) webView:(WKWebView *)webView } } - if (_onLoadingStart) { - // We have this check to filter out iframe requests and whatnot - if (isTopFrame) { - NSMutableDictionary *event = [self baseEvent]; - [event addEntriesFromDictionary: @{ - @"url": (request.URL).absoluteString, - @"navigationType": navigationTypes[@(navigationType)] - }]; - _onLoadingStart(event); - } - } - // Allow all navigation by default - decisionHandler(WKNavigationActionPolicyAllow); + allowNavigation(); } /** From 4b0427b954334569dd53ffdbf6c4d4796f4a5380 Mon Sep 17 00:00:00 2001 From: Kirill Bulgakov Date: Sat, 17 Jan 2026 22:25:30 +0300 Subject: [PATCH 4/7] feat: support async navigation decision handling --- apple/RNCWebView.h | 9 --------- apple/RNCWebView.m | 22 +++++++++++++++------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/apple/RNCWebView.h b/apple/RNCWebView.h index 0ce544db3..30c93d4ac 100644 --- a/apple/RNCWebView.h +++ b/apple/RNCWebView.h @@ -19,14 +19,6 @@ typedef enum RNCWebViewPermissionGrantType : NSUInteger { @class RNCWebView; -@protocol RNCWebViewDelegate - -- (BOOL)webView:(RNCWebView *_Nonnull)webView -shouldStartLoadForRequest:(NSMutableDictionary *_Nonnull)request - withCallback:(RCTDirectEventBlock _Nonnull)callback; - -@end - @interface RNCWeakScriptMessageDelegate : NSObject @property (nonatomic, weak, nullable) id scriptDelegate; @@ -37,7 +29,6 @@ shouldStartLoadForRequest:(NSMutableDictionary *_Nonnull)request @interface RNCWebView : RCTView -@property (nonatomic, weak) id _Nullable delegate; @property (nonatomic, copy) NSDictionary * _Nullable source; @property (nonatomic, assign) BOOL messagingEnabled; @property (nonatomic, copy) NSString * _Nullable injectedJavaScript; diff --git a/apple/RNCWebView.m b/apple/RNCWebView.m index 78ee9cdda..9bad01463 100644 --- a/apple/RNCWebView.m +++ b/apple/RNCWebView.m @@ -9,6 +9,7 @@ #import #import #import "RNCWKProcessPoolManager.h" +#import "RNCWebViewDecisionManager.h" #if !TARGET_OS_OSX #import #else @@ -1122,6 +1123,16 @@ - (void) webView:(WKWebView *)webView }; if (_onShouldStartLoadWithRequest) { + int lockIdentifier = [[RNCWebViewDecisionManager getInstance] setDecisionHandler:^(BOOL shouldStart) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (!shouldStart) { + decisionHandler(WKNavigationActionPolicyCancel); + return; + } + allowNavigation(); + }); + }]; + NSMutableDictionary *event = [self baseEvent]; if (request.mainDocumentURL) { [event addEntriesFromDictionary: @{ @@ -1131,14 +1142,11 @@ - (void) webView:(WKWebView *)webView [event addEntriesFromDictionary: @{ @"url": (request.URL).absoluteString, @"navigationType": navigationTypes[@(navigationType)], - @"isTopFrame": @(isTopFrame) + @"isTopFrame": @(isTopFrame), + @"lockIdentifier": @(lockIdentifier) }]; - if (![self.delegate webView:self - shouldStartLoadForRequest:event - withCallback:_onShouldStartLoadWithRequest]) { - decisionHandler(WKNavigationActionPolicyCancel); - return; - } + _onShouldStartLoadWithRequest(event); + return; } // Allow all navigation by default From 20b49763b51fc034c2f52c3ed24a888909c57a53 Mon Sep 17 00:00:00 2001 From: Kirill Bulgakov Date: Sun, 18 Jan 2026 22:27:31 +0300 Subject: [PATCH 5/7] chore: remove unnecessary pragma mark --- apple/RNCWebViewManager.m | 2 -- 1 file changed, 2 deletions(-) diff --git a/apple/RNCWebViewManager.m b/apple/RNCWebViewManager.m index 494e4ef82..cc36c590e 100644 --- a/apple/RNCWebViewManager.m +++ b/apple/RNCWebViewManager.m @@ -239,8 +239,6 @@ - (RCTUIView *)view }]; } -#pragma mark - Exported methods - RCT_EXPORT_METHOD(startLoadWithResult:(BOOL)result lockIdentifier:(NSInteger)lockIdentifier) { [[RNCWebViewDecisionManager getInstance] setResult:result forLockIdentifier:(int)lockIdentifier]; From 33f905108650c596abf85945e97d525980748dc3 Mon Sep 17 00:00:00 2001 From: Kirill Bulgakov Date: Sun, 18 Jan 2026 22:30:20 +0300 Subject: [PATCH 6/7] refactor: use consistent int type for lockIdentifier --- apple/RNCWebViewManager.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apple/RNCWebViewManager.m b/apple/RNCWebViewManager.m index cc36c590e..accf031ac 100644 --- a/apple/RNCWebViewManager.m +++ b/apple/RNCWebViewManager.m @@ -239,9 +239,9 @@ - (RCTUIView *)view }]; } -RCT_EXPORT_METHOD(startLoadWithResult:(BOOL)result lockIdentifier:(NSInteger)lockIdentifier) +RCT_EXPORT_METHOD(startLoadWithResult:(BOOL)result lockIdentifier:(int)lockIdentifier) { - [[RNCWebViewDecisionManager getInstance] setResult:result forLockIdentifier:(int)lockIdentifier]; + [[RNCWebViewDecisionManager getInstance] setResult:result forLockIdentifier:lockIdentifier]; } @end From b6cad2e3e68db19af627913439b5a4bb47da600c Mon Sep 17 00:00:00 2001 From: kewdex Date: Thu, 22 Jan 2026 16:06:00 +0100 Subject: [PATCH 7/7] fix: thread safe decision manager --- apple/RNCWebViewDecisionManager.m | 47 +++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/apple/RNCWebViewDecisionManager.m b/apple/RNCWebViewDecisionManager.m index 63ae40b6f..268b9c354 100644 --- a/apple/RNCWebViewDecisionManager.m +++ b/apple/RNCWebViewDecisionManager.m @@ -1,8 +1,16 @@ #import "RNCWebViewDecisionManager.h" #import - - +/** + * Thread-safe singleton that manages navigation decision handlers for WKWebView. + * + * This class bridges async navigation decisions between: + * - WKWebView delegate (main thread) - stores decision handlers + * - React Native bridge (background thread) - resolves decisions from JS + * + * All public methods use @synchronized for thread safety since they access + * shared state (nextLockIdentifier and decisionHandlers) from different threads. + */ @implementation RNCWebViewDecisionManager @synthesize nextLockIdentifier; @@ -17,22 +25,39 @@ + (id)getInstance { return lockManager; } +/** + * Stores a decision handler and returns a unique identifier. + * Called from the main thread (WKNavigationDelegate). + * @synchronized ensures atomic increment + insertion. + */ - (int)setDecisionHandler:(DecisionBlock)decisionHandler { - int lockIdentifier = self.nextLockIdentifier++; - - [self.decisionHandlers setObject:decisionHandler forKey:@(lockIdentifier)]; - return lockIdentifier; + @synchronized (self) { + int lockIdentifier = self.nextLockIdentifier++; + [self.decisionHandlers setObject:decisionHandler forKey:@(lockIdentifier)]; + return lockIdentifier; + } } +/** + * Resolves a pending navigation decision. + * Called from the RN bridge thread (background) when JS responds. + * + * The handler is invoked OUTSIDE the @synchronized block to prevent deadlocks, + * since the handler dispatches to the main queue and could potentially + * trigger another navigation that re-enters this class. + */ - (void) setResult:(BOOL)shouldStart forLockIdentifier:(int)lockIdentifier { - DecisionBlock handler = [self.decisionHandlers objectForKey:@(lockIdentifier)]; - if (handler == nil) { - RCTLogWarn(@"Lock not found"); - return; + DecisionBlock handler; + @synchronized (self) { + handler = [self.decisionHandlers objectForKey:@(lockIdentifier)]; + if (handler == nil) { + RCTLogWarn(@"Lock not found"); + return; + } + [self.decisionHandlers removeObjectForKey:@(lockIdentifier)]; } handler(shouldStart); - [self.decisionHandlers removeObjectForKey:@(lockIdentifier)]; } - (id)init {