Description
SuperwallDelegate.handleSuperwallEvent events — including paywallOpen, paywallClose, and other paywall lifecycle events — are silently dropped because the SuperwallDelegateBridge is created and assigned inside the Superwall.configure() completion callback. Any delegate events that fire between "SDK internally ready" and "completion callback executes" are lost.
This means apps forwarding Superwall events to analytics (e.g., PostHog) will see missing paywallOpen events, making paywall funnels unreliable. Handler-based callbacks (onPresent, onDismiss) work fine, creating a confusing discrepancy.
Likely shares the same root cause as SW-4616.
Affected events
Any event flowing through handleSuperwallEvent can be dropped on cold boot, including:
paywallOpen / paywallClose / paywallDecline
willPresentPaywall / didPresentPaywall / willDismissPaywall / didDismissPaywall
- Early lifecycle events (
configAttributes, paywallResponseLoadStart, paywallProductsLoadStart, etc.)
Events tracked via PaywallPresentationHandler (onPresent, onDismiss, onSkip, onError) are not affected — they bypass the delegate.
Steps to reproduce
- Install
expo-superwall 0.6.11
- Set up
useSuperwallEvents({ onSuperwallEvent }) to log all delegate events
- Register a placement on a screen that loads immediately after auth (e.g., a paywall screen)
- Force-kill the app (cold boot)
- Open the app, authenticate, and reach the paywall screen
- Observe:
onPresent (handler) fires, but handleSuperwallEvent with paywallOpen does not
After a hot reload, both fire — because the delegate is already assigned from the previous session.
Root cause
In SuperwallExpoModule.swift lines 99-111, the delegate is assigned inside the configure() completion:
Superwall.configure(
apiKey: apiKey,
purchaseController: usingPurchaseController ? purchaseController : nil,
options: superwallOptions,
completion: {
self.delegate = SuperwallDelegateBridge() // <- too late
Superwall.shared.delegate = self.delegate // <- too late
Superwall.shared.setPlatformWrapper("Expo", version: sdkVersion ?? "0.0.0")
promise.resolve(nil)
}
)
SuperwallKit becomes internally ready before calling the completion. If a placement was queued (via register()) while configure was in progress, the SDK processes it as soon as it's ready — firing delegate events like paywallOpen. But the delegate is still nil, so the events are silently dropped.
Evidence
We confirmed this with PostHog analytics. Over 90 days:
| Metric |
Source |
Count |
paywall_viewed |
PaywallPresentationHandler.onPresent (not affected) |
15 |
superwall_paywall_open |
handleSuperwallEvent delegate (affected) |
2 |
superwall_transaction_start |
handleSuperwallEvent delegate (fires later, after user interaction) |
8 |
Transaction events work because they fire after user interaction, by which time the completion callback has run. Only early-lifecycle delegate events are affected.
Fix
Move the delegate assignment after configure() returns but outside the completion callback:
Superwall.configure(
apiKey: apiKey,
purchaseController: usingPurchaseController ? purchaseController : nil,
options: superwallOptions,
completion: {
Superwall.shared.setPlatformWrapper("Expo", version: sdkVersion ?? "0.0.0")
promise.resolve(nil)
}
)
// Assign delegate immediately after configure() returns.
// Superwall.shared exists (created synchronously by configure),
// but the completion hasn't fired yet, so no delegate events are missed.
self.delegate = SuperwallDelegateBridge()
Superwall.shared.delegate = self.delegate
Note: You cannot set the delegate before configure() — Superwall.shared doesn't exist yet and crashes with EXC_BREAKPOINT in Superwall.shared.getter.
Workaround
Move the delegate assignment after configure() returns (as shown above) via patch-package. This eliminates the race window entirely — the delegate is assigned as soon as Superwall.shared exists, before any async work completes.
Note: The race is timing-dependent and doesn't reproduce on every cold boot. On the simulator it may not reproduce at all if the completion callback fires before queued placements are processed. The PostHog analytics data above (2 delegate events vs 15 handler events over 90 days) is the clearest evidence that events are being intermittently dropped in production.
Environment
expo-superwall: 0.6.11
- Expo SDK: 53
- iOS Simulator: iPhone 15 Pro Max
- macOS: 14.8.3
Description
SuperwallDelegate.handleSuperwallEventevents — includingpaywallOpen,paywallClose, and other paywall lifecycle events — are silently dropped because theSuperwallDelegateBridgeis created and assigned inside theSuperwall.configure()completion callback. Any delegate events that fire between "SDK internally ready" and "completion callback executes" are lost.This means apps forwarding Superwall events to analytics (e.g., PostHog) will see missing
paywallOpenevents, making paywall funnels unreliable. Handler-based callbacks (onPresent,onDismiss) work fine, creating a confusing discrepancy.Likely shares the same root cause as SW-4616.
Affected events
Any event flowing through
handleSuperwallEventcan be dropped on cold boot, including:paywallOpen/paywallClose/paywallDeclinewillPresentPaywall/didPresentPaywall/willDismissPaywall/didDismissPaywallconfigAttributes,paywallResponseLoadStart,paywallProductsLoadStart, etc.)Events tracked via
PaywallPresentationHandler(onPresent,onDismiss,onSkip,onError) are not affected — they bypass the delegate.Steps to reproduce
expo-superwall0.6.11useSuperwallEvents({ onSuperwallEvent })to log all delegate eventsonPresent(handler) fires, buthandleSuperwallEventwithpaywallOpendoes notAfter a hot reload, both fire — because the delegate is already assigned from the previous session.
Root cause
In
SuperwallExpoModule.swiftlines 99-111, the delegate is assigned inside theconfigure()completion:SuperwallKit becomes internally ready before calling the completion. If a placement was queued (via
register()) while configure was in progress, the SDK processes it as soon as it's ready — firing delegate events likepaywallOpen. But the delegate is stillnil, so the events are silently dropped.Evidence
We confirmed this with PostHog analytics. Over 90 days:
paywall_viewedPaywallPresentationHandler.onPresent(not affected)superwall_paywall_openhandleSuperwallEventdelegate (affected)superwall_transaction_starthandleSuperwallEventdelegate (fires later, after user interaction)Transaction events work because they fire after user interaction, by which time the completion callback has run. Only early-lifecycle delegate events are affected.
Fix
Move the delegate assignment after
configure()returns but outside the completion callback:Note: You cannot set the delegate before
configure()—Superwall.shareddoesn't exist yet and crashes withEXC_BREAKPOINTinSuperwall.shared.getter.Workaround
Move the delegate assignment after
configure()returns (as shown above) viapatch-package. This eliminates the race window entirely — the delegate is assigned as soon asSuperwall.sharedexists, before any async work completes.Note: The race is timing-dependent and doesn't reproduce on every cold boot. On the simulator it may not reproduce at all if the completion callback fires before queued placements are processed. The PostHog analytics data above (2 delegate events vs 15 handler events over 90 days) is the clearest evidence that events are being intermittently dropped in production.
Environment
expo-superwall: 0.6.11