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) =>