feat(paywalls): Preboot the WebView engine when a paywall has a web_view component (#3904)
- Calls `WebViewCompat.startUpWebView` when the current offering/workflow actually contains a `web_view` component, so the first render does not pay WebView startup on the UI thread. - **Gated on the current offering's paywall containing a `web_view`, and on RevenueCatUI being on the classpath.** - Worth 42 to 104 ms of first-paint time on its own, and more on slower hardware: Pixel 7a 42 to 70 ms, Mi 9T Pro a consistent 102 to 104 ms, since it moves a fixed amount of work off the UI thread. - **Its value changes once cache warming (#3897) lands**, because a warming WebView does everything startup does and more. See the note below rather than reading the figures above as additive. - **#3938 later widens the trigger.** It moves preboot inside `PaywallAssetWarming.warmWebViewUrls`, so preboot stops having call sites of its own and inherits warming's scope: the current offering, every placement target, and the placement fallback. Until then a paywall reached only through a placement pays engine startup at display time. - Bumps `androidx.webkit` 1.12.1 -> 1.16.0, required by `startUpWebView`. ### Checklist - [x] If applicable, unit tests - [ ] If applicable, create follow-up issues for `purchases-ios` and hybrids <details><summary>Agent description</summary> ### Motivation WebView startup otherwise happens on first use and blocks the UI thread. Measured at ~190 ms for the first `web_view` render, dropping to ~112 ms once startup has already run. `startUpWebView` has no iOS equivalent, so this is strictly cheaper than the pooled-instance approach the iOS plan takes for the same step: no held WebView, no pool, no policy decisions. ### How this relates to cache warming (#3897) Worth being explicit, since #3897 sits directly on top of this branch and changes what preboot is for. - Constructing any WebView does everything `startUpWebView` does, plus per-instance setup. Pixel 7a, cold install: construction costs 85 ms with no or late preboot, 22 ms with early preboot, and 3 ms for a second WebView in the same process. - So once warming runs, it has already loaded the provider by the time a paywall renders, and preboot's first-paint saving is absorbed rather than added to. - What preboot still buys is making the *warmer's* own first construction cheaper on the main thread: ~37 ms of reported UI-thread time plus a 22 ms construction, against 85 ms if the warmer loads the provider itself. That work now lands during app startup rather than at paywall display, which is where it matters. - It is no longer the only warming available on older System WebViews. That was true while #3897 required WebView M137+ for `PRERENDER_WITH_URL`; #3897 now warms with `loadUrl` and has no version floor. - Preboot remains the whole benefit for any paywall where warming does not run at all: an unresolvable url, a WebView without secure messaging, or a paywall whose urls were never queued. ### Description - `PaywallWebViewStartUp` (RevenueCatUI) wraps `WebViewCompat.startUpWebView`, reached from core through `PaywallAssetWarmerImpl.prebootWebView`, the seam implementation added in #3913. - Loads exactly one profile: the paywall profile where `MULTI_PROFILE` is supported, `Default` otherwise, mirroring `applyPaywallProfile`. Naming a set opts every unnamed profile out, and named profiles have resources allocated during startup, so loading both would waste memory on every device. - Everything, including the `MULTI_PROFILE` feature check, runs on the startup executor. `isFeatureSupported` loads the WebView provider, which is the exact work this API exists to defer, and the caller here is the SDK's single-threaded dispatcher, so anything done inline serialises ahead of every other queued SDK job. **`touches no webkit API on the calling thread` is the regression gate for this.** - The executor wraps the delegate so a throw inside a submitted task cannot escape, and it catches `Throwable` rather than `Exception` on purpose: androidx runs provider class loading in there, and a missing or mid-update WebView package surfaces as `NoClassDefFoundError` / `UnsatisfiedLinkError` / `ExceptionInInitializerError`. An uncaught throw on a `Dispatchers.IO` thread kills the host app, so `Exception` alone would leave the crash it exists to prevent. Covered by `the background executor swallows an Error from the task it runs`. - Runs once per process. Startup that was attempted and failed is not retried: androidx says further WebView calls on such a device are likely to throw or crash. A rejected submission is retryable, since startup was never attempted. - Preboot's own UI-thread cost measured at 37 ms median, reported by androidx and paid on a background thread. The intent extra used to take that measurement is not part of this PR; #3897 carries the prewarm harness. ### Known limitations - The preboot trigger sits at two call sites (offerings and workflows) rather than behind the single component that consumes `PaywallComponentAssets`. Each new asset kind is then a two-site edit. #3938 moves it behind the warming seam, so this is transitional. - Warming wants a third method on the same seam. If a per-capability interface starts to feel wrong, `PurchasesService` is the alternative discovery hook, at the cost of a mutable registry in core and lifecycle ordering. - The display path constructs its WebView without the `Throwable` guard this path uses, so a mid-update WebView package is handled inconsistently between preboot, warming and display. Called out rather than fixed here, since changing display behaviour is a separate decision. </details> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches paywall preload and WebView initialization on a background thread; failures are swallowed but dependency and seam API changes affect all apps with RevenueCatUI on the classpath. > > **Overview** > Preloads the WebView engine **before** the first paywall `web_view` paints, so startup work does not land on the UI thread at render time. **`androidx.webkit` is bumped to 1.16.0** for `WebViewCompat.startUpWebView`. > > Core exposes **`PaywallAssetWarmer.prebootWebView`** and **`PaywallAssetWarming.prebootWebView`** (best-effort, same ServiceLoader seam as image warming). **Offerings V2** and **workflow** asset prewarm paths call preboot only when `collectAssets()` finds at least one `web_view` in the component tree. > > RevenueCatUI adds **`PaywallWebViewStartUp`**, which runs startup on a background executor (no WebKit calls on the caller thread), loads the same profile the paywall WebView uses, and runs **once per process** with guarded error handling. **`PaywallAssetWarmerImpl`** delegates preboot there. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e062e4eb3a65902379dd60c565155922da4c7519. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Á
Álvaro Brey committed
5c5979e588c0c605d2b1fe0a7393c06f721784b6
Parent: b2d7cf2
Committed by GitHub <noreply@github.com>
on 8/14/2026, 9:24:08 AM