Fix dead buy button when re-presenting a cached paywall after a purchase#434
Merged
ianrumac merged 12 commits intoJul 22, 2026
Merged
Conversation
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
marked this pull request as ready for review
July 22, 2026 11:44
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Brian Anglin · Slack thread
Changes in this pull request
PaywallManagerhands a cachedPaywallViewback for a new presentation, so a cached paywall always starts a new presentation from a clean slate — for bothregister()and embedded paywalls (getPaywall/getPaywallView).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
onStopthe 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/ManualLoadingspinner back toReadyand calledresetPresentationPreparations()(presentationWillPrepare = true,presentationDidFinishPrepare = false). Buying a consumable launches Google Play's billing sheet as a separate activity, so the paywall activity getsonStop()withisFinishing == false— a non-finishing stop that, since #431, skips teardown. That leavesloadingState == LoadingPurchaseandpresentationDidFinishPrepare == 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, andonViewCreated()early-returns becausepresentationDidFinishPrepareis stilltrue, skipping the re-wiring (SetPresentedAndFinished,didPresentPaywall,loadingStateDidChange(),flushPendingMessages()).How
superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.ktresetTransientPresentationState()(next toresetPresentationPreparations()), which downgrades aLoadingPurchase/ManualLoadingspinner toReady(mirroring exactly the conditional the old finishing teardown used at the formerdestroyed()reset site) and callsresetPresentationPreparations().dismiss()'s callback branch (embedded paywalls, where the host owns teardown and may legitimately keep the view on screen), a staleLoadingPurchase/ManualLoadingspinner is downgraded toReadybeforeonFinishedfires.superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.ktgetPaywallView()'s cache-hit branch now callsview.resetTransientPresentationState()when handing a cached view back for a new presentation, guarded by!isPreloading(preloading never resets a live view) andisForPresentation(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:
register()path and the embeddedgetPaywall/getPaywallViewpath.SuperwallPaywallActivity.onResume()→paywallView.onViewCreated()), which never re-fetches the view fromPaywallManager. So this change cannot double-fire presentation events on a resume.getPresentationResult()— is excluded by theisForPresentationguard (covered by a dedicated unit test).!isPreloadingguard, covered by a dedicated unit test).Deliberately not placed on the
!forceCleanupbranch ofdestroyed(): 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
PaywallViewTest.resetTransientPresentationState_clearsStalePurchaseSpinnerAndPrepareFlag_soRepresentReWires— sets up the post-Fix background teardown #431 stale state (LoadingPurchase+presentationDidFinishPrepare == true), invokes the reset, and asserts the spinner isReadyand the prepare flags are re-armed soonViewCreated()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 orgetPresentationResult()checks.onViewCreated_afterBackgroundStop_tracksPaywallOpenAgainanddestroyed_onNonFinishingBackgroundStop_...tests continue to cover the preserved Fix background teardown #431 backgrounding behavior.PaywallViewDismissTest.dismiss_purchased_resets_loading_spinner_for_embedded_paywallcovers 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 inLoadingPurchase. The bare host activity lives in the debug source set (same must-live-in-the-app pattern asBenchmarkForegroundActivity; the test commits its own fragment), so nofragment-testingartifacts or test libraries are pulled into app builds and nothing ships in release.:superwallunit tests above pass, and bothRepresentTestscases pass on an API 30 emulator (~20s each). Getting them green surfaced two test-harness fixes: the host activity must be launched BEFORE awaitingConfigured(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 onbuy_credits, dismiss, re-triggerbuy_creditsin the same session, and confirm the buy button starts the purchase (atransaction_startfires).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
PaywallViewTest+PaywallManagerTestgreen locally; CI to confirm the full suite)CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.ktlintin the main directory and fixed any issues. (changed files are clean; remaining hits are pre-existing ondevelopunder newer ktlint)Generated by Claude Code