Skip to content

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

Merged
ianrumac merged 12 commits into
developfrom
fix/paywall-transient-state-reset-on-represent
Jul 22, 2026
Merged

Fix dead buy button when re-presenting a cached paywall after a purchase#434
ianrumac merged 12 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) whenever PaywallManager hands a cached PaywallView back for a new presentation, so a cached paywall always starts a new presentation from a clean slate — for both register() and embedded paywalls (getPaywall/getPaywallView).
  • Additionally reset a stale purchase/manual spinner in PaywallView.dismiss()'s callback branch, so an embedded paywall whose host keeps it on screen after a purchase (e.g. a tab-bar embed) doesn't spin forever.

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-wired the callback and merged the paywall data — it did 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().
  • In dismiss()'s callback branch (embedded paywalls, where the host owns teardown and may legitimately keep the view on screen), a stale LoadingPurchase/ManualLoading spinner is downgraded to Ready before onFinished fires.

superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt

  • getPaywallView()'s cache-hit branch now calls view.resetTransientPresentationState() when handing a cached view back for a new presentation, guarded by !isPreloading (preloading never resets a live view) and isForPresentation (getPresentationResult() is a pure query API that also fetches through here — a result check while the same paywall is on screen mid-purchase must not wipe its live spinner or prepare flags).

Why this location is correct:

  • The cache-hit branch runs only when a cached view is handed back for a genuinely new presentation — it is the single funnel common to the full-screen register() path and the embedded getPaywall/getPaywallView path.
  • It does not run on resume-same-instance. A backgrounded paywall resumes via the activity lifecycle (SuperwallPaywallActivity.onResume()paywallView.onViewCreated()), which never re-fetches the view from PaywallManager. So this change cannot double-fire presentation events on a resume.
  • It does not clear the spinner during an in-flight purchase. Presentation requests aren't issued while a transaction is in flight, and the one non-presentation caller that does fetch during that window — getPresentationResult() — is excluded by the isForPresentation guard (covered by a dedicated unit test).
  • It never runs for preloading (!isPreloading guard, covered by a dedicated unit test).
  • For a fresh (cache-miss) view the reset never runs at all, 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

  • Unit (pass locally):
    • PaywallViewTest.resetTransientPresentationState_clearsStalePurchaseSpinnerAndPrepareFlag_soRepresentReWires — sets up the post-Fix background teardown #431 stale state (LoadingPurchase + presentationDidFinishPrepare == true), invokes the reset, and asserts the spinner is Ready and the prepare flags are re-armed so onViewCreated() re-wires instead of early-returning.
    • PaywallManagerTest.test_getPaywallView_resetsTransientState_onCacheHitForNewPresentation, ..._doesNotResetTransientState_whenPreloading, and ..._doesNotResetTransientState_forPresentationResultChecks — the cache-hit reset fires for a new presentation, and never during preloading or getPresentationResult() checks.
    • The existing onViewCreated_afterBackgroundStop_tracksPaywallOpenAgain and destroyed_onNonFinishingBackgroundStop_... tests continue to cover the preserved Fix background teardown #431 backgrounding behavior.
  • Instrumented: PaywallViewDismissTest.dismiss_purchased_resets_loading_spinner_for_embedded_paywall covers the embedded-host spinner reset. RepresentTests (FTL) reproduces the full flow end-to-end via a fragment-embedded paywall: buy → confirm in the test-mode drawer → re-embed the same placement → assert the second buy tap re-invokes the purchase flow and the view isn't stuck in LoadingPurchase. The bare host activity lives in the debug source set (same must-live-in-the-app pattern as BenchmarkForegroundActivity; the test commits its own fragment), so no fragment-testing artifacts or test libraries are pulled into app builds and nothing ships in release.
  • Verified locally: :superwall unit tests above pass, and both RepresentTests cases pass on an API 30 emulator (~20s each). Getting them green surfaced two test-harness fixes: the host activity must be launched BEFORE awaiting Configured (the SDK's config fetch gates on the app being foregrounded — waiting first deadlocks), and the ALWAYS test-mode activation modal must be swiped up to reveal its below-the-fold Continue button before the paywall is tappable. Device repro if desired: buy a consumable on buy_credits, dismiss, 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. (PaywallViewTest + PaywallManagerTest green locally; CI to confirm the full suite)
  • 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. (changed files are clean; remaining hits are pre-existing on develop under newer ktlint)
  • 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 12 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
@ianrumac
ianrumac marked this pull request as ready for review July 22, 2026 11:44
@ianrumac
ianrumac merged commit 7bec0c1 into develop Jul 22, 2026
12 checks passed
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.

2 participants