Propagate selected package forward across workflow steps#6735
Propagate selected package forward across workflow steps#6735facumenzella wants to merge 38 commits into
Conversation
… paywall Early "info" screens in multi-step workflows have no PackageComponents, so selectedPackage is nil and price/period template variables can't resolve. WorkflowPaywallView now pre-computes the default package from the workflow's singleStepFallbackId step once at init time and passes it to every PaywallsV2View as a fallback. Steps with their own packages continue to use their own selection; only packageless steps benefit from the fallback. Port of purchases-android#3431. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- `fallbackPackage` computed property lives on `WorkflowContext` (was a static method on `WorkflowPaywallView`), using `reduce(into:)` for the component-tree walk - `PaywallsV2View` drops `defaultPackage:` init parameter; reads `\.workflowFallbackPackage` from the environment instead and applies it via `.task` when no package is already selected - `WorkflowPaywallView` sets the new environment key alongside the existing workflow environment values - Tests updated to call `context.fallbackPackage` directly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Splits the guard chain in `fallbackPackage` so that a missing/absent `singleStepFallbackId` (valid, no-op) is handled silently, while a set ID that fails to resolve emits a warning to aid debugging. Also documents the assumption that the fallback package itself is not hidden — that configuration would be considered invalid. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces WorkflowPackageContext — a single environment struct that groups all three package-related workflow values (contextPackage, fallbackPackage, onPackageSelected) under one key, replacing the separate WorkflowFallbackPackageKey from the parent PR. WorkflowPaywallView populates the struct per page and injects it via .environment; PaywallsV2View reads it to apply forward-propagated selection and fallback resolution. Cross-offering guard (validatedContextPackage) ensures a package from a previous step is only used if its identifier exists in the destination step's offering, preventing the wrong product from being purchased. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
e7152fd to
1a67e73
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a carried contextPackage is resolvable in the current step's offering, skip the fallbackPackage task entirely. Previously, the fallback task could race ahead, set selectedPackageContext.package, trigger onReceive, and call onPackageSelected with the fallback — corrupting the user's selection from the prior step for all subsequent steps in the workflow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pulls the fallback-suppression condition out of the task body into a testable static method. Five parametrized tests cover the key cases: context resolvable → nil (suppressed), packageless step → fallback, no prior selection → fallback, nil fallback → nil, cross-offering context → fallback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…back separation - Rename fallbackPackage → defaultPackage throughout (WorkflowContext, WorkflowPackageContext, WorkflowPaywallView, PaywallsV2View, tests) - Fix double-negation: pkg.visible != false → pkg.visible ?? true - Traverse stickyFooter and tabs in collectVisiblePackages so packages in those containers are included in the workflow default resolution - Extract onPackageSelected out of WorkflowPackageContext into its own environment key (workflowOnPackageSelected) to separate data from behaviour in the environment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add testWorkflowPackageContextExcludesHiddenPackages: verifies that a package with visible: false is filtered out of workflowPackageContext even when marked isSelectedByDefault - Add testWorkflowPackageContextDefaultsVisibleTrueWhenPropertyIsAbsent: covers the visible ?? true default in collectPackages - Add testPackageCarryForwardStatePreservesSelectionForReEnteredStep: covers the back→forward cycle — step 1 selection survives back navigation and can carry forward again on re-entry - Extend packageComponentJSON helper with optional visible parameter - Add makeScreenJSON(rawPackageComponents:offeringId:) test helper - Add doc comments to WorkflowPackageCarryForwardState and all its methods Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WorkflowPaywallView already passes workflowDefaultPackage as workflowPackageContext?.selectedPackage, so reading the environment value again in the fallback expression was a no-op. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tab components were initialising their per-tab PackageContext from workflowPackageContext?.selectedPackage (the static singleStepFallbackId context), so a package selected on a previous workflow step was never reflected as the pre-selected option inside a Tabs component on the next step. Fix: thread workflowContextPackage (the per-step carry-forward package) via environment from WorkflowPaywallView down to TabsComponentView. LoadedTabsComponentView validates it against each tab's own package list before using it, preventing cross-offering contamination. Priority: contextPackage > workflowDefault > tab default. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tPackage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When going back from step C to step B, the displayed package now matches the carry-forward state (the selection the user had on B), instead of resetting to the step default. The contextPackage is read from selectedPackagesByStepID[previousStep.id] — which is untouched by clearForBackNavigation (that only clears the abandoned step C). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The RevenueCatUITestsDev target was removed by ffa6c18 (replaced by PaywallScreenshotTests Tuist project). The PR had added test files to the old target's build phase; take main's version for both conflict regions since the new tests are already covered by the RevenueCatUITests Tuist glob (Tests/RevenueCatUITests/**/*.swift). 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 2 potential issues.
❌ 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 a826546. Configure here.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves clearForBackNavigation after the navigateBack() guard so the state is not mutated when navigator.navigateBack() returns nil. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the stored `let` property with @Environment(\.workflowContextPackage) so body reads the env key directly. The init parameter is kept as a local for @State initialization (env is not accessible in init). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The initial step was created with shouldRecordInitialPackageSelection: false, so a user who proceeds without explicitly selecting saw the destination step fall back to workflowDefaultPackage rather than what the initial step displayed. Setting it to true aligns the initial step with intermediate steps: the displayed default is recorded passively, explicit selections still win, and validatedContextPackage drops the carry-forward if the destination offering doesn't include the package. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… and sync variableContext - P1: initialPackageToRecordForWorkflow now records the contextPackage (not workflowDefaultPackage) on packageless intermediate steps. Previously, a step with no packages caused the workflow fallback SKU to overwrite the user's real selection in packageCarryForwardState, so downstream steps received the wrong package. - P2: when contextPackage resolves in the current step's offering, the init now passes nil for workflowPackages to makeSelectedPackageContext, so variableContext uses the step's own package catalog rather than the terminal step's catalog. This keeps packageContext.package and packageContext.variableContext.mostExpensivePricePerMonth on the same offering and prevents incorrect relative price rendering. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…bsPackageInheritanceTests didUserSelectPackage was a compile-time constant (true/false) in each test, making one side of each ternary unreachable. Inline the values directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tanceTests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@RCGitBot please test run-revenuecat-ui-ios-26 |
|
🚀 Triggered run-revenuecat-ui-ios-26 → Pipeline #37702 |
| } | ||
|
|
||
| /// Records the initial resolved package for `stepID` only if no explicit selection | ||
| /// exists yet — ensures passive carry-through in 3-step+ workflows without |
There was a problem hiding this comment.
You are right, the qualifier was misleading. Removed it — the mechanism applies equally to any multi-step workflow, not just those with three or more steps.
| defaultPackage: defaultPackage, | ||
| contextPackage: contextPackage, | ||
| stepPackages: stepPackages, | ||
| shouldRecordInitialPackageSelection: self.recordWorkflowInitialSelection, |
There was a problem hiding this comment.
I think there's inconsistency in the naming of this shouldRecordInitialPackageSelection vs recordWorkflowInitialSelection vs recordInitialSelection
There was a problem hiding this comment.
Good catch. Unified all three to recordInitialWorkflowPackageSelection throughout — PaywallsV2View init param, the stored property, the static method parameter, and the RenderedPage field. The recordInitialSelection method on WorkflowPackageCarryForwardState is intentionally different since it is a verb-action method on a distinct type, not a flag.
…ction LoadedTabsComponentView.onAppear calls packageContext.update() to sync the parent context with the active tab's initial package. Because dropFirst() has already consumed the @published replay by then, this emission reaches workflowOnPackageSelected and calls recordSelection() — overwriting the correctly-carried contextPackage with the tab's initialization default. Add isReadyForWorkflowPackageSelection (@State, default false). Guard workflowOnPackageSelected behind it in onReceive. Set it to true in the outer onAppear, which SwiftUI fires after child onAppear callbacks, so tab initialization emissions are blocked and only genuine user selections are forwarded to the carry-forward state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
f975ac3 to
c46a252
Compare
workflowPackageContext → workflowFallbackContext: workflow-wide static context from singleStepFallbackId; stable for the lifetime of the workflow. workflowContextPackage → workflowCarriedPackage: per-step Package? carried from the preceding step; changes on each forward navigation. Also removes the dead @Environment(\.workflowPackageContext) on PaywallsV2View — the value is passed as an init param and stored directly; the @Environment annotation was never read in the body. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three names described the same concept: - shouldRecordInitialPackageSelection (RenderedPage, static method param) - recordWorkflowInitialSelection (PaywallsV2View init param/property) - the recordInitialSelection method (separate – the act of recording) Unified the first two to recordInitialWorkflowPackageSelection throughout. Also removed the "3-step+" qualifier from the recordInitialSelection doc comment; the mechanism applies equally to any multi-step workflow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Superseded by #6790, which takes a simpler approach: per-step |

