From 1f98644c6349e0e6385bbe9b990d784e7a0ec561 Mon Sep 17 00:00:00 2001 From: Oskar Pawica Date: Wed, 23 Sep 2026 12:42:09 +0200 Subject: [PATCH 1/2] Fix differ creating or deleting a view that a nested (un)flattening moves When a view flattens in the same commit in which its parent unflattens, a child with a negative zIndex is sorted before the view that contains it. The final loop of calculateShadowViewMutationsFlattener then took it for a new or removed view, because the nested recursion matched it through a different ShadowViewNodePair. Skip candidates the recursion recorded in the sub-visited map. Also stop keeping a pointer to a loop-local copy in unvisitedRecursiveChildPairs. Fixes https://github.com/react/react-native/issues/58647 Co-Authored-By: Claude Opus 5.5 --- .../react/renderer/mounting/Differentiator.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp index b76a03881be..41de087d3f8 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp @@ -708,9 +708,9 @@ static void calculateShadowViewMutationsFlattener( auto unvisitedOtherNodesIt = unvisitedOtherNodes.find(newChild.shadowView.tag); if (unvisitedOtherNodesIt != unvisitedOtherNodes.end()) { - auto unvisitedItPair = *unvisitedOtherNodesIt->second; + auto* unvisitedItPair = unvisitedOtherNodesIt->second; unvisitedRecursiveChildPairs.insert( - {unvisitedItPair.shadowView.tag, &unvisitedItPair}); + {unvisitedItPair->shadowView.tag, unvisitedItPair}); } else { unvisitedRecursiveChildPairs.insert( {newChild.shadowView.tag, &newChild}); @@ -820,6 +820,9 @@ static void calculateShadowViewMutationsFlattener( // Final step: go through creation/deletion candidates and delete/create // subtrees if they were never visited during the execution of the above // loop and recursions. + const auto& subVisitedMap = reparentMode == ReparentMode::Flatten + ? *subVisitedOldMap + : *subVisitedNewMap; for (auto& deletionCreationCandidatePair : deletionCreationCandidatePairs) { auto& treeChildPair = *deletionCreationCandidatePair.second; @@ -828,7 +831,10 @@ static void calculateShadowViewMutationsFlattener( // already created/deleted and we don't need to do that here. // It is always the responsibility of the matcher to update subtrees when // nodes are matched. - if (treeChildPair.inOtherTree()) { + // The recursion can match the node through a different pair instance + // (e.g. when zIndex orders it before its parent), so check its tag too. + if (treeChildPair.inOtherTree() || + subVisitedMap.contains(treeChildPair.shadowView.tag)) { continue; } From d4b95393882a55e52da29e2bb6769a61c56cf01c Mon Sep 17 00:00:00 2001 From: Oskar Pawica Date: Wed, 23 Sep 2026 15:23:04 +0200 Subject: [PATCH 2/2] Add a Fantom test for a negative zIndex child in a nested flatten/unflatten swap --- .../mounting/__tests__/Mounting-itest.js | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) 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 affe86ef97b..c260930ad58 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 @@ -446,6 +446,73 @@ describe('ViewFlattening', () => { />, ); }); + + test('#58647: child with negative zIndex is kept when its parent flattens and its grandparent unflattens', () => { + const root = Fantom.createRoot(); + + function render(opacityOnGrandparent: boolean) { + Fantom.runTask(() => { + root.render( + + + + + + + + + , + ); + }); + } + + const expectedOutput = ( + + + + + + + + ); + + render(false); + root.takeMountingManagerLogs(); + + render(true); + expect(root.takeMountingManagerLogs()).toEqual([ + 'Remove {type: "View", parentNativeID: "Q", index: 0, nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: (N/A), index: 2, nativeID: "B"}', + 'Remove {type: "View", parentNativeID: (N/A), index: 1, nativeID: "A"}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "K"}', + 'Delete {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Insert {type: "View", parentNativeID: "Q", index: 0, nativeID: (N/A)}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "K"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 1, nativeID: "A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 2, nativeID: "B"}', + ]); + expect(root.getRenderedOutput({props: ['nativeID']}).toJSX()).toEqual( + expectedOutput, + ); + + render(false); + expect(root.takeMountingManagerLogs()).toEqual([ + 'Remove {type: "View", parentNativeID: (N/A), index: 2, nativeID: "B"}', + 'Remove {type: "View", parentNativeID: (N/A), index: 1, nativeID: "A"}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "K"}', + 'Remove {type: "View", parentNativeID: "Q", index: 0, nativeID: (N/A)}', + 'Delete {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "K"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 1, nativeID: "A"}', + 'Insert {type: "View", parentNativeID: (N/A), index: 2, nativeID: "B"}', + 'Insert {type: "View", parentNativeID: "Q", index: 0, nativeID: (N/A)}', + ]); + expect(root.getRenderedOutput({props: ['nativeID']}).toJSX()).toEqual( + expectedOutput, + ); + }); }); describe('reconciliation of setNativeProps and React commit', () => {