fix(mobile): open the OTA workspace page on iOS 18 (#29197)

WebKit reports no source frame for an app-initiated load() before iOS 18.5 (WebKit c28b242), so the iOS shell read its own document load as subframe-initiated and cancelled it, leaving the phone on "Opening workspace" forever. The navigation policy no longer takes the initiating frame as an input: the shell's own document loads when its one-shot load flag is up, the URL is the exact document URL, the target is the main frame, and it is not a download, matching the Android copy.

Verified on an iOS 18.1 simulator (hang before, workspace opens after) and iOS 26.5 (unchanged).

Refs #28626, STA-10545
This commit is contained in:
Jinwoo Hong
2026-10-15 16:33:32 -04:00
committed by GitHub
parent 2744bc7465
commit 11352d4c0c
3 changed files with 8 additions and 20 deletions
@@ -41,9 +41,9 @@ enum MobileWebShellNavigationPolicy {
/// WKWebView, off-device: a sandboxed subframe navigating the top frame to the document URL
/// arrives as `.other` with no gesture at all, and under a gesture-shaped rule that is an allow
/// and a shell reload -- the bridge target cleared, the load state restarted, the page's state
/// gone. `isFromSubframe` is the second discriminator for the same reason: the same probe shows
/// the shell's own load arriving with source and target both the main frame, and a subframe's top
/// navigation arriving with the subframe as its source.
/// gone. The initiating frame is deliberately not an input: before iOS 18.5 WebKit reports no
/// source frame for the view's own `load` (WebKit c28b242), and reading it refused the shell's
/// own document. The one-shot flag already refuses a subframe's top navigation, as on Android.
///
/// What is left for the gesture is the only thing an artifact may ask for: a foreign URL, which
/// is refused and handed to the opener. A download is not a document load, so it takes that path
@@ -56,7 +56,6 @@ enum MobileWebShellNavigationPolicy {
static func verdict(
url: String?,
isMainFrame: Bool,
isFromSubframe: Bool,
isDocumentUrl: Bool,
isShellLoad: Bool,
hasGesture: Bool,
@@ -66,7 +65,7 @@ enum MobileWebShellNavigationPolicy {
return .cancel
}
if isDocumentUrl {
return isShellLoad && !isFromSubframe && !isDownload ? .allow : .cancel
return isShellLoad && !isDownload ? .allow : .cancel
}
guard hasGesture, let offered = offerableUrl(url) else {
return .cancel
@@ -479,7 +479,6 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate
let verdict = MobileWebShellNavigationPolicy.verdict(
url: navigationAction.request.url?.absoluteString,
isMainFrame: navigationAction.targetFrame?.isMainFrame == true,
isFromSubframe: !navigationAction.sourceFrame.isMainFrame,
isDocumentUrl: isDocumentUrl(navigationAction.request.url),
isShellLoad: loadState.isShellLoad,
hasGesture: navigationAction.navigationType == .linkActivated,
@@ -545,7 +545,6 @@ import Foundation
func verdict(
_ url: String? = "https://example.com/artifact-link",
isMainFrame: Bool = true,
isFromSubframe: Bool = false,
isDocumentUrl: Bool = false,
isShellLoad: Bool = false,
hasGesture: Bool = true,
@@ -554,7 +553,6 @@ import Foundation
MobileWebShellNavigationPolicy.verdict(
url: url,
isMainFrame: isMainFrame,
isFromSubframe: isFromSubframe,
isDocumentUrl: isDocumentUrl,
isShellLoad: isShellLoad,
hasGesture: hasGesture,
@@ -571,18 +569,14 @@ import Foundation
// navigating the top frame to the document URL arrives with no gesture at all.
precondition(verdict(document, isDocumentUrl: true, hasGesture: false) == .cancel)
precondition(verdict(document, isDocumentUrl: true, hasGesture: true) == .cancel)
precondition(
verdict(document, isFromSubframe: true, isDocumentUrl: true, isShellLoad: true, hasGesture: false)
== .cancel
)
// The second discriminator, on its own: a load the shell did not start is refused even when the
// initiating frame is the main one, which is the page rewriting its own document away.
// A load the shell did not start is refused whichever frame initiated it: the page rewriting
// its own document away, or the sealed preview navigating the top frame.
precondition(verdict(document, isDocumentUrl: true, isShellLoad: false, hasGesture: false) == .cancel)
// A top-page meta refresh or a redirect to somewhere else: refused, and never opened.
precondition(verdict(hasGesture: false) == .cancel)
// A tap inside the sealed preview is exactly a subframe-initiated foreign navigation, and that
// is the one thing the artifact is allowed to ask for.
precondition(verdict(isFromSubframe: true) == .cancelAndOffer(foreign))
precondition(verdict() == .cancelAndOffer(foreign))
// A download is not a document load, so it is refused there rather than allowed; started by a
// tap it reaches the opener, which is what makes `<a download>` behave as it does natively.
precondition(
@@ -591,11 +585,8 @@ import Foundation
)
precondition(verdict(isDownload: true) == .cancelAndOffer(foreign))
// `<a href="/" download>`: a download that still names the shell's own document, which is the
// one thing never handed to the opener. Refused from either frame, gesture or not.
// one thing never handed to the opener. Refused, gesture or not.
precondition(verdict(document, isDocumentUrl: true, isDownload: true) == .cancel)
precondition(
verdict(document, isFromSubframe: true, isDocumentUrl: true, isDownload: true) == .cancel
)
// A subframe is the sealed preview loading itself, which is not the user leaving the app.
precondition(verdict(isMainFrame: false) == .cancel)
precondition(verdict(isMainFrame: false, hasGesture: false) == .cancel)
@@ -624,7 +615,6 @@ import Foundation
MobileWebShellNavigationPolicy.verdict(
url: document,
isMainFrame: true,
isFromSubframe: false,
isDocumentUrl: true,
isShellLoad: machine.isShellLoad,
hasGesture: false,