From 80970875c10b7ec3205d5963fda270c5d8082ba8 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Thu, 11 Sep 2025 11:59:29 -0500 Subject: [PATCH] Fix PaywallView styling timing issue with safe forced layout updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes an issue where styles applied to RevenueCatUI.Paywall component were not being applied immediately. The problem was that the paywall's native view setup was deferred until layoutSubviews, causing a timing issue with React Native's style application. This solution preserves the original view hierarchy timing while forcing immediate layout updates when React Native applies style changes, with robust protection against infinite layout loops. Changes: - Override reactSetFrame, setBounds, and setFrame methods - Call setNeedsLayout + layoutIfNeeded to force immediate layout updates - Add isInLayoutUpdate flag to prevent infinite layout loops - Use dispatch_async to break synchronous recursion chains - Only trigger layout for actual frame/bounds changes - Preserve original view controller hierarchy setup timing This approach is safer than restructuring the view hierarchy timing as it: - Maintains SwiftUI environment context - Preserves safe area and layout guide behavior - Works with React Native's layout system - Protected against infinite layout loops Fixes #1366 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../ios/PaywallViewWrapper.m | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/react-native-purchases-ui/ios/PaywallViewWrapper.m b/react-native-purchases-ui/ios/PaywallViewWrapper.m index 8a614333b..6dcc0c90a 100644 --- a/react-native-purchases-ui/ios/PaywallViewWrapper.m +++ b/react-native-purchases-ui/ios/PaywallViewWrapper.m @@ -23,6 +23,7 @@ @interface PaywallViewWrapper () @property(strong, nonatomic) RCPaywallViewController *paywallViewController; @property(nonatomic) BOOL addedToHierarchy; +@property(nonatomic) BOOL isInLayoutUpdate; @end @@ -38,15 +39,58 @@ - (instancetype)initWithPaywallViewController:(RCPaywallViewController *)paywall return self; } - - (void)reactSetFrame:(CGRect)frame { NSLog(@"RNPaywalls - reactSetFrame: %@", NSStringFromCGRect(frame)); [super reactSetFrame: frame]; + + // Only trigger layout if we're not already in a layout update to prevent infinite loops + if (!self.isInLayoutUpdate) { + [self setNeedsLayout]; + // Use dispatch_async to break potential synchronous loops + dispatch_async(dispatch_get_main_queue(), ^{ + if (!self.isInLayoutUpdate) { + [self layoutIfNeeded]; + } + }); + } +} + +- (void)setBounds:(CGRect)bounds { + CGRect oldBounds = self.bounds; + [super setBounds:bounds]; + + // Only trigger layout if bounds actually changed and we're not in a layout update + if (!self.isInLayoutUpdate && !CGRectEqualToRect(oldBounds, bounds)) { + [self setNeedsLayout]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (!self.isInLayoutUpdate) { + [self layoutIfNeeded]; + } + }); + } +} + +- (void)setFrame:(CGRect)frame { + CGRect oldFrame = self.frame; + [super setFrame:frame]; + + // Only trigger layout if frame actually changed and we're not in a layout update + if (!self.isInLayoutUpdate && !CGRectEqualToRect(oldFrame, frame)) { + [self setNeedsLayout]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (!self.isInLayoutUpdate) { + [self layoutIfNeeded]; + } + }); + } } - (void)layoutSubviews { + // Set flag to prevent infinite layout loops + self.isInLayoutUpdate = YES; + [super layoutSubviews]; CGSize size = self.bounds.size; @@ -74,6 +118,9 @@ - (void)layoutSubviews { self.addedToHierarchy = YES; } } + + // Clear flag after layout is complete + self.isInLayoutUpdate = NO; } - (void)setOptions:(NSDictionary *)options {