From b8d5e2d98191fe8b44f3ea06d4580ca1d71176c3 Mon Sep 17 00:00:00 2001 From: RealBhupesh Date: Sun, 2 Aug 2026 13:26:55 +0530 Subject: [PATCH 1/3] Fix recycled ScrollView keyboard inset state Fixes #57755 Reset keyboard-managed state and cancel active animations when a Fabric ScrollView is recycled so later views cannot inherit keyboard insets. --- .../ScrollView/RCTScrollViewComponentView.mm | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 2802e65c6586..8727afadd2dd 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -360,9 +360,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & MAP_SCROLL_VIEW_PROP(showsHorizontalScrollIndicator); MAP_SCROLL_VIEW_PROP(showsVerticalScrollIndicator); - if (oldScrollViewProps.automaticallyAdjustKeyboardInsets != newScrollViewProps.automaticallyAdjustKeyboardInsets) { - _automaticallyAdjustKeyboardInsets = newScrollViewProps.automaticallyAdjustKeyboardInsets; - } + _automaticallyAdjustKeyboardInsets = newScrollViewProps.automaticallyAdjustKeyboardInsets; if (oldScrollViewProps.scrollIndicatorInsets != newScrollViewProps.scrollIndicatorInsets) { _scrollView.scrollIndicatorInsets = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.scrollIndicatorInsets); @@ -690,6 +688,8 @@ - (void)prepareForRecycle _state.reset(); const auto &props = static_cast(*_props); + // Cancel keyboard animations before resetting the state they manage. + [_scrollView.layer removeAllAnimations]; _scrollView.contentOffset = RCTCGPointFromPoint(props.contentOffset); // Reset zoom scale to default _scrollView.zoomScale = 1.0; @@ -697,11 +697,13 @@ - (void)prepareForRecycle // container frame after zoomScale reset (which may have mutated it in RTL). _contentSize = CGSizeZero; _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(props.contentInset); + _scrollView.verticalScrollIndicatorInsets = RCTUIEdgeInsetsFromEdgeInsets(props.scrollIndicatorInsets); // We set the default behavior to "never" so that iOS // doesn't do weird things to UIScrollView insets automatically // and keeps it as an opt-in behavior. _scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; _shouldUpdateContentInsetAdjustmentBehavior = YES; + _automaticallyAdjustKeyboardInsets = NO; _isUserTriggeredScrolling = NO; CGRect oldFrame = self.frame; self.frame = CGRectZero; From d547d0855ee64a9f2eda6d441712ce8278d515a1 Mon Sep 17 00:00:00 2001 From: RealBhupesh Date: Fri, 7 Aug 2026 18:50:30 +0530 Subject: [PATCH 2/3] test(ios): cover ScrollView keyboard inset recycling --- .../RCTScrollViewComponentViewTests.mm | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/react-native/React/Tests/Mounting/RCTScrollViewComponentViewTests.mm diff --git a/packages/react-native/React/Tests/Mounting/RCTScrollViewComponentViewTests.mm b/packages/react-native/React/Tests/Mounting/RCTScrollViewComponentViewTests.mm new file mode 100644 index 000000000000..a7fcfb9938ae --- /dev/null +++ b/packages/react-native/React/Tests/Mounting/RCTScrollViewComponentViewTests.mm @@ -0,0 +1,70 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import +#import +#import + +using namespace facebook::react; + +#if TARGET_OS_IOS + +static Props::Shared makeScrollViewProps(bool automaticallyAdjustKeyboardInsets) +{ + auto props = std::make_shared(); + props->automaticallyAdjustKeyboardInsets = automaticallyAdjustKeyboardInsets; + return props; +} + +@interface RCTScrollViewComponentView (Tests) +- (void)_keyboardWillChangeFrame:(NSNotification *)notification; +@end + +@interface RCTScrollViewComponentViewTests : XCTestCase +@end + +@implementation RCTScrollViewComponentViewTests + +- (void)testAutomaticallyAdjustKeyboardInsetsAcrossRecycling +{ + RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + auto props = makeScrollViewProps(true); + [view updateProps:props oldProps:ScrollViewShadowNode::defaultSharedProps()]; + + NSNotification *notification = [NSNotification + notificationWithName:UIKeyboardWillChangeFrameNotification + object:nil + userInfo:@{ + UIKeyboardAnimationDurationUserInfoKey : @0, + UIKeyboardAnimationCurveUserInfoKey : @(UIViewAnimationCurveLinear), + UIKeyboardFrameBeginUserInfoKey : [NSValue valueWithCGRect:CGRectMake(0, 100, 100, 50)], + UIKeyboardFrameEndUserInfoKey : [NSValue valueWithCGRect:CGRectMake(0, 50, 100, 50)], + }]; + + [view _keyboardWillChangeFrame:notification]; + XCTAssertEqual(view.scrollView.contentInset.bottom, 50); + XCTAssertEqual(view.scrollView.verticalScrollIndicatorInsets.bottom, 50); + + [view.scrollView.layer addAnimation:[CABasicAnimation animationWithKeyPath:@"position"] + forKey:@"keyboardInsetAnimation"]; + [view prepareForRecycle]; + XCTAssertNil([view.scrollView.layer animationForKey:@"keyboardInsetAnimation"]); + [view _keyboardWillChangeFrame:notification]; + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(view.scrollView.contentInset, UIEdgeInsetsZero)); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(view.scrollView.verticalScrollIndicatorInsets, UIEdgeInsetsZero)); + + // `_props` still contains the previous owner's opt-in value. The assignment in `updateProps:` must therefore be + // unconditional so a true-to-true reuse is re-armed after recycling. + [view updateProps:props oldProps:ScrollViewShadowNode::defaultSharedProps()]; + [view _keyboardWillChangeFrame:notification]; + XCTAssertEqual(view.scrollView.contentInset.bottom, 50); +} + +@end + +#endif From fc4d46b19dc591cfc8cc60635116c93b1f02bb71 Mon Sep 17 00:00:00 2001 From: RealBhupesh Date: Fri, 7 Aug 2026 18:53:24 +0530 Subject: [PATCH 3/3] Revert "test(ios): cover ScrollView keyboard inset recycling" This reverts commit d547d0855ee64a9f2eda6d441712ce8278d515a1. --- .../RCTScrollViewComponentViewTests.mm | 70 ------------------- 1 file changed, 70 deletions(-) delete mode 100644 packages/react-native/React/Tests/Mounting/RCTScrollViewComponentViewTests.mm diff --git a/packages/react-native/React/Tests/Mounting/RCTScrollViewComponentViewTests.mm b/packages/react-native/React/Tests/Mounting/RCTScrollViewComponentViewTests.mm deleted file mode 100644 index a7fcfb9938ae..000000000000 --- a/packages/react-native/React/Tests/Mounting/RCTScrollViewComponentViewTests.mm +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -#import -#import -#import -#import - -using namespace facebook::react; - -#if TARGET_OS_IOS - -static Props::Shared makeScrollViewProps(bool automaticallyAdjustKeyboardInsets) -{ - auto props = std::make_shared(); - props->automaticallyAdjustKeyboardInsets = automaticallyAdjustKeyboardInsets; - return props; -} - -@interface RCTScrollViewComponentView (Tests) -- (void)_keyboardWillChangeFrame:(NSNotification *)notification; -@end - -@interface RCTScrollViewComponentViewTests : XCTestCase -@end - -@implementation RCTScrollViewComponentViewTests - -- (void)testAutomaticallyAdjustKeyboardInsetsAcrossRecycling -{ - RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - auto props = makeScrollViewProps(true); - [view updateProps:props oldProps:ScrollViewShadowNode::defaultSharedProps()]; - - NSNotification *notification = [NSNotification - notificationWithName:UIKeyboardWillChangeFrameNotification - object:nil - userInfo:@{ - UIKeyboardAnimationDurationUserInfoKey : @0, - UIKeyboardAnimationCurveUserInfoKey : @(UIViewAnimationCurveLinear), - UIKeyboardFrameBeginUserInfoKey : [NSValue valueWithCGRect:CGRectMake(0, 100, 100, 50)], - UIKeyboardFrameEndUserInfoKey : [NSValue valueWithCGRect:CGRectMake(0, 50, 100, 50)], - }]; - - [view _keyboardWillChangeFrame:notification]; - XCTAssertEqual(view.scrollView.contentInset.bottom, 50); - XCTAssertEqual(view.scrollView.verticalScrollIndicatorInsets.bottom, 50); - - [view.scrollView.layer addAnimation:[CABasicAnimation animationWithKeyPath:@"position"] - forKey:@"keyboardInsetAnimation"]; - [view prepareForRecycle]; - XCTAssertNil([view.scrollView.layer animationForKey:@"keyboardInsetAnimation"]); - [view _keyboardWillChangeFrame:notification]; - XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(view.scrollView.contentInset, UIEdgeInsetsZero)); - XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(view.scrollView.verticalScrollIndicatorInsets, UIEdgeInsetsZero)); - - // `_props` still contains the previous owner's opt-in value. The assignment in `updateProps:` must therefore be - // unconditional so a true-to-true reuse is re-armed after recycling. - [view updateProps:props oldProps:ScrollViewShadowNode::defaultSharedProps()]; - [view _keyboardWillChangeFrame:notification]; - XCTAssertEqual(view.scrollView.contentInset.bottom, 50); -} - -@end - -#endif