Fix dead buy button when re-presenting a cached paywall after a purchase#434
Draft
claude[bot] wants to merge 6 commits into
Draft
Fix dead buy button when re-presenting a cached paywall after a purchase#434claude[bot] wants to merge 6 commits into
claude[bot] wants to merge 6 commits into
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
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
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
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-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, 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().present().Why this location is correct:
present()runs only for a genuinely new presentation (it is invoked frompresentPaywallView→paywallView.present(...), which starts a freshSuperwallPaywallActivity). It is the single entry point common to both fresh (cache-miss) and cached (cache-hit) views.SuperwallPaywallActivity.onResume()→paywallView.onViewCreated()), which never callspresent(). So this change cannot double-fire presentation events on a resume.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.loadingStateisUnknown/LoadingURL, and the prepare flags are already at their defaults), so first-present behavior is unchanged.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.kt(resetTransientPresentationState_clearsStalePurchaseSpinnerAndPrepareFlag_soRepresentReWires) that sets up a cached view left inLoadingPurchasewithpresentationDidFinishPrepare == true(the post-Fix background teardown #431 stale state), invokes the reset, and asserts the spinner is back toReadyand the prepare flags are re-armed soonViewCreated()will re-wire instead of early-returning. The existingonViewCreated_afterBackgroundStop_tracksPaywallOpenAgainanddestroyed_onNonFinishingBackgroundStop_...tests continue to cover the preserved Fix background teardown #431 backgrounding behavior.:superwallcompiles and the unit tests pass. A device repro would be: buy a consumable onbuy_credits, dismiss, then 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
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.ktlintin the main directory and fixed any issues.Generated by Claude Code