perf(mobile): warm WebKit's processes on iOS before the first page opens (#39851)

* perf(mobile): warm WebKit's processes on iOS before the first page opens

Builds a blank, non-persistent WKWebView a second after Home has a paired
host, so the first page open starts against WebContent, GPU and Network
processes already running. A release before the delayed start now makes
the start a no-op.

Based on #32163 by Jinwoo-H.

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

* fix(mobile): guard warmUp on older binaries and release the warm view on page failure

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

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Jinwoo-H <73622457+Jinwoo-H@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-08 05:01:36 -08:00
committed by GitHub
co-authored by Jinwoo-H m4air
parent 346dcae485
commit 537d764f58
5 changed files with 79 additions and 2 deletions
@@ -603,6 +603,7 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate
/// 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())
}
@@ -611,15 +612,18 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate
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 = window == nil || UIApplication.shared.applicationState != .active
@@ -0,0 +1,41 @@
import UIKit
import WebKit
/// The first page view in a process waits for WebKit to launch its helper processes. A blank view
/// loaded while Home is idle pays that instead. It is held until the first page has loaded, so that
/// page starts against processes already up, or until the app backgrounds, memory runs low or a
/// minute passes. It has its own non-persistent store and no bridge or scripts, so it shares no
/// state with a page.
enum MobileWebShellWarmUp {
private static var view: WKWebView?
private static var started = false
private static var observers: [NSObjectProtocol] = []
static func start() {
dispatchPrecondition(condition: .onQueue(.main))
// A launch into the background may never show a page, so it gets no web process.
guard !started, UIApplication.shared.applicationState == .active else { return }
started = true
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
let view = WKWebView(frame: .zero, configuration: configuration)
view.loadHTMLString("", baseURL: nil)
self.view = view
DispatchQueue.main.asyncAfter(deadline: .now() + 60) { release() }
observers = [
UIApplication.didReceiveMemoryWarningNotification,
UIApplication.didEnterBackgroundNotification
].map { name in
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { _ in release() }
}
}
static func release() {
dispatchPrecondition(condition: .onQueue(.main))
// A page that finished before the delayed start already has its processes, so start must no-op.
started = true
view = nil
observers.forEach { NotificationCenter.default.removeObserver($0) }
observers = []
}
}
@@ -7,6 +7,14 @@ public class OrcaMobileWebShellModule: Module {
public func definition() -> ModuleDefinition {
Name("OrcaMobileWebShell")
// Home calls this once it has a paired host, so a user with nothing to open pays nothing.
Function("warmUp") {
// A second's delay keeps the warm-up off the launch's first frames.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
MobileWebShellWarmUp.start()
}
}
AsyncFunction("presentPdf") { (url: URL) in
guard let presenter = self.appContext?.utilities?.currentViewController() else {
throw PdfPresenterUnavailableException()
@@ -1,6 +1,10 @@
import { requireNativeModule, requireNativeViewManager } from 'expo-modules-core'
import {
requireNativeModule,
requireOptionalNativeModule,
requireNativeViewManager
} from 'expo-modules-core'
import type { ComponentType, RefAttributes } from 'react'
import type { NativeSyntheticEvent, ViewProps } from 'react-native'
import { Platform, type NativeSyntheticEvent, type ViewProps } from 'react-native'
import type { MobileWebShellLoadStatePayload } from './load-state'
/** One raw JSON envelope, exactly as the page posted it. Parsing is the caller's. */
@@ -66,6 +70,18 @@ export function saveImageToPhotos(uri: string, mime: string): Promise<void> {
return requireNativeModule<ImageModule>('OrcaMobileWebShell').saveImageToPhotos(uri, mime)
}
// iOS only: starts WebKit's helper processes ahead of the first page. Later calls are no-ops.
export function warmUpWebShell(): void {
if (Platform.OS !== 'ios') {
return
}
// A binary built before warmUp existed has the module without the function.
const shell = requireOptionalNativeModule<{ warmUp?: () => void }>('OrcaMobileWebShell')
if (typeof shell?.warmUp === 'function') {
shell.warmUp()
}
}
export {
MOBILE_WEB_SHELL_FAILURE_REASONS,
parseMobileWebShellLoadState,
+8
View File
@@ -1,6 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { useRouter, useFocusEffect } from 'expo-router'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { warmUpWebShell } from '../../modules/orca-mobile-web-shell/src'
import { loadHomeSnapshot, saveHomeSnapshot } from '../cache/home-snapshot-cache'
import type { AccountsSnapshot } from '../components/AccountUsage'
import { getCachedWorktrees, setCachedWorktrees } from '../cache/worktree-cache'
@@ -93,6 +94,13 @@ export function useMobileHomeData() {
}
}, [worktreeInfo])
const hasPairedHost = hosts.length > 0
useEffect(() => {
if (hasPairedHost) {
warmUpWebShell()
}
}, [hasPairedHost])
useEffect(() => {
allClientsRef.current = connections.allClients
}, [connections.allClients])