SIGN IN SIGN UP

fix(paywalls): keep the subscribe button working after a cancelled purchase (#4071)

## Problem

Cancelling the native Google Play purchase sheet left the subscribe
button unresponsive. The button rendered normally but taps did nothing.
Only closing and reopening the paywall fixes it

Fixes
[WFL-503](https://linear.app/revenuecat/issue/WFL-503/paywall-button-unresponsive-after-canceled-android-purchase).

## Device repro

https://github.com/user-attachments/assets/f883f64d-5e89-4313-8813-a9af47a9c800


<details>
<summary>AI session context</summary>

## Metadata

- Branch: `facu/paywall-purchase-action-stranded-on-cancel`
- Commit: `e089a5382`
- Base: `origin/main` @ `43ceaaf70`
- Agent: Claude Opus 5 (1M context), Claude Code
- Human: @facumenzella
- Scope: bug fix plus tests, no public API change

## Original request

"if user cancels the purchase flow in native purchase popup and returns
to paywall, then the subscription button gets unresponsive, so
subscribing after canceling the flow isn't possible until user closes
the paywall and opens it again ==> happens only in android". Follow-ups:
identify the customer's paywalls via the mafdet CLI (app is Yousician),
then "can't you test it yourself?", then run the change through
`/codex-review-fix-loop`.

## What the agent contributed

- Traced the failure from `ButtonComponentView` through
`handlePackagePurchase` / `performPurchase` to
`verifyNoActionInProgressOrStartAction`, and confirmed via
`CoroutinesExtensionsCommon.kt:76` that `awaitPurchase` does not forward
cancellation to the store.
- Compared against
`purchases-ios/RevenueCatUI/Purchasing/PurchaseHandler.swift` (`defer`
at :753, :803, :871, :896) to establish the Android/iOS asymmetry.
- Queried mafdet for the customer's config. The affected paywall is
workflow `wf8d23f58a15da4bc5` ("Copy of RC Back to School - % new") in
project `e9f3d5ff` (Yousician - Staging): single step, `components`
template, offering `Back_to_School_2026_%`. Its CTA is a
`purchase_button`, which StyleFactory compiles to
`ButtonComponentStyle.Action.PurchasePackage` and renders through
`ButtonComponentView`, the path this PR fixes.
- Wrote the failing tests first, then the fix.
- Reproduced the bug on an emulator with the customer's paywall,
identified the `LockToPortrait` trigger, and recorded before/after.

## Decision log

- `Decision:` run the action on `viewModelScope` and have the caller
`join()`, rather than only adding `try/finally` on the caller's scope.
`Rejected:` the finally-only version, because the codex invariants lens
showed it released the gate while the store flow was still live,
allowing an overlapping second purchase and dropping the first
purchase's completion. `Proof:` `purchase outliving a cancelled caller
still completes and blocks a second attempt` fails without the scope
change.
- `Decision:` extract `runExclusiveAction` instead of repeating
guard/launch/finally in both functions. Cut the diff on
`PaywallViewModel.kt` from 121/106 to 33/14 and gave `finishAction` one
call site.
- `Decision:` keep the `finally` in `ButtonComponentView`. It is the
only writer that can reset `state.actionInProgress`, so without it that
retained flag strands true and disables every button.
- `Decision:` defer collapsing the two in-progress flags. Out of scope
for a bug fix, listed above as follow-up.
- `Decision:` restructured `purchase outliving a cancelled caller...` to
launch the second tap rather than call it, after the RED check showed
the original shape deadlocked the whole Gradle task instead of failing
an assertion.

## Review loop

`/codex-review-fix-loop`, pass 1, three lenses on the same snapshot, all
reaching `completed`:

- Correctness (`review`): no findings.
- Invariants (`adversarial-review`): one high finding, the gate released
while the store operation was still alive. **Applied.**
- Coverage (`adversarial-review`): one medium finding, the
`ButtonComponentView` cleanup was pinned by no test, plus a note that
the listener-veto early return was uncovered. **Both applied.**

`/simplify` then ran four cleanup agents. Applied: extract
`runExclusiveAction`; delete an inaccurate comment claiming
`handlePackagePurchase` was `performPurchase`'s only caller (it is also
reached via `performPurchaseIfNecessary`); move the detekt suppressions
onto the extracted body; bound every test wait with `withTimeout` so a
regression fails instead of hanging the suite; dedup the
components-offering setup into `createComponentsModel()`; hoist
`purchaseButtonStyle` to a `val`; replace a never-completing
`CompletableDeferred` with `awaitCancellation()`.

Skipped: rebuilding `purchaseButtonStyle` from
`previewStackComponentStyle` / `previewTextComponentStyle`, because that
helper is a verbatim extraction shared with a pre-existing test whose
assertions depend on those exact values, so substituting different
factory defaults changes that test's subject.

Pass 2 was launched on the new snapshot and stopped early at the human's
request ("dont over complicate this"), so the loop did not reach a
formal fixpoint.

## Files

-
`ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt`
— `runExclusiveAction`, `performRestore`, `performPackagePurchase`
-
`ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt`
— click coroutine `try/finally`
-
`ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt`
-
`ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentViewTests.kt`

## Commands run

- `./gradlew :ui:revenuecatui:testDefaultsDebugUnitTest detektAll` —
1753 tests, 1 pre-existing environmental failure, detekt clean
- RED checks: reverted each fix in turn and confirmed the matching test
failed

## Review focus

- Optional hardening not taken: adding
`android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"`
to `PaywallActivity` would avoid the recreation entirely, but only for
the SDK's own Activity. Apps embedding `Paywall()` in their own Activity
would still be recreated, which is why the coroutine ownership is fixed
instead. Worth a view on whether to add it as defence in depth.

- Is `viewModelScope.launch { }.join()` the right primitive here, versus
`withContext` (which would re-parent to the caller's job and reintroduce
the cancellation)?
- `runExclusiveAction` takes the gate on the caller and releases it
inside the job. If `viewModelScope` is already cancelled, `launch`
returns a pre-cancelled job whose body never runs and the gate strands.
Only reachable after `onCleared`, so the ViewModel is dead anyway, but
worth a second opinion.
- After a teardown mid-purchase, `state.actionInProgress` goes false
while the ViewModel gate is still held, so a re-entered paywall can
briefly show enabled buttons whose taps are refused. That window is
transient and self-heals when the store resolves. The alternative
strands the flag forever.
- `handlePackagePurchase` now returns when the job completes rather than
propagating an unexpected non-`PurchasesException` to the caller; it
surfaces through `viewModelScope` instead.

## Validation gaps

- Reproduced on an emulator, `Not run:` on a physical device.
- `Not run:` a real completed Play purchase. The emulator build is not
signed for this package, so Play returns `DEVELOPER_ERROR` instead of
the purchase sheet. The forced portrait rotation, and therefore the
Activity recreation, happens before that point.
- The sheet path (per the WFL-503 title) is covered by the same fix and
by `ButtonComponentViewTests`, but was `Not run` as a device repro; the
landscape path was.
- Paparazzi snapshots `Not run` (missing
`upstream/paywall-preview-resources` submodule locally).

</details>



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes purchase/restore concurrency and how paywall buttons reflect
in-flight billing; behavior is well-tested but touches the critical
purchase path and dual in-progress flags.
> 
> **Overview**
> Fixes **unresponsive subscribe/restore buttons** after the user
cancels the Play purchase sheet or the paywall UI is torn down mid-flow
(e.g. Activity recreation). The root issue was **stranded “action in
progress” flags**: composition-scoped coroutines could cancel without
clearing state, while the ViewModel gate could stay locked if purchase
work kept running on the wrong scope.
> 
> **Paywall state** splits click-scoped progress
(`clickScopedActionInProgress`) from the ViewModel gate
(`viewModelActionInProgress`). **`actionInProgress`** is now the live OR
of both, so billing that outlives the button still disables taps until
the store flow finishes.
> 
> **`ButtonComponentView`** wraps the click handler in **`try/finally`**
so leaving composition always clears the click-scoped flag even when
`onClick` is cancelled.
> 
> **`PaywallViewModel`** routes purchase and restore through
**`runExclusiveAction`**: work runs on **`viewModelScope`** (so
cancellation of the UI caller does not abandon `awaitPurchase`), the
caller **`join()`s** the job, and **`finishAction()`** runs in
**`finally`**. Components paywall state receives the ViewModel’s
`_actionInProgress` so the UI stays aligned with the gate.
> 
> Tests cover cancelled mid-flight purchases/restores, actions outliving
the click, listener veto, and button recomposition.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ac32fb62068e5c10557d23c197e9e65da4b35beb. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
F
Facundo Menzella committed
e4301a0cedafb48c79b9fee58b39ed33b4481b4f
Parent: 0edf33e
Committed by GitHub <noreply@github.com> on 8/27/2026, 11:44:38 AM