Skip to content

FIX: Optimize time to load paywalls#6694

Merged
JZDesign merged 12 commits into
mainfrom
jzdesign/prevent-redraws
Apr 27, 2026
Merged

FIX: Optimize time to load paywalls#6694
JZDesign merged 12 commits into
mainfrom
jzdesign/prevent-redraws

Conversation

@JZDesign

@JZDesign JZDesign commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Checklist

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

Motivation

Redraws were happening too frequently, causing UI hangs in some environments. Given a paywall with 27 images, from a warmed cache, I was experiencing a 711ms hang on paywall launch. These changes reduce that number to 112ms

Description

Several things are at play here:

promo offer eligibility was causing tree redraws for 2 reasons. The state object was the wrong choice since observation wasn't necessary in the view modifier, and we were passing the values into the function directly—I think causing swiftUI to fail to know when best to redraw.

The image draw cycle was reinvoking for various reasons, some of which were carousel issues because it kept trying to recalculate size when the image was just off the screen


Note

Medium Risk
Touches core paywall rendering and presentation paths (image loading, carousel sizing, and paywall modifiers), so regressions could show up as missing/incorrectly-sized images or stale promo-offer state despite being primarily performance-focused.

Overview
Reduces Paywalls V2 launch/render overhead by caching expensive work and avoiding unnecessary SwiftUI invalidations.

Adds a process-wide DecodedImageCache (NSCache) so file-backed images aren’t repeatedly decoded when views are recreated, and updates URL.asImageAndSize/FileImageLoader to use it.

Optimizes component rendering by memoizing measured image sizes in ImageComponentViewModel, adding an environment flag (requestSizeCalculation) to skip size-measurement work for carousel pages that are off-screen, and refactoring eligibility/promo-offer lookups to stable locals to reduce rebuild churn.

Updates presentPaywall modifiers to hold PaywallPromoOfferCache inside a non-publishing PromoOfferCacheOwner, preventing paywall-wide redraws caused by cache observation while still reusing a single cache instance.

Reviewed by Cursor Bugbot for commit 3a970d5. Bugbot is set up for automated code reviews on this repo. Configure here.

