Open workflow exit offer as a second workflow#6756
Merged
Conversation
…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>
fc68d09 to
972eaff
Compare
…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>
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>
vegaro
approved these changes
May 14, 2026
vegaro
left a comment
Member
There was a problem hiding this comment.
Looks great. Some minor stuff.
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>
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 62ab682. Configure here.
…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>
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.

Checklist
purchases-androidand hybridsMotivation
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
exit_offersfield toWorkflowScreen, decoded fromexit_offersJSON key and passed throughWorkflowScreenMappertoPaywallComponentsDataWorkflowContext.exitOfferOfferingIdresolves the exit offer offering ID synchronously from thesingleStepFallbackIdstep's screenExit offer resolution
ExitOfferHelpergainsfetchValidExitOffer(offeringId:currentOfferingId:)— an async fetch that includes the same-offering guard (rejecting exit offers that point at the currently displayed offering). The existingfetchValidExitOffer(for:)now delegates to this method, removing duplicated logic.exitOffer(offeringId:from:)(lookup only) andvalidExitOffer(offeringId:currentOfferingId:from:)(lookup + same-offering guard).SwiftUI wiring
PaywallViewcomputes aWorkflowExitOfferContext(current offering ID + exit offer offering ID) once afterloadPaywallData()and stores it as@State. It is emitted viaWorkflowExitOfferOfferingIdPreferenceKeyas a single atomic update, avoiding a double preference emission that could occur if the value were derived reactively from two separate state variables inbody.PresentingPaywallModifierobserves the preference key insidepaywallView(_:)(consistent with howRestoredCustomerInfoPreferenceKeyis handled) and starts a cancellableTaskto fetch and store the exit offer offering. The task is cancelled on disappear to avoid stale writes.WorkflowContext. The old offering-level path (fetchValidExitOffer(for:)) only runs when-EnableWorkflowsEndpointis off, preserving pre-workflow exit offer behaviour unchanged.presentPaywall/presentPaywallIfNeeded), consistent with how they worked before this PR.PresentingPaywallBindingModifierretains the original offering-based path unchanged.Gating
The entire new flow is gated by the
-EnableWorkflowsEndpointlaunch argument.Testing
WorkflowResponseTests: decodingexit_offersfromWorkflowScreenJSONWorkflowScreenMapperTests:exit_offerspassed through toPaywallComponentsDataWorkflowContextTests:exitOfferOfferingIdresolution fromsingleStepFallbackIdExitOfferHelperTests(new):exitOffer(offeringId:from:),validExitOffersame-offering guard,fetchValidExitOfferunconfigured-SDK guard-EnableWorkflowsEndpoint, configure an offering whose workflow'ssingleStepFallbackIdstep hasexit_offers.dismiss.offering_idset — dismiss workflow 1 without purchasing and verify workflow 2 opensNote
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
-EnableWorkflowsEndpointand 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
-EnableWorkflowsEndpointis on.Refactors
ExitOfferHelperto centralize validation (fetchValidExitOffer(offeringId:currentOfferingId:)) and extracts pure lookup/validation helpers for synchronous use. Adds aWorkflowExitOfferPreferenceKeypipeline fromWorkflowPaywallView→presentPaywallmodifier so exit offers can be set/cleared based on the current workflow step without extra network fetches.Updates workflow context to compute
exitOfferTriggeringStepId/exitOfferOfferingand adds focused unit tests (including newExitOfferHelperTests) 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.