05bb19dd3f
Fixes the fatal Android crash "IllegalStateException: Required value was null" in ReactViewGroup.updateSubviewClipStatus (Sentry APP-T20Q, 14 users in 14 days on 1.128.0/1.129.0). Reentrant child removal during a clipping pass can leave a null slot in allChildren below allChildrenCount; the checkNotNull on that slot then kills the app during animated scrolls. A null slot means the view is already detached, so skip it and count it as clipped instead of throwing. Unfixed upstream as of July 2026; the sibling fix attempt facebook/react-native#57365 was abandoned. Verified against a deterministic state-injection repro: unpatched RN crashes with the exact Sentry stack (ReactViewGroup.kt:469/435/411), patched RN survives with the app fully functional. Note: takes effect on Android only when react-android is built from source; see the caveat in the patch md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
289 lines
13 KiB
Diff
289 lines
13 KiB
Diff
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
|
index c593d9ee2155a826352ebca34845aa5792b2eec3..3c26cd737f21116ff0aa48190e97e6c0649b5fac 100644
|
|
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
|
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
|
@@ -101,6 +101,20 @@ - (void)setContentOffset:(CGPoint)contentOffset
|
|
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
|
|
}
|
|
|
|
+- (void)setCenterContent:(BOOL)centerContent
|
|
+{
|
|
+ if (_centerContent != centerContent) {
|
|
+ _centerContent = centerContent;
|
|
+ [self centerContentIfNeeded];
|
|
+ }
|
|
+}
|
|
+
|
|
+- (void)setContentSize:(CGSize)contentSize
|
|
+{
|
|
+ [super setContentSize:contentSize];
|
|
+ [self centerContentIfNeeded];
|
|
+}
|
|
+
|
|
- (void)setFrame:(CGRect)frame
|
|
{
|
|
[super setFrame:frame];
|
|
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
|
|
index 914a2494a57923fbf185644b7e2bb8aca8848e56..0deac55f22350f5e8377d8963fb1c2434bf6abfd 100644
|
|
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
|
|
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
|
|
@@ -19,6 +19,8 @@ NS_ASSUME_NONNULL_BEGIN
|
|
*/
|
|
@interface RCTPullToRefreshViewComponentView : RCTViewComponentView <RCTCustomPullToRefreshViewProtocol>
|
|
|
|
+- (void)beginRefreshingProgrammatically;
|
|
+
|
|
@end
|
|
|
|
NS_ASSUME_NONNULL_END
|
|
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
|
index 0d231bc8aa938da296eb3b981e8ac9595a43b87f..be0a10d9c4de1892fa00bcbf8d63d739b66d8ffe 100644
|
|
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
|
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
|
@@ -76,7 +76,17 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
|
|
return;
|
|
}
|
|
|
|
- const auto &oldConcreteProps = static_cast<const PullToRefreshViewProps &>(*_props);
|
|
+ /*
|
|
+ * TODO: Remove after upgrading React Native to 0.82+ (fixed upstream by
|
|
+ * facebook/react-native#52615, #52584 and #53231).
|
|
+ * Diff against oldProps instead of _props. During the initial-layout replay
|
|
+ * from layoutSubviews, _props already holds the new props, so diffing
|
|
+ * against it is a no-op and tintColor/progressViewOffset are never applied
|
|
+ * on mount (facebook/react-native#56343). oldProps is null-guarded because
|
|
+ * the create-mutation path passes nullptr.
|
|
+ */
|
|
+ const auto &oldConcreteProps = static_cast<const PullToRefreshViewProps &>(
|
|
+ oldProps ? *oldProps : *PullToRefreshViewShadowNode::defaultSharedProps());
|
|
const auto &newConcreteProps = static_cast<const PullToRefreshViewProps &>(*props);
|
|
|
|
if (newConcreteProps.tintColor != oldConcreteProps.tintColor) {
|
|
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
|
index 1494fd225aff1fa0429e917404d6b4ca5fc961c5..d0cce700090245444f8ce51e517d5ceca09526f6 100644
|
|
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
|
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
|
@@ -380,7 +380,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
|
|
|
|
MAP_SCROLL_VIEW_PROP(zoomScale);
|
|
|
|
- if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset) {
|
|
+ // When disabling centerContent, reset inset to prop value
|
|
+ // (enabling is handled automatically by the setCenterContent: setter)
|
|
+ if (oldScrollViewProps.centerContent && !newScrollViewProps.centerContent) {
|
|
+ _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
|
|
+ }
|
|
+
|
|
+ // Only apply contentInset from props if centerContent is disabled
|
|
+ // When centerContent is enabled, the inset is calculated by centerContentIfNeeded
|
|
+ if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) {
|
|
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
|
|
}
|
|
|
|
@@ -507,7 +515,7 @@ - (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
|
|
}
|
|
}
|
|
|
|
- return isPointInside ? self : nil;
|
|
+ return isPointInside ? _scrollView : nil;
|
|
}
|
|
|
|
/*
|
|
@@ -1038,6 +1046,11 @@ - (void)_adjustForMaintainVisibleContentPosition
|
|
}
|
|
}
|
|
|
|
++ (BOOL)shouldBeRecycled
|
|
+{
|
|
+ return NO;
|
|
+}
|
|
+
|
|
@end
|
|
|
|
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
|
|
diff --git a/React/Views/RefreshControl/RCTRefreshControl.h b/React/Views/RefreshControl/RCTRefreshControl.h
|
|
index e9b330fa7c29c42653a3b0191d0f8a1b13b2d3de..ec5f58c887bfd949f1279ef1c31352e0b465e9ec 100644
|
|
--- a/React/Views/RefreshControl/RCTRefreshControl.h
|
|
+++ b/React/Views/RefreshControl/RCTRefreshControl.h
|
|
@@ -15,5 +15,8 @@
|
|
@property (nonatomic, copy) NSString *title;
|
|
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
|
|
@property (nonatomic, weak) UIScrollView *scrollView;
|
|
+@property (nonatomic, copy) UIColor *customTintColor;
|
|
+
|
|
+- (void)forwarderBeginRefreshing;
|
|
|
|
@end
|
|
diff --git a/React/Views/RefreshControl/RCTRefreshControl.m b/React/Views/RefreshControl/RCTRefreshControl.m
|
|
index 53bfd04703502d5b8e932c47a528bb03cd79d330..ff1b1ed5e060bcf0d91528c6d3c2c5c8acf24967 100644
|
|
--- a/React/Views/RefreshControl/RCTRefreshControl.m
|
|
+++ b/React/Views/RefreshControl/RCTRefreshControl.m
|
|
@@ -23,6 +23,7 @@ @implementation RCTRefreshControl {
|
|
UIColor *_titleColor;
|
|
CGFloat _progressViewOffset;
|
|
BOOL _hasMovedToWindow;
|
|
+ UIColor *_customTintColor;
|
|
}
|
|
|
|
- (instancetype)init
|
|
@@ -58,6 +59,12 @@ - (void)layoutSubviews
|
|
_isInitialRender = false;
|
|
}
|
|
|
|
+- (void)didMoveToSuperview
|
|
+{
|
|
+ [super didMoveToSuperview];
|
|
+ [self setTintColor:_customTintColor];
|
|
+}
|
|
+
|
|
- (void)didMoveToWindow
|
|
{
|
|
[super didMoveToWindow];
|
|
@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged
|
|
}
|
|
}
|
|
|
|
+// Fix for https://github.com/facebook/react-native/issues/43388
|
|
+// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor
|
|
+// is set before the refresh control gets added to the scrollview. We'll call this
|
|
+// function whenever the superview changes. We'll also call it if the value of customTintColor
|
|
+// changes.
|
|
+- (void)setTintColor:(UIColor *)tintColor
|
|
+{
|
|
+ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) {
|
|
+ [super setTintColor:tintColor];
|
|
+ }
|
|
+}
|
|
+
|
|
+// This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
|
|
+// libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
|
|
+// function.
|
|
+- (void)forwarderBeginRefreshing
|
|
+{
|
|
+ _refreshingProgrammatically = NO;
|
|
+
|
|
+ [self sizeToFit];
|
|
+
|
|
+ if (!self.scrollView) {
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ UIScrollView *scrollView = (UIScrollView *)self.scrollView;
|
|
+
|
|
+ [UIView animateWithDuration:0.3
|
|
+ delay:0
|
|
+ options:UIViewAnimationOptionBeginFromCurrentState
|
|
+ animations:^(void) {
|
|
+ // Whenever we call this method, the scrollview will always be at a position of
|
|
+ // -130 or less. Scrolling back to -65 simulates the default behavior of RCTRefreshControl
|
|
+ [scrollView setContentOffset:CGPointMake(0, -65)];
|
|
+ }
|
|
+ completion:^(__unused BOOL finished) {
|
|
+ [super beginRefreshing];
|
|
+ [self setCurrentRefreshingState:super.refreshing];
|
|
+
|
|
+ if (self->_onRefresh) {
|
|
+ self->_onRefresh(nil);
|
|
+ }
|
|
+ }
|
|
+ ];
|
|
+}
|
|
+
|
|
@end
|
|
diff --git a/React/Views/RefreshControl/RCTRefreshControlManager.m b/React/Views/RefreshControl/RCTRefreshControlManager.m
|
|
index 40aaf9c51ebda9fedb1d1db2e9aacec84b4c39c8..1c60164b69762997b3369b46609a07768a06bad3 100644
|
|
--- a/React/Views/RefreshControl/RCTRefreshControlManager.m
|
|
+++ b/React/Views/RefreshControl/RCTRefreshControlManager.m
|
|
@@ -22,11 +22,12 @@ - (UIView *)view
|
|
|
|
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
|
|
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
|
|
-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
|
|
RCT_EXPORT_VIEW_PROPERTY(title, NSString)
|
|
RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor)
|
|
RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat)
|
|
|
|
+RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor)
|
|
+
|
|
RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
|
|
{
|
|
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
|
|
diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt b/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt
|
|
index 8b6571698fc5dd091a0d8980a33bb40295faf305..27c97bfeb6f13907c89f1d85f2bb8b8af7bdfb43 100644
|
|
--- a/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt
|
|
+++ b/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt
|
|
@@ -313,8 +313,9 @@ public open class JavaTimerManager(
|
|
// We also capture the idleCallbackRunnable to tentatively fix:
|
|
// https://github.com/facebook/react-native/issues/44842
|
|
currentIdleCallbackRunnable?.cancel()
|
|
- currentIdleCallbackRunnable = IdleCallbackRunnable(frameTimeNanos)
|
|
- reactApplicationContext.runOnJSQueueThread(currentIdleCallbackRunnable)
|
|
+ val idleCallbackRunnable = IdleCallbackRunnable(frameTimeNanos)
|
|
+ currentIdleCallbackRunnable = idleCallbackRunnable
|
|
+ reactApplicationContext.runOnJSQueueThread(idleCallbackRunnable)
|
|
reactChoreographer.postFrameCallback(ReactChoreographer.CallbackType.IDLE_EVENT, this)
|
|
}
|
|
}
|
|
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
|
|
index 89b666dcf0258df0702c812600b685463128294c..54e70abca9e2f35de740cfc4fb2f83e9f5af11e1 100644
|
|
--- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
|
|
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
|
|
@@ -431,6 +431,13 @@ public open class ReactViewGroup public constructor(context: Context?) :
|
|
inSubviewClippingLoop = true
|
|
var clippedSoFar = 0
|
|
for (i in 0..<allChildrenCount) {
|
|
+ // Bluesky patch: reentrant child removal during this loop can compact allChildren and
|
|
+ // leave a null at an index below allChildrenCount. A null entry means the view is already
|
|
+ // detached, so treat it as clipped instead of crashing (Sentry APP-T20Q).
|
|
+ if (childArray[i] == null) {
|
|
+ clippedSoFar++
|
|
+ continue
|
|
+ }
|
|
try {
|
|
updateSubviewClipStatus(clippingRect, i, clippedSoFar, excludedViewsSet)
|
|
} catch (ex: IndexOutOfBoundsException) {
|
|
@@ -466,7 +473,9 @@ public open class ReactViewGroup public constructor(context: Context?) :
|
|
) {
|
|
assertOnUiThread()
|
|
|
|
- val child = checkNotNull(allChildren?.get(idx))
|
|
+ // Bluesky patch: allChildren can be mutated reentrantly while a clipping pass is running,
|
|
+ // so a stale index can point at a null slot. Skip it instead of crashing (Sentry APP-T20Q).
|
|
+ val child = allChildren?.get(idx) ?: return
|
|
val intersects = clippingRect.intersects(child.left, child.top, child.right, child.bottom)
|
|
var needUpdateClippingRecursive = false
|
|
|
|
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
|
|
index 216bb23beb023ef6c3ae814c17e05bccbda7fc91..6ad5cc1d9ed5b8cd2df08ad77adca56c6bb58ff4 100644
|
|
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
|
|
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
|
|
@@ -386,9 +386,10 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage
|
|
size.height = enumeratedLinesHeight;
|
|
}
|
|
|
|
+ CGFloat epsilon = 0.001;
|
|
size = (CGSize){
|
|
- ceil(size.width * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
|
|
- ceil(size.height * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
|
|
+ ceil((size.width + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
|
|
+ ceil((size.height + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
|
|
|
|
__block auto attachments = TextMeasurement::Attachments{};
|
|
|
|
diff --git a/third-party-podspecs/fmt.podspec b/third-party-podspecs/fmt.podspec
|
|
index 2f38990e226c13f483aaf1b986302d4094243814..9b02e481e290299be20a6f09c42056ff51695e9b 100644
|
|
--- a/third-party-podspecs/fmt.podspec
|
|
+++ b/third-party-podspecs/fmt.podspec
|
|
@@ -26,4 +26,11 @@ Pod::Spec.new do |spec|
|
|
spec.public_header_files = "include/fmt/*.h"
|
|
spec.header_mappings_dir = "include"
|
|
spec.source_files = ["include/fmt/*.h", "src/format.cc"]
|
|
+
|
|
+ # TODO: Remove after upgrading React Native past 0.83.x
|
|
+ # Fix fmt 11.0.2 consteval build error with Xcode 26.4 (facebook/react-native#55601)
|
|
+ # Fixed in RN 0.84+ which bumps fmt to a compatible version.
|
|
+ spec.prepare_command = <<~SCRIPT
|
|
+ perl -i -pe 's/^# define FMT_USE_CONSTEVAL 1$/# define FMT_USE_CONSTEVAL 0/' include/fmt/base.h
|
|
+ SCRIPT
|
|
end
|