Skip to content

Only run the MMP install match once config enables it - #537

Open
yusuftor wants to merge 5 commits into
codex/device-ip-collectionfrom
fix/mmp-match-waits-for-config
Open

yusuftor wants to merge 5 commits into
codex/device-ip-collectionfrom
fix/mmp-match-waits-for-config

Conversation

@yusuftor

@yusuftor yusuftor commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Changes in this pull request

The MMP install match used to run on every fresh install, whether or not the app uses the Superwall MMP. It now waits for attributionOptions.mmp.enabled in config before firing, the same way Apple Search Ads waits for attributionOptions.appleSearchAds.enabled. It's off unless the backend turns it on.

  • MMPAttributionManager.matchInstallOnceEnabled watches the config and starts the match the first time the switch is on. That's straight away if config is already loaded, and at most once per launch even if config refreshes.
  • The eligibility check (shouldAttemptInitialMMPInstallAttributionMatch) still runs at launch, before waiting for config. It records that this install may be matched, and a later launch relies on that record to retry. If it waited on config, an install whose first launch ended before config loaded would never be matched.
  • On a brand-new install the match now fires after the first config request, usually under a second later. The backend matches on IP, device fingerprint and time, and the click-to-open gap is already minutes, so this barely changes match quality. acquisition_* attributes may land slightly later for paywalls shown right at launch.

This is stacked on #524, which adds the attributionOptions.mmp.enabled config option and uses it for IP collection. Merge that first. The backend needs to send attributionOptions.mmp.enabled: true for apps using the MMP, or matching stops for them.

Validation: 65 tests pass across MMPInstallMatchConfigTests (new), MMPInstallAttributionTests, AdServicesAttributionTests, DeviceHelperTests and DeviceIPCollectorTests on the iPhone 17 Pro / iOS 26.5 simulator. scripts/lint.sh is clean for the changed files (the array_constructor warning on Superwall.swift is pre-existing).

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 for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

🤖 Generated with Claude Code

RetriggerConfidence Score: 4/5

The PR should not merge until an opt-out at config arrival can no longer permanently consume the launch’s matching opportunity.

Findings

  1. P1 Opt-in cannot restart matching ▶
Fix with agent prompt
### Issue 1
Sources/SuperwallKit/Superwall.swift:561-563
When MMP-enabled config arrives while event tracking is set to `.none`, this guard skips the match. The config subscription has already accepted its one enabled value, though, so changing tracking back to `.all` later in the launch does not start a match. The install can miss its attribution window if the app does not relaunch in time.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR waits for remote configuration to enable MMP install matching, adds a tracking opt-out check immediately before matching, and tests the config gate.

  • The new opt-out check can consume the one-shot config trigger without matching if tracking is later re-enabled.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Install eligible] --> B[Wait for MMP-enabled config]
  B --> C{Tracking disabled?}
  C -->|No| D[Start match]
  C -->|Yes| E[Return without match]
  E --> F[One-shot subscription completed]
  F --> G[Later tracking opt-in does not retry]
Loading

Reviews (2) · Last reviewed commit: "Recheck the tracking opt-out when the MM..."

The install match used to fire on every fresh install. It now waits for
`attributionOptions.mmp.enabled` in config, the same way Apple Search Ads
waits for its switch. The eligibility check still runs at launch so a
later launch can retry if this one ends before the match completes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 found.

Reviewed changes

I reviewed the change that makes the MMP install match wait until config enables the MMP.

  • MMPAttributionManager.matchInstallOnceEnabled: subscribes to configState and runs startMatch the first time attribution.mmp.enabled == true. .first { $0 } limits it to one match per launch even if config refreshes, and it runs straight away if config is already loaded.
  • Superwall configure task: shouldAttemptInitialMMPInstallAttributionMatch still runs at launch after identity is configured, so the eligibility record survives a launch that ends before config loads. Only recordMMPInstallAttributionMatch waits for the flag.
  • DependencyContainer: passes configManager into MMPAttributionManager.
  • MMPInstallMatchConfigTests: four Swift Testing cases covering a wait for enable, no match when the flag is nil or false, an immediate match when config is already loaded, and a single match across a true → false → true refresh. Each assertion is exact, so each test would fail if its guard were removed.

ℹ️ Match timing and rollout depend on the config publish and the backend flag

The match now waits for configState to publish .retrieved, and that happens later than the PR's "usually under a second" suggests in two cases. On a first install there is no saved customer info to publish early from, so ConfigManager.processConfig awaits loadPurchasedProducts before it publishes. In test mode it publishes only after the tester closes the test-mode modal. Neither is a problem for a time-decayed match, but acquisition_* can arrive later than the description suggests. If the config fetch fails, configState completes and there is no match this launch. The saved eligibility flag lets a later launch within the 7-day window retry, as intended.

