Resolve price/period variables on packageless workflow screens#6737
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>
vegaro
left a comment
There was a problem hiding this comment.
Does this work? variableContext is never updated so variables like {{ product.relative_discount }} are always "" no?
…lectVisiblePackages
- Add test proving variableContext is populated with packages so pricing
variables (e.g. {{ product.relative_discount }}) resolve on packageless screens
- Scan stickyFooter components alongside main stack in collectVisiblePackages
- Use `pkg.visible ?? true` (consistent with rest of codebase) instead of `!= false`
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rning The .task body already runs on the main actor so await on a non-async @mainactor method is redundant; pod-lib-lint treats the resulting warning as an error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Packages inside TabsComponent.tabs[].stack and CarouselComponent.pages were silently skipped; this matches what ViewModelFactory does when building the PackageValidator. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`collectVisiblePackages` was filtering by `pkg.visible ?? true`, mirroring `PackageValidator`, but that filter exists to avoid selecting a hidden package for display in the UI. Here we're picking a package solely for variable resolution on a packageless screen, so visibility on the fallback step is irrelevant. Rename to `collectPackages` and drop the now-inaccurate comment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous `.task` approach set both `package` and `variableContext`
from the workflow context, but became a no-op once `workflowDefaultPackage`
was passed at init — the `package == nil` guard never fired, leaving
`variableContext` initialized with the page's empty package list.
Pass `workflowPackages` through to `makeSelectedPackageContext` so
packageless screens get the fallback step's packages in `variableContext`
from the start, ensuring variables like `{{ product.relative_discount }}`
resolve correctly. Remove the now-dead `.task`.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| // Tab has its own packages - create context with tab's packages | ||
| let packageContext = PackageContext( | ||
| package: tabViewModel.defaultSelectedPackage, | ||
| package: workflowDefaultPackage ?? tabViewModel.defaultSelectedPackage, |
There was a problem hiding this comment.
Intentional. workflowDefaultPackage comes from the singleStepFallbackId step — which in practice is the terminal step that owns the tabs. So the workflow package is one of the tab's own packages; the ?? is effectively a no-op for that step. For packageless info screens, tabs won't have their own packages, so tabViewModel.defaultSelectedPackage is nil and the workflow package fills in correctly. A non-terminal screen with its own packaged tabs isn't a configuration the builder currently supports.
- Rename `workflowStepId` to `singleWorkflowStepFallbackId` for clarity - Update test comment that incorrectly referenced the removed `.task` path 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 db86280. Configure here.
| private let purchaseHandler: PurchaseHandler | ||
| private let workflowDefaultPackage: Package? | ||
| private let workflowPackages: [Package]? | ||
| private let showZeroDecimalPlacePrices: Bool |
There was a problem hiding this comment.
Stored property showZeroDecimalPlacePrices never read after init
Low Severity
showZeroDecimalPlacePrices is stored as a new private let property (assigned at init) but is never accessed via self.showZeroDecimalPlacePrices anywhere after construction. The init body references the identically-named parameter directly. This is dead stored state that adds unnecessary memory to every PaywallsV2View instance.
Reviewed by Cursor Bugbot for commit db86280. Configure here.
PR #6737 redesigned WorkflowPackageContext to {selectedPackage, packages} in WorkflowContext.swift. Adapt our carry-forward and race-condition fix to use the new shape: - workflowContextPackage (from prev step) passed as init param, not env struct - effectiveDefaultPackage now takes optional contextPackage + stepPackages - Remove async task approach; init-time selection avoids the race entirely - Keep workflowOnPackageSelected callback for WorkflowPaywallView tracking - Add pkg.visible ?? true filter to collectPackages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>


Motivation
In multi-step workflows, early "info" screens have no `PackageComponent`s so `selectedPackage` is `nil` on them. Template variables like `{{ price }}` and `{{ sub_period }}` can't resolve on those screens.
Follows RevenueCat/purchases-android#3431
Description
`WorkflowContext` now exposes a `workflowPackageContext: WorkflowPackageContext?` computed property that resolves a package context from the workflow's `singleStepFallbackId` step by walking its component tree (stacks, tabs, carousels, sticky footer) for `PackageComponent`s. The `isSelectedByDefault` package wins; first found is the fallback. Visibility is not filtered — the backend is trusted.
`WorkflowPaywallView` passes this context to each `PaywallsV2View` via two init parameters (`workflowDefaultPackage` and `workflowPackages`) and injects it into the SwiftUI environment as `.workflowPackageContext`. `PaywallsV2View` uses both at init time to correctly initialize `selectedPackageContext`: `package` from `workflowDefaultPackage ?? pageDefaultPackage`, and `variableContext` from `workflowPackages ?? paywallState.packages`. This ensures variables like `{{ price }}` and `{{ product.relative_discount }}` resolve on packageless screens from first render, with no async `.task` needed.
`RootView` uses `workflowPackageContext` to correctly restore the selected package when a purchase sheet is dismissed on a packageless screen (previously reset to `nil`).
`TabsComponentView` propagates `workflowDefaultPackage` so nested tabs on packageless screens also pick up the correct initial selection for variable resolution.
A `Logger.warning` is emitted when `singleStepFallbackId` is set but the step/screen/offering can't be resolved.
Changes
Test plan
Note
Medium Risk
Medium risk because it changes how default package and pricing variable contexts are derived and propagated across workflow paywall rendering (including tabs/sheets), which can affect displayed prices and purchase selection state.
Overview
Ensures price/period template variables resolve on packageless workflow screens by deriving a
WorkflowPackageContextfrom the workflow’ssingleStepFallbackIdstep and propagating it through the SwiftUI environment.WorkflowPaywallViewnow injects this context into eachPaywallsV2View, which prefers the workflow-selected default package and uses workflow-derived package lists to initializeselectedPackageContext(so pricing variables are available from first render).RootViewandTabsComponentViewalso use the workflow default package to restore/initialize selection correctly, and a new warning string is logged when the fallback step/screen/offering can’t be resolved.Adds targeted unit tests covering package-context resolution across nested component trees (stacks/tabs/carousels/sticky footer), default override precedence, and variable context population.
Reviewed by Cursor Bugbot for commit db86280. Bugbot is set up for automated code reviews on this repo. Configure here.