Skip to content

Propagate selected package forward across workflow steps#6735

Closed
facumenzella wants to merge 38 commits into
mainfrom
worktree-hashed-swinging-dragonfly
Closed

Propagate selected package forward across workflow steps#6735
facumenzella wants to merge 38 commits into
mainfrom
worktree-hashed-swinging-dragonfly

Conversation

@facumenzella
Copy link
Copy Markdown
Member

@facumenzella facumenzella commented May 6, 2026

Checklist

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

Motivation

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 singleStepFallbackId default 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 currentSelectedPackage variable in WorkflowPaywallView with 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. clearForBackNavigation is called after the navigator.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 from singleStepFallbackId. It carries both selectedPackage and the full packages array so packageless steps can populate variableContext and 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.

workflowOnPackageSelected and workflowOnInitialPackageResolved are environment callbacks that let PaywallsV2View report selections and initial resolutions back to WorkflowPaywallView without the two being coupled directly.

Initial step recording

The first step is created with recordInitialWorkflowPackageSelection: true so 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, effectiveDefaultPackage falls back to workflowDefaultPackage for rendering. initialPackageToRecordForWorkflow detects this case and records the incoming workflowCarriedPackage instead of the fallback, preventing the workflow default from overwriting the user's real selection.

Variable context desync fix

When workflowCarriedPackage resolves within the current step's offering, variableContext is 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

TabsComponentView and LoadedTabsComponentView read workflowCarriedPackage from the environment and validate it against each tab's own package list before applying it as the initial selection. A new static validated(_:in:) helper mirrors the existing logic in PaywallsV2View.

Tab onAppear carry-forward fix

Tab components call packageContext.update() in onAppear to sync the parent context. Because SwiftUI fires child onAppear before parent onAppear, this emission arrived before any user interaction and was overwriting the correctly-carried package. The isReadyForWorkflowPackageSelection flag blocks the onReceive handler until the outer onAppear fires.

RootView sheet dismissal fix

When a package selection sheet is dismissed without a new choice, the paywall now restores defaultPackage (the page-level default) instead of workflowFallbackContext?.selectedPackage. This prevents the workflow fallback SKU from overriding the page's own default on sheet close.

WorkflowContext cleanup

The collectPackages traversal now includes the sticky footer and handles tabs and carousel components. Hidden packages (visible: false) are filtered out using where pkg.visible ?? true rather than an inverted condition.

Testing

PaywallsV2ViewContextPackageTests covers makeSelectedPackageContext, effectiveDefaultPackage, validatedContextPackage, and initialPackageToRecordForWorkflow including the packageless step carry-forward path.

WorkflowPaywallViewTests covers WorkflowPackageCarryForwardState mutations, the workflowFallbackContext resolver (nil cases, default selection, hidden package filtering, sticky footer, tabs/carousel traversal), and variableContext population.

TabsPackageInheritanceTests covers workflowCarriedPackage preselection and validation against tab package lists.

facumenzella and others added 6 commits May 6, 2026 18:27
… 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>
@facumenzella facumenzella force-pushed the worktree-hashed-swinging-dragonfly branch from e7152fd to 1a67e73 Compare May 6, 2026 20:52
@facumenzella facumenzella changed the base branch from main to facundo/default-package-info-workflow May 6, 2026 20:52
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@facumenzella facumenzella requested a review from vegaro May 7, 2026 06:54
@facumenzella facumenzella marked this pull request as ready for review May 7, 2026 06:54
@facumenzella facumenzella requested review from a team as code owners May 7, 2026 06:54
Comment thread RevenueCatUI/Templates/V2/PaywallsV2View.swift
facumenzella and others added 2 commits May 7, 2026 09:22
Base automatically changed from facundo/default-package-info-workflow to main May 7, 2026 15:13
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>
Comment thread RevenueCatUI/Purchasing/WorkflowContext.swift Outdated
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>
Comment thread RevenueCatUI/Purchasing/WorkflowContext.swift Outdated
Comment thread RevenueCatUI/Templates/V2/WorkflowPaywallView.swift Outdated
Comment thread RevenueCatUI/Modifiers/EnvironmentValues+Workflow.swift Outdated
Comment thread RevenueCatUI/Purchasing/WorkflowContext.swift Outdated
…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>
Comment thread RevenueCatUI/Purchasing/WorkflowContext.swift
Comment thread RevenueCatUI/Templates/V2/WorkflowPaywallView.swift
facumenzella and others added 6 commits May 9, 2026 20:42
- 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>
Comment thread RevenueCatUI/Templates/V2/WorkflowPaywallView.swift Outdated
Comment thread RevenueCatUI/Templates/V2/WorkflowPaywallView.swift Outdated
Comment thread RevenueCatUI/Templates/V2/Components/Tabs/TabsComponentView.swift Outdated
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>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

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 2 potential issues.

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 a826546. Configure here.

Comment thread RevenueCatUI/Templates/V2/WorkflowPaywallView.swift Outdated
Comment thread RevenueCatUI/Templates/V2/WorkflowPaywallView.swift
facumenzella and others added 7 commits May 13, 2026 06:35
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>
@facumenzella facumenzella requested a review from vegaro May 13, 2026 14:47
@facumenzella
Copy link
Copy Markdown
Member Author

@RCGitBot please test run-revenuecat-ui-ios-26

@github-actions
Copy link
Copy Markdown

🚀 Triggered run-revenuecat-ui-ios-26Pipeline #37702

}

/// Records the initial resolved package for `stepID` only if no explicit selection
/// exists yet — ensures passive carry-through in 3-step+ workflows without
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.

why 3-step+ only?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment thread RevenueCatUI/Templates/V2/Components/Tabs/TabsComponentView.swift Outdated
defaultPackage: defaultPackage,
contextPackage: contextPackage,
stepPackages: stepPackages,
shouldRecordInitialPackageSelection: self.recordWorkflowInitialSelection,
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.

I think there's inconsistency in the naming of this shouldRecordInitialPackageSelection vs recordWorkflowInitialSelection vs recordInitialSelection

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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>
@facumenzella facumenzella force-pushed the worktree-hashed-swinging-dragonfly branch from f975ac3 to c46a252 Compare May 13, 2026 15:06
facumenzella and others added 2 commits May 13, 2026 09:15
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>
@facumenzella
Copy link
Copy Markdown
Member Author

Superseded by #6790, which takes a simpler approach: per-step PackageContext caching + step-scoped workflowPackageContext environment, rather than dual env keys and carry-forward state.

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