From c183a8ff5b258a2e2ac87db46f139da667a0e58b Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Thu, 22 Oct 2026 20:04:26 +0200 Subject: [PATCH 1/2] Keep skipping unchanged children when a leaf without a view is replaced With skipUnchangedChildrenInDifferentiator on, the side-by-side slicing gives up when a child's tag changes. React gives a Text a new raw text node whenever its string changes, and on Android a Paragraph doesn't form a stacking context, so the differ reaches that node while slicing the Paragraph's parent and slices all of the parent's children in full. A replaced leaf that doesn't form a view has no mount index, so both nodes can be listed as they are. Co-Authored-By: Claude Opus 5.5 --- .../sliceChildShadowNodeViewPairs.cpp | 34 ++++++++++++- .../mounting/__tests__/Mounting-itest.js | 48 ++++++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp index e7bff5e95c9..8f2b5f2007d 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp @@ -267,7 +267,39 @@ bool sliceChangedChildShadowNodeViewPairsRecursively( if (oldChildShadowNode.getTag() != newChildShadowNode.getTag() || oldChildShadowNode.getOrderIndex() != newChildShadowNode.getOrderIndex()) { - return false; + // A replaced leaf that doesn't form a view (like the raw text of a + // `Text` whose string changed) has no mount index, so both nodes can be + // listed as they are without shifting the views around them. + if (!oldChildShadowNode.getChildren().empty() || + !newChildShadowNode.getChildren().empty()) { + return false; + } + auto oldLeafSlice = sliceChild( + oldShadowNode, + oldChildShadowNode, + ShadowView(oldChildShadowNode), + oldLayoutOffset); + auto newLeafSlice = sliceChild( + newShadowNode, + newChildShadowNode, + ShadowView(newChildShadowNode), + newLayoutOffset); + if (oldLeafSlice.isConcreteView || newLeafSlice.isConcreteView) { + return false; + } + addChildSlice( + oldPairList, + oldStartOfStaticIndex, + scope, + oldLeafSlice, + oldChildShadowNode); + addChildSlice( + newPairList, + newStartOfStaticIndex, + scope, + newLeafSlice, + newChildShadowNode); + continue; } auto oldChildSlice = sliceChild( diff --git a/packages/react-native/src/private/renderer/mounting/__tests__/Mounting-itest.js b/packages/react-native/src/private/renderer/mounting/__tests__/Mounting-itest.js index 5f7af7cb14f..741f90d2ecd 100644 --- a/packages/react-native/src/private/renderer/mounting/__tests__/Mounting-itest.js +++ b/packages/react-native/src/private/renderer/mounting/__tests__/Mounting-itest.js @@ -17,7 +17,7 @@ import type {HostInstance} from 'react-native'; import * as Fantom from '@react-native/fantom'; import nullthrows from 'nullthrows'; import * as React from 'react'; -import {ScrollView, View} from 'react-native'; +import {ScrollView, Text, View} from 'react-native'; describe('ViewFlattening', () => { /** @@ -593,6 +593,52 @@ describe('unchanged children', () => { ]); }); + test('a text whose string changes next to unchanged children is updated', () => { + const root = Fantom.createRoot(); + const render = (value: number) => + Fantom.runTask(() => { + root.render( + + + Value {value} + + , + ); + }); + + render(1); + root.takeMountingManagerLogs(); + + // React gives the changed string a new raw text node, which doesn't form + // a view, so only the paragraph is updated. + render(2); + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "Paragraph", nativeID: "label"}', + ]); + }); + + test('an empty view that is remounted next to unchanged children causes no mutations', () => { + const root = Fantom.createRoot(); + const render = (version: number) => + Fantom.runTask(() => { + root.render( + + + + + , + ); + }); + + render(1); + root.takeMountingManagerLogs(); + + // The new key gives the empty view a new tag. It is flattened and has no + // children, so it doesn't form a view and nothing is mounted for it. + render(2); + expect(root.takeMountingManagerLogs()).toEqual([]); + }); + test('a child added next to unchanged children is inserted', () => { const root = Fantom.createRoot(); const render = (showAdded: boolean) => From 5f55c0d5cee1b1d16e9a2c5a879872ec09005ea6 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Thu, 22 Oct 2026 21:04:04 +0200 Subject: [PATCH 2/2] Share the slicing code between replaced leaves and unchanged children Co-Authored-By: Claude Opus 5.5 --- .../sliceChildShadowNodeViewPairs.cpp | 55 ++++++------------- 1 file changed, 18 insertions(+), 37 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp index 8f2b5f2007d..9e68746cca4 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp @@ -264,42 +264,18 @@ bool sliceChangedChildShadowNodeViewPairsRecursively( continue; } - if (oldChildShadowNode.getTag() != newChildShadowNode.getTag() || + // A child whose tag or order index changed can still be listed if it is a + // leaf that doesn't form a view, like the raw text of a `Text` whose + // string changed: it has no mount index, so it doesn't shift the views + // around it, and both lists are sorted by order index at the end. + bool isReplaced = + oldChildShadowNode.getTag() != newChildShadowNode.getTag() || oldChildShadowNode.getOrderIndex() != - newChildShadowNode.getOrderIndex()) { - // A replaced leaf that doesn't form a view (like the raw text of a - // `Text` whose string changed) has no mount index, so both nodes can be - // listed as they are without shifting the views around them. - if (!oldChildShadowNode.getChildren().empty() || - !newChildShadowNode.getChildren().empty()) { - return false; - } - auto oldLeafSlice = sliceChild( - oldShadowNode, - oldChildShadowNode, - ShadowView(oldChildShadowNode), - oldLayoutOffset); - auto newLeafSlice = sliceChild( - newShadowNode, - newChildShadowNode, - ShadowView(newChildShadowNode), - newLayoutOffset); - if (oldLeafSlice.isConcreteView || newLeafSlice.isConcreteView) { - return false; - } - addChildSlice( - oldPairList, - oldStartOfStaticIndex, - scope, - oldLeafSlice, - oldChildShadowNode); - addChildSlice( - newPairList, - newStartOfStaticIndex, - scope, - newLeafSlice, - newChildShadowNode); - continue; + newChildShadowNode.getOrderIndex(); + if (isReplaced && + (!oldChildShadowNode.getChildren().empty() || + !newChildShadowNode.getChildren().empty())) { + return false; } auto oldChildSlice = sliceChild( @@ -314,7 +290,12 @@ bool sliceChangedChildShadowNodeViewPairsRecursively( newLayoutOffset); bool isStatic = newChildSlice.shadowView.layoutMetrics.positionType == PositionType::Static; - if (oldChildSlice.isConcreteView != newChildSlice.isConcreteView || + if (isReplaced) { + if (oldChildSlice.isConcreteView || newChildSlice.isConcreteView) { + return false; + } + } else if ( + oldChildSlice.isConcreteView != newChildSlice.isConcreteView || oldChildSlice.areChildrenFlattened != newChildSlice.areChildrenFlattened || (oldChildSlice.shadowView.layoutMetrics.positionType == @@ -335,7 +316,7 @@ bool sliceChangedChildShadowNodeViewPairsRecursively( newChildSlice, newChildShadowNode); - if (!newChildSlice.areChildrenFlattened) { + if (isReplaced || !newChildSlice.areChildrenFlattened) { continue; } if (isStatic) {