From 11352d4c0c659883e616fef984907f499f64676d Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Thu, 15 Oct 2026 16:33:32 -0400
Subject: [PATCH] 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
---
.../ios/MobileWebShellNavigationPolicy.swift | 9 ++++-----
.../ios/MobileWebShellView.swift | 1 -
.../tests/MobileWebShellChecks.swift | 18 ++++--------------
3 files changed, 8 insertions(+), 20 deletions(-)
diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellNavigationPolicy.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellNavigationPolicy.swift
index 7d0005e30e5..5ca4a5f156c 100644
--- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellNavigationPolicy.swift
+++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellNavigationPolicy.swift
@@ -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
diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift
index e5575b36e2a..6ed13b44610 100644
--- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift
+++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift
@@ -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,
diff --git a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift
index 81afd6fdfc6..be70ef8d184 100644
--- a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift
+++ b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift
@@ -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 `` behave as it does natively.
precondition(
@@ -591,11 +585,8 @@ import Foundation
)
precondition(verdict(isDownload: true) == .cancelAndOffer(foreign))
// ``: 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,