Checklist
purchases-androidand hybridsMotivation
Multi-step workflow paywalls had no way to carry the user's package selection from one step to the next. If a user selected Annual on step 1 and then navigated forward, step 2 would ignore that choice and fall back to either the workflow's
singleStepFallbackIddefault or the step's own page default. The same issue applied in reverse: tapping back and then forward again would re-apply a stale selection from a previous forward trip.Android counterpart: RevenueCat/purchases-android#3431
Description
Per-step carry-forward state (
WorkflowPackageCarryForwardState)Replaces the single
currentSelectedPackagevariable inWorkflowPaywallViewwith a per-step dictionary. Explicit user selections (recordSelection) always win over the initial resolved default (recordInitialSelection). Back navigation clears only the abandoned step's record so earlier selections can carry forward again on re-entry.clearForBackNavigationis called after thenavigator.navigateBack()guard so carry-forward state is never mutated when there is no previous step to return to.Two distinct environment keys instead of one
workflowFallbackContext(WorkflowPackageContext?) is the workflow-wide static context derived fromsingleStepFallbackId. It carries bothselectedPackageand the fullpackagesarray so packageless steps can populatevariableContextand resolve price/period template variables. It stays the same for the entire workflow lifetime.workflowCarriedPackage(Package?) is the per-step carry-forward. It holds the single package selected (or resolved as the default) on the immediately preceding step, changes on each forward navigation, and is cleared on back navigation. Its only job is pre-selection, not variable resolution.workflowOnPackageSelectedandworkflowOnInitialPackageResolvedare environment callbacks that letPaywallsV2Viewreport selections and initial resolutions back toWorkflowPaywallViewwithout the two being coupled directly.Initial step recording
The first step is created with
recordInitialWorkflowPackageSelection: trueso that a user who taps Continue without making an explicit selection still carries the displayed default forward to step 2, rather than falling back to the workflow default SKU.Packageless step carry-forward
On steps with no packages of their own,
effectiveDefaultPackagefalls back toworkflowDefaultPackagefor rendering.initialPackageToRecordForWorkflowdetects this case and records the incomingworkflowCarriedPackageinstead of the fallback, preventing the workflow default from overwriting the user's real selection.Variable context desync fix
When
workflowCarriedPackageresolves within the current step's offering,variableContextis built from the step's own package set rather than the workflow-wide packages. This keeps relative-price variables (e.g.product.relative_discount) referencing the same catalog as the selected package.Tab preselection
TabsComponentViewandLoadedTabsComponentViewreadworkflowCarriedPackagefrom the environment and validate it against each tab's own package list before applying it as the initial selection. A new staticvalidated(_:in:)helper mirrors the existing logic inPaywallsV2View.Tab
onAppearcarry-forward fixTab components call
packageContext.update()inonAppearto sync the parent context. Because SwiftUI fires childonAppearbefore parentonAppear, this emission arrived before any user interaction and was overwriting the correctly-carried package. TheisReadyForWorkflowPackageSelectionflag blocks theonReceivehandler until the outeronAppearfires.RootViewsheet dismissal fixWhen a package selection sheet is dismissed without a new choice, the paywall now restores
defaultPackage(the page-level default) instead ofworkflowFallbackContext?.selectedPackage. This prevents the workflow fallback SKU from overriding the page's own default on sheet close.WorkflowContextcleanupThe
collectPackagestraversal now includes the sticky footer and handlestabsandcarouselcomponents. Hidden packages (visible: false) are filtered out usingwhere pkg.visible ?? truerather than an inverted condition.Testing
PaywallsV2ViewContextPackageTestscoversmakeSelectedPackageContext,effectiveDefaultPackage,validatedContextPackage, andinitialPackageToRecordForWorkflowincluding the packageless step carry-forward path.WorkflowPaywallViewTestscoversWorkflowPackageCarryForwardStatemutations, theworkflowFallbackContextresolver (nil cases, default selection, hidden package filtering, sticky footer, tabs/carousel traversal), and variableContext population.TabsPackageInheritanceTestscoversworkflowCarriedPackagepreselection and validation against tab package lists.