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..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,9 +264,17 @@ 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()) {
+ newChildShadowNode.getOrderIndex();
+ if (isReplaced &&
+ (!oldChildShadowNode.getChildren().empty() ||
+ !newChildShadowNode.getChildren().empty())) {
return false;
}
@@ -282,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 ==
@@ -303,7 +316,7 @@ bool sliceChangedChildShadowNodeViewPairsRecursively(
newChildSlice,
newChildShadowNode);
- if (!newChildSlice.areChildrenFlattened) {
+ if (isReplaced || !newChildSlice.areChildrenFlattened) {
continue;
}
if (isStatic) {
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) =>