Skip to content

Wire custom callbacks through getPaywall#464

Merged
yusuftor merged 3 commits intodevelopfrom
feat/get-paywall-custom-callback
Apr 24, 2026
Merged

Wire custom callbacks through getPaywall#464
yusuftor merged 3 commits intodevelopfrom
feat/get-paywall-custom-callback

Conversation

@yusuftor
Copy link
Copy Markdown
Collaborator

@yusuftor yusuftor commented Apr 22, 2026

Changes in this pull request

  • Adds an optional onCustomCallback parameter to Superwall.getPaywall(forPlacement:params:paywallOverrides:delegate:onCustomCallback:) so paywalls retrieved for embedding can handle custom webview callbacks. Previously this was only wired up through register(), which presents the paywall in its own UIWindow — embedding code (e.g. SwiftUI onboarding flows that need to layer their own UI over the paywall) had no way to receive callbacks and the webview always got a failure result.
  • Extends PaywallViewControllerDelegateAdapter with an onCustomCallback closure and registers/unregisters it with the existing CustomCallbackRegistry from PaywallViewController (init, delegate.didSet, deinit). Reuses the same registry as the register() path, so the underlying message-handler dispatch in PaywallMessageHandler.handleRequestCallback is unchanged.
  • Bumps version to 4.15.1 (Constants.swift, podspec, CHANGELOG).
  • Motivated by Pylon #18179.

Tests

Adds CustomCallbackGetPaywallTests with 4 tests covering: handler registered when adapter has one; no registration when handler is nil; reassigning the delegate updates registration (and delegate = nil clears it); deinit unregisters. Updates existing test mocks (SurveyManagerTests, PresentPaywallOperatorTests, WebEntitlementRedeemerTests, TrackingLogicTests) to pass the new customCallbackRegistry: init parameter.

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

Greptile Summary

This PR wires onCustomCallback through getPaywall() so embedded paywalls can receive custom webview callback results instead of always getting a failure. It extends PaywallViewControllerDelegateAdapter with the closure and manages registration/unregistration with the shared CustomCallbackRegistry via a new syncCustomCallbackRegistration() helper called from init, delegate.didSet, and deinit.

Confidence Score: 5/5

Safe to merge; the one concern is a low-probability cross-path interference scenario that is a P2 design suggestion, not a blocking defect.

All findings are P2. The unconditional unregister in deinit / syncCustomCallbackRegistration() only becomes a problem when both register() and getPaywall() are used simultaneously with the same paywall identifier, which is an unusual usage pattern. Core logic, threading, and test coverage are all solid.

PaywallViewController.swift — the syncCustomCallbackRegistration / deinit unregister interaction warrants a second look if the two presentation paths are ever exercised concurrently on the same paywall identifier.

Important Files Changed

Filename Overview
Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift Adds customCallbackRegistry dependency and syncCustomCallbackRegistration(); the unconditional unregister in deinit and the else branch can silently evict a handler registered by the register() path.
Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift Adds onCustomCallback closure to the adapter with a default-nil init parameter; clean, minimal change.
Sources/SuperwallKit/Paywall/Presentation/Get Paywall/PublicGetPaywall.swift Threads new onCustomCallback parameter through both the completion-block and async getPaywall overloads; properly defaults to nil for backwards compatibility.
Tests/SuperwallKitTests/Paywall/Presentation/CustomCallbackRegistryTests.swift New test suite covering registration on init, nil handler, delegate reassignment, and deinit unregistration — good coverage of the happy paths.
Sources/SuperwallKit/Dependencies/DependencyContainer.swift Passes customCallbackRegistry to PaywallViewController init; mechanical, correct change.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Superwall
    participant PaywallViewControllerDelegateAdapter
    participant PaywallViewController
    participant CustomCallbackRegistry
    participant WebView

    Caller->>Superwall: getPaywall(forPlacement:onCustomCallback:)
    Superwall->>PaywallViewControllerDelegateAdapter: init(swiftDelegate:objcDelegate:onCustomCallback:)
    Superwall->>PaywallViewController: init(...customCallbackRegistry:)
    PaywallViewController->>PaywallViewController: syncCustomCallbackRegistration()
    PaywallViewController->>CustomCallbackRegistry: register(paywallIdentifier:handler:)

    Caller->>Caller: embed / present PaywallViewController

    WebView->>PaywallMessageHandler: handleRequestCallback
    PaywallMessageHandler->>CustomCallbackRegistry: getHandler(paywallIdentifier:)
    CustomCallbackRegistry-->>PaywallMessageHandler: handler closure
    PaywallMessageHandler->>PaywallViewControllerDelegateAdapter: onCustomCallback(callback)
    PaywallViewControllerDelegateAdapter-->>PaywallMessageHandler: CustomCallbackResult

    Caller->>PaywallViewController: delegate = nil / deinit
    PaywallViewController->>CustomCallbackRegistry: unregister(paywallIdentifier:)
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift
Line: 292-295

Comment:
**Unconditional `unregister` in `deinit` may evict a `register()` handler**

`deinit` always calls `customCallbackRegistry.unregister(paywallIdentifier:)`, even when this VC was created via `getPaywall()` with `onCustomCallback: nil` (i.e., it never registered anything). The same applies to the `else` branch in `syncCustomCallbackRegistration()`. Because the registry is shared with the `register()` path, a sequence like:

1. `register()` presents paywall "abc" → handler stored under key "abc"
2. `getPaywall()` is called for the same paywall without `onCustomCallback``syncCustomCallbackRegistration()` calls `unregister("abc")`, silently evicting the live `register()` handler

A guard that only unregisters when this VC actually owns the current registration would prevent the cross-path interference:

```swift
private var hasRegisteredCallback = false

private func syncCustomCallbackRegistration() {
  if let handler = delegate?.onCustomCallback {
    customCallbackRegistry.register(paywallIdentifier: paywall.identifier, handler: handler)
    hasRegisteredCallback = true
  } else if hasRegisteredCallback {
    customCallbackRegistry.unregister(paywallIdentifier: paywall.identifier)
    hasRegisteredCallback = false
  }
}

deinit {
  introOfferTokenManager.stopObservingAppLifecycle()
  if hasRegisteredCallback {
    customCallbackRegistry.unregister(paywallIdentifier: paywall.identifier)
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Wire custom callbacks through getPaywall" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Adds an optional `onCustomCallback` parameter to `getPaywall(...)`
so paywalls embedded by the developer can handle custom webview
callbacks. Previously this was only wired up through `register()`,
which presents in its own UIWindow.

The handler is stored on `PaywallViewControllerDelegateAdapter` and
registered with `CustomCallbackRegistry` from `PaywallViewController`
on init / when the delegate is reassigned, and unregistered on deinit.

Bumps version to 4.15.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
yusuftor and others added 2 commits April 22, 2026 17:55
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Only unregister the custom callback handler when this PVC was the one
that registered it. Prevents a getPaywall-flow VC from clearing a
register-flow handler that happens to share the same paywall identifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@yusuftor yusuftor merged commit 23c873e into develop Apr 24, 2026
3 checks passed
@yusuftor yusuftor deleted the feat/get-paywall-custom-callback branch April 24, 2026 12:21
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