Skip to content

feat(debug): preview paywalls in a specific state via deep link - #499

Open
konroj wants to merge 1 commit into
superwall:developfrom
konroj:feat/paywall-preview-deep-link-overrides
Open

feat(debug): preview paywalls in a specific state via deep link#499
konroj wants to merge 1 commit into
superwall:developfrom
konroj:feat/paywall-preview-deep-link-overrides

Conversation

@konroj

@konroj konroj commented Aug 4, 2026

Copy link
Copy Markdown

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 + token debug link; authorization is unchanged.

Parameters

  • trial_state=eligible|ineligible — override free-trial eligibility
  • appearance=light|dark|system — override interface style
  • locale=<code> — override paywall locale (e.g. de)
  • present=true — open the paywall directly instead of stopping at the preview

Combinable, e.g. myapp://?superwall_debug=true&token=TOKEN&paywall_id=ID&trial_state=ineligible&appearance=dark&locale=de&present=true

Implementation

  • New DebugPaywallOverrides value type parses the params (tolerant — unknown values ignored).
  • DebugManager/DebugViewController apply 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 when appearance was overridden (so closing the debugger doesn't emit a stray device-attributes event).
  • present auto-opens once, after the debugger appears, using the resolved trial state; the preview thumbnail honors trial_state too.

Testing

  • Unit tests for parsing + outcome wiring (DebugPaywallOverridesTests, DebugManagerTests, extended SWDebugManagerLogicTests); full suite passes.
  • Manually verified on a physical device: the debug link opens the debugger and the paywall reflects the overridden appearance (light/dark), locale, and auto-present, reverting cleanly on exit.

Notes

  • User-attribute overrides (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.
  • No version bump / CHANGELOG entry (intentionally out of scope here).
  • Appearance relies on the paywall's device.interfaceStyle theming; paywalls without a dark design or extra localizations have nothing to switch to.
  • Docs: no new public API is added (the parameters are parsed internally), so the DocC reference is unchanged — 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

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md (intentionally omitted — see Notes).
  • I have run swiftlint in the main directory and fixed any issues.
  • SDK documentation (DocC): no change needed — no public API added (see Notes). Online-docs entry for the new parameters prepared separately for docs.superwall.com.
  • I have reviewed the contributing guide.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

PR author is not in the allowed authors list.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  • DebugPaywallOverrides value type — parses trial_state, appearance, locale, present and attr_<key> query items off the debug deep link, tolerant of unknown values.
  • SWDebugManagerLogic.QueryItemName — four new cases backing those params.
  • Override threadingDebugManager.DeepLinkOutcome carries overrides through launchDebuggerpresentDebuggerViewControllerFactory.makeDebugViewController (protocol signature change).
  • DebugViewController apply/restoreviewDidLoad snapshots locale + interface style then applies overrides; viewDidDisappear restores all three; viewDidAppear and finishLoadingPreview both drive a one-shot presentAutomaticallyIfNeeded().
  • Preview honours trial_state — the thumbnail request now passes PaywallRequest.Overrides(isFreeTrial:).
  • Tests — exhaustive parsing coverage in DebugPaywallOverridesTests / DebugManagerTests with exact-value assertions, plus four getQueryItemValue cases and a call-site fix in CheckDebuggerPresentationOperatorTests.
  • Generated projectproject.pbxproj and 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

  • present accepts true / 1 / yes, while superwall_debug on the same link is parsed with strict Bool(_:) — 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 a CHANGELOG.md entry. 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/Debug/DebugViewController.swift Outdated
Comment thread Sources/SuperwallKit/Debug/DebugViewController.swift Outdated
return
}
viewController.paywallDatabaseId = paywallDatabaseId
viewController.overrides = overrides

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assigning overrides here only half-works: applyOverrides() is viewDidLoad-gated, so on this reuse branch locale, appearance and attr_* are silently dropped while trial_state and present are still honoured via loadPreview()presentAutomaticallyIfNeeded(). Today launchDebugger always nils the view controller through closeDebugger first so the branch is effectively unreachable, but the assignment implies otherwise — worth either applying the overrides here too or dropping the line.

@konroj
konroj force-pushed the feat/paywall-preview-deep-link-overrides branch from 0705e71 to 60a10b9 Compare August 5, 2026 09:50

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 pathapplyOverrides() no longer touches user attributes; a new applyAttributeOverridesIfNeeded() routes them through two new internal IdentityManager methods that skip storage.save and the user_attributes track.
  • Deferred attribute application until the preview resolves — the call now sits in finishLoadingPreview() after paywallRequestManager.getPaywall(...) succeeds, so an unusable debug token no longer applies anything.
  • Reworked the restorerestoreAttributes: [String: Any?] replaced by a whole-dictionary attributesSnapshot: [String: Any]?, put back on viewDidDisappear via replaceAttributesInMemoryOnly(_:).

ℹ️ 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)`.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/Identity/IdentityManager.swift Outdated
Comment thread Sources/SuperwallKit/Debug/DebugViewController.swift Outdated
Comment thread Sources/SuperwallKit/Debug/DebugViewController.swift Outdated
@konroj
konroj force-pushed the feat/paywall-preview-deep-link-overrides branch 2 times, most recently from 9af9d58 to 5c6226a Compare August 5, 2026 10:19

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 mergemergeAttributesInMemoryOnly_appliesInMemoryButNeverPersists asserts the override is visible through userAttributes and absent from storage.get(UserAttributes.self).
  • Pinned the no-notify contractmergeAttributesInMemoryOnly_doesNotNotifyDelegate asserts notifyUserChange is never invoked for the in-memory path.
  • Pinned the restorereplaceAttributesInMemoryOnly_restoresSnapshotWithoutPersisting asserts 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.sleep waits at IdentityManagerTests.swift:109, :155 and :160 aren't load-bearing — each is followed by a read through the userAttributes getter, which is queue.sync on the same serial queue the writes were queue.async'd onto, so the ordering is already guaranteed. Only :135 in doesNotNotifyDelegate needs a drain, and _ = identityManager.userAttributes would do it deterministically instead of costing 300 ms. It matches the file's existing style, so entirely optional.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Tests/SuperwallKitTests/Identity/IdentityManagerTests.swift Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 churnproject.pbxproj is back to objectVersion = 54 with preferredProjectObjectVersion, minimizedProjectReferenceProxies, productRefGroup and the Core Data group reordering gone, and the hand-added parallelizable = "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.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

@konroj

konroj commented Aug 5, 2026

Copy link
Copy Markdown
Author

Thanks for the review — both points addressed (force-pushed).

attr_* write on an unvalidated token

Reworked so attribute overrides never reach durable state or analytics, and only apply once the token is validated:

  • Added IdentityManager.mergeAttributesInMemoryOnly / replaceAttributesInMemoryOnly — they mutate only the in-memory _userAttributes; no storage.save, no user_attributes tracking.
  • Attribute overrides now apply in finishLoadingPreview after getPaywall succeeds — that fetch is authed by the debug token, so success means the backend accepted it — instead of synchronously in viewDidLoad. A bad token throws before any attribute is touched.
  • Because nothing is persisted, a force-quit / jetsam / crash mid-session leaves no injected attributes behind; normal exit restores in-memory via replaceAttributesInMemoryOnly.
  • locale / appearance stay pre-fetch — they shape the request and are session-only options that don't write persisted user state.

Added three IdentityManagerTests that lock this in: the override is visible via userAttributes but never appears in storage, never notifies, and restore leaves storage untouched — so a future storage.save on this path fails CI.

Generated-project churn

Dropped the project.pbxproj / .xcscheme changes — the project is xcodegen-generated from project.yml (which globs Sources//Tests/), so the new files are picked up on regeneration and the diff is now source-only.

Full unit suite passes (925).

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.
@konroj
konroj force-pushed the feat/paywall-preview-deep-link-overrides branch from 5c6226a to 96a0abd Compare August 5, 2026 11:04
@konroj

konroj commented Aug 5, 2026

Copy link
Copy Markdown
Author

Update — I've pulled attr_* (user-attribute overrides) out of this PR.

The in-memory approach I pushed earlier didn't actually hold: _userAttributes is the merge base for every persisting write in IdentityManager, so any ordinary write while the debugger is open (setUserAttributes, identify(), an attribution callback, or the paywall's own user_attribute_updated) would bake the debug value onto disk. Doing it safely means injecting the overrides only into the paywall's template render, never into identity state — a separate, larger change.

So this PR now covers trial_state / appearance / locale / present only, and attr_* will follow as its own PR done that way.

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 appearance was actually overridden (so closing the debugger no longer emits a stray DeviceAttributes event).

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_* entirelyDebugPaywallOverrides.attributes / attributePrefix / parseAttributes, DebugViewController.applyAttributeOverridesIfNeeded / attributesSnapshot, both IdentityManager in-memory-only methods and the three IdentityManagerTests cases 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 restoreviewDidDisappear now only calls Superwall.shared.setInterfaceStyle(to: initialInterfaceStyleOverride) when overrides.appearance != nil, so closing the debugger no longer emits a spurious DeviceAttributes event for sessions with no appearance override.
  • Tracked the preview load in a cancellable TaskviewDidLoad stores previewTask = Task { await loadPreview() } and viewDidDisappear calls previewTask?.cancel() before the rest of the teardown.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏


override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
previewTask?.cancel()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cancel() only sets a flag: nothing in loadPreview() / finishLoadingPreview() checks Task.isCancelled, and both network hops suspend on an unstructured Task's .valueCustomURLSession.swift:93-100 (Task.retrying(...).value) and PaywallRequestManager.swift:78/:102 (activeTasksawait task.value) — which is immune to the caller's cancellation.

So exiting mid-load still runs the whole tail: addPaywallPreview() attaches a fresh PaywallViewController + WKWebView as a child of the dismissed controller after resetCache(), and with present=true presentAutomaticallyIfNeeded() still passes its guard (didAppear is never reset) and calls loadAndShowPaywall(presenter: self) on a detached presenter. The PR description's "the in-flight preview load is cancelled on dismiss" doesn't hold as written.

Technical details
# `previewTask?.cancel()` cannot interrupt the preview load

## Affected sites
- `Sources/SuperwallKit/Debug/DebugViewController.swift:587``previewTask?.cancel()`. Sets the flag and nothing observes it.
- `Sources/SuperwallKit/Debug/DebugViewController.swift:240-299``loadPreview()` / `finishLoadingPreview()`. Zero `Task.isCancelled` / `try Task.checkCancellation()` calls, including after the three awaits (`network.resolvePaywallIdentifier` at `:255`, `paywallRequestManager.getPaywall` at `:279`, `storeKitManager.getProductVariables` at `:281`).
- `Sources/SuperwallKit/Network/Custom URL Session/CustomURLSession.swift:93-100``try await Task.retrying(...).value`, where `Task+Retrying.swift:24` builds a detached `Task(priority:) { }`. Per the stdlib `Task.cancel()` contract, cancellation reaches only *structured* children, and `await task.value` does not throw on the awaiting task's own cancellation.
- `Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift:78`, `:102` — same shape: the fetch lives in an unstructured `Task` stored in `activeTasks` and coalesced across callers, awaited via `.value`.
- `Sources/SuperwallKit/Debug/DebugViewController.swift:287-289``addPaywallPreview()` then `presentAutomaticallyIfNeeded()`, both reached unconditionally. The file contains no `removeFromParent()` anywhere, so the child controller added here is retained by the dismissed `DebugViewController` for its lifetime.
- `Sources/SuperwallKit/Debug/DebugViewController.swift:172-176`, `:187-195``didAppear` is set in `viewDidAppear` and never reset in `viewDidDisappear` (`:585-593`), so the `present=true` latch survives dismissal.
- `Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/CheckDebuggerPresentation.swift:24-29` — guards on `request.presenter is DebugViewController` only, never on whether that instance is still attached, so the stale presentation is allowed through.
- `Sources/SuperwallKit/Debug/DebugViewController.swift:393`, `:437` — the picker and localization-picker reloads spawn `Task { await self?.loadPreview() }` without assigning `previewTask`, so even a working cancel would miss them.

## Required outcome
- Dismissing the debugger must prevent `addPaywallPreview()` and `presentAutomaticallyIfNeeded()` from running for a load that was in flight at dismissal, for every path that starts a preview load — not just the `viewDidLoad` one.
- Whatever the mechanism, `loadAndShowPaywall` must not be reachable with `self` detached from the window hierarchy.

## Suggested approach (optional)
- Since the network layer is deliberately unstructured, the cheapest honest fix is an explicit checkpoint rather than relying on task cancellation: guard the mutation tail in `finishLoadingPreview()` on `!Task.isCancelled` (or on `viewIfLoaded?.window != nil`), and add the same condition to `presentAutomaticallyIfNeeded()`'s guard alongside `didAppear`.
- Alternatively reset `didAppear = false` in `viewDidDisappear` — that alone closes the `present=true` half, though it leaves the orphaned child controller from `addPaywallPreview()`.
- If the intent is only to stop the auto-present and not the fetch, dropping `previewTask` and the `cancel()` in favour of the window/`didAppear` check would be less misleading than a cancel that has no effect.

## Open questions for the human
- Is `present=true` expected to be usable in an automated harness that can tear the debugger down mid-load (Appium/XCUITest), or is dismissal-during-load considered out of scope for the QA flow?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant