Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup
- Fixes the SDK getting stuck in test mode when a sheet such as the Customer Center is already open as it finishes loading.
- Stops matching installs for attribution unless the Superwall MMP is turned on for your app.
- Fixes paywalls on first launch missing acquisition attributes in their audience filters: they now wait up to 2 seconds for install attribution to finish.
- Fixes a paywall staying blank after iOS terminated its web content process. Paywalls that gate their entrance on `paywall_open`, which includes every framework paywall, rendered nothing in two cases. When the process died under a presented paywall, the SDK reloaded the page but never sent `paywall_open` to the new page. When the process died under a preloaded paywall, the SDK reloaded it at presentation but sent `paywall_open` before the new page was ready, so the page never received it. The SDK now tracks readiness per page load: it holds `paywall_open` until the new page says it is ready, and sends it again to a page reloaded while the paywall is open. The analytics `paywall_open` event is not tracked a second time.
- Fixes paywalls served by a `superwall dev` server never opening when they gate their entrance on `paywall_open`. The SDK sent `paywall_open` before the local page was ready, so the page never received it.

## 4.17.0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate {
}

loadingState = .loadingURL
webView.messageHandler.documentWillLoad()

if let paywallArchiveManager = self.paywallArchiveManager,
paywallArchiveManager.shouldAlwaysUseWebArchive(manifest: paywall.manifest) {
Expand Down Expand Up @@ -537,6 +538,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate {
}

@objc private func reloadWebView() {
webView.messageHandler.documentWillLoad()
webView.reload()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
}
/// Used to enqueue `paywall_open` messages if the paywall isn't ready to receive them yet.
private var messageQueue: Queue<EnqueuedMessage> = Queue()
private var isDocumentReady = false
private var isPaywallOpen = false

init(
receiptManager: ReceiptManager,
Expand Down Expand Up @@ -77,6 +79,7 @@
}
case .onReady(let paywalljsVersion):
delegate?.paywall.paywalljsVersion = paywalljsVersion
isDocumentReady = true
let loadedAt = Date()
Task {
await self.didLoadWebView(for: paywall, at: loadedAt)
Expand All @@ -85,8 +88,9 @@
hapticFeedback()
delegate?.eventDidOccur(.closed)
case .paywallOpen:
isPaywallOpen = true
let paywallOpen = SuperwallEventObjc.paywallOpen.description
if delegate?.paywall.paywalljsVersion == nil {
if !isDocumentReady {
let message = EnqueuedMessage(
name: paywallOpen,
paywall: paywall
Expand All @@ -98,8 +102,9 @@
}
}
case .paywallClose:
isPaywallOpen = false
let paywallClose = SuperwallEventObjc.paywallClose.description
if delegate?.paywall.paywalljsVersion == nil {
if !isDocumentReady {
let message = EnqueuedMessage(
name: paywallClose,
paywall: paywall
Expand Down Expand Up @@ -275,6 +280,23 @@
}
}

func documentWillLoad() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

paywall_open is re-armed when the SDK starts a load, not when a new document pings. A new document that pings without an SDK-started load before it still misses paywall_open and stays blank. One case is a page that reloads itself, such as the dev server's live reload of a DevServerPaywall (which DevServerPaywall.swift notes happens on every edit) or location.reload(). Another is the previous document's didLoadWebView drain landing after documentWillLoad(). Re-arming in onReady closes all of these with less state.

Technical details
# `paywall_open` re-arm is tied to load initiation, not to the document that pings

## Affected sites
- `PaywallMessageHandler.swift:283-298` — `documentWillLoad()` enqueues `paywall_open` at load start.
- `PaywallMessageHandler.swift:80-86` — `onReady` only sets `isDocumentReady`; it relies on the queue already holding `paywall_open`.
- `PaywallMessageHandler.swift:493-502` — the drain runs after `await TemplateLogic.getBase64EncodedTemplates(...)`, so a drain started by the old document's ping can run after `documentWillLoad()` has re-enqueued `paywall_open`, flushing it into the new, not-yet-ready document. The new document's ping then finds an empty queue.

## Gaps
1. Page-initiated full reloads (dev-server live reload, `location.reload()`) never call `documentWillLoad()`: `isDocumentReady` stays `true` and the new document's ping drains an empty queue.
2. The stale drain race above.
3. An old document that pings between `load()`/`reload()` and the new navigation committing. This is plausible for the refresh button, which appears on slow loads. The queued `paywall_open` goes to the outgoing document.

All three are narrow or not regressions (the old `paywalljsVersion` check had the same holes), so this is not blocking.

## Required outcome
- Every document that pings while `isPaywallOpen` is true gets exactly one `paywall_open` after its templates, however it was loaded.

## Suggested approach
- In `case .onReady`, after `isDocumentReady = true`, rebuild the queue from state: `messageQueue = Queue()`, and if `isPaywallOpen`, enqueue `paywall_open`. The queue only ever holds `paywall_open`/`paywall_close`, so the current open state is all a fresh document needs. A close that was queued before the ping, with nothing open, is moot for a page that was never told it is open.
- `documentWillLoad()` then shrinks to `isDocumentReady = false` (plus clearing the queue), and the re-enqueue there can go.
- Add a test: `onReady` → `paywallOpen` → `onReady` again with no `documentWillLoad()` delivers `paywall_open` twice.

isDocumentReady = false
messageQueue = Queue()
guard
isPaywallOpen,
let paywall = delegate?.paywall
else {
return
}
messageQueue.enqueue(
EnqueuedMessage(
name: SuperwallEventObjc.paywallOpen.description,
paywall: paywall
)
)
}

nonisolated private func pass(
placement: String,
from paywall: Paywall,
Expand Down Expand Up @@ -445,7 +467,7 @@
// block selection
let selectionString =
// swiftlint:disable:next line_length
"var css = '*{-webkit-touch-callout:none;-webkit-user-select:none} .w-webflow-badge { display: none !important; }'; "

Check warning on line 470 in Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

Superfluous Disable Command Violation: SwiftLint rule 'line_length' did not trigger a violation in the disabled region; remove the disable command (superfluous_disable_command)
+ "var head = document.head || document.getElementsByTagName('head')[0]; "
+ "var style = document.createElement('style'); style.type = 'text/css'; "
+ "style.appendChild(document.createTextNode(css)); head.appendChild(style); "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ extension SWWebView: WKNavigationDelegate {
}

func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
messageHandler.documentWillLoad()
if delegate?.isActive == true,
activeProcessTerminationRetryCount < maxActiveProcessTerminationRetries {
activeProcessTerminationRetryCount += 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,104 @@ struct PaywallMessageHandlerTests {
#expect(didHandleJs == true)
}

private func paywallOpenCount(
in webView: FakeWebView,
reaching expected: Int,
keeping delegate: PaywallMessageHandlerDelegateMock
) async -> Int {
let count = {
self.passedEvents(in: webView).filter { $0["event_name"] as? String == "paywall_open" }.count
}
for _ in 0..<250 {
if count() >= expected {
break
}
try? await Task.sleep(nanoseconds: 20_000_000)
}
try? await Task.sleep(nanoseconds: 200_000_000)
withExtendedLifetime(delegate) {}
return count()
}

@Test
func paywallOpenIsPassedStraightThroughOnceThePageIsReady() async {
let (messageHandler, webView, delegate) = makeHandler()

messageHandler.documentWillLoad()
messageHandler.handle(.onReady(paywallJsVersion: "2"))
messageHandler.handle(.paywallOpen)

#expect(await paywallOpenCount(in: webView, reaching: 1, keeping: delegate) == 1)
}

@Test
func paywallOpenWaitsForTheReadyMessage() async {
let (messageHandler, webView, delegate) = makeHandler()

messageHandler.documentWillLoad()
messageHandler.handle(.paywallOpen)
#expect(passedEvents(in: webView).isEmpty)

messageHandler.handle(.onReady(paywallJsVersion: "2"))

#expect(await paywallOpenCount(in: webView, reaching: 1, keeping: delegate) == 1)
}

@Test
func paywallOpenIsResentToADocumentReloadedWhileOpen() async {
let (messageHandler, webView, delegate) = makeHandler()

messageHandler.documentWillLoad()
messageHandler.handle(.onReady(paywallJsVersion: "2"))
messageHandler.handle(.paywallOpen)
#expect(await paywallOpenCount(in: webView, reaching: 1, keeping: delegate) == 1)

messageHandler.documentWillLoad()
#expect(await paywallOpenCount(in: webView, reaching: 1, keeping: delegate) == 1)

messageHandler.handle(.onReady(paywallJsVersion: "2"))

#expect(await paywallOpenCount(in: webView, reaching: 2, keeping: delegate) == 2)
}

@Test
func paywallOpenIsNotSentToADocumentReloadedWhileHidden() async {
let (messageHandler, webView, delegate) = makeHandler()

messageHandler.documentWillLoad()
messageHandler.handle(.onReady(paywallJsVersion: "2"))
messageHandler.documentWillLoad()
messageHandler.handle(.onReady(paywallJsVersion: "2"))

#expect(await paywallOpenCount(in: webView, reaching: 1, keeping: delegate) == 0)
}

@Test
func paywallOpenIsNotResentAfterThePaywallCloses() async {
let (messageHandler, webView, delegate) = makeHandler()

messageHandler.documentWillLoad()
messageHandler.handle(.onReady(paywallJsVersion: "2"))
messageHandler.handle(.paywallOpen)
messageHandler.handle(.paywallClose)
messageHandler.documentWillLoad()
messageHandler.handle(.onReady(paywallJsVersion: "2"))

#expect(await paywallOpenCount(in: webView, reaching: 2, keeping: delegate) == 1)
}

@Test
func paywallOpenIsSentOnceWhenTheDocumentDiesBeforeItIsReady() async {
let (messageHandler, webView, delegate) = makeHandler()

messageHandler.documentWillLoad()
messageHandler.handle(.paywallOpen)
messageHandler.documentWillLoad()
messageHandler.handle(.onReady(paywallJsVersion: "2"))

#expect(await paywallOpenCount(in: webView, reaching: 2, keeping: delegate) == 1)
}

@Test
func close() {
let dependencyContainer = DependencyContainer()
Expand Down
Loading