[expo-web-browser][iOS] Let the module recover from a presentation that never happened - #49756
[expo-web-browser][iOS] Let the module recover from a presentation that never happened#49756LizunovSergey wants to merge 1 commit into
Conversation
…at never happened
`WebBrowserSession.open()` presents through an optional chain, so when no
presenter can be resolved the call is a silent no-op: `didPresent` never runs,
neither do the delegate callbacks, and the module keeps a session it has no way
to release. Every later `openBrowserAsync` then resolves `{ type: 'locked' }`
for the rest of the process.
Report the session as finished when there is no presenter, so the module lets
go of it, and let the module drop a session whose browser never reached the
screen. The second half also covers presentations dropped for other reasons —
UIKit does not call the completion block, but the controller has no presenting
controller either, and that is observable.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Subscribed to pull request
Generated by CodeMention |
|
Adding the state table, because the obvious question about the new
Only the last row moves. The animating row is the one worth being explicit about: Worth noting the failure mode if that premise were ever wrong on some future OS: the new branch would clear a live session and open a second browser — the pre-existing behaviour of the On ordering, since the no-presenter path now calls |
Why
Fixes #49749.
WebBrowserSession.open()presents through an optional chain:When
UIApplication.shared.keyWindow?.rootViewControllerisnil— whichkeyWindowreturns readily in a multi-scene app, since it has been deprecated since iOS 13 — the whole statement is a no-op. Nothing is presented and, more importantly, nothing reports that:didPresentnever runs, and neitherSFSafariViewControllerDelegatenorUIAdaptivePresentationControllerDelegatecan fire for a controller that was never presented.The module is then holding
currentWebBrowserSession != nilwithvcDidPresent == false, and that is exactly the state its guard reads as "locked":Nothing can clear it afterwards — the flag can only be set from the completion block that never ran, the delegates belong to an unpresented controller, and
dismissBrowser()callsdismiss()on a controller that is not presented, which is a no-op. So every lateropenBrowserAsyncresolves{ type: 'locked' }until the app restarts, and because it resolves rather than throws it reaches users as "links silently stopped opening".How
Two changes, both aimed at the same invariant: the module must never be left holding a session that nothing can release.
WebBrowserSession.open()reports failure when there is no presenter.guard let currentViewController else { onDismiss("cancel"); return }.onDismissis what resolves the promise and clears the module's session, so the call now ends instead of leaking. Unwrapping once also removes the four?/?? 0fallbacks in the iPad popover block.The module drops a session whose browser never reached the screen. A new
isPresented(viewController.presentingViewController != nil) letsopenBrowserAsyncrecognise a session that is not on screen and replace it, rather than answeringlockedforever.(2) is deliberately additive rather than a rewrite of the
vcDidPresentheuristic: it runs only in theelsebranch, so a browser that is currently up still answers exactly as before, and no working path changes behaviour.Scope, stated plainly. The issue also suggests waiting on the presenter's
transitionCoordinatorso a call landing mid-dismissal succeeds instead of being dropped. That is not in this PR. It depends on UIKit presentation timing I could not reproduce on a device, and getting it wrong would trade a recoverable failure for a subtler one. What this PR guarantees is the part the issue title is about: the module can no longer be wedged for the lifetime of the process. A call that races a dismissal may still come backcancelorlockedonce — the next one works.Test Plan
expo-web-browserships no iOS unit tests, so this is a build-level check plus source reasoning.Compiled against the real
ExpoModulesCorethroughapps/minimal-testerwith precompiled modules disabled, so the pod is built from source rather than a shipped xcframework:Confirmed the edited files were the ones compiled, not a prebuilt binary — from the build log:
Zero errors. (
expo-web-browseris not a dependency ofminimal-tester; I added it locally to get the pod into that workspace and reverted the change — the diff here does not touch any app.)The macOS
WebBrowserSessiongets the matchingisPresented(window?.isVisible == true) so the shared module compiles on that platform too.Reviewer note on what is and is not proven: the no-presenter path is provable from the source — an optional chain on
nilcannot callpresent, sodidPresentcannot run. The recovery half rests onpresentingViewControllerbeingnilfor a controller that was never presented, which is documented UIKit behaviour. Neither claim needs the timing repro from the issue. I did not reproduce the mid-dismissal race on a device, and have not claimed to fix it.