Repository navigation
Ensure paywall sharing among experiments doesnt reuse their id - #468
Conversation
There was a problem hiding this comment.
ℹ️ No correctness problems found in the fix itself — two informational notes inline plus a missing changelog entry.
Reviewed changes — the full diff at f1ffb7d8: moving the two request-scoped fields off the shared cached PaywallView merge and onto request binding, plus the two new test files.
MergePaywallno longer refreshesexperiment/presentationSourceType—PaywallManager.getPaywallViewfires this update on every cache hit (including pure queries likegetPresentationResult), which is how a second campaign's fetch overwrote the experiment an in-flight presentation would report.SetRequestbinds them atomically with the request — setspaywall.experiment = experiment ?: state.paywall.experimentandpaywall.presentationSourceType = req.presentationSourceType, soPaywallViewState.infocan never pair one request's placement with another's experiment.- Experiment threaded through the presentation pipeline — new
PaywallComponents.experiment(from(outcome.triggerResult as? InternalTriggerResult.Paywall)?.experiment) reachesPaywallView.set(...)viaInternalGetPaywallandPaywallView.present(...)viapresentPaywallView. PaywallManagerExperimentIsolationTest— Robolectric regression test replaying the interleaving (B fetches, A fetches the same cached view, A binds, B binds) against a realPaywallManager+ realPaywallView.PaywallViewStateTestadditions — unit-level coverage thatMergePaywallleaves both fields alone and thatSetRequestbinds experiment + source + placement together.
I verified that req.presentationSourceType in SetRequest is the same value the old merge produced (PaywallRequest.presentationSourceType is itself derived from it at GetPaywallVC.kt:52), and enumerated every reader of PaywallView.info / state.paywall.experiment to confirm dropping the fields from the merge introduces no stale-read path — each is provably preceded by that request's own set()/present(). The second Then block of the new isolation test is genuinely discriminating: two of its three assertions fail under the pre-fix merge/bind logic.
ℹ️ No CHANGELOG.md entry for a user-visible analytics fix
This changes which experiment paywall_open and transaction events attribute to a shared paywall, which is exactly the kind of fix integrators read the changelog for. The PR checklist item is unchecked and no entry was added.
Technical details
# Add a `CHANGELOG.md` entry
## Affected sites
- `CHANGELOG.md` — top section is `## 2.8.3`, which matches `version.env`
(`SUPERWALL_VERSION=2.8.3`), so 2.8.3 is the in-development version and no new
version heading is needed.
## Required outcome
- One bullet under the existing `## 2.8.3` → `## Fixes` heading (note: `## Fixes`
is a same-level heading inside each version block in this repo, not `###`),
describing that paywalls shared between campaigns no longer report another
campaign's experiment or presentation source.ℹ️ Nitpicks
models/paywall/Paywall.kt:240-248—Paywall.update(paywall)is referenced nowhere in the repo (src/main,src/test,androidTest,superwall-compose,app,example) and still copiespresentationSourceType+experiment, i.e. exactly the policy this PR reverses. Worth deleting so it can't be picked up as the "right" way to merge a refreshed paywall.PaywallViewState.kt:97-103— the new comment sits betweenbase.copy(...)andmerged.productItems = from.productItems, directly above the pre-existing// Update productItems via setterline, so it reads at a glance as if it's about products. It belongs above thebase.copy(...)whose field list it explains.PaywallManagerExperimentIsolationTest—PaywallView'smainScope/ioScopelive in a private companion backed by real dispatchers and aren't injectable, so theloadWebView()job kicked off during B's cache-miss fetch runs outside the virtual clock andtestScheduler.advanceUntilIdle()isn't authoritative for it. Harmless today (every stub it touches is a no-op andIOScopeswallows throwables), but worth a comment so a future reader doesn't assume the test is fully deterministic.
Claude Opus | 𝕏
…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>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the delta since the f1ffb7d8 review: both inline notes from that review were taken on board, and the mechanical call-site updates that follow.
presentationIdis now bound per presentation —SetRequestkeeps the creation-time id for a view's first binding (state.request == null) and mints a freshUUIDon every re-binding of a cached view, so a shared paywall no longer reports onepresentation_idacross every presentation.experimentis required on the whole seam — the= nulldefault and the?: state.paywall.experimentfallback are gone fromSetRequest, and the requirement propagates up throughPaywallView.set/present,presentPaywallView,presentPaywallViewSyncandPaywallComponents, so a future call site that forgets the experiment fails to compile.MergePaywall's policy comment extended topresentationIdand theSetRequestKDoc rewritten to state that it is the single writer of per-presentation metadata.- New discriminating unit test —
setRequest_mintsPresentationIdPerPresentationOfACachedViewasserts the first binding keeps the id,MergePaywallleaves it alone, and the second binding mints a different one; the pre-existing "bound without an experiment" case was flipped to assert the stale experiment is not inherited. - Call sites updated — explicit
experimentarguments inPaywallViewDismissTest,PaywallViewTestandPaywallViewStateTest.
I confirmed PaywallViewState.request is never reset to null by any transform (CleanupAfterDestroy, ResetPresentationPreparations, resetTransientPresentationState, destroyed() all leave it), so state.request == null is a sound one-shot "first bind" signal, and that SuperwallPaywallActivity re-creation and the webview crash-retry path call onViewCreated() rather than set/present, so neither re-mints the id mid-presentation. The id also stays coherent with the load events: paywall_response_load_* / paywall_products_load_* are tracked before PaywallRequestManager.updatePaywall mints the id (so they already carry no presentation_id), and loadWebView() only runs on a cache miss, which is exactly the case where the first SetRequest preserves the creation-time id. PaywallComponents.experiment is never actually null — EvaluateRules synthesizes InternalTriggerResult.Paywall(Experiment.presentById(...)) even for the debugger path.
ℹ️ A re-bind can still land on a view that is currently on screen
SetRequest is reached by InternalGetPaywall.kt:41 without any "is this view live?" check: checkNoPaywallAlreadyPresented is wired only into InternalPresentation.kt:29, PublicGetPaywall.kt:105 hardcodes isPaywallPresented = false, and PaywallManager.getPaywallView's cache-hit branch hands back the same instance regardless of activePaywallVcKey. So an embedded getPaywall() for campaign A, while campaign B's presentation of the same paywall is on screen, rebinds that live view. The root cause predates this PR — the merge clobbered experiment the same way, and prepareToDisplay() would additionally detach the live view — but adding presentationId to the rebound set means the visible paywall's remaining events (transaction_*, paywall_close) can now report an id that no paywall_open ever carried. Nothing to fix in this diff; flagging it as the one shape of shared-view collision the new tests don't cover.
Technical details
# Rebinding a live cached view is unguarded
## Affected sites
- `paywall/view/PaywallViewState.kt:135-143` — `SetRequest` mints a new `presentationId` (and
rewrites `experiment` / `presentationSourceType`) whenever `state.request != null`, with no
regard for `state.isPresented`.
- `paywall/presentation/get_paywall/InternalGetPaywall.kt:41` — calls `set()` unconditionally on
whatever view the manager returned.
- `paywall/manager/PaywallManager.kt:79-98` — the cache-hit branch returns the shared instance
without comparing against `cache.activePaywallVcKey`.
- `paywall/presentation/get_paywall/PublicGetPaywall.kt:105` — the embedded path builds its
`PresentationRequest` with `isPaywallPresented = false`, so
`checkNoPaywallAlreadyPresented` (only called from `InternalPresentation.kt:29`) never runs
for it.
## Required outcome
- Decide whether an embedded `getPaywall()` for a paywall that is currently presented by another
campaign is supported at all. If it is not, it should fail the request rather than silently
rebind the live view; if it is, the two presentations need separate view instances.
## Open questions for the human
- Is this collision reachable in practice for your integrators, or does the fact that
`prepareToDisplay()` detaches the live view mean it is already a known-unsupported usage?
- Does it belong on this PR at all, or as a follow-up issue against `PaywallViewCache`
sharing semantics?Claude Opus | 𝕏

Changes in this pull request
Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.ktlintin the main directory and fixed any issues.