Fix: dismiss was called before onPurchaseComplete callback invocation#3353
Conversation
Move the finish into the dismiss request to allow the listener time to do its thing.
There was a bug where dismiss could get invoked before the on purchase completed listener can finish it's callback
tonidero
left a comment
There was a problem hiding this comment.
Just a question about the need for the yield functions... PaywallActivity changes make sense but just a question.
vegaro
left a comment
There was a problem hiding this comment.
The changes in PaywallActivity make sense, but looking at the original report, they are not using the PaywallActivity...
This is a simplified version of their code:
var purchaseCompleted by remember { mutableStateOf(false) }
Paywall(
options = PaywallOptions.Builder(
dismissRequest = {
// Suppress auto-dismiss after purchase so we can run
// post-purchase logic (e.g. show a link-account sheet)
// and dismiss manually when ready.
if (!purchaseCompleted) onCloseClick()
},
)
.setOffering(offering)
.setListener(object : PaywallListener {
override fun onPurchaseCompleted(
customerInfo: CustomerInfo,
storeTransaction: StoreTransaction,
) {
purchaseCompleted = true
viewModel.onPurchaseCompleted(customerInfo)
}
override fun onRestoreCompleted(customerInfo: CustomerInfo) {
purchaseCompleted = true
viewModel.onRestoreCompleted(customerInfo)
}
})
.build(),
)
The yield changes I am a bit more hesitant about, specially after looking at their code. It’s true that if they are doing any async work in viewModel.onPurchaseCompleted, the yield would let that work run on the main thread before we call options.dismissRequest(). But notice that they set purchaseCompleted = true synchronously in the listener, before doing anything else. So by the time dismissRequest() fires, the guard already evaluates to false and onCloseClick() is suppressed yield or no yield, their pattern would work the same independently of the yields.
The other concern is the one I raised earlier: yield only flushes one level of Handler.post. If the listener’s posted work itself posts more work (or hops to a different dispatcher), we’re back to racing the dismiss. If we do want a first-class story for "finish my post-purchase flow, then dismiss," I think that deserves an explicit API (e.g. an opt-out flag or a completion signal to the listener) rather than relying on yiel based timing.
Stepping back a bit, the customer says: "the paywall should stay presented (or at least not report 'dismissed') until we finish follow-up steps and then dismiss it." But the SDK doesn’t actually dismiss anything itself, the only thing it does after purchase is call options.dismissRequest(), and what happens from there is entirely up to the developer's lambda. With their guard in place (the check for purchaseCompleted), the lambda is a no-op and the paywall should stay presented. The "not report dismissed" wording makes me think they’re misinterpreting the callback firing as the SDK dismissing them, when it isn’t.
My suggestion: keep the structural change in PaywallActivity because I think it makes sense and drop the yields (since I think it won't fix their probelm anyways). The actual issue is more likely either (a) something in their viewModel.onPurchaseCompleted is unmounting the paywall (navigation, parent state change), or (b) they’re misreading the dismissRequest callback as the dismissal itself.
Not sure how easy it would be, would it be worth trying to write a failing test on main using Paywall() composable that reproduces the report? If we can’t reproduce it, that strengthens the case that the issue is in their app code, not ours.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3353 +/- ##
=======================================
Coverage 79.47% 79.47%
=======================================
Files 362 362
Lines 14547 14547
Branches 1977 1977
=======================================
Hits 11561 11561
Misses 2190 2190
Partials 796 796 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
…ck' of https://github.com//RevenueCat/purchases-android into jzdesign/dismiss-called-before-purchase-complete-callback
**This is an automatic release.** ## RevenueCat SDK ### 🐞 Bugfixes * fix: url encode query prameters (RevenueCat#3451) via Jacob Rakidzich (@JZDesign) ## RevenueCatUI SDK ### 🐞 Bugfixes * Fix: dismiss was called before onPurchaseComplete callback invocation (RevenueCat#3353) via Jacob Rakidzich (@JZDesign) * Propagate default package across workflow steps (RevenueCat#3431) via Cesar de la Vega (@vegaro) ### Paywallv2 #### ✨ New Features * feat: Allow disabling of automatic font scaling (RevenueCat#3438) via Jacob Rakidzich (@JZDesign) ### 🔄 Other Changes * Extract `PaywallComponentsImagePreDownloader` (RevenueCat#3448) via Cesar de la Vega (@vegaro) * Simplify `WorkflowTransitionState` with explicit from/to step fields (RevenueCat#3441) via Cesar de la Vega (@vegaro) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk release bookkeeping: primarily flips version strings from `10.5.0-SNAPSHOT` to `10.5.0` and updates docs/changelogs, with no functional code changes beyond the reported version constant. > > **Overview** > Cuts the `10.5.0` release by switching the project from `10.5.0-SNAPSHOT` to `10.5.0` across build metadata (`.version`, `gradle.properties`, sample/test app `libs.versions.toml`, and `Config.frameworkVersion`). > > Updates release artifacts and documentation pointers: CircleCI docs deploy now syncs the `10.5.0` docs folder to S3, `docs/index.html` redirects to `10.5.0`, and changelogs are rolled forward with the `10.5.0` entries in `CHANGELOG.md`/`CHANGELOG.latest.md`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 48537d6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Checklist
purchases-iosand hybridsMotivation
Apps were missing the on purchase complete event
Description
Moves finish out of it's handler functions and relies on the on dismiss request. Also uses yield to ensure correct ordering of events
Note
Medium Risk
Changes the paywall activity’s lifecycle/auto-dismiss behavior after purchase/restore, which can affect callback ordering and whether the activity closes in edge cases (e.g., errors, exit offers, entitlement-gated display). Scope is contained to UI paywall flow and related tests.
Overview
Ensures
PaywallActivityno longer callsfinish()directly from purchase/restore callbacks; it now relies on the configureddismissRequest/onDismissRequestflow to close (or launch an exit offer) after results are set, preventing premature activity termination that could droponPurchaseCompletedevents.Also wires
requiredEntitlementIdentifierinto the paywall view model viashouldDisplayBlockwhen creating it in the activity, and updatesPaywallViewModelTest’s factory helper to allow injecting a customdismissRequestfor more precise dismissal assertions.Reviewed by Cursor Bugbot for commit fd8c30c. Bugbot is set up for automated code reviews on this repo. Configure here.