From 537d764f58cc0ebad4746cedfe98ca26e109695f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 8 Nov 2026 05:01:36 -0800 Subject: [PATCH] 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 Co-authored-by: Jinwoo-H <73622457+Jinwoo-H@users.noreply.github.com> --- .../ios/MobileWebShellView.swift | 4 ++ .../ios/MobileWebShellWarmUp.swift | 41 +++++++++++++++++++ .../ios/OrcaMobileWebShellModule.swift | 8 ++++ .../orca-mobile-web-shell/src/index.ts | 20 ++++++++- mobile/src/home/use-mobile-home-data.ts | 8 ++++ 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 mobile/modules/orca-mobile-web-shell/ios/MobileWebShellWarmUp.swift diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift index 80a461963db..d341acab6bc 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift @@ -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 diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellWarmUp.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellWarmUp.swift new file mode 100644 index 00000000000..d0ca0ebafc2 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellWarmUp.swift @@ -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 = [] + } +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift b/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift index bbf430aa2ed..0f3bcaa5ca7 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift @@ -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() diff --git a/mobile/modules/orca-mobile-web-shell/src/index.ts b/mobile/modules/orca-mobile-web-shell/src/index.ts index 009ce111336..97979cd7019 100644 --- a/mobile/modules/orca-mobile-web-shell/src/index.ts +++ b/mobile/modules/orca-mobile-web-shell/src/index.ts @@ -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 { return requireNativeModule('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, diff --git a/mobile/src/home/use-mobile-home-data.ts b/mobile/src/home/use-mobile-home-data.ts index c7969d57ce6..3783a9b030d 100644 --- a/mobile/src/home/use-mobile-home-data.ts +++ b/mobile/src/home/use-mobile-home-data.ts @@ -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])