From 878651607c81c79c984130ddfe82ad47931a548f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 10:34:15 +0000 Subject: [PATCH 1/4] Deliver paywall webview messages in order Building the template variables is far slower than encoding a simple event, so sending each webview message from its own coroutine let `paywall_open` overtake `template_variables`. The paywall runtime treats a `template_variables` that lands after `paywall_open` as a fresh load: it marks the paywall as closed and then discards every subsequent `page_view`. Only the entry page view survived, so campaign results for multi-page flows showed users dropping off on the first page while they were in fact going through the flow and starting purchases. Chain each outbound send onto the previous one so delivery is FIFO in `handle` order, with a bounded wait so a stalled send can never drop the messages behind it. `didLoadWebView` now awaits its own evaluation rather than launching it, so the not-preloaded path is ordered too. Also defers the open sent when a crashed webview is recreated: it was evaluated against a page with no `window.paywall` yet and silently dropped, leaving the recreated paywall closed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KD1BdtpZL3MYo5F6R1CcLu --- CHANGELOG.md | 2 + .../superwall/sdk/paywall/view/PaywallView.kt | 4 +- .../messaging/PaywallMessageHandler.kt | 81 +++++-- .../messaging/PaywallMessageOrderingTest.kt | 217 ++++++++++++++++++ 4 files changed, 284 insertions(+), 20 deletions(-) create mode 100644 superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f68eae7..76695d5b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Unreleased +- Fix multi-page paywalls only reporting the entry page view. The template variables and the paywall open message were sent to the webview from independent coroutines, so the slower template build often landed after the open. The paywall runtime treats that as a fresh load and stops tracking page views, which made campaign results show users dropping off on the first page. Messages destined for the webview are now delivered in the order they are produced. +- Fix a paywall not being reopened after its webview process crashes and is recreated. The open message was sent before the replacement webview had loaded, so it never reached the paywall. - Fix prices not showing when product/offers are fetched from cache - Fix a JSON null in placement parameters or user attributes reaching audience filters as the text `"null"`, so a filter checking whether a field is null never matched. diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt index c041e571d..f7b6f2033 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt @@ -1015,7 +1015,9 @@ class PaywallView( }) webView.attach(this) webView.delegate = this - webView.messageHandler.handle(PaywallMessage.PaywallOpen) + // The replacement webview has no content yet, so the open is deferred until the + // reload finishes and the templates have been injected. + webView.messageHandler.sendWhenLoaded(PaywallMessage.PaywallOpen) loadWebView() } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt index 1446d9f89..d418f9a72 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt @@ -32,6 +32,7 @@ import com.superwall.sdk.storage.core_data.convertToJsonElement import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -94,11 +95,40 @@ class PaywallMessageHandler( meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; var head = document.getElementsByTagName('head')[0]; head.appendChild(meta);""" + + // Upper bound on how long an outbound message waits for the ones queued + // before it. Ordering matters, but never at the cost of dropping a message + // entirely, so a stalled send eventually lets the rest of the queue through. + const val OUTBOUND_TIMEOUT_MS = 10_000L } var messageHandler: PaywallMessageHandlerDelegate? = null private val queue: Queue = LinkedList() + // The webview must receive messages in the order they were produced. Building the + // template variables is far slower than encoding a simple event, so sending each + // message from its own coroutine let `paywall_open` overtake `template_variables`. + // The runtime treats a `template_variables` that lands after `paywall_open` as a + // fresh load, marks the paywall as closed and then discards every subsequent + // `page_view`, which made multi-page flows look like users dropped off on page 1. + // Chaining each send onto the previous one keeps delivery FIFO in `handle` order + // without keeping a consumer coroutine alive for the life of the handler. + private val outboundLock = Any() + private var lastOutbound: Job? = null + + private fun enqueueOutbound(block: suspend () -> Unit) { + synchronized(outboundLock) { + val previous = lastOutbound + lastOutbound = + ioScope.launch { + if (previous != null) { + withTimeoutOrNull(OUTBOUND_TIMEOUT_MS) { previous.join() } + } + block() + } + } + } + @JavascriptInterface fun postMessage(message: String) { // Print out the message to the console using Log.d @@ -140,7 +170,7 @@ class PaywallMessageHandler( ) { "!! PaywallMessageHandler: Paywall: $paywall, delegeate: $messageHandler" } when (message) { is PaywallMessage.TemplateParamsAndUserAttributes -> - ioScope.launch { passTemplatesToWebView(paywall) } + enqueueOutbound { passTemplatesToWebView(paywall) } is PaywallMessage.OnReady -> { messageHandler?.updateState( @@ -152,7 +182,7 @@ class PaywallMessageHandler( LogScope.superwallCore, "!! PaywallMessageHandler: Ready !!", ) - ioScope.launch { didLoadWebView(paywall, loadedAt) } + enqueueOutbound { didLoadWebView(paywall, loadedAt) } } is PaywallMessage.Close -> { @@ -181,7 +211,7 @@ class PaywallMessageHandler( if (messageHandler?.state?.paywall?.paywalljsVersion == null) { queue.offer(message) } else { - ioScope.launch { + enqueueOutbound { pass(eventName = SuperwallEvents.PaywallOpen.rawName, paywall = paywall) } } @@ -191,7 +221,7 @@ class PaywallMessageHandler( if (messageHandler?.state?.paywall?.paywalljsVersion == null) { queue.offer(message) } else { - ioScope.launch { + enqueueOutbound { val eventName = SuperwallEvents.PaywallClose.rawName pass(eventName = eventName, paywall = paywall) } @@ -199,27 +229,27 @@ class PaywallMessageHandler( } is PaywallMessage.BackButtonPressed -> - ioScope.launch { + enqueueOutbound { pass(eventName = "back_button_input", paywall = paywall) } is PaywallMessage.Custom -> handleCustomEvent(message.data) is PaywallMessage.CustomPlacement -> handleCustomPlacement(message.name, message.params) is PaywallMessage.RestoreFailed -> - ioScope.launch { + enqueueOutbound { pass(SuperwallEvents.RestoreFail.rawName, paywall) } is PaywallMessage.RequestReview -> handleRequestReview(message) is PaywallMessage.TransactionStart -> { - ioScope.launch { + enqueueOutbound { pass(eventName = SuperwallEvents.TransactionStart.rawName, paywall = paywall) } } is PaywallMessage.TransactionAbandon -> { - ioScope.launch { + enqueueOutbound { pass(eventName = SuperwallEvents.TransactionAbandon.rawName, paywall = paywall) } } @@ -229,7 +259,7 @@ class PaywallMessageHandler( } is PaywallMessage.TransactionComplete -> { - ioScope.launch { + enqueueOutbound { pass( SuperwallEvents.TransactionComplete.rawName, paywall, @@ -239,7 +269,7 @@ class PaywallMessageHandler( } is PaywallMessage.TrialStarted -> { - ioScope.launch { + enqueueOutbound { pass( eventName = SuperwallEvents.FreeTrialStart.rawName, paywall = paywall, @@ -395,22 +425,24 @@ class PaywallMessageHandler( paywall: Paywall, loadedAt: Date, ) { - ioScope.launch { - val delegate = this@PaywallMessageHandler.messageHandler - if (delegate != null) { - delegate.updateState(PaywallViewState.Updates.WebLoadingEnded(loadedAt)) + val delegate = this@PaywallMessageHandler.messageHandler + if (delegate != null) { + delegate.updateState(PaywallViewState.Updates.WebLoadingEnded(loadedAt)) - val paywallInfo = delegate.state.info + val paywallInfo = delegate.state.info + // Tracking talks to the network, so it stays off the outbound queue - only + // the messages the webview receives need to keep their order. + ioScope.launch { val trackedEvent = InternalSuperwallEvent.PaywallWebviewLoad( state = InternalSuperwallEvent.PaywallWebviewLoad.State.Complete(), paywallInfo = paywallInfo, ) track(trackedEvent) - - val behavior = options.makeSuperwallOptions().eventTrackingBehavior - passEventTrackingBehaviorToWebView(behavior) } + + val behavior = options.makeSuperwallOptions().eventTrackingBehavior + passEventTrackingBehaviorToWebView(behavior) } Logger.debug( @@ -451,7 +483,10 @@ class PaywallMessageHandler( message = { "Posting Message" }, ) - mainScope.launch { + // Awaited rather than launched, so this send completes before anything queued + // behind it - a `paywall_open` that arrives while the templates are still + // building must not reach the webview first. + withContext(Dispatchers.Main) { messageHandler?.evaluate(scriptSrc) { error -> if (error != null) { Logger.debug( @@ -476,6 +511,14 @@ class PaywallMessageHandler( } } + // Holds a message back until the webview reports it has (re)loaded, at which point + // it is delivered after the templates. Used when the webview is about to be replaced + // or reloaded, where sending straight away would evaluate against a page that has no + // `window.paywall` yet and silently drop the message. + fun sendWhenLoaded(message: PaywallMessage) { + queue.offer(message) + } + fun flushPendingMessages() { ioScope.launch { mainScope.launch { diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt new file mode 100644 index 000000000..d2bcbdee9 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt @@ -0,0 +1,217 @@ +package com.superwall.sdk.paywall.view.webview.messaging + +import android.app.Activity +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent +import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.dependencies.OptionsFactory +import com.superwall.sdk.dependencies.VariablesFactory +import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.misc.MainScope +import com.superwall.sdk.models.config.ComputedPropertyRequest +import com.superwall.sdk.models.events.EventData +import com.superwall.sdk.models.paywall.Paywall +import com.superwall.sdk.models.product.ProductVariable +import com.superwall.sdk.paywall.presentation.CustomCallbackRegistry +import com.superwall.sdk.paywall.view.PaywallViewState +import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables +import com.superwall.sdk.paywall.view.webview.templating.models.Variables +import com.superwall.sdk.permissions.PermissionStatus +import com.superwall.sdk.permissions.PermissionType +import com.superwall.sdk.permissions.UserPermissions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * The paywall runtime treats a `template_variables` message that lands after + * `paywall_open` as a fresh load: it marks the paywall as closed and then discards + * every subsequent `page_view`. Multi-page flows then look like every user dropped + * off on the first page. These tests pin the delivery order, with a template build + * that is deliberately slower than the open message it must precede. + */ +class PaywallMessageOrderingTest { + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private class RecordingDelegate( + initial: PaywallViewState, + ) : PaywallMessageHandlerDelegate { + private var _state: PaywallViewState = initial + override val state: PaywallViewState + get() = _state + + val evaluations = mutableListOf() + + override fun updateState(update: PaywallViewState.Updates) { + _state = update.transform(_state) + } + + override fun eventDidOccur(paywallWebEvent: PaywallWebEvent) {} + + override fun openDeepLink(url: String) {} + + override fun presentBrowserInApp(url: String) {} + + override fun presentBrowserExternal(url: String) {} + + override fun evaluate( + code: String, + resultCallback: ((String?) -> Unit)?, + ) { + evaluations.add(code) + resultCallback?.invoke(null) + } + + override fun presentPaymentSheet(url: String) {} + } + + // Mirrors production, where building the variables hits the store and user + // attributes and so takes far longer than encoding a plain event. + private class SlowVariablesFactory : VariablesFactory { + override suspend fun makeJsonVariables( + products: List?, + computedPropertyRequests: List, + event: EventData?, + ): JsonVariables { + delay(TEMPLATE_BUILD_MS) + return JsonVariables("template_variables", Variables(emptyMap(), emptyMap(), emptyMap())) + } + } + + private class FakeUserPermissions : UserPermissions { + override fun hasPermission(permission: PermissionType): PermissionStatus = PermissionStatus.GRANTED + + override suspend fun requestPermission( + activity: Activity, + permission: PermissionType, + ): PermissionStatus = PermissionStatus.GRANTED + } + + private fun createHandler(): PaywallMessageHandler = + PaywallMessageHandler( + factory = SlowVariablesFactory(), + options = + object : OptionsFactory { + override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions() + }, + track = { _: TrackableSuperwallEvent -> }, + setAttributes = { }, + getView = { null }, + mainScope = MainScope(testDispatcher), + ioScope = IOScope(testDispatcher), + encodeToB64 = { it }, + userPermissions = FakeUserPermissions(), + getActivity = { null }, + customCallbackRegistry = CustomCallbackRegistry(), + ) + + private fun List.indexOfMessage(needle: String): Int = indexOfFirst { it.contains(needle) } + + private fun assertTemplatesPrecedeOpen(evaluations: List) { + val templates = evaluations.indexOfMessage(TEMPLATE_VARIABLES) + val open = evaluations.indexOfMessage(PAYWALL_OPEN) + assertTrue("template_variables was never sent to the webview", templates >= 0) + assertTrue("paywall_open was never sent to the webview", open >= 0) + assertTrue( + "paywall_open (index $open) overtook template_variables (index $templates)", + templates < open, + ) + } + + @Test + fun templateVariablesReachWebviewBeforePaywallOpenOnAPreloadedPaywall() = + runTest { + Given("a paywall whose webview has already loaded") { + val state = PaywallViewState(paywall = Paywall.stub(), locale = "en-US") + val delegate = RecordingDelegate(state) + delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION)) + val handler = createHandler() + handler.messageHandler = delegate + + When("presentation sends the templates and the open follows immediately") { + handler.handle(PaywallMessage.TemplateParamsAndUserAttributes) + handler.handle(PaywallMessage.PaywallOpen) + advanceUntilIdle() + + Then("the templates reach the webview first") { + assertTemplatesPrecedeOpen(delegate.evaluations) + } + } + } + } + + @Test + fun templateVariablesReachWebviewBeforePaywallOpenWhenPaywallIsNotPreloaded() = + runTest { + Given("a paywall whose webview has just reported it is ready") { + val state = PaywallViewState(paywall = Paywall.stub(), locale = "en-US") + val delegate = RecordingDelegate(state) + val handler = createHandler() + handler.messageHandler = delegate + + When("the open lands while the templates are still being built") { + handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION)) + handler.handle(PaywallMessage.PaywallOpen) + advanceUntilIdle() + + Then("the templates reach the webview first") { + assertTemplatesPrecedeOpen(delegate.evaluations) + } + } + } + } + + @Test + fun deferredPaywallOpenIsSentAfterTheTemplatesOnceTheWebviewLoads() = + runTest { + Given("an open deferred while the webview is being recreated") { + val state = PaywallViewState(paywall = Paywall.stub(), locale = "en-US") + val delegate = RecordingDelegate(state) + val handler = createHandler() + handler.messageHandler = delegate + + When("the replacement webview finishes loading") { + handler.sendWhenLoaded(PaywallMessage.PaywallOpen) + advanceUntilIdle() + assertTrue( + "the deferred open was sent before the webview loaded", + delegate.evaluations.indexOfMessage(PAYWALL_OPEN) < 0, + ) + + handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION)) + advanceUntilIdle() + + Then("the open is delivered after the templates") { + assertTemplatesPrecedeOpen(delegate.evaluations) + } + } + } + } + + private companion object { + const val TEMPLATE_BUILD_MS = 500L + const val PAYWALL_JS_VERSION = "3.0.0" + const val TEMPLATE_VARIABLES = "template_variables" + const val PAYWALL_OPEN = "paywall_open" + } +} From 78dda306f6eed941f9c9496602e5ccc544a16b59 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:49:41 +0000 Subject: [PATCH 2/4] Tighten the outbound message queue Review pass over the ordering fix. `PaywallOpen` and `PaywallClose` each open-coded the same "defer until the webview has loaded" idiom, so the pending queue now has one door: `sendWhenLoaded`, gated by a named `isWebViewLoaded`. That queue is written from IO threads and drained on main, so it is a `ConcurrentLinkedQueue` rather than a `LinkedList`. The timeout comment claimed to bound the wait for the whole queue; it bounds the wait for the message directly ahead. The queue comment now records why a mutex cannot work here - it fixes the order at acquisition rather than at enqueue, which is the race itself. Drops the launch-inside-a-launch in `flushPendingMessages`, and adds a test that a send which throws does not hold back what is queued behind it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KD1BdtpZL3MYo5F6R1CcLu --- .../messaging/PaywallMessageHandler.kt | 61 ++++++++++--------- .../messaging/PaywallMessageOrderingTest.kt | 34 ++++++++++- 2 files changed, 64 insertions(+), 31 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt index d418f9a72..b249c8398 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt @@ -42,8 +42,8 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import java.net.URI import java.util.Date -import java.util.LinkedList import java.util.Queue +import java.util.concurrent.ConcurrentLinkedQueue import kotlin.coroutines.resume interface PaywallStateDelegate { @@ -96,23 +96,28 @@ class PaywallMessageHandler( var head = document.getElementsByTagName('head')[0]; head.appendChild(meta);""" - // Upper bound on how long an outbound message waits for the ones queued - // before it. Ordering matters, but never at the cost of dropping a message - // entirely, so a stalled send eventually lets the rest of the queue through. + // How long a message waits for the one directly ahead of it. Ordering matters, + // but never at the cost of never delivering: a send that wedges (a stalled Room + // read while templating, say) hands the queue on rather than silencing the + // paywall. Losing a page view beats losing `paywall_open`. const val OUTBOUND_TIMEOUT_MS = 10_000L } var messageHandler: PaywallMessageHandlerDelegate? = null - private val queue: Queue = LinkedList() - - // The webview must receive messages in the order they were produced. Building the - // template variables is far slower than encoding a simple event, so sending each - // message from its own coroutine let `paywall_open` overtake `template_variables`. - // The runtime treats a `template_variables` that lands after `paywall_open` as a - // fresh load, marks the paywall as closed and then discards every subsequent - // `page_view`, which made multi-page flows look like users dropped off on page 1. - // Chaining each send onto the previous one keeps delivery FIFO in `handle` order - // without keeping a consumer coroutine alive for the life of the handler. + private val queue: Queue = ConcurrentLinkedQueue() + + // The webview must receive messages in the order they were produced: the runtime + // treats a `template_variables` that lands after `paywall_open` as a fresh load and + // then discards every `page_view` that follows. Templating is far slower than + // encoding an event, so a coroutine per message let the open win that race. + // + // The order has to be fixed when `handle` is called, not when a coroutine happens to + // get scheduled, which rules out a mutex - whoever reaches it first wins, and that is + // the race itself. Chaining onto the previous job pins the order at enqueue time, and + // unlike a channel with a consumer it leaves nothing running on the shared IO scope + // once the queue drains. Sends that wait on the user (permission and callback + // replies) deliberately stay off the queue: their order does not matter and they + // would hold everything behind them. private val outboundLock = Any() private var lastOutbound: Job? = null @@ -207,26 +212,23 @@ class PaywallMessageHandler( shouldDismiss = message.shouldDismiss, ) - is PaywallMessage.PaywallOpen -> { - if (messageHandler?.state?.paywall?.paywalljsVersion == null) { - queue.offer(message) + is PaywallMessage.PaywallOpen -> + if (!isWebViewLoaded) { + sendWhenLoaded(message) } else { enqueueOutbound { pass(eventName = SuperwallEvents.PaywallOpen.rawName, paywall = paywall) } } - } - is PaywallMessage.PaywallClose -> { - if (messageHandler?.state?.paywall?.paywalljsVersion == null) { - queue.offer(message) + is PaywallMessage.PaywallClose -> + if (!isWebViewLoaded) { + sendWhenLoaded(message) } else { enqueueOutbound { - val eventName = SuperwallEvents.PaywallClose.rawName - pass(eventName = eventName, paywall = paywall) + pass(eventName = SuperwallEvents.PaywallClose.rawName, paywall = paywall) } } - } is PaywallMessage.BackButtonPressed -> enqueueOutbound { @@ -519,11 +521,14 @@ class PaywallMessageHandler( queue.offer(message) } + // The webview reports its paywall.js version once it has loaded, so an absent + // version means there is nothing on the other side to receive a message yet. + private val isWebViewLoaded: Boolean + get() = messageHandler?.state?.paywall?.paywalljsVersion != null + fun flushPendingMessages() { - ioScope.launch { - mainScope.launch { - flushPendingMessagesInternal() - } + mainScope.launch { + flushPendingMessagesInternal() } } diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt index d2bcbdee9..5689bbe56 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt @@ -87,13 +87,16 @@ class PaywallMessageOrderingTest { // Mirrors production, where building the variables hits the store and user // attributes and so takes far longer than encoding a plain event. - private class SlowVariablesFactory : VariablesFactory { + private class SlowVariablesFactory( + private val fail: Boolean = false, + ) : VariablesFactory { override suspend fun makeJsonVariables( products: List?, computedPropertyRequests: List, event: EventData?, ): JsonVariables { delay(TEMPLATE_BUILD_MS) + if (fail) throw IllegalStateException("could not build the templates") return JsonVariables("template_variables", Variables(emptyMap(), emptyMap(), emptyMap())) } } @@ -107,9 +110,9 @@ class PaywallMessageOrderingTest { ): PermissionStatus = PermissionStatus.GRANTED } - private fun createHandler(): PaywallMessageHandler = + private fun createHandler(failTemplateBuild: Boolean = false): PaywallMessageHandler = PaywallMessageHandler( - factory = SlowVariablesFactory(), + factory = SlowVariablesFactory(fail = failTemplateBuild), options = object : OptionsFactory { override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions() @@ -208,6 +211,31 @@ class PaywallMessageOrderingTest { } } + @Test + fun aFailedSendDoesNotHoldBackTheMessagesQueuedBehindIt() = + runTest { + Given("a paywall whose template build throws") { + val state = PaywallViewState(paywall = Paywall.stub(), locale = "en-US") + val delegate = RecordingDelegate(state) + delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION)) + val handler = createHandler(failTemplateBuild = true) + handler.messageHandler = delegate + + When("an open is queued behind the failing send") { + handler.handle(PaywallMessage.TemplateParamsAndUserAttributes) + handler.handle(PaywallMessage.PaywallOpen) + advanceUntilIdle() + + Then("the open is still delivered") { + assertTrue( + "paywall_open was lost behind a failed send", + delegate.evaluations.indexOfMessage(PAYWALL_OPEN) >= 0, + ) + } + } + } + } + private companion object { const val TEMPLATE_BUILD_MS = 500L const val PAYWALL_JS_VERSION = "3.0.0" From 7f0b7567f12c165e450d6396e050fb2f74413f2d Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 10 Sep 2026 15:01:47 +0200 Subject: [PATCH 3/4] Minor fixes --- CHANGELOG.md | 4 +- .../superwall/sdk/paywall/view/PaywallView.kt | 4 +- .../messaging/PaywallMessageHandler.kt | 159 +++++++++------- .../messaging/PaywallMessageOrderingTest.kt | 177 +++++++++++++++++- 4 files changed, 263 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76695d5b8..e91a7bbeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Unreleased -- Fix multi-page paywalls only reporting the entry page view. The template variables and the paywall open message were sent to the webview from independent coroutines, so the slower template build often landed after the open. The paywall runtime treats that as a fresh load and stops tracking page views, which made campaign results show users dropping off on the first page. Messages destined for the webview are now delivered in the order they are produced. -- Fix a paywall not being reopened after its webview process crashes and is recreated. The open message was sent before the replacement webview had loaded, so it never reached the paywall. +- Fix multi-page paywalls only reporting the entry page view. +- Fix an active paywall not being reopened after its webview process crashes and is recreated. Recovery cancels messages for the old webview and sends the open after the replacement loads, only if the same presentation is still active. - Fix prices not showing when product/offers are fetched from cache - Fix a JSON null in placement parameters or user attributes reaching audience filters as the text `"null"`, so a filter checking whether a field is null never matched. diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt index f7b6f2033..d33dddb42 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt @@ -1007,6 +1007,7 @@ class PaywallView( private fun recreateWebview() { val oldWebView = webView + oldWebView.messageHandler.resetForWebViewReload() oldWebView.detach(this) oldWebView.destroyView() webView = @@ -1015,9 +1016,6 @@ class PaywallView( }) webView.attach(this) webView.delegate = this - // The replacement webview has no content yet, so the open is deferred until the - // reload finishes and the templates have been injected. - webView.messageHandler.sendWhenLoaded(PaywallMessage.PaywallOpen) loadWebView() } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt index b249c8398..8f8a32d67 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt @@ -33,6 +33,8 @@ import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -42,8 +44,6 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import java.net.URI import java.util.Date -import java.util.Queue -import java.util.concurrent.ConcurrentLinkedQueue import kotlin.coroutines.resume interface PaywallStateDelegate { @@ -95,42 +95,85 @@ class PaywallMessageHandler( meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; var head = document.getElementsByTagName('head')[0]; head.appendChild(meta);""" - - // How long a message waits for the one directly ahead of it. Ordering matters, - // but never at the cost of never delivering: a send that wedges (a stalled Room - // read while templating, say) hands the queue on rather than silencing the - // paywall. Losing a page view beats losing `paywall_open`. - const val OUTBOUND_TIMEOUT_MS = 10_000L } var messageHandler: PaywallMessageHandlerDelegate? = null - private val queue: Queue = ConcurrentLinkedQueue() - - // The webview must receive messages in the order they were produced: the runtime - // treats a `template_variables` that lands after `paywall_open` as a fresh load and - // then discards every `page_view` that follows. Templating is far slower than - // encoding an event, so a coroutine per message let the open win that race. - // - // The order has to be fixed when `handle` is called, not when a coroutine happens to - // get scheduled, which rules out a mutex - whoever reaches it first wins, and that is - // the race itself. Chaining onto the previous job pins the order at enqueue time, and - // unlike a channel with a consumer it leaves nothing running on the shared IO scope - // once the queue drains. Sends that wait on the user (permission and callback - // replies) deliberately stay off the queue: their order does not matter and they - // would hold everything behind them. + + private data class PendingMessage( + val message: PaywallMessage, + val shouldSend: () -> Boolean = { true }, + ) + + // Reserve the order synchronously; template construction can suspend before the + // main-thread evaluation. Never bypass an unfinished send: late templates reset + // the runtime even if an open has already arrived. Reload cancels obsolete work. + // Permission and callback replies remain independent. private val outboundLock = Any() + private val queue = ArrayDeque() + private val outboundJobs = mutableSetOf() private var lastOutbound: Job? = null private fun enqueueOutbound(block: suspend () -> Unit) { synchronized(outboundLock) { val previous = lastOutbound - lastOutbound = + val job = ioScope.launch { - if (previous != null) { - withTimeoutOrNull(OUTBOUND_TIMEOUT_MS) { previous.join() } - } + previous?.join() block() } + lastOutbound = job + outboundJobs.add(job) + job.invokeOnCompletion { + synchronized(outboundLock) { + outboundJobs.remove(job) + if (lastOutbound === job) lastOutbound = null + } + } + } + } + + // Called on main before replacing the WebView. Cancel work for the old document + // and only restore an open if that same presentation is still active at delivery. + internal fun resetForWebViewReload() { + synchronized(outboundLock) { + outboundJobs.toList().forEach { it.cancel() } + lastOutbound = null + queue.clear() + val state = messageHandler?.state + messageHandler?.updateState(PaywallViewState.Updates.SetPaywallJsVersion(null)) + if (state?.isPresented == true && !state.closedForBackground) { + // SetLastOpen replaces this object for each new presentation. + val lastOpen = state.lastOpen + queue.addLast( + PendingMessage(PaywallMessage.PaywallOpen) { + val current = messageHandler?.state + current?.isPresented == true && !current.closedForBackground && current.lastOpen === lastOpen + }, + ) + } + } + } + + private fun sendLifecycleMessage(pending: PendingMessage) { + synchronized(outboundLock) { + if (!isWebViewLoaded) { + queue.addLast(pending) + } else { + val paywall = messageHandler?.state?.paywall ?: return + enqueueOutbound { + withContext(Dispatchers.Main) { + if (pending.shouldSend()) { + val eventName = + if (pending.message is PaywallMessage.PaywallOpen) { + SuperwallEvents.PaywallOpen.rawName + } else { + SuperwallEvents.PaywallClose.rawName + } + pass(eventName = eventName, paywall = paywall) + } + } + } + } } } @@ -178,16 +221,15 @@ class PaywallMessageHandler( enqueueOutbound { passTemplatesToWebView(paywall) } is PaywallMessage.OnReady -> { - messageHandler?.updateState( - PaywallViewState.Updates.SetPaywallJsVersion(message.paywallJsVersion), - ) - val loadedAt = Date() - Logger.debug( - LogLevel.debug, - LogScope.superwallCore, - "!! PaywallMessageHandler: Ready !!", - ) - enqueueOutbound { didLoadWebView(paywall, loadedAt) } + // Publishing readiness and reserving initialization must be atomic + // with lifecycle sends from other threads. + synchronized(outboundLock) { + messageHandler?.updateState( + PaywallViewState.Updates.SetPaywallJsVersion(message.paywallJsVersion), + ) + val loadedAt = Date() + enqueueOutbound { didLoadWebView(paywall, loadedAt) } + } } is PaywallMessage.Close -> { @@ -212,23 +254,11 @@ class PaywallMessageHandler( shouldDismiss = message.shouldDismiss, ) - is PaywallMessage.PaywallOpen -> - if (!isWebViewLoaded) { - sendWhenLoaded(message) - } else { - enqueueOutbound { - pass(eventName = SuperwallEvents.PaywallOpen.rawName, paywall = paywall) - } - } - - is PaywallMessage.PaywallClose -> - if (!isWebViewLoaded) { - sendWhenLoaded(message) - } else { - enqueueOutbound { - pass(eventName = SuperwallEvents.PaywallClose.rawName, paywall = paywall) - } - } + is PaywallMessage.PaywallOpen, + is PaywallMessage.PaywallClose, + -> { + sendLifecycleMessage(PendingMessage(message)) + } is PaywallMessage.BackButtonPressed -> enqueueOutbound { @@ -408,6 +438,7 @@ class PaywallMessageHandler( ) withContext(Dispatchers.Main) { + currentCoroutineContext().ensureActive() messageHandler?.evaluate(templateScript) { error -> if (error != null) { Logger.debug( @@ -489,6 +520,7 @@ class PaywallMessageHandler( // behind it - a `paywall_open` that arrives while the templates are still // building must not reach the webview first. withContext(Dispatchers.Main) { + currentCoroutineContext().ensureActive() messageHandler?.evaluate(scriptSrc) { error -> if (error != null) { Logger.debug( @@ -513,16 +545,6 @@ class PaywallMessageHandler( } } - // Holds a message back until the webview reports it has (re)loaded, at which point - // it is delivered after the templates. Used when the webview is about to be replaced - // or reloaded, where sending straight away would evaluate against a page that has no - // `window.paywall` yet and silently drop the message. - fun sendWhenLoaded(message: PaywallMessage) { - queue.offer(message) - } - - // The webview reports its paywall.js version once it has loaded, so an absent - // version means there is nothing on the other side to receive a message yet. private val isWebViewLoaded: Boolean get() = messageHandler?.state?.paywall?.paywalljsVersion != null @@ -533,11 +555,12 @@ class PaywallMessageHandler( } private fun flushPendingMessagesInternal() { - if (queue.isEmpty()) return - - val pending = queue.toList() - queue.clear() - pending.forEach { handle(it) } + synchronized(outboundLock) { + if (!isWebViewLoaded) return + while (queue.isNotEmpty()) { + sendLifecycleMessage(queue.removeFirst()) + } + } } private fun openUrl( diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt index 5689bbe56..9fb74fd57 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt @@ -16,22 +16,28 @@ import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.product.ProductVariable import com.superwall.sdk.paywall.presentation.CustomCallbackRegistry import com.superwall.sdk.paywall.view.PaywallViewState +import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables import com.superwall.sdk.paywall.view.webview.templating.models.Variables import com.superwall.sdk.permissions.PermissionStatus import com.superwall.sdk.permissions.PermissionType import com.superwall.sdk.permissions.UserPermissions import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import java.util.Date /** * The paywall runtime treats a `template_variables` message that lands after @@ -40,6 +46,7 @@ import org.junit.Test * off on the first page. These tests pin the delivery order, with a template build * that is deliberately slower than the open message it must precede. */ +@OptIn(ExperimentalCoroutinesApi::class) class PaywallMessageOrderingTest { private val testDispatcher = StandardTestDispatcher() @@ -89,13 +96,14 @@ class PaywallMessageOrderingTest { // attributes and so takes far longer than encoding a plain event. private class SlowVariablesFactory( private val fail: Boolean = false, + private val buildDelayMs: Long = TEMPLATE_BUILD_MS, ) : VariablesFactory { override suspend fun makeJsonVariables( products: List?, computedPropertyRequests: List, event: EventData?, ): JsonVariables { - delay(TEMPLATE_BUILD_MS) + delay(buildDelayMs) if (fail) throw IllegalStateException("could not build the templates") return JsonVariables("template_variables", Variables(emptyMap(), emptyMap(), emptyMap())) } @@ -110,9 +118,12 @@ class PaywallMessageOrderingTest { ): PermissionStatus = PermissionStatus.GRANTED } - private fun createHandler(failTemplateBuild: Boolean = false): PaywallMessageHandler = + private fun createHandler( + failTemplateBuild: Boolean = false, + buildDelayMs: Long = TEMPLATE_BUILD_MS, + ): PaywallMessageHandler = PaywallMessageHandler( - factory = SlowVariablesFactory(fail = failTemplateBuild), + factory = SlowVariablesFactory(fail = failTemplateBuild, buildDelayMs = buildDelayMs), options = object : OptionsFactory { override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions() @@ -145,7 +156,7 @@ class PaywallMessageOrderingTest { fun templateVariablesReachWebviewBeforePaywallOpenOnAPreloadedPaywall() = runTest { Given("a paywall whose webview has already loaded") { - val state = PaywallViewState(paywall = Paywall.stub(), locale = "en-US") + val state = PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US") val delegate = RecordingDelegate(state) delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION)) val handler = createHandler() @@ -167,7 +178,7 @@ class PaywallMessageOrderingTest { fun templateVariablesReachWebviewBeforePaywallOpenWhenPaywallIsNotPreloaded() = runTest { Given("a paywall whose webview has just reported it is ready") { - val state = PaywallViewState(paywall = Paywall.stub(), locale = "en-US") + val state = PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US") val delegate = RecordingDelegate(state) val handler = createHandler() handler.messageHandler = delegate @@ -188,13 +199,21 @@ class PaywallMessageOrderingTest { fun deferredPaywallOpenIsSentAfterTheTemplatesOnceTheWebviewLoads() = runTest { Given("an open deferred while the webview is being recreated") { - val state = PaywallViewState(paywall = Paywall.stub(), locale = "en-US") + val state = + PaywallViewState( + paywall = Paywall.stub().copy(paywalljsVersion = null), + locale = "en-US", + isPresented = true, + lastOpen = Date(1), + ) val delegate = RecordingDelegate(state) + delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION)) val handler = createHandler() handler.messageHandler = delegate When("the replacement webview finishes loading") { - handler.sendWhenLoaded(PaywallMessage.PaywallOpen) + handler.resetForWebViewReload() + handler.flushPendingMessages() advanceUntilIdle() assertTrue( "the deferred open was sent before the webview loaded", @@ -215,7 +234,7 @@ class PaywallMessageOrderingTest { fun aFailedSendDoesNotHoldBackTheMessagesQueuedBehindIt() = runTest { Given("a paywall whose template build throws") { - val state = PaywallViewState(paywall = Paywall.stub(), locale = "en-US") + val state = PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US") val delegate = RecordingDelegate(state) delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION)) val handler = createHandler(failTemplateBuild = true) @@ -236,6 +255,148 @@ class PaywallMessageOrderingTest { } } + @Test + fun slowTemplatesCannotBeOvertakenOnAPreloadedPaywall() = + runTest { + val delegate = RecordingDelegate(PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")) + delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION)) + val handler = createHandler(buildDelayMs = 11_000L) + handler.messageHandler = delegate + + handler.handle(PaywallMessage.TemplateParamsAndUserAttributes) + handler.handle(PaywallMessage.PaywallOpen) + handler.handle(PaywallMessage.TransactionStart) + advanceUntilIdle() + + assertTemplatesPrecedeOpen(delegate.evaluations) + assertTrue(delegate.evaluations.indexOfMessage(PAYWALL_OPEN) < delegate.evaluations.indexOfMessage("transaction_start")) + } + + @Test + fun slowInitializationFinishesBeforeOpeningANewPaywall() = + runTest { + for (openBeforeReady in listOf(false, true)) { + val delegate = RecordingDelegate(PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")) + val handler = createHandler(buildDelayMs = 11_000L) + handler.messageHandler = delegate + + if (openBeforeReady) handler.handle(PaywallMessage.PaywallOpen) + handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION)) + if (!openBeforeReady) handler.handle(PaywallMessage.PaywallOpen) + advanceUntilIdle() + + assertTemplatesPrecedeOpen(delegate.evaluations) + assertEquals(PaywallLoadingState.Ready, delegate.state.loadingState) + } + } + + @Test + fun recoveryDoesNotOpenCachedOrBackgroundedPaywalls() = + runTest { + for (presented in listOf(false, true)) { + val delegate = + RecordingDelegate( + PaywallViewState( + paywall = Paywall.stub().copy(paywalljsVersion = null), + locale = "en-US", + isPresented = presented, + closedForBackground = presented, + ), + ) + val handler = createHandler() + handler.messageHandler = delegate + + handler.resetForWebViewReload() + handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION)) + advanceUntilIdle() + + assertTrue(delegate.evaluations.indexOfMessage(TEMPLATE_VARIABLES) >= 0) + assertEquals(-1, delegate.evaluations.indexOfMessage(PAYWALL_OPEN)) + } + } + + @Test + fun recoveryDoesNotReopenAPaywallDismissedOrBackgroundedWhileLoading() = + runTest { + for (update in listOf(PaywallViewState.Updates.CleanupAfterDestroy, PaywallViewState.Updates.SetClosedForBackground(true))) { + val delegate = + RecordingDelegate( + PaywallViewState( + paywall = Paywall.stub().copy(paywalljsVersion = null), + locale = "en-US", + isPresented = true, + lastOpen = Date(1), + ), + ) + val handler = createHandler() + handler.messageHandler = delegate + + handler.resetForWebViewReload() + handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION)) + // Put the recovery open on the outbound queue while initialization is + // still suspended, then dismiss before that open can be evaluated. + handler.flushPendingMessages() + runCurrent() + delegate.updateState(update) + advanceUntilIdle() + + assertEquals(-1, delegate.evaluations.indexOfMessage(PAYWALL_OPEN)) + } + } + + @Test + fun recoveryOpenDoesNotCarryOverToANewPresentation() = + runTest { + val delegate = + RecordingDelegate( + PaywallViewState( + paywall = Paywall.stub().copy(paywalljsVersion = null), + locale = "en-US", + isPresented = true, + lastOpen = Date(1), + ), + ) + val handler = createHandler() + handler.messageHandler = delegate + handler.resetForWebViewReload() + delegate.updateState(PaywallViewState.Updates.CleanupAfterDestroy) + delegate.updateState(PaywallViewState.Updates.SetPresentedAndFinished) + delegate.updateState(PaywallViewState.Updates.SetLastOpen) + + handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION)) + handler.handle(PaywallMessage.PaywallOpen) + advanceUntilIdle() + + assertTemplatesPrecedeOpen(delegate.evaluations) + assertEquals(1, delegate.evaluations.count { it.contains(PAYWALL_OPEN) }) + } + + @Test + fun replacingWebViewCancelsOldTemplatesAndQueuedEvents() = + runTest { + val delegate = RecordingDelegate(PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")) + delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION)) + val handler = createHandler() + handler.messageHandler = delegate + handler.handle(PaywallMessage.TemplateParamsAndUserAttributes) + handler.handle(PaywallMessage.TransactionStart) + runCurrent() + + handler.resetForWebViewReload() + assertNull(delegate.state.paywall.paywalljsVersion) + handler.handle(PaywallMessage.PaywallOpen) + handler.flushPendingMessages() + advanceUntilIdle() + assertTrue(delegate.evaluations.isEmpty()) + + handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION)) + advanceUntilIdle() + + assertTemplatesPrecedeOpen(delegate.evaluations) + assertEquals(1, delegate.evaluations.count { it.contains(TEMPLATE_VARIABLES) }) + assertEquals(-1, delegate.evaluations.indexOfMessage("transaction_start")) + } + private companion object { const val TEMPLATE_BUILD_MS = 500L const val PAYWALL_JS_VERSION = "3.0.0" From c66baf7cf47e8e8d2d65ff8f1017a3f1c9bae665 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 10 Sep 2026 14:48:38 +0000 Subject: [PATCH 4/4] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 01318ccb1..98b75ff22 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches36.5% \ No newline at end of file +branches36.8% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index eb67a0eb4..a08d53e80 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage45.7% \ No newline at end of file +coverage46% \ No newline at end of file