Skip to content

[expo-web-browser][iOS] Let the module recover from a presentation that never happened - #49756

Open
LizunovSergey wants to merge 1 commit into
expo:mainfrom
LizunovSergey:fix/web-browser-ios-locked-session
Open

[expo-web-browser][iOS] Let the module recover from a presentation that never happened#49756
LizunovSergey wants to merge 1 commit into
expo:mainfrom
LizunovSergey:fix/web-browser-ios-locked-session

Conversation

@LizunovSergey

Copy link
Copy Markdown

Why

Fixes #49749.

WebBrowserSession.open() presents through an optional chain:

currentViewController?.present(viewController, animated: true) {
  self.didPresent()
}

When UIApplication.shared.keyWindow?.rootViewController is nil — which keyWindow returns 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: didPresent never runs, and neither SFSafariViewControllerDelegate nor UIAdaptivePresentationControllerDelegate can fire for a controller that was never presented.

The module is then holding currentWebBrowserSession != nil with vcDidPresent == false, and that is exactly the state its guard reads as "locked":

if vcDidPresent {
  self.currentWebBrowserSession = nil
  vcDidPresent = false
}
guard self.currentWebBrowserSession == nil else {
  promise.resolve(["type": "locked"])   // nothing presented, nothing thrown
  return
}

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() calls dismiss() on a controller that is not presented, which is a no-op. So every later openBrowserAsync resolves { 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.

  1. WebBrowserSession.open() reports failure when there is no presenter. guard let currentViewController else { onDismiss("cancel"); return }. onDismiss is what resolves the promise and clears the module's session, so the call now ends instead of leaking. Unwrapping once also removes the four ?/?? 0 fallbacks in the iPad popover block.

  2. The module drops a session whose browser never reached the screen. A new isPresented (viewController.presentingViewController != nil) lets openBrowserAsync recognise a session that is not on screen and replace it, rather than answering locked forever.

(2) is deliberately additive rather than a rewrite of the vcDidPresent heuristic: it runs only in the else branch, 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 transitionCoordinator so 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 back cancel or locked once — the next one works.

Test Plan

expo-web-browser ships no iOS unit tests, so this is a build-level check plus source reasoning.

Compiled against the real ExpoModulesCore through apps/minimal-tester with precompiled modules disabled, so the pod is built from source rather than a shipped xcframework:

EXPO_USE_PRECOMPILED_MODULES=0 pod install
xcodebuild -workspace minimaltester.xcworkspace -scheme ExpoWebBrowser \
  -destination 'generic/platform=iOS Simulator' -configuration Debug build

** BUILD SUCCEEDED **

Confirmed the edited files were the ones compiled, not a prebuilt binary — from the build log:

SwiftCompile normal arm64 Compiling WebAuthSession.swift, WebBrowserExceptions.swift,
  WebBrowserModule.swift, WebBrowserOptions.swift, WebBrowserSession.swift
  …/expo/packages/expo-web-browser/ios/WebBrowserModule.swift
  …/expo/packages/expo-web-browser/ios/WebBrowserSession.swift

Zero errors. (expo-web-browser is not a dependency of minimal-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 WebBrowserSession gets the matching isPresented (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 nil cannot call present, so didPresent cannot run. The recovery half rests on presentingViewController being nil for 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.

…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.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@expo-bot expo-bot added the contributor: external PR author is not a member of the expo GitHub org label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Subscribed to pull request

File Patterns Mentions
packages/expo-web-browser/** @alanjhughes

Generated by CodeMention

@LizunovSergey

Copy link
Copy Markdown
Author

Adding the state table, because the obvious question about the new else if is whether it can drop a session that is still coming up.

state isPresented vcDidPresent before after
no session false opens opens
browser on screen true true first branch clears, opens a second browser unchanged — first branch still wins
browser dismissed normally false session already nil via onDismiss, opens unchanged
presentation still animating true false locked for this call unchanged
presentation never happened false false locked for the life of the process clears, opens

Only the last row moves. The animating row is the one worth being explicit about: present(_:animated:completion:) establishes the presentation relationship before it starts animating, so the presented controller's presentingViewController is already set when the call returns — only the completion block waits for the animation. That is exactly the difference the fix leans on: vcDidPresent tracks the completion block, isPresented tracks the relationship, and a dropped presentation sets up neither while a running one sets up the second immediately.

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 vcDidPresent branch one line above — rather than reintroducing a wedge.

On ordering, since the no-presenter path now calls onDismiss synchronously from inside open(): the module assigns currentWebBrowserSession before calling open(), so the onDismiss closure's self.currentWebBrowserSession = nil lands on the session that was just stored, and the module ends the call with no session rather than a stuck one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: external PR author is not a member of the expo GitHub org

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[expo-web-browser][iOS] A failed present() permanently locks the module — every later openBrowserAsync resolves { type: 'locked' }

2 participants