From 61bebd5151d74656235b4fe15ee21da2ac12c3d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20T=C3=B6r?= Date: Tue, 10 Mar 2026 13:49:36 +0000 Subject: [PATCH 1/8] Fix WebView process termination to use presentation state instead of retry counter The previous counter-based approach had a bug: processTerminationRetryCount reset to 0 on every successful didFinish, defeating the max-retry cap entirely. This caused infinite kill-reload loops on memory-constrained devices with preloaded paywalls. Replace with presentation-state logic: - Active (presenting) paywalls: always reload immediately on process termination - Background/preloaded paywalls: mark didFailToLoad for deferred reload on next viewWillAppear Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 6 +++++ .../View Controller/Web View/SWWebView.swift | 22 +++++-------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2607fd051..c624192b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.14.2 + +### Fixes + +- Fixes WebView process termination handling to use presentation state instead of a retry counter. Previously, the counter reset on every successful load, allowing infinite kill-reload loops on memory-constrained devices. Now, actively-presenting paywalls always reload immediately, while background/preloaded paywalls defer reloading until next presentation. + ## 4.14.1 ### Enhancements diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift index 018989e60..e0eb7d781 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift @@ -11,6 +11,7 @@ import WebKit protocol SWWebViewDelegate: AnyObject { var info: PaywallInfo { get } + var isActive: Bool { get } func webViewDidFail() } @@ -45,14 +46,6 @@ class SWWebView: WKWebView { private var completion: ((Error?) -> Void)? private let enableIframeNavigation: Bool - /// Tracks the number of times the WebView process has terminated and been reloaded. - /// Used to prevent infinite reload loops on memory-constrained devices. - private var processTerminationRetryCount = 0 - - /// Maximum number of automatic reloads after process termination. - /// After this limit, the WebView will be reloaded when presented instead. - private let maxProcessTerminationRetries = 1 - init( isMac: Bool, messageHandler: PaywallMessageHandler, @@ -242,8 +235,6 @@ extension SWWebView: WKNavigationDelegate { } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - // Reset retry count on successful load - processTerminationRetryCount = 0 completion?(nil) } @@ -264,15 +255,12 @@ extension SWWebView: WKNavigationDelegate { } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - // Only reload if we haven't exceeded the retry limit. - // This prevents infinite reload loops on memory-constrained devices - // where iOS keeps terminating the WebView process. - if processTerminationRetryCount < maxProcessTerminationRetries { - processTerminationRetryCount += 1 + if delegate?.isActive == true { + // The user is looking at this paywall - reload immediately. webView.reload() } else { - // Mark as failed so the WebView will be reloaded when presented again - // via PaywallViewController.viewWillAppear checking didFailToLoad. + // Background/preloaded WebView - mark for reload when next presented. + // PaywallViewController.viewWillAppear checks didFailToLoad. loadingHandler.didFailToLoad = true } From 182d0ef319bb77827df30cf16c3129fee53ab1ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20T=C3=B6r?= Date: Tue, 10 Mar 2026 13:55:04 +0000 Subject: [PATCH 2/8] Add retry cap for active paywalls on process termination Caps active-paywall reloads at 3 to prevent indefinite loops under sustained memory pressure where didFinish never fires. The counter resets on successful load since that breaks the rapid-termination cycle. Co-Authored-By: Claude Opus 4.6 --- .../Paywall/View Controller/Web View/SWWebView.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift index e0eb7d781..403382fba 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift @@ -45,6 +45,8 @@ class SWWebView: WKWebView { private let isOnDeviceCacheEnabled: Bool private var completion: ((Error?) -> Void)? private let enableIframeNavigation: Bool + private var activeProcessTerminationRetryCount = 0 + private let maxActiveProcessTerminationRetries = 3 init( isMac: Bool, @@ -235,6 +237,7 @@ extension SWWebView: WKNavigationDelegate { } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + activeProcessTerminationRetryCount = 0 completion?(nil) } @@ -255,12 +258,12 @@ extension SWWebView: WKNavigationDelegate { } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - if delegate?.isActive == true { - // The user is looking at this paywall - reload immediately. + if delegate?.isActive == true, + activeProcessTerminationRetryCount < maxActiveProcessTerminationRetries { + activeProcessTerminationRetryCount += 1 webView.reload() } else { - // Background/preloaded WebView - mark for reload when next presented. - // PaywallViewController.viewWillAppear checks didFailToLoad. + activeProcessTerminationRetryCount = 0 loadingHandler.didFailToLoad = true } From 3370291061c0a93a9ee1e32194f21b9207456910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20T=C3=B6r?= Date: Tue, 10 Mar 2026 13:56:37 +0000 Subject: [PATCH 3/8] Move changelog entry to 4.14.1 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c624192b1..813c3011f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,6 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. -## 4.14.2 - -### Fixes - -- Fixes WebView process termination handling to use presentation state instead of a retry counter. Previously, the counter reset on every successful load, allowing infinite kill-reload loops on memory-constrained devices. Now, actively-presenting paywalls always reload immediately, while background/preloaded paywalls defer reloading until next presentation. - ## 4.14.1 ### Enhancements @@ -17,6 +11,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes +- Fixes WebView process termination handling to use presentation state instead of a retry counter. Previously, the counter reset on every successful load, allowing infinite kill-reload loops on memory-constrained devices. Now, actively-presenting paywalls always reload immediately, while background/preloaded paywalls defer reloading until next presentation. - Makes `device.isSandbox` more reliable. - Fixes the web restore alert not showing the "Yes" action button and "Cancel" incorrectly triggering the restore action. - Fixes a rare issue where a user's subscription could remain active after a refund, preventing paywalls from being shown. From 8809297d87bb1d665d4fe3b3a019931eac6e963f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20T=C3=B6r?= Date: Tue, 10 Mar 2026 14:03:51 +0000 Subject: [PATCH 4/8] Remove counter reset in else branch to enforce hard retry cap Only didFinish should reset the counter, since it signals a genuinely successful load. Resetting in the else branch re-armed the cycle for active paywalls that hit the cap. Co-Authored-By: Claude Opus 4.6 --- .../Paywall/View Controller/Web View/SWWebView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift index 403382fba..86bafeaf4 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift @@ -263,7 +263,6 @@ extension SWWebView: WKNavigationDelegate { activeProcessTerminationRetryCount += 1 webView.reload() } else { - activeProcessTerminationRetryCount = 0 loadingHandler.didFailToLoad = true } From 145563e586b394e6a6e6c3291b718d18ec67381e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20T=C3=B6r?= Date: Tue, 10 Mar 2026 14:13:41 +0000 Subject: [PATCH 5/8] Fix retry cap: remove didFinish reset, dismiss active paywalls on cap Two bugs fixed: - didFinish reset defeated the retry cap (terminate -> reload -> success -> counter back to 0 -> terminate again, infinitely) - else branch set didFailToLoad for active paywalls, but viewWillAppear never fires while presented, leaving users with a blank screen Now: no counter reset (lifetime cap per WebView instance), and active paywalls that exhaust retries are dismissed via webViewDidFail(). Co-Authored-By: Claude Opus 4.6 --- .../Paywall/View Controller/Web View/SWWebView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift index 86bafeaf4..3c3e84812 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift @@ -237,7 +237,6 @@ extension SWWebView: WKNavigationDelegate { } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - activeProcessTerminationRetryCount = 0 completion?(nil) } @@ -262,6 +261,8 @@ extension SWWebView: WKNavigationDelegate { activeProcessTerminationRetryCount < maxActiveProcessTerminationRetries { activeProcessTerminationRetryCount += 1 webView.reload() + } else if delegate?.isActive == true { + delegate?.webViewDidFail() } else { loadingHandler.didFailToLoad = true } From a45af3902f2cade8f4d5bd111ec3ff0fbc8adf35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20T=C3=B6r?= Date: Tue, 10 Mar 2026 14:21:40 +0000 Subject: [PATCH 6/8] Reset retry counter on fresh load, set didFailToLoad on active dismissal - Reset activeProcessTerminationRetryCount in loadURL(from:) so cached SWWebView instances get a fresh retry budget per intentional load. This is the correct reset point: didFinish fires after terminate- triggered reloads too, but loadURL(from:) only fires for SDK- initiated loads. - Set didFailToLoad = true before calling webViewDidFail() so that cached VCs re-presenting trigger a fresh load via viewWillAppear. Co-Authored-By: Claude Opus 4.6 --- .../Paywall/View Controller/Web View/SWWebView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift index 3c3e84812..72aee9e72 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift @@ -148,6 +148,7 @@ class SWWebView: WKWebView { } func loadURL(from paywall: Paywall) async { + activeProcessTerminationRetryCount = 0 let didLoad = await loadingHandler.loadURL( paywallUrlConfig: paywall.urlConfig, paywallUrl: paywall.url @@ -262,6 +263,7 @@ extension SWWebView: WKNavigationDelegate { activeProcessTerminationRetryCount += 1 webView.reload() } else if delegate?.isActive == true { + loadingHandler.didFailToLoad = true delegate?.webViewDidFail() } else { loadingHandler.didFailToLoad = true From 02bc8faf1a8352f061aa1411b9cafb6678851164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20T=C3=B6r?= Date: Tue, 10 Mar 2026 14:29:31 +0000 Subject: [PATCH 7/8] Reset retry counter in didFinish to track consecutive unrecovered kills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counter should cap consecutive unrecovered terminations (rapid kill loops where didFinish never fires), not total lifetime terminations. A didFinish after a terminate-triggered reload() means the termination was recovered — the counter should reset so devices with chronic but manageable memory pressure aren't penalised. This is safe because the counter now only gates the active path. The old bug was a shared counter where didFinish from an unrelated initial load reset the cap for background paywalls — that path now bypasses the counter entirely. Co-Authored-By: Claude Opus 4.6 --- .../Paywall/View Controller/Web View/SWWebView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift index 72aee9e72..fd5835640 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift @@ -238,6 +238,7 @@ extension SWWebView: WKNavigationDelegate { } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + activeProcessTerminationRetryCount = 0 completion?(nil) } From 5354f4dbc041fa0b7c3b4330c86a483356ae96f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:00:55 +0100 Subject: [PATCH 8/8] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 813c3011f..7dc6b4f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes -- Fixes WebView process termination handling to use presentation state instead of a retry counter. Previously, the counter reset on every successful load, allowing infinite kill-reload loops on memory-constrained devices. Now, actively-presenting paywalls always reload immediately, while background/preloaded paywalls defer reloading until next presentation. - Makes `device.isSandbox` more reliable. - Fixes the web restore alert not showing the "Yes" action button and "Cancel" incorrectly triggering the restore action. - Fixes a rare issue where a user's subscription could remain active after a refund, preventing paywalls from being shown. - Fixes trial eligibility for Stripe products. - Fixes an issue where `transaction_complete` could be missing transaction information when a crossgrade occurred while using a purchase controller. +- Fixes terminated webviews refreshing in a loop on low RAM devices. ## 4.14.0