feat: support async onShouldStartLoad requests#49
feat: support async onShouldStartLoad requests#49kbulgakov-exo wants to merge 7 commits intomasterfrom
Conversation
Co-authored-by: Thibault Malbranche <malbranche.thibault@gmail.com>
| int lockIdentifier = [[RNCWebViewDecisionManager getInstance] setDecisionHandler:^(BOOL shouldStart) { | ||
| dispatch_async(dispatch_get_main_queue(), ^{ | ||
| if (!shouldStart) { | ||
| decisionHandler(WKNavigationActionPolicyCancel); | ||
| return; | ||
| } | ||
| allowNavigation(); | ||
| }); | ||
| }]; | ||
|
|
There was a problem hiding this comment.
The allowing code is moved into the allowNavigation helper to be reused below to keep the old fallback behavior
apple/RNCWebViewManager.m
Outdated
| RCTLogWarn(@"startLoadWithResult invoked with invalid lockIdentifier: " | ||
| "got %lld, expected %lld", (long long)lockIdentifier, (long long)_shouldStartLoadLock.condition); | ||
| } | ||
| [[RNCWebViewDecisionManager getInstance] setResult:result forLockIdentifier:(int)lockIdentifier]; |
There was a problem hiding this comment.
kbulgakov-exo
left a comment
There was a problem hiding this comment.
tACK
- WebView loads content normally when JS responds with
true - WebView rejects loading when JS responds with
false
|
@guten-exodus Could you check whether this also addresses the pain points you had in Pay? |
Tested, worked great! |
There was a problem hiding this comment.
@timlanahan This should be tested in an emulator by someone actively trying to bypass that, including (but not limited to) in the following conditions:
- main thread hanged
- main thread is slow and does not respond in time
- there are several requests queued some of which should pass and some should not - we should not load the non-passing one
- (3) but main thread is slow
- handler was not even installed in time before the load happened
- All of the above but the load requests are spammed, mixing allowed and non-allowed ones
The current test plan is unsatisfactory
There was a problem hiding this comment.
Pull request overview
This PR migrates from a synchronous blocking approach to an asynchronous architecture for handling onShouldStartLoad navigation decisions in iOS/macOS WebView. The synchronous approach was causing reliability issues when the JavaScript thread couldn't respond within the 500ms timeout, leading to blocked navigations even when JS eventually responded with "allow".
Changes:
- Introduced
RNCWebViewDecisionManagersingleton to manage async navigation decisions across threads - Removed synchronous
RNCWebViewDelegateprotocol and blocking timeout logic - Updated navigation decision flow to store handlers and resolve them asynchronously when JS responds
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| apple/RNCWebViewDecisionManager.h | New header defining the decision manager interface with thread-safe handler storage |
| apple/RNCWebViewDecisionManager.m | Implementation of singleton pattern for managing async navigation decision handlers |
| apple/RNCWebViewManager.m | Simplified to delegate decision resolution to RNCWebViewDecisionManager, removed blocking logic |
| apple/RNCWebView.h | Removed RNCWebViewDelegate protocol definition |
| apple/RNCWebView.m | Updated decidePolicyForNavigationAction to use async decision handlers instead of synchronous delegate calls |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| */ | ||
| - (int)setDecisionHandler:(DecisionBlock)decisionHandler { | ||
| @synchronized (self) { | ||
| int lockIdentifier = self.nextLockIdentifier++; |
There was a problem hiding this comment.
The nextLockIdentifier counter could theoretically overflow after 2^31-1 increments (about 2.1 billion). While unlikely in normal usage, if this happens, the identifier will wrap around to negative values or repeat, potentially causing collisions with pending navigation decisions. Consider using a more robust identifier generation strategy, such as checking for existing keys before assignment, or using a larger integer type.
| @interface RNCWebViewDecisionManager : NSObject { | ||
| int nextLockIdentifier; | ||
| NSMutableDictionary *decisionHandlers; | ||
| } | ||
|
|
||
| @property (nonatomic) int nextLockIdentifier; | ||
| @property (nonatomic, retain) NSMutableDictionary *decisionHandlers; | ||
|
|
There was a problem hiding this comment.
The instance variables in the @interface block are redundant when @Property declarations are present, as properties automatically synthesize their backing instance variables. The duplicate declarations of nextLockIdentifier and decisionHandlers in lines 6-7 should be removed to follow modern Objective-C conventions.
| @interface RNCWebViewDecisionManager : NSObject { | |
| int nextLockIdentifier; | |
| NSMutableDictionary *decisionHandlers; | |
| } | |
| @property (nonatomic) int nextLockIdentifier; | |
| @property (nonatomic, retain) NSMutableDictionary *decisionHandlers; | |
| @interface RNCWebViewDecisionManager : NSObject | |
| @property (nonatomic) int nextLockIdentifier; | |
| @property (nonatomic, retain) NSMutableDictionary *decisionHandlers; |
| } | ||
|
|
||
| @property (nonatomic) int nextLockIdentifier; | ||
| @property (nonatomic, retain) NSMutableDictionary *decisionHandlers; |
There was a problem hiding this comment.
The decisionHandlers property should use 'copy' semantic instead of 'retain' for storing blocks. In Objective-C, blocks need to be copied to move them from the stack to the heap to ensure they remain valid after the scope in which they were created ends. This is a critical memory management issue that could lead to crashes when the block is invoked after the original stack frame is destroyed.
| int lockIdentifier = [[RNCWebViewDecisionManager getInstance] setDecisionHandler:^(BOOL shouldStart) { | ||
| dispatch_async(dispatch_get_main_queue(), ^{ | ||
| if (!shouldStart) { | ||
| decisionHandler(WKNavigationActionPolicyCancel); | ||
| return; | ||
| } | ||
| allowNavigation(); | ||
| }); | ||
| }]; |
There was a problem hiding this comment.
When the WKWebView is deallocated while there are pending navigation decisions, the stored decision handlers in the singleton RNCWebViewDecisionManager will never be called, leading to a memory leak. The decisionHandler closure from WKWebView's decidePolicyForNavigationAction captures references that won't be released until the handler is invoked. Consider adding a mechanism to cancel pending decisions when the webview is deallocated, or add a timeout mechanism to automatically clean up stale handlers.
| - (void) setResult:(BOOL)shouldStart | ||
| forLockIdentifier:(int)lockIdentifier { | ||
| DecisionBlock handler; | ||
| @synchronized (self) { | ||
| handler = [self.decisionHandlers objectForKey:@(lockIdentifier)]; | ||
| if (handler == nil) { | ||
| RCTLogWarn(@"Lock not found"); | ||
| return; | ||
| } | ||
| [self.decisionHandlers removeObjectForKey:@(lockIdentifier)]; | ||
| } | ||
| handler(shouldStart); |
There was a problem hiding this comment.
The WKWebView decisionHandler must be called exactly once. If JS calls startLoadWithResult multiple times with the same lockIdentifier (either due to a bug or race condition), the decisionHandler will be invoked multiple times, which will cause a crash with the error "Completion handler passed to -[WKWebView decidePolicyForNavigationAction:decisionHandler:] was called more than once". Consider adding a guard to track whether a decision has already been made for a given lockIdentifier to prevent double invocation.
|
There’s an ongoing effort to upgrade |
The existing approach with locking the main thread to make a synchronous call asking JS "can I proceed with this URL" isn't reliable. Even the recent timeout bump to 500ms #47 didn't help, we still experience the loading issues. Bumping the timeout even more (for example with #48) would inevitably lead to performance degradation because of the locked thread. We should come up with a more reliable solution
The bottleneck
I've put some logs to diagnose what exactly is the bottleneck. The JS callback works super fast, but the problem is that sometimes it seems to be called after Native already makes the decision after the 500ms default timeout:
The solution
Let's mirror the async architecture of the
onShouldStartLoadcalls from https://github.com/react-native-webview/react-native-webview. We can easily backport most of the code.I recommend reviewing commit by commit
Notes:
WebViewDecisionManagerfrom upstream with minor formatting changesTesting
truefalse