Fix differ creating or deleting a view that a nested (un)flattening moves (#58648)

Summary:
Fixes https://github.com/react/react-native/issues/58647

When a view flattens in the same commit in which its parent unflattens, and one of its children has a negative `zIndex`, the differ creates that child again while it's still mounted, or deletes it even though it only moves.

On iOS this crashes in `RCTComponentViewRegistry` (Debug) or with a SIGSEGV in `RCTMountingManager` (Release). The `zIndex` sorts the child before the view that contains it, and the nested recursion then matches it through a different `ShadowViewNodePair`, so `inOtherTree()` stayed false on the candidate.

I made the final create/delete loop in `calculateShadowViewMutationsFlattener` also skip candidates whose tag the recursion recorded in the sub-visited map. I also fixed `unvisitedRecursiveChildPairs` storing a pointer to a loop-local copy.

## Changelog:

[GENERAL] [FIXED] - Fix the differ creating a mounted view again, or deleting a moved view, when a child with a negative `zIndex` moves in a nested flatten/unflatten

Pull Request resolved: https://github.com/react/react-native/pull/58648

Test Plan: The [reproducer](https://github.com/pawicao/rn-differ-zindex-flatten-repro) from https://github.com/react/react-native/issues/58647 no longer crashes on iOS with React Native built from source with this change; without it, it crashes on the first swap.

Reviewed By: christophpurrer

Differential Revision: D121619877

Pulled By: javache

fbshipit-source-id: d0e692c8645e5cedff9fee0e88133e18809fd3c7
This commit is contained in:
Oskar Pawica
2026-09-25 03:37:58 -07:00
committed by meta-codesync[bot]
parent 53bf98bf40
commit 361bc240e4
2 changed files with 76 additions and 3 deletions
@@ -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;
}
@@ -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(
<View nativeID="Q" style={{opacity: 0.5}}>
<View style={opacityOnGrandparent ? {opacity: 0.5} : null}>
<View style={opacityOnGrandparent ? null : {opacity: 0.5}}>
<View nativeID="A" />
<View nativeID="K" style={{zIndex: -1}} />
<View nativeID="B" />
</View>
</View>
</View>,
);
});
}
const expectedOutput = (
<rn-view nativeID="Q">
<rn-view>
<rn-view key="0" nativeID="K" />
<rn-view key="1" nativeID="A" />
<rn-view key="2" nativeID="B" />
</rn-view>
</rn-view>
);
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', () => {