@JZDesign JZDesign requested a review from a team as a code owner April 24, 2026 13:43
let shouldActivateCarouselImage = self.isWithinImagePreloadWindow
let effectiveSize = self.size ?? self.viewModel.cachedMeasuredSize
Group {
if !shouldActivateCarouselImage {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Now, I'll be honest… I don't love that the image is aware of where it may be rendered, but this improved the redraw cycle by a notable amount. I can spend time considering how to clean up the responsibilities so the image view has no knowledge of the carousel state, but that can be a clean up pr IMO

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe there's a way we can inject that into each ImageComponentView when the outside state changes, so that the image doesn't need to be aware of this?

Also I'm wondering if there's a way to tighten up the size ?? viewModel.cachedMeasuredSize dance somehow, since we're doing a variation of this in multiple places 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've addressed the first part of your comment, but the second I left alone. Because: Functions make it harder for SwiftUI to intelligently compare and redraw at the right time so I'm reluctant to DRY this up when it's only 2 spots

Replace the ForceSizeCalculation environment key with RequestSizeCalculation and update usages. Deleted ForceSizeCalculation.swift and added RequestSizeCalculation.swift. Updated CarouselComponentView and ImageComponentView to use .requestSizeCalculation; refactored ImageComponentView to compute shouldForceSizeCalculation and reorganized the view/ZStack and RemoteImage render flow to rely on the new environment value. No behavioral change intended beyond the env key rename and minor view cleanup.
@RevenueCat-Danger-Bot

RevenueCat-Danger-Bot commented Apr 24, 2026

Copy link
Copy Markdown
1 Warning
⚠️ RevenueCat.xcodeproj is out of sync.

The following Swift files were added but are missing from RevenueCat.xcodeproj:
RevenueCatUI/Templates/V2/EnvironmentObjects/RequestSizeCalculation.swift

To fix: open RevenueCat.xcodeproj in Xcode, add/remove the files above in the appropriate target. Check where similar files in the same directory are assigned if you're unsure which target to use.

Generated by 🚫 Danger

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

Comment thread RevenueCatUI/View+PresentPaywall.swift Outdated
Comment thread RevenueCatUI/Templates/V2/Components/Carousel/CarouselComponentView.swift Outdated
Replace the @State PaywallPromoOfferCache with a @StateObject PromoOfferCacheOwner wrapper to hold the PaywallPromoOfferCache instance. Update initializers and usages in PresentingPaywallModifier and PresentingPaywallBindingModifier to reference promoOfferCacheOwner.cache. Add PromoOfferCacheOwner (@mainactor, availability annotations) so the promo offer cache is initialized once without published properties, preventing unnecessary paywall redraws.
@JZDesign JZDesign enabled auto-merge (squash) April 24, 2026 19:54
@JZDesign JZDesign requested a review from alexrepty April 24, 2026 19:54
@JZDesign JZDesign requested a review from a team as a code owner April 24, 2026 19:56
@JZDesign JZDesign changed the title FIX: Prevent SwiftUI redraw churn FIX: Optimize time to load paywalls Apr 24, 2026
@JZDesign JZDesign disabled auto-merge April 24, 2026 19:57
@JZDesign JZDesign enabled auto-merge (squash) April 24, 2026 19:57
@emerge-tools

emerge-tools Bot commented Apr 24, 2026

Copy link
Copy Markdown

4 builds increased size

Name Version Download Change Install Change Approval
RevenueCat
com.revenuecat.PaywallsTester
1.0 (1) 17.8 MB ⬆️ 12.5 kB (0.07%) 63.7 MB ⬆️ 57.3 kB (0.09%) N/A
BinarySizeTest
com.revenuecat.binary-size-test.local-source
1.0 (1) 4.0 MB ⬆️ 2.3 kB (0.06%) 12.1 MB ⬆️ 10.6 kB (0.09%) N/A
BinarySizeTest
com.revenuecat.binary-size-test.cocoapods
1.0 (1) 6.1 MB ⬆️ 5.8 kB (0.1%) 26.8 MB ⬆️ 19.1 kB (0.07%) N/A
BinarySizeTest
com.revenuecat.binary-size-test.spm
1.0 (1) 4.1 MB ⬆️ 2.1 kB (0.05%) 10.5 MB ⬆️ 6.0 kB (0.06%) N/A

RevenueCat 1.0 (1)
com.revenuecat.PaywallsTester

⚖️ Compare build
⏱️ Analyze build performance

Total install size change: ⬆️ 57.3 kB (0.09%)
Total download size change: ⬆️ 12.5 kB (0.07%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 22.0 kB
RevenueCatUI.FileImageLoader.FileImageLoader ⬆️ 1.8 kB
Code Signature ⬆️ 1.2 kB
DYLD.Exports ⬆️ 1.0 kB
📝 RevenueCatUI.DecodedImageCache.DecodedImageCache ⬆️ 876 B
View Treemap

Image of diff

BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.local-source

⚖️ Compare build
📦 Install build
⏱️ Analyze build performance

Total install size change: ⬆️ 10.6 kB (0.09%)
Total download size change: ⬆️ 2.3 kB (0.06%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 1.9 kB
📝 RevenueCatUI.DecodedImageCache.imageAndSize(for) ⬆️ 836 B
📝 RevenueCatUI.DecodedImageCache.Objc Metadata ⬆️ 791 B
RevenueCatUI.CarouselView.body ⬆️ 760 B
Code Signature ⬆️ 520 B
View Treemap

Image of diff

BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.cocoapods

⚖️ Compare build
📦 Install build
⏱️ Analyze build performance

Total install size change: ⬆️ 19.1 kB (0.07%)
Total download size change: ⬆️ 5.8 kB (0.1%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 7.3 kB
📝 RevenueCatUI.DecodedImageCache.imageAndSize(for) ⬆️ 836 B
📝 RevenueCatUI.DecodedImageCache.Objc Metadata ⬆️ 784 B
RevenueCatUI.CarouselView.body ⬆️ 760 B
Code Signature ⬆️ 728 B
View Treemap

Image of diff

BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.spm

⚖️ Compare build
📦 Install build
⏱️ Analyze build performance

Total install size change: ⬆️ 6.0 kB (0.06%)
Total download size change: ⬆️ 2.1 kB (0.05%)

Largest size changes

Item Install Size Change
📝 RevenueCatUI.DecodedImageCache.imageAndSize(for) ⬆️ 836 B
📝 RevenueCatUI.DecodedImageCache.Objc Metadata ⬆️ 771 B
RevenueCatUI.CarouselView.body ⬆️ 760 B
Other ⬆️ 3.7 kB
View Treemap

Image of diff


🛸 Powered by Emerge Tools

@JZDesign JZDesign disabled auto-merge April 24, 2026 20:02
@JZDesign JZDesign force-pushed the jzdesign/prevent-redraws branch from 5c9d66b to 3a970d5 Compare April 24, 2026 20:04
@JZDesign JZDesign enabled auto-merge (squash) April 24, 2026 20:05
@JZDesign JZDesign merged commit 4f37cef into main Apr 27, 2026
16 of 18 checks passed
@JZDesign JZDesign deleted the jzdesign/prevent-redraws branch April 27, 2026 07:09
ajpallares added a commit that referenced this pull request Apr 30, 2026
The file was added in #6694 but not registered in the hand-maintained
RevenueCat.xcodeproj used by the XCFramework build, breaking
release-checks.

Made-with: Cursor
ajpallares added a commit that referenced this pull request Apr 30, 2026
The Dangerfile already detects Swift files that are added (or removed)
on disk but not registered in the hand-maintained `RevenueCat.xcodeproj`.
Until now, that mismatch was reported as a warning, so PRs could merge
in a state that breaks the xcframework build (see #6713/#6694).

Promote the existing `warn` to `fail` so the PR's required Danger check
blocks merge until the project file is in sync.

Made-with: Cursor
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.

3 participants