Conversation
…the seam Address review on #468: - SetRequest now also owns presentationId: the first binding of a view keeps the id minted at fetch time (its load events already carry it), every re-binding of a cached view mints a new one. MergePaywall leaves it alone, like experiment and presentationSourceType. - `experiment` is a required parameter on SetRequest, PaywallView.set/present, presentPaywallView(Sync) and PaywallComponents, and the `?: state.paywall.experiment` fallback is gone, so a caller can no longer silently inherit whatever the shared cached view was last bound to. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ensure paywall sharing among experiments doesnt reuse their id
Decouple the TrialStarted webview message from the fallback reminder block so paywalls without trial reminders configured still receive it. Reminder scheduling stays gated on a started trial plus a configured TRIAL_STARTED notification. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ensure legacy notifications only schedule when trial starts
There was a problem hiding this comment.
ℹ️ No blocking issues — two minor points inline plus one scope question.
Reviewed changes — the 2.8.4 release branch: two bug fixes, their tests, and the version/CHANGELOG/badge bump.
- Experiment bound at request time, not fetch time —
MergePaywallno longer copiesexperiment/presentationSourceTypeoff the freshly fetchedPaywall;SetRequestnow writes them (pluspresentationId) atomically with the request, so two campaigns sharing a cachedPaywallViewstop overwriting each other's metadata at fetch time. experimentthreaded through the presentation pipeline — new required parameter onPaywallComponents,presentPaywallView,presentPaywallViewSync,PaywallView.presentandPaywallView.set, sourced from(outcome.triggerResult as? InternalTriggerResult.Paywall)?.experiment.- Fresh
presentationIdper re-binding —SetRequestkeeps the fetch-time id only for a view's first binding and mints a new UUID for every subsequent one. - Trial reminders gated on an actual trial — new
TrialReminderLogic.fallbackTrialNotificationsreturns nothing unlessdidStartFreeTrial;TransactionManagernullstrialEndin the same case and passes the flag throughnotifyOfTransactionComplete. freeTrial_startdecoupled from reminder config — thePaywallMessage.TrialStartedwebview message moved out of thetrialNotifications.isNotEmpty()block into its ownif (didStartFreeTrial), so a paywall with no reminders configured still receives it.- Tests — new
PaywallManagerExperimentIsolationTest, three newPaywallViewStateTestcases, two newTransactionManagerTestcases, and a newTrialReminderLogicTest; existing call sites updated for the new parameters. - Release chores —
version.env2.8.3 → 2.8.4, CHANGELOG entry, coverage badge.
I checked the paths that could have regressed from dropping experiment out of MergePaywall and found none: preloading skips the merge (isPreloading), the debugger preview builds its own view outside the cache, getPresentationResult never calls set(), and the webview experiment template is rebuilt in presentationWillBegin() which runs after present() → set(). The (outcome.triggerResult as? …)?.experiment cast can't be null on a real presentation because getExperiment() throws for every other trigger result before a view is produced.
ℹ️ The shared cached view still resolves last-writer-wins across concurrent consumers
The fix moves the metadata write from fetch time to bind time, which closes the reported window, but the cached PaywallView still has exactly one metadata slot shared by every campaign that uses the paywall. PaywallManagerExperimentIsolationTest encodes this as intended behavior: after B binds, the shared view reports B even though A bound first. If A is the presentation actually on screen, its paywall_close and transaction events will report B's experiment. Worth confirming that's the intended long-term contract rather than a second stage of the same fix.
Technical details
# Concurrent consumers of one cached PaywallView
## Affected sites
- `superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt:126-149` — `SetRequest` writes `experiment` / `presentationSourceType` / `presentationId` into the single shared `state.paywall`.
- `superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt:79-98` — the cache-hit branch hands the same instance to every non-preloading caller, including one whose view is currently presented.
- `superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerExperimentIsolationTest.kt:237-242` — asserts the later binder wins.
## Open questions for the human
- Is "last binder wins on a shared view" the intended long-term contract, or is per-presentation isolation (a view instance or metadata slot per in-flight request) a follow-up?
- Should the embedded `getPaywall()` path refuse to re-bind a view whose `state.isPresented` is true, the way `register()` is protected by `checkNoPaywallAlreadyPresented`?ℹ️ Nitpicks
PaywallView.present,Superwall.presentPaywallView,Superwall.presentPaywallViewSyncand thePaywallComponentsconstructor are all public and each gained a required parameter, which is source- and binary-incompatible for external callers on a patch version bump. No in-repo caller breaks, so this may well be an acceptable trade — just worth being a deliberate choice rather than a side effect.- The headline CHANGELOG claim — "the paywall receives the
freeTrial_startmessage for every started trial, even when it has no trial reminders configured" — lives in theDependencyContainer.notifyOfTransactionCompletelambda, which has no test.TrialReminderLogicTestcovers only the extracted pure function, so theif (didStartFreeTrial)branch that actually sendsPaywallMessage.TrialStartedis unverified.
Claude Opus | 𝕏
SetRequest minted a new presentationId on every re-binding of a cached view, including one that is currently on screen. The embedded getPaywall() path has no already-presented guard, so calling it for a paywall that is live re-bound the view and split the funnel: paywall_open and the load events carried one id, paywall_close and transaction_* another. The keep-existing-id predicate now also covers a presented view. A cached view that is not on screen still mints a fresh id per presentation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
Important
The new state.isPresented guard fixes the reported id churn but latches on for host-managed embeds, so distinct presentations of an embedded paywall now share one presentation_id.
Reviewed changes — the delta since the prior pullfrog review at 265ec9a5: one behavioral commit plus a CI badge refresh.
- Kept
presentationIdstable while a view is presented —SetRequest's predicate widened fromstate.request == nulltostate.request == null || state.isPresented, so a re-binding of a view that is already on screen no longer splitspaywall_openfrom thepaywall_close/transaction_*events that follow it. This is the fix for the prior review's inline thread, which I've resolved. - New regression test —
PaywallViewStateTest.setRequest_keepsPresentationIdWhileTheViewIsPresentedwalks fresh bind → cached re-bind →SetPresentedAndFinished→ mid-presentation re-bind →CleanupAfterDestroy→ re-bind, asserting the id is held across the live re-bind and minted afresh after teardown. It fails pre-fix, so it is real coverage. - CHANGELOG entry for the new fix, and a coverage badge bump (
branches38.3 → 38.4%,jacoco47.1 → 47.2%) from the[skip ci]automation.
I traced the isPresented lifecycle across the full-screen Activity teardown (finishing and non-finishing), the billing-sheet onStop window, the Compose wrapper and the raw getPaywall() embed path. Compose is safe — PaywallComposable's onRelease calls destroyed(forceCleanup = true). The full-screen path is safe — finishing onDestroy reaches CleanupAfterDestroy, and a resume early-returns in onViewCreated() without touching SetRequest. The raw embed path is not, which is the first comment below.
⚠️ Presentation boundaries are inferred from view state rather than known at the seam
Both inline findings come from the same root: SetRequest has to guess whether a re-binding is "the live presentation being repainted" or "the next presentation starting", and isPresented is a proxy that is wrong in both directions. InternalGetPaywall.getPaywall still has no checkNoPaywallAlreadyPresented equivalent (InternalPresentation.internallyPresent does), so the seam that creates the ambiguity is the one place that could resolve it definitively. The previous review raised rejecting a re-bind on a live view as the alternative to widening the predicate; this commit took the predicate route, which is worth confirming as the deliberate long-term contract rather than the cheaper of the two.
Technical details
# `getPaywall()` can rebind a live, shared paywall view and nothing at the seam says so
## Affected sites
- `superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt:36-51` — calls `view.set(...)` on whatever `PaywallManager` returns, with no liveness check.
- `superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentation.kt:29` — the `register()` path *is* guarded by `checkNoPaywallAlreadyPresented`.
- `superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt:78-98` — the cache-hit branch hands the same instance to every caller and knows a new presentation is starting, but only resets transient spinner/prepare state.
- `superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt:139-141` — the inference site.
## Open questions for the human
- Is "a `getPaywall()` call may repaint a paywall that is currently on screen" a supported pattern, or should that seam reject the rebind the way `register()` does?
- If it stays supported, should the decision move to `PaywallManager`'s cache-hit branch (which already distinguishes a new presentation from a resume) instead of being re-derived from `isPresented` inside `SetRequest`?Claude Opus | 𝕏
Follow-up to 77c13c4. Keeping the id while `isPresented` was wrong in both directions: the flag is only cleared by a finishing destroyed(), so an embedded host that re-presents a cached view without tearing it down (PaywallHostFragment, RepresentTests) latched it on and reused the first presentation's id forever; and SetRequest still could not tell a live re-binding from the next presentation starting. A presentation is now identified by its paywall_open. SetRequest no longer touches the id. BeginPresentation mints a new one when the current id has already been consumed by an open (or was never set), and runs when a presentation is prepared on a view that is not presented and when a new presentation's onViewCreated() fires, before the Presented state snapshots info. Between two opens the id is stable no matter how often the view is re-bound, so paywall_close and transaction_* always match their open. The consumed flag deliberately survives CleanupAfterDestroy. Rejecting a re-bind at the getPaywall() seam was ruled out: getPaywall() itself marks the view active via prepareToDisplay(), and re-embedding the cached view is a supported pattern the integration tests exercise. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No blocking issues — the prior review's latched-
isPresentedfinding is properly fixed; two minor points inline.
Reviewed changes — the delta since the prior pullfrog review at 77c13c4f: one behavioral commit that rewrites how presentation_id rotates.
- Moved id ownership off
SetRequestonto a presentation boundary — thestate.request == null || state.isPresentedpredicate is gone;SetRequestno longer touchespresentationIdat all. A newUpdates.BeginPresentationmints one iff the current id has already been consumed by apaywall_openor was never set. - Added
presentationIdOpenedtoPaywallViewState— written only bySetLastOpen, deliberately survivingCleanupAfterDestroyandResetPresentationPreparationsso a consumed id stays consumed across a teardown. - Wired two mint sites in
PaywallView—presentationWillBegin()(guarded by!state.isPresented) andonViewCreated()(guarded by the newly hoistedisNewPresentation = !state.presentationDidFinishPrepare, computed before the completion soPaywallState.Presented(info)carries the new id).BeginPresentationis idempotent, so the second call is a no-op. - Rewrote the id tests —
PaywallViewStateTestnow haspresentationId_isKeptFromFetchThroughTheFirstOpen,presentationId_isMintedPerPresentationOfACachedView(including an "embedded view re-presented without ever being torn down" sub-case) andpresentationId_isStableWhileAPresentationIsLive;PaywallViewTestgainsonViewCreated_mintsPresentationIdOnlyForANewPresentation, which drives the real view through the cache-hit reset. - CHANGELOG entry for the id fix expanded to state the rotation semantics.
This does resolve the prior review's thread: keying on presentationIdOpened rather than isPresented means a host-managed embed that never calls destroyed(forceCleanup = true) now gets a distinct id per presentation, and the new PaywallViewStateTest sub-case pins exactly that case. I re-traced the full-screen Activity lifecycle (finishing teardown, non-finishing background stop, plain resume) and the Compose wrapper against the new predicate and found them consistent — a backgrounded live paywall keeps presentationDidFinishPrepare = true, so presentationWillBegin() early-returns and onViewCreated() takes the resume branch without minting.
The PaywallViewState.kt:145-147 thread from the earlier review (one presentation_id reporting two experiments when a live view is re-bound) is untouched by this commit, so I've left it open.
Claude Opus | 𝕏
Follow-up to 6947840, addressing review: - The two BeginPresentation sites disagreed: presentationWillBegin() also required !isPresented, so on the embedded re-present path (cache-hit reset clears the prepare flag while isPresented stays latched) willPresentPaywall reported the previous, already-consumed id while didPresentPaywall and paywall_open reported the fresh one. Both sites now run under the same guard (a new presentation, i.e. presentationDidFinishPrepare == false). - The reason that guard existed - a getPaywall() for a paywall that is on screen must not start a second presentation on the live view - is handled where it originates instead. resetTransientPresentationState() refuses to reset a view that is presented AND attached to a window. Such a view is a live presentation, not a cached view being re-presented; resetting it would make its next onResume -> onViewCreated() re-run the flow, fire a second paywall_open and rotate the id away from the pending paywall_close. isPresented alone cannot be used: an embedded host that re-presents a cached view detaches it first without tearing it down (PaywallHostFragment), which is the case the reset exists for. - Tests: the onViewCreated id test now awaits the first paywall_open, which runs on the real IO scope, before relying on the consumed flag. Two new tests drive beforeViewCreated(): a detached re-present reports one new id to willPresentPaywall, didPresentPaywall and paywall_open; a live attached view handed back by the cache keeps its id, prepare flag and open count. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
Important
The single mint predicate and the will/did/open id fix look right, but the new cache-hit guard has a second effect: a cached view that is on screen for one presentation now hands itself back to a different presentation request with stale prepare flags and a stale purchase spinner.
Reviewed changes — the delta since the prior pullfrog review at 6947840d: one behavioral commit that collapses the two presentation_id mint sites onto a single predicate and moves the "don't disturb a live presentation" decision from presentationWillBegin() into the cache-hit reset.
- Collapsed the mint onto one predicate — the
!state.isPresentedguard is gone frompresentationWillBegin(), so the outer:418guard (!presentationWillPrepare || presentationDidFinishPrepare) is the only gate andwillPresentPaywall,didPresentPaywalland the followingpaywall_openall report the same id. - Made the cache-hit reset refuse a live, attached view —
resetTransientPresentationState()early-returns whenstate.isPresented && isAttachedToWindow, so the mint site can only fire on a view whose prepare flags were deliberately reset. - Documented the trade at the seam — the
PaywallManagercache-hit comment now states that the view itself refuses the reset while presented and attached. - Stabilised the racy precondition —
onViewCreated_mintsPresentationIdOnlyForANewPresentationawaits the firstpaywall_openviawaitUntiland assertspresentationIdOpenedbefore relying on it. - Added two
PaywallViewTestcases —rePresentingADetachedCachedView_reportsOneNewPresentationIdToWillDidAndOpendrivesbeforeViewCreated()and pins will/did/open to one new id;reBindingALivePresentedView_doesNotStartANewPresentationpins the new guard using a Robolectric-attached view.
Both prior review threads are properly addressed and I've resolved them. I re-traced the mint predicate against every writer of presentationDidFinishPrepare: ResetPresentationPreparations is the only one that clears it, reachable only from the cache-hit reset or from destroyed(forceCleanup = true) (which also clears isPresented and detaches the view via cleanup()), so the unconditional mint in presentationWillBegin() is sound for the full-screen lifecycle, the billing-sheet/background stop and plain resume. register() → register() also cannot reach the new guard: checkNoPaywallAlreadyPresented blocks it while activePaywallVcKey is set, and that key is cleared in the same non-suspending block as CleanupAfterDestroy. The embed seam is the gap, and it's the one comment below.
Claude Opus | 𝕏
…esentation Follow-up to 28349b9, addressing review. The isAttachedToWindow guard in resetTransientPresentationState() could not tell "the live presentation is being re-bound" from "a different request wants this paywall while it is on screen", and picked the worst outcome for the latter: no spinner reset, no prepare-flag reset, so the new host got a view that emitted no willPresentPaywall, no didPresentPaywall, no paywall_open and no fresh id, with a possibly stale LoadingPurchase spinner (the #434 dead-buy-button bug). Meanwhile prepareToDisplay() detached the view from its old parent in the same call, so the old presentation was not being left alone either. The guard is gone. The only request that reaches the cache-hit reset while a paywall is on screen is a getPaywall() for that same paywall (register() is rejected earlier by checkNoPaywallAlreadyPresented), and that call always takes the view over. So there is no live re-binding to protect: the takeover is the next presentation of the view, gets a clean slate and a new id like any other, and the two mint sites keep their shared predicate. The taken-over presentation ends without a paywall_close of its own, as before. The "live re-bind keeps everything" test is replaced by one that attaches the view to a Robolectric activity, puts it mid-purchase, runs the takeover, and checks the reset happened and willPresent, didPresent and paywall_open all report one new id. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No blocking issues — the prior review's thread is properly resolved; one minor point inline and one scope question below.
Reviewed changes — the delta since the prior pullfrog review at 28349b95: one behavioral commit that reverses the "leave a live presented view alone" decision, plus a CI badge refresh.
- Made the cache-hit reset unconditional again — the
state.isPresented && isAttachedToWindowearly return is gone fromresetTransientPresentationState(), so a cached view handed back for a new presentation always gets its spinner and prepare flags cleared, even while it is still on screen. - Adopted takeover semantics for
getPaywall()on a presented paywall — the new comments inPaywallManager.getPaywallViewandresetTransientPresentationState()state the contract: such a call takes the view over (prepareToDisplay()detaches it from its current parent), so it is a new presentation with a new id and a freshwillPresentPaywall/didPresentPaywall/paywall_opentriple. - Rewrote the guard's test into its inverse —
reBindingALivePresentedView_doesNotStartANewPresentationbecamehandingALiveAttachedViewToANewPresentation_resetsItAndMintsANewId, which puts a Robolectric-attached, presented view intoLoadingPurchaseand pins the spinner reset, the cleared prepare flag, and one new id reported towillPresentPaywall,didPresentPaywalland the secondpaywall_open.
I verified the load-bearing claim in the new comment. register() genuinely cannot reach the cache-hit branch while a paywall is on screen: internallyPresent runs checkNoPaywallAlreadyPresented first, and Superwall.isPaywallPresented resolves to cache.activePaywallVcKey != null, which prepareToDisplay() sets for embedded views too. Every public getPaywall entry point (PublicGetPaywall.kt:39 and :69, which PaywallBuilder and PaywallComposable both go through) does call prepareToDisplay(), so "takes the view over" is accurate rather than aspirational. PaywallPreload still passes isPreloading = true and GetPaywallVC still maps the result-only request types to isForPresentation = false, so neither reaches the reset. The new test fails against 28349b95 — its first Then asserts exactly the two things the removed guard used to skip — so it is real coverage, and it also replaces the previous test's Thread.sleep(300) with latch waits.
ℹ️ The superseded presentation ends with no paywall_close, and that is not in the CHANGELOG
The takeover contract means a getPaywall() for a paywall that is currently on screen silently terminates the presentation that was there: no paywall_close, no didDismissPaywall, and the full-screen host is left alive with an empty container (SuperwallPaywallActivity.paywallView() resolves via findViewWithTag, which returns null once the view is detached, so its onStop/onDestroy teardown no-ops). Downstream funnel analytics that pair paywall_open with paywall_close will now see an unmatched open where before this commit they saw no second open at all. The CHANGELOG bullet describes the id rotation but not this consequence.
Technical details
# Takeover semantics drop the superseded presentation's close event
## Affected sites
- `superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt:538-539` — the comment states "The presentation being taken over ends here without a `paywall_close` of its own" as a settled trade, with no note of the analytics consequence.
- `superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt:211` — `paywallView()` is `contentView?.findViewWithTag(ACTIVE_PAYWALL_TAG)`, so after `prepareToDisplay()` detaches the view, `onStop`/`onDestroy` (`:813-821`, `:831-864`) no-op and the orphaned Activity never finishes itself.
- `superwall-compose/src/main/java/com/superwall/sdk/compose/PaywallComposable.kt:108-115` — the opposite failure for embeds: the *original* host's `onRelease` still runs `destroyed(forceCleanup = true)` + `cleanup()` on the now-shared view, so the close, the `PaywallState.Dismissed` emission and the teardown all land on the *successor* presentation.
- `CHANGELOG.md:9` — describes id rotation only.
## Required outcome
- The new behavior for "`getPaywall()` for a paywall that is already on screen" is a deliberate, documented product decision rather than a side effect of resolving the previous review thread.
- Customers reading the 2.8.4 CHANGELOG can tell that a takeover produces an extra `paywall_open` with no matching `paywall_close` for the presentation it replaced.
## Open questions for the human
- Should the takeover dismiss the superseded presentation properly (track `paywall_close`, fire `didDismissPaywall`, finish the orphaned `SuperwallPaywallActivity`) rather than dropping it, now that this path is endorsed instead of avoided?
- Is the Compose case — host A's `onRelease` tearing down host B's live presentation on the shared view — in scope for this release, or a separate follow-up? It is pre-existing, but the takeover contract makes overlapping host lifetimes a supported pattern rather than an accident.Claude Opus | 𝕏

Changes in this pull request
Fixes
freeTrial_startmessage for every started trial, even when it has no trial reminders configured.Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.ktlintin the main directory and fixed any issues.