Skip to content

Fix: dismiss was called before onPurchaseComplete callback invocation#3353

Merged
JZDesign merged 10 commits into
mainfrom
jzdesign/dismiss-called-before-purchase-complete-callback
May 7, 2026
Merged

Fix: dismiss was called before onPurchaseComplete callback invocation#3353
JZDesign merged 10 commits into
mainfrom
jzdesign/dismiss-called-before-purchase-complete-callback

Conversation

@JZDesign

@JZDesign JZDesign commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-ios and hybrids

Motivation

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 PaywallActivity no longer calls finish() directly from purchase/restore callbacks; it now relies on the configured dismissRequest/onDismissRequest flow to close (or launch an exit offer) after results are set, preventing premature activity termination that could drop onPurchaseCompleted events.

Also wires requiredEntitlementIdentifier into the paywall view model via shouldDisplayBlock when creating it in the activity, and updates PaywallViewModelTest’s factory helper to allow injecting a custom dismissRequest for more precise dismissal assertions.

Reviewed by Cursor Bugbot for commit fd8c30c. Bugbot is set up for automated code reviews on this repo. Configure here.

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
@JZDesign JZDesign requested a review from a team as a code owner April 16, 2026 14:42

@tonidero tonidero left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just a question about the need for the yield functions... PaywallActivity changes make sense but just a question.

@JZDesign JZDesign requested review from tonidero and vegaro April 30, 2026 15:39

@vegaro vegaro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

codecov Bot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.47%. Comparing base (67a763d) to head (fd8c30c).
⚠️ Report is 7 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@JZDesign JZDesign enabled auto-merge May 7, 2026 12:40
@JZDesign JZDesign added this pull request to the merge queue May 7, 2026
Merged via the queue into main with commit 17eff89 May 7, 2026
36 checks passed
@JZDesign JZDesign deleted the jzdesign/dismiss-called-before-purchase-complete-callback branch May 7, 2026 13:48
matteinn pushed a commit to matteinn/purchases-android that referenced this pull request Jun 5, 2026
**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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants