Skip to content
Open
36 changes: 36 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,7 @@
57BB08682DD3D9EC007493E1 /* SubscriptionDetailViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57BB08672DD3D9EA007493E1 /* SubscriptionDetailViewModelTests.swift */; };
57BD50AA27692B7500211D6D /* StoreKitError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57BD50A927692B7500211D6D /* StoreKitError+Extensions.swift */; };
57BF87592967880C00424254 /* MockCachingTrialOrIntroPriceEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57BF87582967880C00424254 /* MockCachingTrialOrIntroPriceEligibilityChecker.swift */; };
57BFCD0B2EEB0E56009E5844 /* TabsPackageInheritanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57BFCD0A2EEB0E56009E5844 /* TabsPackageInheritanceTests.swift */; };
57C0BB6929F840BD00827807 /* FrameworkDisambiguation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C0BB6829F840BD00827807 /* FrameworkDisambiguation.swift */; };
57C2931528BFEF4F0054EDFC /* PurchasesError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C2931428BFEF4F0054EDFC /* PurchasesError.swift */; };
57C381B72791E593009E3940 /* StoreKit2TransactionListenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C381B62791E593009E3940 /* StoreKit2TransactionListenerTests.swift */; };
Expand Down Expand Up @@ -2356,6 +2357,7 @@
57BB08672DD3D9EA007493E1 /* SubscriptionDetailViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionDetailViewModelTests.swift; sourceTree = "<group>"; };
57BD50A927692B7500211D6D /* StoreKitError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoreKitError+Extensions.swift"; sourceTree = "<group>"; };
57BF87582967880C00424254 /* MockCachingTrialOrIntroPriceEligibilityChecker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockCachingTrialOrIntroPriceEligibilityChecker.swift; sourceTree = "<group>"; };
57BFCD0A2EEB0E56009E5844 /* TabsPackageInheritanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsPackageInheritanceTests.swift; sourceTree = "<group>"; };
57C0BB6829F840BD00827807 /* FrameworkDisambiguation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameworkDisambiguation.swift; sourceTree = "<group>"; };
57C2931428BFEF4F0054EDFC /* PurchasesError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesError.swift; sourceTree = "<group>"; };
57C381B62791E593009E3940 /* StoreKit2TransactionListenerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2TransactionListenerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2958,6 +2960,7 @@
030890822D2B77DD0069677B /* PaywallsV2 */ = {
isa = PBXGroup;
children = (
57BFCD0A2EEB0E56009E5844 /* TabsPackageInheritanceTests.swift */,
03CCFCE12DE9638F0052B764 /* __PreviewResources__ */,
83EE33252E1E20360011CF1C /* PaywallPreviewResourcesLoader.swift */,
038EC8222DE7A8AC00786C42 /* TakeScreenshot.swift */,
Expand Down Expand Up @@ -7892,6 +7895,7 @@
887A632B2C1D177800E1A461 /* LocalizedAlertErrorTests.swift in Sources */,
887A632C2C1D177800E1A461 /* PackageVariablesTests.swift in Sources */,
887A632D2C1D177800E1A461 /* PaywallDataValidationTests.swift in Sources */,
57BFCD0B2EEB0E56009E5844 /* TabsPackageInheritanceTests.swift in Sources */,
887A632E2C1D177800E1A461 /* TemplateViewConfigurationTests.swift in Sources */,
887A632F2C1D177800E1A461 /* VariablesTests.swift in Sources */,
887A63302C1D177800E1A461 /* AsyncTestHelpers.swift in Sources */,
Expand Down
166 changes: 154 additions & 12 deletions RevenueCatUI/Templates/V2/Components/Tabs/TabsComponentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,22 @@ struct LoadedTabsComponentView: View {

@State var wasConfigured: Bool = false

// MARK: - Parent's Own Selection Tracking
//
// These track the parent's "own" package selection (before any tab propagation).
// When switching to a tab WITHOUT packages, we restore this selection.
//
// Example:
// 1. Parent has Package A selected
// 2. User switches to Tab 1 (has Package C) → C is propagated to parent
// 3. User switches to Tab 2 (no packages) → restore parent to A (not C)
//
@State
private var parentOwnedPackage: Package?

@State
private var parentOwnedVariableContext: PackageContext.VariableContext

var activeTabViewModel: TabViewModel? {
return self.viewModel.tabViewModels[self.tabControlContext.selectedTabId] ??
self.viewModel.tabViewModels.values.first
Expand All @@ -106,16 +122,44 @@ struct LoadedTabsComponentView: View {
defaultTabId: viewModel.defaultTabId
))

// Store the parent's initial selection for restoration when switching to package-less tabs
self._parentOwnedPackage = .init(initialValue: parentPackageContext.package)
self._parentOwnedVariableContext = .init(initialValue: parentPackageContext.variableContext)

// MARK: - Package Context Inheritance for Tabs
//
// This handles a nuanced scenario where tabs may or may not have their own packages:
//
// Example structure:
// - Package A (parent scope, default)
// - Package B (parent scope)
// - Tabs Component
// - Tab 1: has Package C (its own package)
// - Tab 2: no packages (should inherit from parent)
//
// Solution:
// - Tabs WITH packages: create their own PackageContext with their packages
// - Tabs WITHOUT packages: use parentPackageContext directly (same instance)
// This ensures they always reflect the current parent selection and stay in sync
// automatically when the parent context changes.
//
self._tierPackageContexts = .init(initialValue: Dictionary(
uniqueKeysWithValues: viewModel.tabViewModels.map { key, tabViewModel in
let packageContext = PackageContext(
package: tabViewModel.defaultSelectedPackage,
variableContext: .init(
packages: tabViewModel.packages,
showZeroDecimalPlacePrices: parentPackageContext.variableContext.showZeroDecimalPlacePrices
uniqueKeysWithValues: viewModel.tabViewModels.map { key, tabViewModel -> (String, PackageContext) in
if !tabViewModel.packages.isEmpty {
// Tab has its own packages - create context with tab's packages
let packageContext = PackageContext(
package: tabViewModel.defaultSelectedPackage,
variableContext: .init(
packages: tabViewModel.packages,
showZeroDecimalPlacePrices: parentPackageContext.variableContext.showZeroDecimalPlacePrices
)
)
)
return (key, packageContext)
return (key, packageContext)
} else {
// Tab has no packages - use parent context directly.
// This ensures the tab always shows the current parent selection.
return (key, parentPackageContext)
}
}
))
}
Expand All @@ -138,10 +182,8 @@ struct LoadedTabsComponentView: View {
.onAppear {
if !wasConfigured {
self.wasConfigured = true
// In the event that the tabs components contain unique selected packages, we need to ensure that
// the first selected tab's selected package is propagated up to the purchase button. This sends
// that signal only for the initially rendered tab, then the onChange passed into the loadedTabView
// handles subsequent changes
// Propagate the initial tab's package to parent context for the purchase button.
// Subsequent changes are handled by the onChange callback in LoadedTabComponentView.
if let package = tierPackageContext.package {
self.packageContext.update(
package: package,
Expand All @@ -150,6 +192,106 @@ struct LoadedTabsComponentView: View {
}
}
}
// MARK: - Tab Switch Handling
//
// When switching to a tab, we need to determine which package to show:
//
// 1. If the tab has NO packages → restore parent's "own" selection
// (the selection before any tab propagated its package)
//
// 2. If the tab HAS packages:
// - If parent's selected package IS in the tab's packages → keep parent's selection
// - If parent's selected package IS NOT in the tab's packages → use tab's default
//
.onChangeOf(self.tabControlContext.selectedTabId) { newTabId in
guard let newTabViewModel = self.viewModel.tabViewModels[newTabId],
let newTierPackageContext = self.tierPackageContexts[newTabId] else {
return
}

if newTabViewModel.packages.isEmpty {
// Tab has NO packages - restore parent's own selection
self.packageContext.update(
package: self.parentOwnedPackage,
variableContext: self.parentOwnedVariableContext
)
return
}

// Tab HAS packages - check if parent's current package is in the new tab's packages
let tabPackageIdentifiers = Set(newTabViewModel.packages.map(\.identifier))

// Use parentOwnedPackage for the check, not the potentially-propagated packageContext.package
if let parentPackage = self.parentOwnedPackage,
tabPackageIdentifiers.contains(parentPackage.identifier) {
// Parent's own package IS in this tab - keep parent's selection
newTierPackageContext.update(
package: parentPackage,
variableContext: self.parentOwnedVariableContext
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this may result in variable calculation issues…

I suppose it depends on the point of view, but I would assume that the calculations would be against the packages in the tab not the parent.

)
self.packageContext.update(
package: parentPackage,
variableContext: self.parentOwnedVariableContext
)
} else {
// Parent's package is NOT in this tab - use tab's default
// and propagate to parent
let showZeroDecimalPlacePrices = self.packageContext.variableContext.showZeroDecimalPlacePrices
if let defaultPackage = newTabViewModel.defaultSelectedPackage {
newTierPackageContext.update(
package: defaultPackage,
variableContext: .init(
packages: newTabViewModel.packages,
showZeroDecimalPlacePrices: showZeroDecimalPlacePrices
)
)
self.packageContext.update(
package: defaultPackage,
variableContext: newTierPackageContext.variableContext
)
}
}
}
// MARK: - Parent Selection Propagation to Tabs
//
// When the user selects a package in the parent scope (outside the tabs),
// we need to propagate that selection to the current tab so it shows
// the correct variable values (e.g., price).
//
// We track "parentOwnedPackage" for user selections:
// - Tab propagation: newPackage == tab's current package → don't track
// - User selection: newPackage != tab's current package → track it
//
.onChangeOf(self.packageContext.package) { newPackage in
guard let newPackage = newPackage else { return }

// Check if the new package is in the current tab's packages
let tabPackageIdentifiers = Set(activeTabViewModel.packages.map(\.identifier))
let tabHasNoPackages = tabPackageIdentifiers.isEmpty
let packageIsInTab = tabPackageIdentifiers.contains(newPackage.identifier)
let isTabPropagation = !tabHasNoPackages &&
newPackage.identifier == tierPackageContext.package?.identifier

if isTabPropagation {
// This is the tab propagating its own package to parent
// Don't update parentOwnedPackage
return
}

// This is a user selection - track it
self.parentOwnedPackage = newPackage
self.parentOwnedVariableContext = self.packageContext.variableContext

// If the package is in the current tab's packages, also update the tab
if packageIsInTab {
tierPackageContext.update(
package: newPackage,
variableContext: self.packageContext.variableContext
)
}
// If package is NOT in tab's packages, we still track it as parentOwnedPackage
// but we don't update the tab (it can't display this package)
}
}
}

Expand Down
Loading