feat(debug): preview paywalls in a specific state via deep link - #499
feat(debug): preview paywalls in a specific state via deep link#499konroj wants to merge 3 commits into
Conversation
|
PR author is not in the allowed authors list. |
There was a problem hiding this comment.
Important
attr_* turns an unauthenticated deep link into a disk-persisted, backend-tracked write on the real user's profile, and the generated Xcode project picked up unrelated toolchain churn that CI will discard. Both are worth a decision before merge.
Reviewed changes — full initial review of 0705e711, all 12 files.
DebugPaywallOverridesvalue type — parsestrial_state,appearance,locale,presentandattr_<key>query items off the debug deep link, tolerant of unknown values.SWDebugManagerLogic.QueryItemName— four new cases backing those params.- Override threading —
DebugManager.DeepLinkOutcomecarriesoverridesthroughlaunchDebugger→presentDebugger→ViewControllerFactory.makeDebugViewController(protocol signature change). DebugViewControllerapply/restore —viewDidLoadsnapshots locale + interface style then applies overrides;viewDidDisappearrestores all three;viewDidAppearandfinishLoadingPreviewboth drive a one-shotpresentAutomaticallyIfNeeded().- Preview honours
trial_state— the thumbnail request now passesPaywallRequest.Overrides(isFreeTrial:). - Tests — exhaustive parsing coverage in
DebugPaywallOverridesTests/DebugManagerTestswith exact-value assertions, plus fourgetQueryItemValuecases and a call-site fix inCheckDebuggerPresentationOperatorTests. - Generated project —
project.pbxprojand the shared scheme were regenerated with a newer toolchain.
⚠️ The Xcode project and scheme carry toolchain churn that CI will throw away
project.pbxproj jumps objectVersion 54 → 77 and gains preferredProjectObjectVersion / minimizedProjectReferenceProxies, and the shared scheme gains parallelizable = "NO". Both files are regenerated by xcodegen in scripts/build.sh, scripts/test.sh and every CI job, and project.yml does not declare parallelizable — so the scheme change is not reproducible and will vanish on the next regeneration, while the format bump raises the Xcode version needed to open the checked-in project for anyone who doesn't regenerate.
Technical details
# Generated Xcode project churn is unrelated to the feature and non-durable
## Affected sites
- `SuperwallKit.xcodeproj/project.pbxproj:6` — `objectVersion = 77` (was 54), plus `preferredProjectObjectVersion = 77`, `minimizedProjectReferenceProxies = 1`, removal of `compatibilityVersion = "Xcode 14.0"`, and reordering of the `Core Data` group children. All generator-version artifacts, not feature changes.
- `SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme:44` — `parallelizable = "NO"` on the `SuperwallKitTests` testable reference.
- `project.yml:11-15` — declares the `SuperwallKit` scheme with `testTargets: [SuperwallKitTests]` and no `parallelizable` key.
- `.github/workflows/tests.yml:32-33` — CI runs `xavierLowmiller/xcodegen-action@1.2.3` before `xcodebuild`, so the committed project is regenerated on every run.
## Required outcome
- The pbxproj/scheme diff should contain only what a `xcodegen` run at the team's pinned version produces for the two new source files, with no format-version bump riding along.
- If serial test execution is genuinely needed, it has to be expressed in `project.yml` so it survives regeneration; if it isn't needed, drop it.
## Open questions for the human
- Was `parallelizable = "NO"` deliberate (e.g. working around the known shared-`Superwall.shared` flakiness) or an incidental IDE/generator artifact? The new tests here are pure value-type parsing and don't touch global state.
- Is the project team ready to require the Xcode version that `objectVersion = 77` implies, or should the generator be pinned back?ℹ️ Nothing exercises the apply/restore of global SDK state
The parsing layer is covered thoroughly and the assertions are exact, but the behaviour that carries the risk — applyOverrides() mutating Superwall.shared and viewDidDisappear putting it back — has no test at all. A test that sets a pre-existing user attribute, applies an override for that key plus a key that did not exist, and asserts both are exactly restored (the second one removed, not left set) would pin the part most likely to regress, and would confirm the restoreAttributes.updateValue(current[key], …) explicit-nil trick behaves as intended end-to-end.
ℹ️ Nitpicks
presentacceptstrue/1/yes, whilesuperwall_debugon the same link is parsed with strictBool(_:)— two truthiness vocabularies in one URL. Worth aligning, or documenting the looser set as deliberate (it is convenient for Appium/XCUITest, which is presumably why it exists).- Per
CLAUDE.md, customer-facing changes get aCHANGELOG.mdentry. Five new documented deep-link parameters read as customer-facing even though the surface is debug-only; consider adding the entry rather than deferring it.
Claude Opus | 𝕏
0705e71 to
60a10b9
Compare
There was a problem hiding this comment.
Important
The new in-memory-only attribute path doesn't hold its guarantee. _userAttributes is the merge base for every persisting write in IdentityManager, so any ordinary setUserAttributes / attribution callback / identify() that fires while the debugger is open bakes the deep-link attr_* values into on-disk UserAttributes and reports them in the user_attributes event.
Reviewed changes — the delta since the prior pullfrog review of 0705e711; the branch was force-pushed to a single commit, 60a10b9f, touching DebugViewController and IdentityManager only.
- Moved
attr_*off the persisting path —applyOverrides()no longer touches user attributes; a newapplyAttributeOverridesIfNeeded()routes them through two new internalIdentityManagermethods that skipstorage.saveand theuser_attributestrack. - Deferred attribute application until the preview resolves — the call now sits in
finishLoadingPreview()afterpaywallRequestManager.getPaywall(...)succeeds, so an unusable debug token no longer applies anything. - Reworked the restore —
restoreAttributes: [String: Any?]replaced by a whole-dictionaryattributesSnapshot: [String: Any]?, put back onviewDidDisappearviareplaceAttributesInMemoryOnly(_:).
ℹ️ Nothing pins the in-memory-only guarantee
mergeAttributesInMemoryOnly / replaceAttributesInMemoryOnly are the load-bearing part of this revision and have no test at all, while the parsing layer that carries no risk is covered exhaustively. Tests/SuperwallKitTests/Identity/IdentityManagerTests.swift already builds a real IdentityManager over dependencyContainer.storage, so asserting the guarantee directly is a few lines — and the interesting assertion fails today.
Technical details
# The in-memory-only attribute lifecycle is untested
## Affected sites
- `Sources/SuperwallKit/Identity/IdentityManager.swift:437-448` — `mergeAttributesInMemoryOnly(_:)`, no test.
- `Sources/SuperwallKit/Identity/IdentityManager.swift:452-456` — `replaceAttributesInMemoryOnly(_:)`, no test.
- `Tests/SuperwallKitTests/Identity/IdentityManagerTests.swift` — existing suite; constructs a real `IdentityManager` with `dependencyContainer.storage` / `.deviceHelper` / `.configManager`, so no new fixture is needed.
## Required outcome
- A test asserts that `mergeAttributesInMemoryOnly` leaves `storage.get(UserAttributes.self)` byte-for-byte unchanged while `userAttributes` reflects the new value.
- A test asserts that a subsequent ordinary write (`mergeUserAttributes([...])`) does NOT carry the in-memory-only value into the dictionary that gets saved. This is the assertion that fails against the current implementation and the one worth having.
- A test asserts `replaceAttributesInMemoryOnly` restores exactly the snapshot, including removing a key that the merge introduced.
## Suggested approach (optional)
- Match the existing style in that file: build the manager, call the method, drain with `await MainActor.run {}` or a short `Task.sleep` as the neighbouring tests do, then read back through `storage.get(UserAttributes.self)`.Claude Opus | 𝕏
9af9d58 to
5c6226a
Compare
There was a problem hiding this comment.
ℹ️ No new issues in the added tests themselves — one note on what they claim to pin.
Reviewed changes — the delta since the prior pullfrog review of 60a10b9f. The branch was force-pushed to a single commit, 9af9d58e, whose only change against the previously reviewed tree is test-only: three tests appended to IdentityManagerTests.swift. No source file changed, so the open threads on DebugViewController and IdentityManager are untouched by this revision.
- Pinned the in-memory merge —
mergeAttributesInMemoryOnly_appliesInMemoryButNeverPersistsasserts the override is visible throughuserAttributesand absent fromstorage.get(UserAttributes.self). - Pinned the no-notify contract —
mergeAttributesInMemoryOnly_doesNotNotifyDelegateassertsnotifyUserChangeis never invoked for the in-memory path. - Pinned the restore —
replaceAttributesInMemoryOnly_restoresSnapshotWithoutPersistingasserts the snapshot restore removes the key the merge introduced, with storage untouched.
All three are genuinely falsifiable: Cache.write populates memCache synchronously before the async disk write and Cache.read consults memCache first, so a persisting implementation would be visible to the immediate storage.get. They also can't be perturbed by parallel execution over the process-wide Cache paths — they write nothing to disk and assert only on the suite-unique debugOnlyKey.
ℹ️ Nitpicks
- The
Task.sleepwaits atIdentityManagerTests.swift:109,:155and:160aren't load-bearing — each is followed by a read through theuserAttributesgetter, which isqueue.syncon the same serial queue the writes werequeue.async'd onto, so the ordering is already guaranteed. Only:135indoesNotNotifyDelegateneeds a drain, and_ = identityManager.userAttributeswould do it deterministically instead of costing 300 ms. It matches the file's existing style, so entirely optional.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ The generated-project churn is gone — one consequence of how it was removed is worth a look.
Reviewed changes — the delta since my review of 9af9d58e. The branch was force-pushed to 5c6226a7, whose only change is to revert the two generated Xcode files; no source or test file differs from the previously reviewed tree.
- Dropped the generated-project churn —
project.pbxprojis back toobjectVersion = 54withpreferredProjectObjectVersion,minimizedProjectReferenceProxies,productRefGroupand theCore Datagroup reordering gone, and the hand-addedparallelizable = "NO"is gone from the shared scheme.
ℹ️ The committed Xcode project no longer knows about the three new files
Both generated files now match develop exactly, which means project.pbxproj carries no reference to DebugPaywallOverrides.swift, DebugPaywallOverridesTests.swift or DebugManagerTests.swift. Opening the checked-in project and building without first running xcodegen therefore fails, because DebugManager.swift references DebugPaywallOverrides. CI is unaffected, but this is the opposite corner from the churn: the ask was a regeneration at the team's pinned generator version, not omitting the file.
Technical details
# The reverted `project.pbxproj` omits the PR's new files
## Affected sites
- `SuperwallKit.xcodeproj/project.pbxproj` — identical to `develop`; zero occurrences of `DebugPaywallOverrides`. The SDK target's `Sources` phase does not compile `Sources/SuperwallKit/Debug/DebugPaywallOverrides.swift`, and the test target does not compile `DebugPaywallOverridesTests.swift` / `DebugManagerTests.swift`.
- `Sources/SuperwallKit/Debug/DebugManager.swift` — references `DebugPaywallOverrides` in `DeepLinkOutcome`, `launchDebugger` and `presentDebugger`, so a stale project fails to compile rather than silently skipping the feature.
- `scripts/pre-commit:3-4` — runs `xcodegen` then `git add SuperwallKit.xcodeproj`, and every recent commit on `develop` carries the regenerated file, so keeping the committed project in sync is the house convention.
- `.github/workflows/tests.yml:32-33`, `build-platforms.yml:27-28` — regenerate via `xavierLowmiller/xcodegen-action@1.2.3` before building, which is why CI stays green either way.
## Required outcome
- The committed project registers the three new files, with no generator-format churn riding along (`objectVersion` stays 54, no `preferredProjectObjectVersion` / `minimizedProjectReferenceProxies` / group reordering).
## Suggested approach (optional)
- Regenerate with the `xcodegen` version that `xavierLowmiller/xcodegen-action@1.2.3` installs (the one that produced the current `objectVersion = 54` file) rather than a locally newer toolchain; the resulting diff should be the handful of `PBXBuildFile` / `PBXFileReference` / group / `Sources` entries for the three files.
## Open questions for the human
- If the team's position is that the committed project is a pure build artifact and may lag, leaving it untouched is fine — but then the pre-commit hook regenerating it on the next commit will reintroduce whatever the contributor's local generator emits. Worth deciding which way this PR should land.Claude Opus | 𝕏
|
Thanks for the review — both points addressed (force-pushed).
Reworked so attribute overrides never reach durable state or analytics, and only apply once the token is validated:
Added three Generated-project churn Dropped the Full unit suite passes (925). |
5c6226a to
96a0abd
Compare
|
Update — I've pulled The in-memory approach I pushed earlier didn't actually hold: So this PR now covers Also addressed the two lifecycle notes from the last review: the in-flight preview load is now cancelled on dismiss, and the interface-style restore only runs when |
There was a problem hiding this comment.
Important
previewTask?.cancel() doesn't cancel anything on this path, so the dismiss-during-load hole it was added to close is still open — and with present=true it can now fire a paywall presentation through an already-dismissed debugger.
Reviewed changes — the delta since my review of 5c6226a7. The branch was force-pushed to a single commit, 96a0abdd, which is a scope reduction plus two fixes; the PR is down to 10 files and IdentityManager.swift / IdentityManagerTests.swift are no longer touched.
- Removed
attr_*entirely —DebugPaywallOverrides.attributes/attributePrefix/parseAttributes,DebugViewController.applyAttributeOverridesIfNeeded/attributesSnapshot, bothIdentityManagerin-memory-only methods and the threeIdentityManagerTestscases are gone, with no dangling references left. This retires the persistence, tracking and whole-dict-clobber concerns from the last three reviews. - Gated the interface-style restore —
viewDidDisappearnow only callsSuperwall.shared.setInterfaceStyle(to: initialInterfaceStyleOverride)whenoverrides.appearance != nil, so closing the debugger no longer emits a spuriousDeviceAttributesevent for sessions with no appearance override. - Tracked the preview load in a cancellable
Task—viewDidLoadstorespreviewTask = Task { await loadPreview() }andviewDidDisappearcallspreviewTask?.cancel()before the rest of the teardown.
Claude Opus | 𝕏
Add debug deep link parameters so QA can open the paywall preview in a chosen state without changing device settings: trial_state (eligible/ineligible), appearance (light/dark/system), locale, attr_<key>=<value> to override user attributes, and present to open the paywall directly. The overrides apply only while the debugger is open and are reverted on exit.
96a0abd to
cf017f9
Compare
There was a problem hiding this comment.
Important
The new cancellation guard is sound, but it only covers the viewDidLoad load. The picker and localization reloads still start untracked tasks, so dismissing the debugger mid-reload can still render into — and present a paywall through — a detached controller.
Reviewed changes — the delta since my review of 96a0abdd. The branch was force-pushed to a single commit, cf017f90, whose only change against the previously reviewed tree is one five-line hunk in DebugViewController.swift; no other source or test file differs.
- Guarded the preview mutation tail on cancellation —
finishLoadingPreview()returns early whenTask.isCancelledis true (DebugViewController.swift:284-288), beforeself.paywall = paywall,previewPickerButton.setTitle(...),activityIndicator.stopAnimating(),addPaywallPreview()andpresentAutomaticallyIfNeeded().
The mechanism itself checks out, which was the open question from the last review: previewTask = Task { await loadPreview() } (:168) and loadPreview()'s direct await finishLoadingPreview() (:243) run in the same task — an async call stays part of the caller's task, and the @MainActor hops don't change task identity — so Task.isCancelled there really does read the flag that viewDidDisappear's previewTask?.cancel() (:592) sets. Exiting during the initial load now stops before the tail.
⚠️ Two of the three preview-load entry points are still untracked, so the guard can't trip for them
previewTask is only ever assigned in viewDidLoad. The picker selection (:398) and the localization picker (:442) each spawn a fresh Task { await self?.loadPreview() } that nothing holds, so previewTask?.cancel() doesn't reach them and their own Task.isCancelled is never true. Dismissing the debugger while either reload is in flight still runs the whole tail against a controller that has already had viewDidDisappear fire: addPaywallPreview() attaches a fresh PaywallViewController + WKWebView as a child of the dismissed controller after resetCache(), and presentAutomaticallyIfNeeded() can call loadAndShowPaywall(presenter: self) on a detached presenter whenever the present=true one-shot is still armed. The open thread on :592 stays open for this reason, and the PR description's "the in-flight preview load is cancelled on dismiss" is still broader than what the code does.
Technical details
# The cancellation guard covers only the `viewDidLoad` preview load
## Affected sites
- `Sources/SuperwallKit/Debug/DebugViewController.swift:398` — `Task { await self?.loadPreview() }` in the "Your Paywalls" picker's `AlertOption` action. Not assigned to `previewTask`.
- `Sources/SuperwallKit/Debug/DebugViewController.swift:442` — `Task { await self?.loadPreview() }` in `showLocalizationPicker()`'s completion. Not assigned to `previewTask`.
- `Sources/SuperwallKit/Debug/DebugViewController.swift:284-288` — the new `guard !Task.isCancelled` reads the *current* task's flag, which for both sites above is a task nobody cancels.
- `Sources/SuperwallKit/Debug/DebugViewController.swift:590-599` — `viewDidDisappear` cancels `previewTask` only, and does not reset `didAppear` (`:174`) or `overrides.shouldPresent`, so the auto-present latch survives dismissal.
- `Sources/SuperwallKit/Debug/DebugViewController.swift:292`, `:294` — `addPaywallPreview()` (the file contains no `removeFromParent()` anywhere) and `presentAutomaticallyIfNeeded()`, the two calls the guard exists to prevent.
- `Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/CheckDebuggerPresentation.swift:24-29` — gates on `request.presenter is DebugViewController` only, never on whether that instance is still in the window hierarchy.
## Required outcome
- Dismissing the debugger prevents `addPaywallPreview()` and `presentAutomaticallyIfNeeded()` from running for a load that was in flight at dismissal, whichever entry point started it.
## Suggested approach (optional)
- Assign `previewTask = Task { await self?.loadPreview() }` at `:398` and `:442` as well, so each reload supersedes the tracked task and the existing guard applies uniformly.
- Independently, `presentAutomaticallyIfNeeded()`'s guard reading `viewIfLoaded?.window != nil` — or `viewDidDisappear` setting `didAppear = false` — would keep `loadAndShowPaywall` unreachable from a detached presenter regardless of how a future reload path is spawned.
## Open questions for the human
- Is dismissal-during-reload (as opposed to dismissal-during-initial-load) in scope for the QA flow this PR targets, or is closing the `viewDidLoad` path deliberately where you want to stop?Claude Opus | 𝕏
…ides on reuse - Route all three preview-load entry points (initial load, paywall picker, locale picker) through a tracked previewTask so the existing Task.isCancelled guard in finishLoadingPreview applies to every load, not just the viewDidLoad one. - Guard presentAutomaticallyIfNeeded on the view being in a window and reset didAppear on disappear, so present=true can never fire through a dismissed debugger. - Apply locale/appearance overrides in DebugManager's view-controller reuse branch, where viewDidLoad doesn't run again. - Register the PR's three new files in the committed Xcode project (clean xcodegen regeneration, no format churn). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues — both open threads are properly closed. One residual path noted inline.
Reviewed changes — the delta since my review of cf017f90. The branch is no longer a single force-pushed commit: ee7f97de landed on top, touching DebugManager.swift, DebugViewController.swift and project.pbxproj.
- Routed every
DebugViewControllerpreview load through a tracked task — a newstartPreviewLoad()helper cancels the previouspreviewTaskand stores the new one, and now backsviewDidLoad(:168), the "Your Paywalls" picker action (:406) and the localization-picker completion (:450), soviewDidDisappear'spreviewTask?.cancel()reaches whichever load is in flight. - Closed the auto-present latch on dismissal —
viewDidDisappearresetsdidAppear = false(:600) andpresentAutomaticallyIfNeeded()additionally requiresviewIfLoaded?.window != nil(:196), which makesloadAndShowPaywallunreachable from a detached presenter independently of task cancellation. - Applied overrides on the
presentDebuggerreuse branch —viewController.applyOverrides()(DebugManager.swift:112) after theoverridesassignment, solocale/appearanceare no longer silently dropped there;applyOverrides()widened fromprivateto internal for this. - Registered the three new files in the committed Xcode project —
DebugPaywallOverrides.swift,DebugPaywallOverridesTests.swiftandDebugManagerTests.swiftgain theirPBXBuildFile/PBXFileReference/ group /Sources-phase entries with no generator churn riding along:objectVersionstays 54, and every added UUID resolves.
Claude Opus | 𝕏
…d preview load The reuse branch awaited loadPreview() directly, running it in the deep-link handler's task, so the Task.isCancelled checkpoint read a flag nothing ever set and addPaywallPreview() could still attach to a dismissed controller. startPreviewLoad() now returns its task so the branch can await it, keeping every preview load cancellable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found. The one open thread from the last review is properly closed.
Reviewed changes — the delta since my review of ee7f97de. One commit, 7209339d, two effective lines across DebugManager.swift and DebugViewController.swift; no test or generated-project file differs.
startPreviewLoad()now hands back its task —@discardableResult func startPreviewLoad() -> Task<Void, Never>(DebugViewController.swift:180-186) stores the new task inpreviewTaskand returns it, leaving the three existing call sites (:168,:409,:453) unchanged in behaviour.- Routed the
presentDebuggerreuse branch through the tracked load —await viewController.startPreviewLoad().value(DebugManager.swift:113) replaces the directawait viewController.loadPreview(), so that load supersedes any in-flightpreviewTaskand the!Task.isCancelledcheckpoint atDebugViewController.swift:296reads the flagviewDidDisappear'spreviewTask?.cancel()(:604) actually sets.addPaywallPreview()can no longer attach aPaywallViewController+WKWebViewto a dismissed controller on that path, and the helper's "every preview load runs through here" doc comment is now literally true.
The .value await is safe on MainActor: Task {} is unstructured and inherits the enclosing actor isolation, and .value is a suspension point rather than a blocking wait, so the actor is released while the child runs. @discardableResult is honoured at the two optional-chained call sites (self?.startPreviewLoad()), verified against the compiler rather than assumed.
Claude Opus | 𝕏

