Files
orca/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellDocument.swift
Kelvin AmoabaandJinwoo-H a44cb9ac3e perf(mobile): prewarm one page while Home is shown so opening a page skips the WebView start (#40018)
* perf(mobile): answer a page's ready that arrived before its bridge host

A page's ready can reach the shell before the screen has built its bridge
host (the host waits on the client and the host profile read). It used to
be dropped, so init waited for the page's next retry, up to 2 s later.
Keep the latest ready per session and answer it once the host exists.

* perf(mobile): keep one prewarmed page document while Home is shown

While native Home is on screen, boot one page document for the most
recently connected host with a cached build, parked invisibly in the
window. Opening a page from that build adopts it instead of building a
WebView, which removes the WebView start and script parse from the tap.

The spare is dropped when Home is left without a claim (2 s grace), on
background, memory pressure, its own failure and before the cache deletes
its files. It is never kept beside a shown page, and only phones with
more than 3 GiB of RAM (4 GB phones) get one. A parked page is told to
stop asking for init; the adopting view answers the ready it sent.

Builds on Jinwoo's spare pool and replay work in #32203 and #32204.

Co-authored-by: Jinwoo-H <73622457+Jinwoo-H@users.noreply.github.com>

* fix(mobile): unregister the shell's memory callbacks from the context that registered them

---------

Co-authored-by: Jinwoo-H <73622457+Jinwoo-H@users.noreply.github.com>
2026-11-09 02:14:05 +00:00

607 lines
23 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import ExpoModulesCore
import WebKit
private let networkBlockIdentifier = "dev.orca.mobile-web-shell.network-block-v1"
/// Block http(s)/ws(s) beneath CSP; compilation failure is terminal and must load nothing.
private let networkBlockRules = """
[
{ "trigger": { "url-filter": "^https?://" }, "action": { "type": "block" } },
{ "trigger": { "url-filter": "^wss?://" }, "action": { "type": "block" } }
]
"""
/// Compiled once per launch and shared by every view, so later mounts load without waiting; main thread only.
private enum NetworkBlockRuleList {
private static var compiled: WKContentRuleList?
/// Synchronous once compiled in this process; nil means isolation is unavailable.
static func load(_ completion: @escaping (WKContentRuleList?) -> Void) {
if let compiled {
completion(compiled)
return
}
// Optional chaining would skip completion when the store is absent, leaving loading forever; fail closed instead.
guard let store = WKContentRuleListStore.default() else {
completion(nil)
return
}
store.compileContentRuleList(
forIdentifier: networkBlockIdentifier,
encodedContentRuleList: networkBlockRules
) { ruleList, _ in
DispatchQueue.main.async {
if let ruleList {
compiled = ruleList
}
completion(ruleList)
}
}
}
}
/// CSP fences fetch/XHR; document-start script blocks unobservable WebSocket and service-worker APIs.
/// Match Android; both non-configurable and non-writable prevent page replacement.
private let networkApiBlocker = """
(function(){
var deny=function(){throw new TypeError('Network access is disabled')};
try{Object.defineProperty(globalThis,'WebSocket',{value:deny,configurable:false,writable:false})}catch(_){}
try{Object.defineProperty(Navigator.prototype,'serviceWorker',{get:function(){return undefined},configurable:false})}catch(_){}
try{Object.defineProperty(navigator,'serviceWorker',{value:undefined,configurable:false,writable:false})}catch(_){}
})();
"""
/// Inject the same orcaBridge surface as Android before scripts, alongside the network blocker.
/// Capture the sink so deleting window.webkit cannot break it; freeze properties against replacement.
private let bridgeInstaller = """
(function(){
var sink=window.webkit.messageHandlers.orcaBridge;
var handler=null;
var bridge={};
Object.defineProperty(bridge,'postMessage',{value:function(json){
if(typeof json!=='string'){throw new TypeError('orcaBridge.postMessage expects a string')}
sink.postMessage(json)},configurable:false,writable:false,enumerable:true});
Object.defineProperty(bridge,'onmessage',{get:function(){return handler},
set:function(value){handler=typeof value==='function'?value:null},configurable:false,enumerable:true});
Object.defineProperty(bridge,'__deliver',{value:function(json){if(handler){handler({data:json})}},
configurable:false,writable:false,enumerable:false});
Object.defineProperty(globalThis,'orcaBridge',{value:bridge,configurable:false,writable:false,enumerable:true});
})();
"""
/// Bind payload as JS value m, never script text. A missing installer must throw to reject undelivered posts.
private let bridgeDeliver = """
globalThis.orcaBridge.__deliver(m)
"""
private final class MobileWebShellSchemeHandler: NSObject, WKURLSchemeHandler {
/// Read up-to-10-MiB assets off WebKit’s main-thread scheme callbacks.
private let readQueue = DispatchQueue(label: "dev.orca.mobile-web-shell.read")
/// Main-thread live-task set prevents stopped-task delivery’s uncatchable Objective-C exception.
private var liveTasks: Set<ObjectIdentifier> = []
var sessionId: String?
var generation: MobileWebShellGeneration?
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
let key = ObjectIdentifier(urlSchemeTask)
liveTasks.insert(key)
guard
let sessionId,
let generation,
let url = urlSchemeTask.request.url,
let parts = MobileWebShellRequestParts(request: urlSchemeTask.request),
let path = MobileWebShellOrigin.resolveRequestPath(parts, sessionId: sessionId),
let asset = generation.entries[path]
else {
fail(urlSchemeTask, key)
return
}
readQueue.async { [weak self] in
let data = try? Data(contentsOf: asset.file)
DispatchQueue.main.async {
guard let self, self.liveTasks.contains(key) else { return }
guard
let data,
let response = Self.makeResponse(
url: url,
asset: asset,
byteCount: data.count,
path: path
)
else {
self.fail(urlSchemeTask, key)
return
}
self.liveTasks.remove(key)
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
}
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
liveTasks.remove(ObjectIdentifier(urlSchemeTask))
}
private func fail(_ urlSchemeTask: WKURLSchemeTask, _ key: ObjectIdentifier) {
guard liveTasks.remove(key) != nil else { return }
urlSchemeTask.didFailWithError(URLError(.resourceUnavailable))
}
private static func makeResponse(
url: URL,
asset: MobileWebShellAsset,
byteCount: Int,
path: String
) -> HTTPURLResponse? {
HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: MobileWebShellResponseHeaders.forPath(
path,
contentType: asset.contentType,
byteCount: byteCount
)
)
}
}
/// WKUserContentController retains handlers; a weak document reference prevents a React-unmount leak.
private final class MobileWebShellBridgeReceiver: NSObject, WKScriptMessageHandler {
weak var document: MobileWebShellDocument?
func userContentController(
_ controller: WKUserContentController,
didReceive message: WKScriptMessage
) {
document?.receiveBridgeMessage(message)
}
}
/// The RN host sees this, never the page: it is the difference between a request that failed and
/// one that never settles.
internal final class MobileWebShellBridgeDeliveryFailedException: GenericException<String>,
@unchecked Sendable {
override var reason: String {
"The mobile web shell bridge could not deliver a message: \(param)"
}
}
internal final class MobileWebShellBridgeUnavailableException: Exception, @unchecked Sendable {
override var reason: String {
"The mobile web shell bridge is not installed on this view"
}
}
/// Throw so a host chunking bug rejects rather than leaving its request unsettled.
internal final class MobileWebShellBridgeMessageTooLargeException: GenericException<Int>,
@unchecked Sendable {
override var reason: String {
"A bridge message of \(param) bytes exceeds the \(MobileWebShellBridge.maxMessageByteCount) byte cap"
}
}
/// Where a document's reports go: the view showing it, or the spare pool while it is parked.
protocol MobileWebShellDocumentSink: AnyObject {
func documentLoadState(_ payload: [String: Any])
func documentBridgeMessage(_ json: String)
func documentExternalNavigation(_ url: String)
}
/// One WKWebView and every fence on it, apart from React. The view hosts it and lays it out.
final class MobileWebShellDocument: NSObject, WKNavigationDelegate, WKUIDelegate {
weak var sink: MobileWebShellDocumentSink?
let webView: WKWebView
private let schemeHandler = MobileWebShellSchemeHandler()
private let bridgeReceiver = MobileWebShellBridgeReceiver()
private let bridgeGate = MobileWebShellBridgeGate()
private var bridgeInstalled = false
private var bridgeTarget = MobileWebShellBridgeTarget<WKFrameInfo>()
private var applied: MobileWebShellAppliedProps?
private var appliedSessionId: String? { applied?.sessionId }
private var pendingDocumentUrl: URL?
private var isolationReady = false
private var isolationFailed = false
private var destroyed = false
private let loadState = MobileWebShellLoadStateMachine()
private var keyboardPreparationPending = false
private var windowObservers: [NSObjectProtocol] = []
override init() {
let configuration = WKWebViewConfiguration()
configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = .all
// WebKit cannot disable DOM storage/databases; isolate with a non-persistent store, per-session origin and unmount destruction.
configuration.websiteDataStore = .nonPersistent()
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
// Disabling text interaction suppresses text after keydown; page user-select handles long-press selection.
configuration.setURLSchemeHandler(schemeHandler, forURLScheme: MobileWebShellOrigin.scheme)
configuration.userContentController.addUserScript(Self.makeBlockerScript())
webView = WKWebView(frame: .zero, configuration: configuration)
super.init()
bridgeReceiver.document = self
webView.navigationDelegate = self
webView.uiDelegate = self
webView.allowsBackForwardNavigationGestures = false
// WKWebView’s opaque white default flashes during dark-page boot; transparency exposes the shell’s themed frame.
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.backgroundColor = .clear
webView.scrollView.contentInsetAdjustmentBehavior = .never
hideKeyboardAccessoryBar(of: webView)
ignoreKeyboardNotifications(in: webView)
for name in [
UIWindow.didBecomeKeyNotification, UIScene.didActivateNotification,
UIApplication.didBecomeActiveNotification,
UIResponder.keyboardDidHideNotification, UITextField.textDidEndEditingNotification,
UITextView.textDidEndEditingNotification
] {
windowObservers.append(NotificationCenter.default.addObserver(
forName: name, object: nil, queue: .main
) { [weak self] _ in
self?.loadWhenIsolated()
})
}
installNetworkBlock(into: configuration.userContentController)
}
deinit {
for observer in windowObservers {
NotificationCenter.default.removeObserver(observer)
}
}
/// Still showable: not torn down, and no terminal load failure or content-process loss.
var isUsable: Bool { !destroyed && !loadState.isTerminal }
/// The spare pool's teardown; the content process goes with the last reference.
func destroy() {
destroyed = true
sink = nil
clearBridgeTarget()
pendingDocumentUrl = nil
webView.stopLoading()
webView.removeFromSuperview()
}
/// Apply unordered props together; identical triples do not retry (remount required).
/// Include bridgeEnabled: document-start scripts require a reload to change.
func apply(generationDirectory: String, sessionId: String, bridgeEnabled: Bool) {
let next = MobileWebShellAppliedProps(
generationDirectory: generationDirectory,
sessionId: sessionId,
bridgeEnabled: bridgeEnabled
)
guard applied?.matches(next) != true else { return }
applied = next
clearBridgeTarget()
loadState.reset()
pendingDocumentUrl = nil
webView.stopLoading()
webView.isHidden = false
emit(loadState.started())
guard
MobileWebShellOrigin.isValidSessionId(sessionId),
let documentUrl = MobileWebShellOrigin.documentUrl(sessionId: sessionId)
else {
// The private origin is the isolation primitive; a malformed session id leaves us without one.
failPropUpdate(.isolationUnavailable)
return
}
guard
let generation = try? MobileWebShellGeneration.load(directoryPath: generationDirectory)
else {
failPropUpdate(.generationUnreadable)
return
}
schemeHandler.sessionId = sessionId
schemeHandler.generation = generation
applyBridgeInstallation(bridgeEnabled)
if isolationFailed {
failPropUpdate(.isolationUnavailable)
return
}
pendingDocumentUrl = documentUrl
loadWhenIsolated()
}
/// Retire the old visible generation even when the replacement fails.
private func failPropUpdate(_ reason: MobileWebShellFailureReason) {
clearBridgeTarget()
schemeHandler.sessionId = nil
schemeHandler.generation = nil
pendingDocumentUrl = nil
webView.stopLoading()
webView.isHidden = true
emit(loadState.failed(reason))
}
/// removeAllUserScripts is WebKit’s only removal API, so disabling the bridge must reinstall the blocker.
private static func makeBlockerScript() -> WKUserScript {
WKUserScript(
source: networkApiBlocker,
injectionTime: .atDocumentStart,
forMainFrameOnly: false
)
}
/// Nothing here runs while the prop stays false, which is what keeps Phase B byte-identical.
private func applyBridgeInstallation(_ bridgeEnabled: Bool) {
guard bridgeEnabled != bridgeInstalled else { return }
clearBridgeTarget()
let controller = webView.configuration.userContentController
if bridgeEnabled {
controller.add(bridgeReceiver, name: MobileWebShellBridge.handlerName)
controller.addUserScript(
WKUserScript(
source: bridgeInstaller,
injectionTime: .atDocumentStart,
// Main-frame injection is convenience; accepts must still refuse subframes that reach the handler.
forMainFrameOnly: true
)
)
} else {
controller.removeScriptMessageHandler(forName: MobileWebShellBridge.handlerName)
controller.removeAllUserScripts()
controller.addUserScript(Self.makeBlockerScript())
}
bridgeInstalled = bridgeEnabled
}
/// Use the loaded session, not latest props; the replaced same-origin document survives until commit.
fileprivate func receiveBridgeMessage(_ message: WKScriptMessage) {
guard bridgeInstalled, let json = message.body as? String else { return }
let origin = message.frameInfo.securityOrigin
let source = MobileWebShellBridgeSource(
isOurWebView: message.webView === webView,
isMainFrame: message.frameInfo.isMainFrame,
hasCommittedDocument: loadState.hasCommittedDocument,
originProtocol: origin.`protocol`,
originHost: origin.host
)
guard
MobileWebShellBridge.accepts(source, sessionId: appliedSessionId ?? ""),
bridgeGate.accepts(byteCount: json.utf8.count)
else { return }
bridgeTarget.arm(frame: message.frameInfo, originHost: origin.host)
sink?.documentBridgeMessage(json)
}
/// Anything that ends the document the page spoke from ends the only target native has.
private func clearBridgeTarget() {
bridgeTarget.clear()
}
/// Reject posts without a live committed frame that spoke; resolve only after page execution so host requests cannot hang.
func postBridgeMessage(_ json: String, promise: Promise) throws {
guard
MobileWebShellBridge.canPost(
toFrameOriginHost: bridgeTarget.originHost,
sessionId: appliedSessionId ?? "",
hasCommittedDocument: loadState.hasCommittedDocument
),
let frame = bridgeTarget.frame
else {
throw MobileWebShellBridgeUnavailableException()
}
let byteCount = json.utf8.count
guard MobileWebShellBridge.acceptsByteCount(byteCount) else {
throw MobileWebShellBridgeMessageTooLargeException(byteCount)
}
// Use the two in: labels plus completionHandler to select the callback overload and reply to the frame that spoke.
webView.callAsyncJavaScript(
bridgeDeliver,
arguments: ["m": json],
in: frame,
in: .page
) { result in
switch result {
case .success:
promise.resolve()
case .failure(let error):
promise.reject(MobileWebShellBridgeDeliveryFailedException(error.localizedDescription))
}
}
}
/// A frame from the shell itself, to the frame that spoke; nothing waits on its delivery.
func postHostFrame(_ json: String) {
guard let frame = bridgeTarget.frame else { return }
webView.callAsyncJavaScript(bridgeDeliver, arguments: ["m": json], in: frame, in: .page) { _ in }
}
private func installNetworkBlock(into controller: WKUserContentController) {
NetworkBlockRuleList.load { [weak self] ruleList in
guard let self else { return }
guard let ruleList else {
self.isolationFailed = true
self.pendingDocumentUrl = nil
// Rule compilation can finish after refusal; the state machine prevents a second terminal reason.
if self.appliedSessionId != nil {
self.failPropUpdate(.isolationUnavailable)
}
return
}
controller.add(ruleList)
self.isolationReady = true
self.loadWhenIsolated()
}
}
/// Load only in a foreground-active key window; the view calls this again when it moves to one.
func loadWhenIsolated() {
guard isolationReady, pendingDocumentUrl != nil, !keyboardPreparationPending,
let window = webView.window, window.isKeyWindow,
window.windowScene?.activationState == .foregroundActive else { return }
keyboardPreparationPending = true
DispatchQueue.main.async { [weak self, weak window] in
guard let self else { return }
guard let window, self.webView.window === window, window.isKeyWindow,
window.windowScene?.activationState == .foregroundActive else {
self.keyboardPreparationPending = false
self.loadWhenIsolated()
return
}
let delay: TimeInterval
switch MobileWebShellKeyboardWarmup.prepare(in: window) {
case .ready(let remainingDelay):
delay = remainingDelay
case .waitingForTextInput:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
guard let self else { return }
self.keyboardPreparationPending = false
self.loadWhenIsolated()
}
return
case .failed:
self.keyboardPreparationPending = false
self.failPropUpdate(.documentLoadFailed)
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, weak window] in
guard let self else { return }
self.keyboardPreparationPending = false
guard let window, self.webView.window === window, window.isKeyWindow,
window.windowScene?.activationState == .foregroundActive else {
self.loadWhenIsolated()
return
}
guard self.isolationReady, let url = self.pendingDocumentUrl else { return }
self.pendingDocumentUrl = nil
self.loadState.shellLoadStarted()
self.webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData))
}
}
}
private func emit(_ emission: MobileWebShellLoadEmission?) {
guard let emission else { return }
sink?.documentLoadState(emission.payload)
}
private func reportDocumentFailure() {
clearBridgeTarget()
emit(loadState.failed(.documentLoadFailed))
}
/// A cancelled navigation is our own doing, not the document's; see MobileWebShellNavigationError.
private func reportNavigationFailure(_ error: Error) {
let error = error as NSError
guard !MobileWebShellNavigationError.isIgnorable(domain: error.domain, code: error.code) else {
return
}
reportDocumentFailure()
}
private func isDocumentUrl(_ url: URL?) -> Bool {
guard let url, let parts = MobileWebShellRequestParts(url: url) else { return false }
return MobileWebShellOrigin.resolveRequestPath(parts, sessionId: appliedSessionId ?? "") == "/"
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
var isDownload = false
if #available(iOS 14.5, *) {
isDownload = navigationAction.shouldPerformDownload
}
let isMainFrame = navigationAction.targetFrame?.isMainFrame == true
// linkActivated gates only opener offers; gesture-free subframe top navigation must never authorize a load.
let verdict = MobileWebShellNavigationPolicy.verdict(
url: navigationAction.request.url?.absoluteString,
isMainFrame: isMainFrame,
isDocumentUrl: isDocumentUrl(navigationAction.request.url),
isShellLoad: loadState.isShellLoad,
hasGesture: navigationAction.navigationType == .linkActivated,
isDownload: isDownload
)
if case let .cancelAndOffer(url) = verdict {
sink?.documentExternalNavigation(url)
}
if verdict == .allow && isMainFrame {
// Spend before handing back the decision so another main-frame action cannot reuse the allowance.
loadState.shellLoadConsumed()
}
decisionHandler(verdict == .allow ? .allow : .cancel)
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
) {
// Only srcdoc subframes pass the action policy; refusing one must not fail the shell document.
guard navigationResponse.isForMainFrame else {
let url = navigationResponse.response.url?.absoluteString
decisionHandler(url == MobileWebShellNavigationPolicy.inlineDocumentUrl ? .allow : .cancel)
return
}
let allowed = navigationResponse.canShowMIMEType &&
isDocumentUrl(navigationResponse.response.url)
if !allowed {
reportDocumentFailure()
}
decisionHandler(allowed ? .allow : .cancel)
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
// Clear replies and acceptance when the document is replaced; its successor must commit and send ready.
clearBridgeTarget()
loadState.documentEnded()
guard appliedSessionId != nil else { return }
emit(loadState.started())
}
/// Arm at commit, before document-start messages and didFinish.
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
guard isDocumentUrl(webView.url) else { return }
// Commit rearms acceptance, so clear the replaced frame here as well as at provisional start.
clearBridgeTarget()
loadState.committed()
}
/// history.replaceState changes the path before finish; finished() uses commit state instead of URL equality.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
MobileWebShellWarmUp.release()
emit(loadState.finished())
}
func webView(
_ webView: WKWebView,
didFailProvisionalNavigation navigation: WKNavigation!,
withError error: Error
) {
MobileWebShellWarmUp.release()
reportNavigationFailure(error)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
MobileWebShellWarmUp.release()
reportNavigationFailure(error)
}
/// Report renderer death; memory pressure/provider updates look alike, so the caller owns retry policy.
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
MobileWebShellWarmUp.release()
clearBridgeTarget()
// Jetsam takes WebContent while Orca is backgrounded; the report can arrive as the app resumes, before it is active.
let background = webView.window == nil || UIApplication.shared.applicationState != .active
emit(loadState.failed(.renderProcessGone, background: background))
}
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
nil
}
}