Technical details
# Match timing and rollout ordering

## Affected sites
- Sources/SuperwallKit/Config/ConfigManager.swift:511 — first-install path awaits `loadPurchasedProducts` before the `.retrieved` send at :538
- Sources/SuperwallKit/Config/ConfigManager.swift:525-533 — test mode publishes only after `presentTestModeModal` returns
- Sources/SuperwallKit/Config/ConfigManager.swift:432 — fetch failure completes `configState`, so `matchInstallOnceEnabled` never fires that launch

## Required outcome
- The backend sends `attributionOptions.mmp.enabled: true` for every app using the MMP before an SDK release containing this change ships. Otherwise install matching stops for those apps without any error.

## Open questions for the human
- Is the backend flag already rolled out for current MMP customers, or does the release need to wait for it?

Pullfrog  | View workflow run | Using Claude Opus | 𝕏

Comment thread Sources/SuperwallKit/Superwall.swift Outdated
Comment thread Sources/SuperwallKit/Superwall.swift Outdated
The match can wait a while for config, so check that the app hasn't
opted out of tracking in the meantime. Hold the dependency container
weakly so a match that never fires doesn't keep it alive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 found.

Reviewed changes

I reviewed a64dd1f, which hardens the deferred MMP match callback in Superwall.swift.

  • Checked the opt-out again when the match fires: the matchInstallOnceEnabled closure now returns early if eventTrackingBehavior has changed to .none while it waited for config. Because .first { $0 } has already fired, this skips the match for the rest of the launch. The eligibility record from launch still lets a later launch within the 7-day window retry.
  • Captured dependencyContainer weakly: this breaks the container → mmpAttributionManager → pendingMatch → closure → container cycle. That cycle would otherwise last as long as the MMP stays disabled.

Pullfrog  | View workflow run | Using Claude Opus | 𝕏

Comment thread Sources/SuperwallKit/Superwall.swift
yusuftor and others added 2 commits September 30, 2026 17:13
On a first launch the match starts when config arrives, the same moment
an app_open paywall is unblocked, so its audiences were checked before
the acquisition attributes existed. If the MMP is on, a match is still
pending and the placement's audiences use acquisition_ attributes, the
paywall now waits for the match, for up to 2 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The match was skipped for the rest of the launch if tracking was off
when config enabled the MMP. It now starts when the app opts back in,
and the launch-time setup no longer depends on the tracking setting.
The match still starts at most once per launch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 critical issues. One test suggestion is inline.

Reviewed changes

I reviewed b8e9694 and 7bec056, which make paywalls wait for a running install match and restart a skipped match when the app turns tracking back on.

  • Paywalls wait for a pending match: waitForSubsStatusAndConfig now ends with waitForPendingMatch(ifUsedBy:). It waits up to 2 s only when a match is pending, the MMP is on, and an audience expression mentions acquisition_. throwableAsync is built on AsyncThrowingStream, which stops when its task is cancelled, so group.cancelAll() does end the wait at the timeout. The attribute merge (queue.async) and the audience read (queue.sync) use the same serial queue, so the acquisition_* values are visible once the match task finishes.
  • Matches skipped by the opt-out now retry: the start closure returns Task<Void, Never>?, and nil means it was skipped. The Superwall.eventTrackingBehavior setter calls startMatchIfEnabled() when tracking isn't .none, and hasStartedMatch under NSLock keeps it to one match per launch. This resolves the open greptile thread about opting back in.
  • The config subscription stays open for the whole launch: .first { $0 } became removeDuplicates().filter { $0 }, and hasStartedMatch now enforces the single match.
  • Added tests: opt-in retry, opt-in while the MMP is off, usesAcquisitionAttributes, and three wait/no-wait cases.

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

Comment thread Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift Outdated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 found.

Reviewed changes

I reviewed c8669ed. It changes only tests, so that the paywall-wait tests fail if waitForPendingMatch stops waiting.

  • Made paywallWaitsForARunningMatch depend on a real wait: the match task now sleeps 200 ms before it increments finished. isMatchPending flips to false only after await match.value, and that happens after the increment. So finished.count == 1 can only pass if the waiter blocked until the match finished. This fixes the earlier race where the gate was released before the waiter started.
  • Gave paywallStopsWaitingAfterTheTimeout a lower bound: elapsed >= 0.1 is safe from flakes, because the timeout's Task.sleep starts inside waitForPendingMatch, after start is recorded.

Pullfrog  | View workflow run | Using Claude Opus | 𝕏

This branch has not been deployed

No deployments
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