Skip to content

Open workflow exit offer as a second workflow#6756

Merged
facumenzella merged 21 commits into
mainfrom
facu/workflow-exit-offer-preload
May 14, 2026
Merged

Open workflow exit offer as a second workflow#6756
facumenzella merged 21 commits into
mainfrom
facu/workflow-exit-offer-preload

Conversation

@facumenzella

@facumenzella facumenzella commented May 8, 2026

Copy link
Copy Markdown
Member

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-android and hybrids

Motivation

Paywalls V2 workflows need to support exit offers: when a user dismisses a workflow paywall without purchasing, a second workflow paywall (the exit offer) should open automatically.

Description

Data layer

  • Adds exit_offers field to WorkflowScreen, decoded from exit_offers JSON key and passed through WorkflowScreenMapper to PaywallComponentsData
  • WorkflowContext.exitOfferOfferingId resolves the exit offer offering ID synchronously from the singleStepFallbackId step's screen

Exit offer resolution

  • ExitOfferHelper gains fetchValidExitOffer(offeringId:currentOfferingId:) — an async fetch that includes the same-offering guard (rejecting exit offers that point at the currently displayed offering). The existing fetchValidExitOffer(for:) now delegates to this method, removing duplicated logic.
  • Two new testable synchronous helpers are extracted: exitOffer(offeringId:from:) (lookup only) and validExitOffer(offeringId:currentOfferingId:from:) (lookup + same-offering guard).

SwiftUI wiring

  • PaywallView computes a WorkflowExitOfferContext (current offering ID + exit offer offering ID) once after loadPaywallData() and stores it as @State. It is emitted via WorkflowExitOfferOfferingIdPreferenceKey as a single atomic update, avoiding a double preference emission that could occur if the value were derived reactively from two separate state variables in body.
  • PresentingPaywallModifier observes the preference key inside paywallView(_:) (consistent with how RestoredCustomerInfoPreferenceKey is handled) and starts a cancellable Task to fetch and store the exit offer offering. The task is cancelled on disappear to avoid stale writes.
  • When the workflows endpoint is enabled, offerings don't include paywall components, so exit offers are resolved exclusively from WorkflowContext. The old offering-level path (fetchValidExitOffer(for:)) only runs when -EnableWorkflowsEndpoint is off, preserving pre-workflow exit offer behaviour unchanged.
  • Exit offers are only supported via the presenting modifiers (presentPaywall / presentPaywallIfNeeded), consistent with how they worked before this PR. PresentingPaywallBindingModifier retains the original offering-based path unchanged.

Gating

The entire new flow is gated by the -EnableWorkflowsEndpoint launch argument.

Testing

  • WorkflowResponseTests: decoding exit_offers from WorkflowScreen JSON
  • WorkflowScreenMapperTests: exit_offers passed through to PaywallComponentsData
  • WorkflowContextTests: exitOfferOfferingId resolution from singleStepFallbackId
  • ExitOfferHelperTests (new): exitOffer(offeringId:from:), validExitOffer same-offering guard, fetchValidExitOffer unconfigured-SDK guard
  • Manual: launch with -EnableWorkflowsEndpoint, configure an offering whose workflow's singleStepFallbackId step has exit_offers.dismiss.offering_id set — dismiss workflow 1 without purchasing and verify workflow 2 opens

Note

Medium Risk
Changes paywall dismissal flow to optionally present a second (exit-offer) paywall when using workflows, which can impact purchase/dismiss behavior and state timing in SwiftUI. The logic is gated behind -EnableWorkflowsEndpoint and is covered by new unit tests, but still touches critical presentation/session state.

Overview
Enables Paywalls V2 workflows to surface an exit offer as a second paywall when the user dismisses without purchasing, resolving the exit offer from workflow-loaded offerings when -EnableWorkflowsEndpoint is on.

Refactors ExitOfferHelper to centralize validation (fetchValidExitOffer(offeringId:currentOfferingId:)) and extracts pure lookup/validation helpers for synchronous use. Adds a WorkflowExitOfferPreferenceKey pipeline from WorkflowPaywallViewpresentPaywall modifier so exit offers can be set/cleared based on the current workflow step without extra network fetches.

Updates workflow context to compute exitOfferTriggeringStepId/exitOfferOffering and adds focused unit tests (including new ExitOfferHelperTests) plus test helpers to cover step-guarding and multi-page workflow cases.

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

Base automatically changed from facu/workflow-exit-offer-resolution to main May 8, 2026 14:00
@facumenzella facumenzella changed the title Preload workflow exit offer offering, gated by EnableWorkflowsEndpoint Open workflow exit offer as a second workflow, gated by EnableWorkflowsEndpoint May 8, 2026
…owsEndpoint

PaywallView emits WorkflowExitOfferOfferingIdPreferenceKey after resolving
the workflow context. PresentPaywallViewModifier (both variants) reads it
and calls ExitOfferHelper.fetchValidExitOffer(offeringId:) — a new
ID-based overload — to preload the exit offering. The entire path is
gated behind -EnableWorkflowsEndpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@facumenzella facumenzella force-pushed the facu/workflow-exit-offer-preload branch from fc68d09 to 972eaff Compare May 13, 2026 15:13
facumenzella and others added 7 commits May 13, 2026 09:15
…wallView

