From c5940d92d3bed5629d73c59d7b802408850ade32 Mon Sep 17 00:00:00 2001 From: Wojciech Lewicki Date: Wed, 17 Jun 2026 12:44:28 +0200 Subject: [PATCH] fix: less often remount on scrollview On the New Architecture (Fabric), Reanimated commits the shadow tree ~once per animation frame, and each mount transaction unconditionally called _remountChildren, re-clipping the entire descendant subtree even when nothing scrolled or changed layout. Guard _remountChildren with RCTMountingTransactionAffectsClipping, which returns YES only when the transaction can change clipping: an Insert/Remove mutation, an Update with changed layoutMetrics, or an Update toggling removeClippedSubviews. maintainVisibleContentPosition is unaffected since _adjustForMaintainVisibleContentPosition still runs on every mount. Moves the change from the React-RCTFabric local pod patch (discord/discord#292855) directly into the RN fork. --- .../ScrollView/RCTScrollViewComponentView.mm | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) 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 cc1cf2738884..7cdc72d9a079 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -57,6 +57,41 @@ static UIScrollViewIndicatorStyle RCTUIScrollViewIndicatorStyleFromProps(const S } } +// Child clipping only depends on which descendants exist and their layout frames. +// A mounting transaction that contains only prop updates with unchanged layout +// metrics (e.g. opacity/transform commits from Reanimated, which fire every +// animation frame) cannot change clipping, so re-clipping can be skipped. +static BOOL RCTMountingTransactionAffectsClipping(const facebook::react::MountingTransaction &transaction) +{ + for (const auto &mutation : transaction.getMutations()) { + switch (mutation.type) { + case facebook::react::ShadowViewMutation::Insert: + case facebook::react::ShadowViewMutation::Remove: + return YES; + case facebook::react::ShadowViewMutation::Update: { + const auto &oldChild = mutation.oldChildShadowView; + const auto &newChild = mutation.newChildShadowView; + if (oldChild.layoutMetrics != newChild.layoutMetrics) { + return YES; + } + // A `removeClippedSubviews` toggle changes clipping without changing + // layout, so it must also force a re-clip. + if (oldChild.props && newChild.props) { + const auto &oldProps = static_cast(*oldChild.props); + const auto &newProps = static_cast(*newChild.props); + if (oldProps.removeClippedSubviews != newProps.removeClippedSubviews) { + return YES; + } + } + break; + } + default: + break; + } + } + return NO; +} + // Once Fabric implements proper NativeAnimationDriver, this should be removed. // This is just a workaround to allow animations based on onScroll event. // This is only used to animate sticky headers in ScrollViews, and only the contentOffset and tag is used. @@ -282,7 +317,9 @@ - (void)mountingTransactionWillMount:(const facebook::react::MountingTransaction - (void)mountingTransactionDidMount:(const MountingTransaction &)transaction withSurfaceTelemetry:(const facebook::react::SurfaceTelemetry &)surfaceTelemetry { - [self _remountChildren]; + if (RCTMountingTransactionAffectsClipping(transaction)) { + [self _remountChildren]; + } [self _adjustForMaintainVisibleContentPosition]; }