Skip to content

Fix dead buy button when re-presenting a cached paywall after a purchase#434

Draft
claude[bot] wants to merge 6 commits into
developfrom
fix/paywall-transient-state-reset-on-represent
Draft

Fix dead buy button when re-presenting a cached paywall after a purchase#434
claude[bot] wants to merge 6 commits into
developfrom
fix/paywall-transient-state-reset-on-represent

Conversation

@claude

@claude claude Bot commented Jul 13, 2026

Copy link
Copy Markdown

Requested by Brian Anglin · Slack thread

Changes in this pull request

  • Reset per-presentation transient state (the purchase/manual loading spinner and the presentation-prepared flags) at the start of PaywallView.present(), so a cached paywall view always starts a new presentation from a clean slate.

Before / After

Before (2.7.21): buying a consumable (e.g. on buy_credits) and then re-opening the same paywall within the same app session leaves the buy button dead — the tap does nothing and no purchase starts — until the app is killed.

After: the re-presented paywall accepts taps and starts the purchase normally.

Root cause

2.7.21 (PR #431, "Fix background teardown") correctly stopped fully tearing down a paywall when it is merely backgrounded: on a non-finishing onStop the view is kept alive so a backgrounded paywall can resume. That change is correct and this PR does not revert it — the don't-destroy-on-background behavior is preserved.

The side effect: the full (finishing) teardown was also the only place that reset per-presentation transient state — it downgraded a LoadingPurchase/ManualLoading spinner back to Ready and called resetPresentationPreparations() (presentationWillPrepare = true, presentationDidFinishPrepare = false). Buying a consumable launches Google Play's billing sheet as a separate activity, so the paywall activity gets onStop() with isFinishing == false — a non-finishing stop that, since #431, skips teardown. That leaves loadingState == LoadingPurchase and presentationDidFinishPrepare == true.

Paywall views are cached and reused by PaywallManager (the cache-hit path only re-wires the callback and merges the paywall data — it does not reload or reset state). On the next presentation of that cached view the stale state carries over: the stale loading overlay swallows the buy tap, and onViewCreated() early-returns because presentationDidFinishPrepare is still true, skipping the re-wiring (SetPresentedAndFinished, didPresentPaywall, loadingStateDidChange(), flushPendingMessages()).

How

superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt

  • Added resetTransientPresentationState() (next to resetPresentationPreparations()), which downgrades a LoadingPurchase/ManualLoading spinner to Ready (mirroring exactly the conditional the old finishing teardown used at the former destroyed() reset site) and calls resetPresentationPreparations().
  • Called it as the first line of present().

Why this location is correct:

  • present() runs only for a genuinely new presentation (it is invoked from presentPaywallViewpaywallView.present(...), which starts a fresh SuperwallPaywallActivity). It is the single entry point common to both fresh (cache-miss) and cached (cache-hit) views.
  • It does not run on resume-same-instance. A backgrounded paywall resumes via the activity lifecycle (SuperwallPaywallActivity.onResume()paywallView.onViewCreated()), which never calls present(). So this change cannot double-fire presentation events on a resume.
  • It does not clear the spinner during an in-flight purchase. The billing sheet stop is what triggers the non-finishing onStop; present() is not called while that transaction is in flight (the paywall is already presented and is guarded against re-presentation), so the mid-purchase spinner is untouched.
  • For a fresh view the reset is a no-op (loadingState is Unknown/LoadingURL, and the prepare flags are already at their defaults), so first-present behavior is unchanged.

Deliberately not placed on the !forceCleanup branch of destroyed(): that branch fires during the purchase (billing sheet up), so resetting the spinner there would wipe it mid-purchase and could disturb same-instance resume — exactly the cases this location avoids.

Testing

  • Added a focused unit test in PaywallViewTest.kt (resetTransientPresentationState_clearsStalePurchaseSpinnerAndPrepareFlag_soRepresentReWires) that sets up a cached view left in LoadingPurchase with presentationDidFinishPrepare == true (the post-Fix background teardown #431 stale state), invokes the reset, and asserts the spinner is back to Ready and the prepare flags are re-armed so onViewCreated() will re-wire instead of early-returning. The existing onViewCreated_afterBackgroundStop_tracksPaywallOpenAgain and destroyed_onNonFinishingBackgroundStop_... tests continue to cover the preserved Fix background teardown #431 backgrounding behavior.
  • Not compiled locally: this environment has no Android SDK and no dependency cache, so a Gradle build cannot run here. CI / a reviewer should confirm :superwall compiles and the unit tests pass. A device repro would be: buy a consumable on buy_credits, dismiss, then re-trigger buy_credits in the same session and confirm the buy button starts the purchase (a transaction_start fires).

Reviewer note

Ian Rumac (author of #431) — please confirm the backgrounding behavior from #431 is fully preserved; this PR only relocates the transient-state reset to the next presentation and does not change when the view is torn down.

Checklist

  • All unit tests pass. (added test; CI to verify — not compiled locally, no Android SDK here)
  • All UI tests pass.
  • Demo project builds and runs.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run ktlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

Generated by Claude Code

claude and others added 6 commits July 13, 2026 21:43
Since #431 stopped tearing down backgrounded paywalls on a non-finishing
onStop, per-presentation transient state was no longer reset. A purchase
launches Google Play's billing sheet (a separate activity), producing a
non-finishing onStop that leaves loadingState=LoadingPurchase and
presentationDidFinishPrepare=true. On the next presentation of the same
cached PaywallView, the stale spinner swallows the buy tap and
onViewCreated() early-returns without re-wiring the view.

Reset the transient state (purchase/manual spinner -> Ready and the
presentation-prepared flags) at the start of PaywallView.present(), which
runs only for a genuinely new presentation. This does not revert #431: the
don't-destroy-on-background behavior is untouched, resume-same-instance
(onResume -> onViewCreated) is unaffected, and the in-flight purchase
spinner is never cleared because present() is not called mid-purchase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6NCb2BMXSXwAN1vCMFvyc
Add 2 emulator (Firebase Test Lab) androidTest cases covering the PR #434
fix: after a consumable purchase, re-presenting the same cached paywall used
to leave the buy button dead because a stale LoadingPurchase overlay carried
over. Both tests drive the full-screen register() path (the fixed path) from a
fragment-hosted screen and purchase via Superwall test mode (no Play billing on
CI):

- PaywallHostFragment: minimal fragment that calls register() on view-create,
  mirroring the customer's fragment-hosted setup.
- RepresentTests.test_represent_after_consumable_purchase_reinvokes_purchase:
  present -> buy -> drawer -> Purchased -> dismiss -> re-present -> tap buy
  again; asserts the purchase drawer re-appears (dead pre-fix).
- RepresentTests.test_represent_resets_loading_state: after re-present, asserts
  the public loadingState is not LoadingPurchase (public-API analog of the
  internal reset unit test).

Wire up androidx.fragment:fragment-testing (+ fragment-testing-manifest for the
FragmentScenario host activity) via the version catalog. Flow keys off the
CONSUMABLE_REBUY_PLACEMENT / BUY_BUTTON_TEXT constants so the placement and CTA
label can be adjusted server-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6NCb2BMXSXwAN1vCMFvyc
…tPaywallView re-present is covered; rewire UI test to embed

The dead-buy-button fix previously lived in PaywallView.present(), which only
runs on the full-screen register() path. The embedded getPaywall/getPaywallView
API never calls present(), so an embedded re-present returned the same cached
PaywallView with stale LoadingPurchase/presentationDidFinishPrepare and the bug
reproduced there. Both paths funnel through PaywallManager.getPaywallView's
cache-hit branch on a re-present, so the reset now happens there (guarded by
!isPreloading). Rewire the :app: UI test to embed the paywall via PaywallBuilder
instead of register(), so it exercises the embedded cache-hit reset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6NCb2BMXSXwAN1vCMFvyc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant