Skip to content

feat(workflows): bridge workflow exit offer into PaywallViewController#6911

Merged
facumenzella merged 8 commits into
mainfrom
facu/workflow-exit-offer-uikit-bridge
Jun 8, 2026
Merged

feat(workflows): bridge workflow exit offer into PaywallViewController#6911
facumenzella merged 8 commits into
mainfrom
facu/workflow-exit-offer-uikit-bridge

Conversation

@facumenzella

@facumenzella facumenzella commented Jun 4, 2026

Copy link
Copy Markdown
Member

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 prefetchExitOffer even said so). So today, with workflows on, presenting through PaywallViewController and 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

  • PR: 6911
  • Branch: facu/workflow-exit-offer-uikit-bridge
  • Author / human owner: facumenzella (Facundo Menzella)
  • Agent(s): Claude Code (Opus 4.8, 1M context)
  • Session source: current conversation
  • Generated: 2026-06-05
  • Context document version: 4

Goal

Bridge the step-aware workflow exit offer into the UIKit PaywallViewController so 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

  • "WorkflowA must be able to open WorkflowB as an exitOffer" — rejected SwiftUI-style nested-offer suppression; mandated chaining, which drove the always-bridge (no suppression flag) design.
  • "I wonder if it would be simpler to handle everything within the SwiftUI world" — evaluated; rejected because swipe-to-dismiss interception must stay in UIKit and the SwiftUI sheet path can't chain. Chose the UIKit bridge (Option A).
  • Codex review surfaced a P2 (stale offer on host rebuild); addressed.
  • Review feedback from vegaro (this session): (1) configuration.didSet cleared the exit offer on every mutation, including non-content ones; (2) the binding get is 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 making Content Equatable.

Agent Contribution

  • Traced both exit-offer paths (UIKit PaywallViewController and SwiftUI View+PresentPaywall / WorkflowPaywallView / WorkflowExitOfferPreferenceKey / workflowExitOfferOfferingBinding).
  • Implemented the original bridge in RevenueCatUI/UIKit/PaywallViewController.swift: env-binding injection + preference fallback into exitOfferOffering, a guarded updateWorkflowExitOffer, and a stale-offer clear.
  • This session: diagnosed vegaro's comment 1, confirmed PaywallViewConfiguration.Content is not Equatable (so her literal content != oldValue.content would not compile and would cascade conformance into Offering/PresentedOfferingContext), and implemented the targeted alternative.
  • Cherry-picked vegaro's two commits (regression tests + test hooks) onto this branch, preserving her authorship.
  • Ran the cherry-picked tests RED (2 failures) against the buggy code, applied the fix, re-ran GREEN (0 failures).
  • Replied to both review threads; added a clarifying comment on the binding get.

Human Decisions

Key Implementation Decisions

  • Decision: feed the offer up into the existing UIKit exitOfferOffering and reuse presentationControllerShouldDismiss / handleDismissalRequest / presentExitOffer.
    • Rationale: the VC is presented by UIKit, so swipe-to-dismiss interception must use UIAdaptivePresentationControllerDelegate; SwiftUI inside a UIHostingController can't intercept the parent sheet's interactive dismissal.
    • Rejected: SwiftUI-owned presentation (Option B) — still needs UIKit swipe interception, can't chain, and drops the willPresentExitOfferController delegate callback.
  • Decision: environment binding primary, preference key fallback.
    • Rationale: environment propagates downward deterministically; WorkflowPaywallView writes the binding in onAppear/onChange.
  • Decision: no suppression flag — exit-offer children bridge too (enables WorkflowA -> WorkflowB chaining).
  • Decision (this session): clear the workflow exit offer only on offering/content change, by moving updateWorkflowExitOffer(nil) out of configuration.didSet and into the three content mutators (update(with:offering), update(with:offeringIdentifier), update(with:offeringIdentifier:presentedOfferingContext:)), before the assignment.
    • Rationale: didSet fires on all mutations, so update(with displayCloseButton:) and updateFont(with:) were wiping a valid exit offer. Placing the clear in only the content mutators is precise and needs no new conformances. Kept routed through updateWorkflowExitOffer, so the "don't clear while presenting" guard (from the earlier Codex P2 fix) and the legacy no-op still hold.
    • Rejected: vegaro's literal if configuration.content != oldValue.contentContent is not Equatable; conforming it cascades into Offering (NSObject, reference equality) and PresentedOfferingContext.
  • Decision (this session): keep the binding get even though it is never read.
    • Rationale: the env key is Binding<Offering?> because the SwiftUI path (View+PresentPaywall.swift:643) injects a real two-way $exitOfferOffering; Binding structurally requires a get. Added a comment marking it write-only here.
  • Decision (this session): in PaywallContainerView, forward only non-nil values from the WorkflowExitOfferPreferenceKey onPreferenceChange into the binding (guard let offering = context?.exitOfferOffering else { return }).
    • Rationale: addresses a bug introduced by this PR's bridge commit (not in main): on a host rebuild (update(with:displayCloseButton:) / updateFont), the new container can emit a default-nil preference before WorkflowPaywallView re-syncs, momentarily clearing a still-valid exit offer. The preference is a set-only fallback; clears are owned by the direct binding (syncExitOfferBinding on step change), so ignoring nil is safe.
    • Note: render-path behavior, so verified by no-regression on the binding-path tests + manual; not separately unit-tested (a helper would only assert a tautological guard let).

Files / Symbols Touched

  • RevenueCatUI/UIKit/PaywallViewController.swift
    • Why: the bridge + this session's regression fix.
    • Symbols: updateWorkflowExitOffer(_:), configuration didSet (clear removed), update(with:) content mutators (clear added), createHostingController() (binding + comment), PaywallContainerView (onPreferenceChange now forwards non-nil only), prefetchExitOffer(), and the test hooks (workflowsEndpointEnabled var, exitOfferOfferingForTesting, simulateWorkflowExitOfferUpdate).
    • Review relevance: the clear now lives only in content mutators; confirm cosmetic reconfigures (close button, font) keep the offer and replacing the offering clears it; the preference forwarding ignores nil.
  • RevenueCat.xcodeproj/project.pbxproj
    • Why: register the new test file (danger requires it). Added as a file reference under a new UIKit group, NOT in the UnitTests target (RevenueCatUITests files build via SPM/Tuist; adding it to UnitTests broke run-test-ios-26 with Unable to find module dependency: 'RevenueCatUI', since that target doesn't link RevenueCatUI).
  • Tests/RevenueCatUITests/UIKit/PaywallViewControllerExitOfferTests.swift
    • Why: vegaro's regression tests covering exit-offer retention vs clearing on reconfigure (cherry-picked, her authorship).
    • Symbols: testUpdateDisplayCloseButtonDoesNotClearWorkflowExitOffer, testUpdateFontDoesNotClearWorkflowExitOffer, testUpdateOfferingClearsWorkflowExitOffer.
  • Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/OfferingList/APIKeyDashboardList.swift
    • Why: adds a "UIKit View Controller" Live Paywalls menu entry that modally presents PaywallViewController.

Dependencies / Config / Migrations

  • Feature flag: -EnableWorkflowsEndpoint (ProcessInfo.processInfo.workflowsEndpointEnabled). No new deps, no public API change.

Validation

  • Commands run:
    • 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).
    • Same command after the fix (GREEN): Executed 3 tests, 0 failures.
    • swiftlint lint RevenueCatUI/UIKit/PaywallViewController.swift: no violations.
    • After the preference-nil guard: same command, Executed 3 tests, 0 failures (no regression on the binding path).
  • Manual verification:
    • Original bridge exercised on a simulator via the PaywallsTester "UIKit View Controller" mode; the workflow exit offer surfaced on swipe-to-dismiss / close.
  • Source control:
    • Rebased onto origin/main (resolved a project.pbxproj conflict: kept only the new file ref; WorkflowsCacheTests was relocated upstream). Force-pushed.
  • CI:
    • run-test-ios-26 initially failed because the test file was added to the UnitTests target; fixed by making it a file reference only. Full suite re-runs on push; the run-all-tests job stays on its manual-approval hold by design.

Validation Gaps

  • Local run covered only PaywallViewControllerExitOfferTests, not the full suite (CI is the backstop). No-regression argument for the rest is structural: updateWorkflowExitOffer is a no-op when workflowsEndpointEnabled is false, so legacy-path behavior is unchanged by both the didSet removal and the new calls.
  • No automated test for the SwiftUI-presented bridge path itself (render/UIKit-presentation dependent); covered manually.

Review Focus

  • Confirm the clear now fires only for offering/content changes: update(with displayCloseButton:) and updateFont(with:) must keep a valid workflow exit offer; update(with: offering) must clear it.
  • Confirm the "don't clear while presenting" guard still holds for a content update during an active exit-offer presentation.
  • Chaining: dismissing WorkflowB-as-exit-offer should present its own exit offer with no leak across the fresh child VCs.

Risks / Reviewer Notes

  • Risk: a content update arriving mid-dismiss could clear the offer.
    • Evidence: routed through updateWorkflowExitOffer, which no-ops a nil clear while isShowingExitOffer is true.
    • Mitigation: covered by the guard; bugbot's earlier P2 on direct = nil no longer applies (the direct didSet clear was removed).
  • Risk: preference doesn't propagate up through the UIHostingController.
    • Mitigation: environment binding is the primary mechanism; preference is a fallback.

Non-goals / Out of Scope

  • Removing the workflows flag.
  • Wiring workflow exit offers into the SwiftUI .presentPaywall(offering:) modifier (PresentingPaywallBindingModifier). Separate follow-up.

Omitted Context

  • Raw transcript, unrelated exploration, sensitive details, repetitive attempts, and chain-of-thought-style content were omitted.

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 exitOfferOffering via an environment binding (primary) and preference fallback (non-nil only, so host rebuilds from update(with displayCloseButton:) / updateFont don’t briefly wipe a valid offer). Legacy prefetch is skipped when workflows are enabled; offering/content update calls 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 PaywallViewController for manual QA.

Reviewed by Cursor Bugbot for commit 67162d5. Bugbot is set up for automated code reviews on this repo. Configure here.

@facumenzella facumenzella force-pushed the facu/workflow-exit-offer-uikit-bridge branch from 16c4c6b to 156363d Compare June 4, 2026 14:36
@facumenzella facumenzella marked this pull request as ready for review June 5, 2026 08:05
@facumenzella facumenzella requested a review from a team as a code owner June 5, 2026 08:05
Comment thread RevenueCatUI/UIKit/PaywallViewController.swift Outdated
Comment thread RevenueCatUI/UIKit/PaywallViewController.swift
Comment thread RevenueCatUI/UIKit/PaywallViewController.swift Outdated
@facumenzella facumenzella requested a review from a team as a code owner June 5, 2026 14:05

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread RevenueCatUI/UIKit/PaywallViewController.swift
@facumenzella facumenzella force-pushed the facu/workflow-exit-offer-uikit-bridge branch 2 times, most recently from 0bb10bf to 3f76100 Compare June 5, 2026 14:23
@facumenzella facumenzella requested a review from vegaro June 8, 2026 13:14
facumenzella and others added 8 commits June 8, 2026 16:46
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>
@facumenzella facumenzella force-pushed the facu/workflow-exit-offer-uikit-bridge branch from b359c54 to 67162d5 Compare June 8, 2026 14:48
@facumenzella facumenzella enabled auto-merge (squash) June 8, 2026 14:48
@facumenzella facumenzella merged commit bb2ab34 into main Jun 8, 2026
18 of 20 checks passed
@facumenzella facumenzella deleted the facu/workflow-exit-offer-uikit-bridge branch June 8, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants