Skip to content

Resolve price/period variables on packageless workflow screens#6737

Merged
facumenzella merged 16 commits into
mainfrom
facundo/default-package-info-workflow
May 7, 2026
Merged

Resolve price/period variables on packageless workflow screens#6737
facumenzella merged 16 commits into
mainfrom
facundo/default-package-info-workflow

Conversation

@facumenzella

@facumenzella facumenzella commented May 6, 2026

Copy link
Copy Markdown
Member

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

  • `WorkflowContext`: add `workflowPackageContext: WorkflowPackageContext?` computed property and `collectPackages` recursive helper (no visibility filter — trust the backend)
  • `WorkflowPackageContext`: new struct holding `selectedPackage` and `packages`
  • `EnvironmentValues+Workflow`: add `.workflowPackageContext` key
  • `WorkflowPaywallView`: pass `workflowDefaultPackage` and `workflowPackages` to `PaywallsV2View`; inject `.workflowPackageContext` into environment; extract `failedToLoadFont` as a method
  • `PaywallsV2View`: accept `workflowDefaultPackage` and `workflowPackages`; initialize `selectedPackageContext` with effective default package and workflow packages at init time; add `effectiveDefaultPackage` helper
  • `RootView`: restore workflow package on sheet dismissal
  • `TabsComponentView`: propagate `workflowDefaultPackage` to tab package contexts
  • `Strings`: add `workflow_package_context_unresolvable` warning string

Test plan

  • Unit tests in `WorkflowPaywallViewTests` covering: nil when no fallbackId, nil when fallbackId points to missing step, returns `isSelectedByDefault` package, falls back to first package when none is default, nil for packageless fallback step, resolves from sticky footer, resolves through tabs → carousel nesting, workflow package overrides page default, `variableContext` populated correctly for pricing variables
  • Full test suite passes (pre-existing snapshot/receipt failures unrelated to this change)
  • Manual: workflow paywall where an early screen has a `{{ price }}` variable and `singleStepFallbackId` points to the terminal step with packages — variable resolves correctly

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 WorkflowPackageContext from the workflow’s singleStepFallbackId step and propagating it through the SwiftUI environment.

WorkflowPaywallView now injects this context into each PaywallsV2View, which prefers the workflow-selected default package and uses workflow-derived package lists to initialize selectedPackageContext (so pricing variables are available from first render). RootView and TabsComponentView also 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.

… 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>
facumenzella and others added 4 commits May 6, 2026 18:36
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>
@facumenzella facumenzella marked this pull request as ready for review May 6, 2026 16:58
@facumenzella facumenzella requested review from a team as code owners May 6, 2026 16:58
@facumenzella facumenzella changed the title Propagate default package from singleStepFallbackId to workflow paywalls Resolve price variables on packageless workflow screens via fallback package May 6, 2026

@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.

Does this work? variableContext is never updated so variables like {{ product.relative_discount }} are always "" no?

Comment thread RevenueCatUI/Purchasing/WorkflowContext.swift Outdated
Comment thread RevenueCatUI/Purchasing/WorkflowContext.swift Outdated
Comment thread RevenueCatUI/Purchasing/WorkflowContext.swift Outdated
…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>
Comment thread RevenueCatUI/Purchasing/WorkflowContext.swift
facumenzella and others added 3 commits May 7, 2026 14:20
…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>
@facumenzella facumenzella changed the title Resolve price variables on packageless workflow screens via fallback package Resolve price variables on packageless workflow screens via workflow package context May 7, 2026
Comment thread RevenueCatUI/Purchasing/WorkflowContext.swift Outdated
`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>
@facumenzella facumenzella requested a review from vegaro May 7, 2026 13:07
Comment thread RevenueCatUI/Templates/V2/PaywallsV2View.swift Outdated
Comment thread RevenueCatUI/Templates/V2/Components/Tabs/TabsComponentView.swift
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>
@facumenzella facumenzella changed the title Resolve price variables on packageless workflow screens via workflow package context Resolve price/period variables on packageless workflow screens May 7, 2026
// Tab has its own packages - create context with tab's packages
let packageContext = PackageContext(
package: tabViewModel.defaultSelectedPackage,
package: workflowDefaultPackage ?? tabViewModel.defaultSelectedPackage,

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.

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.

@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.

Ready after some nits

Comment thread RevenueCatUI/Purchasing/WorkflowContext.swift Outdated
Comment thread Tests/RevenueCatUITests/PaywallsV2/WorkflowPaywallViewTests.swift
facumenzella and others added 2 commits May 7, 2026 17:01
- 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>
@facumenzella facumenzella enabled auto-merge (squash) May 7, 2026 15:01

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

private let purchaseHandler: PurchaseHandler
private let workflowDefaultPackage: Package?
private let workflowPackages: [Package]?
private let showZeroDecimalPlacePrices: Bool

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit db86280. Configure here.

@facumenzella facumenzella merged commit 47569a8 into main May 7, 2026
16 of 18 checks passed
@facumenzella facumenzella deleted the facundo/default-package-info-workflow branch May 7, 2026 15:13
facumenzella added a commit that referenced this pull request May 7, 2026
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>
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