feat(workflows): bridge workflow exit offer into PaywallViewController#6911
Merged
Conversation
16c4c6b to
156363d
Compare
vegaro
reviewed
Jun 5, 2026
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d306c54. Configure here.
0bb10bf to
3f76100
Compare
vegaro
approved these changes
Jun 8, 2026
Feed the embedded SwiftUI paywall's step-aware workflow exit offer into the UIKit controller's exitOfferOffering (via the exit-offer environment binding plus the preference key), so swipe-to-dismiss and the close button surface it through the existing exit-offer machinery. Exit-offer children bridge too, so a workflow can open another workflow as its exit offer and chain further. Clear the offer when the host is rebuilt under workflows so update(with:) can't surface a stale exit offer during the reload window. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "UIKit View Controller" entry to the Live Paywalls context menu that modally presents the offering through PaywallViewController, so its UIKit dismissal handling and the workflow exit-offer bridge can be exercised (the SwiftUI PaywallView path does not go through that controller). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
configuration.didSet cleared exitOfferOffering directly, bypassing updateWorkflowExitOffer's "don't clear while an exit offer is presenting" guard. Route through it so a hybrid update(with:) during an in-flight exit-offer dismiss can't drop the offer. Behavior is unchanged for the normal reload case (not presenting) and remains a no-op under the legacy path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n-content reconfigures Adds three tests that document expected behavior: - update(with displayCloseButton:) must not clear exitOfferOffering - updateFont(with:) must not clear exitOfferOffering - update(with offering:) should clear exitOfferOffering (legitimate rebuild) Also exposes workflowsEndpointEnabled as an overridable var and adds @_spi(Internal) test hooks (exitOfferOfferingForTesting, simulateWorkflowExitOfferUpdate) so tests don't need a scheme launch argument and can inspect private state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lass init issues Convenience inits on PaywallViewController aren't inherited by subclasses because the class defines its own designated init(content:fonts:...). Replacing the overridable computed var with a stored internal var lets tests set it directly on an instance, removing the need for a subclass entirely. Also drops the @_spi(Internal) annotation from the test hooks — @testable import is sufficient since they only need to be internal. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
configuration.didSet fired updateWorkflowExitOffer(nil) on every mutation, so update(with displayCloseButton:) and updateFont(with:) wiped a valid workflow exit offer even though they don't change the offering. Move the clear into the three content mutators (before the assignment), so cosmetic reconfigures keep the offer. Still routed through updateWorkflowExitOffer, so the "don't clear while presenting" guard and the legacy no-op hold. Covered by cesar's regression tests in PaywallViewControllerExitOfferTests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…odeproj Danger flagged the new test file as missing from the project. Add it as a file reference under a new UIKit group, mirroring how other Tests/RevenueCatUITests files are referenced (file ref only, no xcodeproj target). The RevenueCatUITests sources are built via SPM/Tuist, not an xcodeproj target, so it must NOT be added to the UnitTests target (that target does not link RevenueCatUI, which would break run-test-ios-26). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The preference->binding forwarding in PaywallContainerView forwarded nil into the exit-offer binding. On a host rebuild (update(with:displayCloseButton:) / updateFont), the new container can emit a default-nil WorkflowExitOfferPreferenceKey before WorkflowPaywallView re-syncs, momentarily clearing a still-valid exit offer and letting swipe/close skip it in that window. Forward only non-nil offers. Clears are owned by the direct binding (WorkflowPaywallView.syncExitOfferBinding fires on step change), so ignoring nil from the set-only preference fallback is safe. Render-path behavior, verified manually like the rest of the bridge; the binding-path clear semantics stay covered by PaywallViewControllerExitOfferTests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
b359c54 to
67162d5
Compare
Closed
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Follow-up to #6887, which wired workflow exit offers into the SwiftUI presentation layer but explicitly left the UIKit side as a TODO (the comment in
prefetchExitOffereven said so). So today, with workflows on, presenting throughPaywallViewControllerand swiping to dismiss just closes the paywall, the step-aware workflow exit offer never shows up.This bridges it 🚀
This PR also adds a "UIKit View Controller" entry to PaywallsTester's Live Paywalls menu.
Known gap, separate follow-up (not in this PR): the SwiftUI
.presentPaywall(offering:)modifier (PresentingPaywallBindingModifier) still uses the legacy offering-level exit offer.Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-06-05.at.09.56.21.mov
AI session context
AI Context
Metadata
facu/workflow-exit-offer-uikit-bridgeGoal
Bridge the step-aware workflow exit offer into the UIKit
PaywallViewControllerso swipe-to-dismiss and the close button surface it when the workflows endpoint is enabled. Follow-up to the TODO left in #6887. Requirement added mid-session: WorkflowA must be able to open WorkflowB as an exit offer (and chain further). Later iteration (this session): fix a code-review regression where reconfiguring the controller dropped a valid workflow exit offer.Initial Prompt
"Check #6887. We need to add support for ExitOffers + Workflows in PaywallViewController. Take a look at how that works, and let's pick it up from there." (Resolved to: implement the UIKit exit-offer bridge that #6887 deferred.) Follow-up session: "Check #6911" resolved to addressing reviewer (vegaro) feedback on the bridge.
Important Follow-up Prompts
configuration.didSetcleared the exit offer on every mutation, including non-content ones; (2) the bindinggetis never read. Decided: bring vegaro's regression tests (test(paywalls): regression tests for workflow exit offer surviving non-content reconfigures #6919) into this branch and fix here (Option A), using the "more targeted" fix rather than makingContentEquatable.Agent Contribution
PaywallViewControllerand SwiftUIView+PresentPaywall/WorkflowPaywallView/WorkflowExitOfferPreferenceKey/workflowExitOfferOfferingBinding).RevenueCatUI/UIKit/PaywallViewController.swift: env-binding injection + preference fallback intoexitOfferOffering, a guardedupdateWorkflowExitOffer, and a stale-offer clear.PaywallViewConfiguration.Contentis notEquatable(so her literalcontent != oldValue.contentwould not compile and would cascade conformance intoOffering/PresentedOfferingContext), and implemented the targeted alternative.get.Human Decisions
Key Implementation Decisions
exitOfferOfferingand reusepresentationControllerShouldDismiss/handleDismissalRequest/presentExitOffer.UIAdaptivePresentationControllerDelegate; SwiftUI inside aUIHostingControllercan't intercept the parent sheet's interactive dismissal.willPresentExitOfferControllerdelegate callback.WorkflowPaywallViewwrites the binding in onAppear/onChange.updateWorkflowExitOffer(nil)out ofconfiguration.didSetand into the three content mutators (update(with:offering),update(with:offeringIdentifier),update(with:offeringIdentifier:presentedOfferingContext:)), before the assignment.didSetfires on all mutations, soupdate(with displayCloseButton:)andupdateFont(with:)were wiping a valid exit offer. Placing the clear in only the content mutators is precise and needs no new conformances. Kept routed throughupdateWorkflowExitOffer, so the "don't clear while presenting" guard (from the earlier Codex P2 fix) and the legacy no-op still hold.if configuration.content != oldValue.content—Contentis notEquatable; conforming it cascades intoOffering(NSObject, reference equality) andPresentedOfferingContext.geteven though it is never read.Binding<Offering?>because the SwiftUI path (View+PresentPaywall.swift:643) injects a real two-way$exitOfferOffering;Bindingstructurally requires aget. Added a comment marking it write-only here.PaywallContainerView, forward only non-nil values from theWorkflowExitOfferPreferenceKeyonPreferenceChangeinto the binding (guard let offering = context?.exitOfferOffering else { return }).main): on a host rebuild (update(with:displayCloseButton:)/updateFont), the new container can emit a default-nil preference beforeWorkflowPaywallViewre-syncs, momentarily clearing a still-valid exit offer. The preference is a set-only fallback; clears are owned by the direct binding (syncExitOfferBindingon step change), so ignoring nil is safe.guard let).Files / Symbols Touched
RevenueCatUI/UIKit/PaywallViewController.swiftupdateWorkflowExitOffer(_:),configurationdidSet (clear removed),update(with:)content mutators (clear added),createHostingController()(binding + comment),PaywallContainerView(onPreferenceChangenow forwards non-nil only),prefetchExitOffer(), and the test hooks (workflowsEndpointEnabledvar,exitOfferOfferingForTesting,simulateWorkflowExitOfferUpdate).RevenueCat.xcodeproj/project.pbxprojUIKitgroup, NOT in theUnitTeststarget (RevenueCatUITests files build via SPM/Tuist; adding it toUnitTestsbrokerun-test-ios-26withUnable to find module dependency: 'RevenueCatUI', since that target doesn't link RevenueCatUI).Tests/RevenueCatUITests/UIKit/PaywallViewControllerExitOfferTests.swifttestUpdateDisplayCloseButtonDoesNotClearWorkflowExitOffer,testUpdateFontDoesNotClearWorkflowExitOffer,testUpdateOfferingClearsWorkflowExitOffer.Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/OfferingList/APIKeyDashboardList.swiftPaywallViewController.Dependencies / Config / Migrations
-EnableWorkflowsEndpoint(ProcessInfo.processInfo.workflowsEndpointEnabled). No new deps, no public API change.Validation
xcodebuild test -scheme RevenueCatUI -destination 'platform=iOS Simulator,id=…iPhone 16 Pro' -only-testing:RevenueCatUITests/PaywallViewControllerExitOfferTests(RED, before fix): Executed 3 tests, 2 failures (…DisplayCloseButton…and…Font…failed;…Offering…passed).swiftlint lint RevenueCatUI/UIKit/PaywallViewController.swift: no violations.origin/main(resolved aproject.pbxprojconflict: kept only the new file ref;WorkflowsCacheTestswas relocated upstream). Force-pushed.run-test-ios-26initially failed because the test file was added to theUnitTeststarget; fixed by making it a file reference only. Full suite re-runs on push; therun-all-testsjob stays on its manual-approval hold by design.Validation Gaps
PaywallViewControllerExitOfferTests, not the full suite (CI is the backstop). No-regression argument for the rest is structural:updateWorkflowExitOfferis a no-op whenworkflowsEndpointEnabledis false, so legacy-path behavior is unchanged by both thedidSetremoval and the new calls.Review Focus
update(with displayCloseButton:)andupdateFont(with:)must keep a valid workflow exit offer;update(with: offering)must clear it.Risks / Reviewer Notes
updateWorkflowExitOffer, which no-ops a nil clear whileisShowingExitOfferis true.= nilno longer applies (the directdidSetclear was removed).UIHostingController.Non-goals / Out of Scope
.presentPaywall(offering:)modifier (PresentingPaywallBindingModifier). Separate follow-up.Omitted Context
Note
Medium Risk
Touches paywall dismissal and exit-offer state in a core UIKit path; behavior is gated on workflows being enabled and legacy prefetch is unchanged when off.
Overview
Workflow exit offers now reach UIKit
PaywallViewController, so swipe-to-dismiss and the close button can show step-aware exit offers when the workflows endpoint is on (previously only the SwiftUI path had this).The embedded paywall feeds offers into existing
exitOfferOfferingvia an environment binding (primary) and preference fallback (non-nil only, so host rebuilds fromupdate(with displayCloseButton:)/updateFontdon’t briefly wipe a valid offer). Legacy prefetch is skipped when workflows are enabled; offering/contentupdatecalls clear the stale offer, while cosmetic reconfigures do not. Test hooks and PaywallViewControllerExitOfferTests cover that behavior.PaywallsTester adds a UIKit View Controller Live Paywalls menu action to present
PaywallViewControllerfor manual QA.Reviewed by Cursor Bugbot for commit 67162d5. Bugbot is set up for automated code reviews on this repo. Configure here.