Summary
Adds debug deep link parameters so a paywall can be previewed in a specific state directly from the debugger, without changing device settings or App Store account — useful for manual QA and for automating real-device paywall testing (Appium/XCUITest). All parameters are optional, take effect only while the debugger is open, and are reverted on exit. They extend the existing
superwall_debug+tokendebug link; authorization is unchanged.Parameters
trial_state=eligible|ineligible— override free-trial eligibilityappearance=light|dark|system— override interface stylelocale=<code>— override paywall locale (e.g.de)present=true— open the paywall directly instead of stopping at the previewCombinable, e.g.
myapp://?superwall_debug=true&token=TOKEN&paywall_id=ID&trial_state=ineligible&appearance=dark&locale=de&present=trueImplementation
DebugPaywallOverridesvalue type parses the params (tolerant — unknown values ignored).DebugManager/DebugViewControllerapply overrides on launch and restore prior global state (locale, interface style) on dismiss. The in-flight preview load is cancelled on dismiss, and the interface-style restore only runs whenappearancewas overridden (so closing the debugger doesn't emit a stray device-attributes event).presentauto-opens once, after the debugger appears, using the resolved trial state; the preview thumbnail honorstrial_statetoo.Testing
DebugPaywallOverridesTests,DebugManagerTests, extendedSWDebugManagerLogicTests); full suite passes.Notes
attr_*) were intentionally left out of this PR. Applying them through the identity layer risked persisting deep-link values to disk via the shared merge base; doing it safely means injecting them only into the paywall's template render, which is a separate change and will follow as its own PR.device.interfaceStyletheming; paywalls without a dark design or extra localizations have nothing to switch to.handleDeepLink(_:)is unchanged and already listed under In-App Previews. The developer-facing write-up for these QA parameters belongs on the online docs (docs.superwall.com → In-App Previews) and is prepared separately.Checklist
CHANGELOG.md(intentionally omitted — see Notes).swiftlintin the main directory and fixed any issues.🤖 Generated with Claude Code