Preferences emitted by PaywallView inside a sheet do not propagate to
the outer modifier's view tree. Move onPreferenceChange into paywallView(_:)
and paywallView(for:) — the same pattern used for RestoredCustomerInfoPreferenceKey.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract exitOffer(offeringId:from:) to make the lookup logic testable
without requiring a configured Purchases instance. Tests cover: offering
found, offering not found (nil + warning logged), and Purchases unconfigured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Computing WorkflowExitOfferContext in body from two separate @State vars
risks firing the preference twice if SwiftUI renders between assignments.
Storing it as its own @State and setting it atomically alongside offering
and workflowContext after loadPaywallData() guarantees a single emission.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
usesWorkflowExitOfferPreferencePreload() was redundant — when the
workflows endpoint is enabled, offerings don't include paywall components,
so the old exit offer path would return nil anyway. Replace the method
with a direct workflowsEndpointEnabled check and remove the now-dead
content-type tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move WorkflowExitOfferContext emission from PaywallView into
WorkflowPaywallView, which owns the WorkflowNavigator and knows
the current step. A new `exitOfferTriggeringStepId` property on
WorkflowContext exposes the singleStepFallbackId only when an exit
offer is configured on that step.

WorkflowPaywallView.exitOfferContext(for:currentStepId:) returns nil
whenever the user is not on the triggering step. Because navigator is
@StateObject with @published currentStepId, SwiftUI re-evaluates body
on every step transition and re-emits the preference key — PresentingPaywallModifier's
existing cancel/fetch/clear cycle does the rest with no modifier changes needed.

This matches Android's shouldTriggerExitOfferForCurrentStep guard and
prevents the exit offer from firing when the user dismisses from a
step that doesn't have the exit offer configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@facumenzella facumenzella changed the title Open workflow exit offer as a second workflow, gated by EnableWorkflowsEndpoint Open workflow exit offer as a second workflow May 13, 2026
facumenzella and others added 7 commits May 13, 2026 14:50
The field is always non-nil when a context is emitted: workflowExitOfferContext
guards on exitOfferOfferingId before constructing the struct, so the downstream
guard let unwrap in handleWorkflowExitOfferPreferenceChange was redundant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes Danger bot warning: file was added to the filesystem but missing
from the Xcode project file references and Purchasing group.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Instead of kicking off an async Task when the triggering step preference
key fires, WorkflowContext now resolves the exit offer offering synchronously
from the already-loaded allOfferings bundle. This eliminates the timing window
where a fast dismiss could cancel the task before the fetch completed.

- WorkflowContext.exitOfferOffering: synchronous lookup via ExitOfferHelper.validExitOffer
  (including same-offering guard). Private exitOfferEntry helper covers both
  single-page (singleStepFallbackId screen) and multi-page workflows (step scan;
  assumption: at most one exit offer per workflow).
- WorkflowExitOfferContext now carries Offering? with identifier-based ==,
  dropping the exitOfferOfferingId/currentOfferingId string pair.
- WorkflowPaywallView.exitOfferContext reads context.exitOfferOffering directly.
- PresentingPaywallModifier.handleWorkflowExitOfferPreferenceChange is now a
  one-liner assignment; workflowExitOfferTask, cancelWorkflowExitOfferTask(),
  and the onDisappear cancellation are removed.
- Non-workflows path (async fetch via ExitOfferHelper) is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Android's `dismissExitOffer` relies solely on `singleStepFallbackId` and
returns nil when it is absent — no dict scan. Align iOS to the same
contract to eliminate non-deterministic iteration over `workflow.steps`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…referenceKey

The key carries a WorkflowExitOfferContext, not an offering ID string,
so the previous name was misleading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A non-nil WorkflowExitOfferContext now always carries a resolved Offering.
The nil guard is moved into exitOfferContext(for:currentStepId:), so both
"not on triggering step" and "offering not in allOfferings" collapse to a
nil preference value rather than a nil-carrying context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@facumenzella facumenzella marked this pull request as ready for review May 13, 2026 21:44
@facumenzella facumenzella requested review from a team as code owners May 13, 2026 21:44

@vegaro vegaro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great. Some minor stuff.

Comment thread RevenueCatUI/Purchasing/PurchaseHandler.swift Outdated
Comment thread RevenueCatUI/Helpers/ExitOfferHelper.swift
Comment thread RevenueCatUI/View+PresentPaywall.swift
Comment thread RevenueCatUI/Templates/V2/WorkflowPaywallView.swift Outdated
Comment thread RevenueCatUI/Helpers/ExitOfferHelper.swift Outdated
facumenzella and others added 4 commits May 14, 2026 06:03
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ext call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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 62ab682. Configure here.

Comment thread RevenueCatUI/Helpers/ExitOfferHelper.swift Outdated
…wiftUI render

validExitOffer is called synchronously via .preference on every WorkflowPaywallView
body render. Moving all logging into the one-shot async fetchValidExitOffer path
prevents exitOfferSameAsCurrent from firing hundreds of times on misconfiguration.
Also fixes a secondary bug where both exitOfferSameAsCurrent and exitOfferNotFound
were logged for the same event.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@facumenzella facumenzella merged commit 30fac43 into main May 14, 2026
17 of 19 checks passed
@facumenzella facumenzella deleted the facu/workflow-exit-offer-preload branch May 14, 2026 13:11
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