Repository navigation
Conversation
|
This PR does not match any of the 1 configured review trigger rule. |
Maple review🔴 Confidence 4/10 · risky as written Adds bundled static-config fallback and URL-matched paywall archives. Needs changes before merge: archive construction blocks the UI thread, and the bundled fallback is bypassed for
Findings🟠 Warning · F1 ·
|
| Change | Kind | Observable | Evidence |
|---|---|---|---|
| Bundled config fallback | configuration | yes | Existing ConfigRefresh events record build ID, cached status, retries, and duration; refresh failures use Logger. |
| Bundled paywall archive loading | cache | yes | Existing PaywallWebviewLoad lifecycle surrounds loading; bundle decode and archive construction failures use Logger with errors. |
8a83e72 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.
|
|
||
| if let webArchiveURL = paywallArchiveManager?.getCachedArchiveURL(manifest: paywall.manifest) { | ||
| if let webArchiveURL = paywallArchiveManager?.getCachedArchiveURL(manifest: paywall.manifest) | ||
| ?? paywallArchiveManager?.getBundledArchiveURL(for: paywall) { |
There was a problem hiding this comment.
Warning
loadWebView builds bundled archives on the main thread
F1 · Warning · performance
On the first load of each bundled paywall, this synchronous call reads the document and every resource, encodes the entire binary plist, and writes it to disk before returning. viewDidLoad calls loadWebView at line 333, so all of this work blocks UI rendering and interaction; the cost grows with the bundled assets.
Build and cache the archive off the main actor, then return to the main actor to call `loadWebViewFromArchive`; keep the existing network fallback on failure.
🤖 Prompt to fix with an AI agent
In `Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift:486`: `loadWebView` builds bundled archives on the main thread.
On the first load of each bundled paywall, this synchronous call reads the document and every resource, encodes the entire binary plist, and writes it to disk before returning. `viewDidLoad` calls `loadWebView` at line 333, so all of this work blocks UI rendering and interaction; the cost grows with the bundled assets.
Suggested fix: Build and cache the archive off the main actor, then return to the main actor to call `loadWebViewFromArchive`; keep the existing network fallback on failure.
Verify the problem exists at that location before changing it, and keep the fix to those lines.
| } | ||
|
|
||
| if let webArchiveURL = paywallArchiveManager?.getCachedArchiveURL(manifest: paywall.manifest) { | ||
| if let webArchiveURL = paywallArchiveManager?.getCachedArchiveURL(manifest: paywall.manifest) |
There was a problem hiding this comment.
Warning
loadWebView skips bundled paywalls for .always manifests
F2 · Warning · correctness
A paywall with manifest.use == .always returns from the earlier branch at lines 472–482 and never reaches this bundled lookup. On an offline first launch, the manifest download fails and that branch loads the network URL instead, so a matching bundled document is not shown.
Use the bundled archive as the fallback when `getArchiveURL(forManifest:)` returns nil in the `.always` branch, before calling `webView.loadURL`.
🤖 Prompt to fix with an AI agent
In `Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift:485-486`: `loadWebView` skips bundled paywalls for `.always` manifests.
A paywall with `manifest.use == .always` returns from the earlier branch at lines 472–482 and never reaches this bundled lookup. On an offline first launch, the manifest download fails and that branch loads the network URL instead, so a matching bundled document is not shown.
Suggested fix: Use the bundled archive as the fallback when `getArchiveURL(forManifest:)` returns nil in the `.always` branch, before calling `webView.loadURL`.
Verify the problem exists at that location before changing it, and keep the fix to those lines.
There was a problem hiding this comment.
ℹ️ No critical issues. Two rough edges in the bundled archive path are worth a look.
Reviewed changes
I reviewed the initial commit, which adds a Superwall.bundle fallback for config and paywall documents.
BundledPaywalls: readsbundle.jsonandstatic_config.jsonfrom the app'sSuperwall.bundleonce, matches paywalls by exact URL, and builds aWebArchivefrom the bundled files.- Config fallback:
ConfigManager.fetchConfigurationuses the bundled config as the cached config when nothing is cached. When a bundle is present, it always takes the 1s‑timeout path and then refreshes in the background witholdConfig, so the refresh runs whether or notenableConfigRefreshis set. - Paywall loading:
PaywallViewController.loadWebViewtries the manifest archive first, then a bundled archive cached underCaches/paywalls/bundled/, then the network. - Tests: new
BundledPaywallsTestscover decoding, matching, archive contents, version gating, archive caching, and offline and online first launches.
claude-opus-5.5 | 𝕏
| return archiveURL | ||
| } | ||
| do { | ||
| try fileSystemManager.write(archive: bundledPaywalls.webArchive(for: bundled)) |
There was a problem hiding this comment.
loadWebView runs on the main actor, so the first match builds the archive on the main thread. That means reading every bundled file into memory, binary‑plist‑encoding it, and writing it to disk, and your halo bundle came to 5 MB with 53 subresources. This most likely happens during the post‑config preload on first launch, which is exactly when the onboarding UI is on screen. The manifest path does the same work off the main thread inside WebArchiveManager.getArchiveURL(forManifest:).
Technical details
# Bundled archive is built synchronously on the main thread
## Affected sites
- Sources/SuperwallKit/Paywall/Archive Manager/PaywallArchiveManager.swift:94-95 — `bundledPaywalls.webArchive(for:)` + `fileSystemManager.write(archive:)` run inline
- Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift:485-486 — called synchronously from MainActor `loadWebView()` (reached from `PaywallManager` preload, `viewDidLoad` and the reload paths)
## Required outcome
- File reads, plist encoding and the write happen off the main thread; `loadWebView` stays non-blocking.
- The cheap "already built" check can stay synchronous so later loads keep the fast path.
## Suggested approach (optional)
- Split into a sync `existingBundledArchiveURL(for:)` (match + `archiveExists`) and an `async` builder run from a detached/utility task, mirroring the `shouldAlwaysUseWebArchive` branch in `loadWebView` that awaits `getArchiveURL` and then falls back to `webView.loadURL(from:)`.
- Alternatively warm the bundled archives from `preloadArchive(paywall:)` (already async) so `loadWebView` normally only hits the existence check.| .deletingPathExtension() | ||
| .appendingPathExtension("webarchive") | ||
| let fileSystemManager = WebArchiveFileSytemManager(archiveURL: archiveURL) | ||
| if fileSystemManager.archiveExists { |
There was a problem hiding this comment.
The only check that the cached archive is good is archiveExists, and WebArchiveFileSytemManager.write uses a non‑atomic plistData.write(to:). If the app is killed partway through the write, or the disk fills up, a truncated .webarchive is left behind and served on every later launch until iOS purges Caches. Offline, that leaves the paywall with nothing to fall back to. Writing with .atomic would fix this path and the existing manifest path.

What
If the app ships a
Superwall.bundle, written bysuperwall bundle(superwall/superwall#14), the SDK uses it as a fallback for both config and paywall documents:fetchConfigurationtreats the bundled static config as the cached config when nothing is cached yet. It waits up to 1s for fresh config, the existingenableConfigRefreshpath, falls back to the bundled one, then refreshes in the background.PaywallViewController.loadWebViewnow tries, in order: the manifest archive, then a bundled archive, then the network.PaywallArchiveManager.getBundledArchiveURL(for:)matches the paywall'surlandurl_configendpoints against the bundle. On the first match it builds a.webarchiveinCaches/paywalls/bundled/from the bundled document and resources, using the existingWebArchivetypes, then loads it withloadFileURL.Decisions
BundledPaywalls.sharedreadsSuperwall.bundleonce. BothConfigManagerandPaywallArchiveManagertake it as an injectable init parameter with.sharedas the default.sw_cache_key). Once a paywall is republished, fresh config points to a new URL, the bundled copy no longer matches, and the paywall loads from the network. A stale bundle can't pin users to an old paywall once they're online.manifest. The live static configs I checked havemanifest: nullfor every paywall, including framework ones. Hanging this on the existing manifest cache would have done nothing in practice.enableConfigRefreshis off. On later launches with a cached config, the SDK waits 1s and then uses the cache, instead of retrying the network indefinitely. When it starts from a cached or bundled config, the background refresh passesoldConfig, so it runs regardless of the flag. Apps without a bundle behave exactly as before.bundle.jsonorstatic_config.json, or a newer bundle format (version > 1), logs a warning and ignores the bundle. A bundled archive that fails to build logs a warning and falls back to the network.Not covered
Verified
BundledPaywallsTests(7 tests): no bundle, config decode and endpoint matching, web archive contents, newer format ignored, archive cached and served only for the bundled URL, offline first launch uses the bundled config, online first launch prefers fresh config.ConfigManagerTestsandConfigManagerEarlyPublishTestsstill pass, 32/32 together with the new suite.halo. The config decoded (buildOCwtkmzAbCPnJmCQo41IE), and the web archive came out at 5 MB with all 53 subresources.SuperwallKit.xcodeprojis not in the diff: CI regenerates it with XcodeGen, which picks up the two new files.🤖 Generated with Claude Code