Skip to content

handleSuperwallEvent delegate events (paywallOpen, paywallClose, etc.) silently dropped due to late delegate assignment in configure() #186

@raisingdibar

Description

@raisingdibar

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

  1. Install expo-superwall 0.6.11
  2. Set up useSuperwallEvents({ onSuperwallEvent }) to log all delegate events
  3. Register a placement on a screen that loads immediately after auth (e.g., a paywall screen)
  4. Force-kill the app (cold boot)
  5. Open the app, authenticate, and reach the paywall screen
  6. 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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions