Skip to content

Commit a2b2040

Browse files
committed
fix: don't double-invoke effects for moved children in StrictMode
In StrictMode, `doubleInvokeEffectsInDEVIfNecessary` fires for any fiber that has the `PlacementDEV` flag set. Previously, `placeChild` set `PlacementDEV` on both newly inserted fibers AND fibers that were moved to a different position in an array. This caused a regression in React 19: when a keyed child is reordered within an array, its effects are re-run in dev/StrictMode even when dependencies are empty `[]`. The same component in production, or in React 18, correctly skips effect re-runs for moves. The fix is to only set `PlacementDEV` for actual insertions (where `current === null`). Moved fibers (`current !== null`, `oldIndex < lastPlacedIndex`) still receive the `Placement` flag so the host node is correctly repositioned in the DOM, but StrictMode no longer treats them as new mounts. Also fix unused `ref3` variable in ReactFabric-test.internal.js introduced in #35912 (copy-paste: third View used ref2 instead of ref3). Fixes #32561
1 parent 7207a65 commit a2b2040

3 files changed

Lines changed: 54 additions & 3 deletions

File tree

packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1215,7 +1215,7 @@ describe('ReactFabric', () => {
12151215
}}
12161216
/>
12171217
<View
1218-
ref={ref2}
1218+
ref={ref3}
12191219
id="explicitTimeStampLowerCase"
12201220
onTouchEnd={event => {
12211221
expect(event.timeStamp).toBe(explicitTimeStampLowerCase);

packages/react-reconciler/src/ReactChildFiber.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,8 +524,11 @@ function createChildReconciler(
524524
if (current !== null) {
525525
const oldIndex = current.index;
526526
if (oldIndex < lastPlacedIndex) {
527-
// This is a move.
528-
newFiber.flags |= Placement | PlacementDEV;
527+
// This is a move. The fiber already exists and is being repositioned in
528+
// the DOM. We set Placement so the host node is moved, but we do NOT
529+
// set PlacementDEV because the component is not newly mounting —
530+
// StrictMode should not double-invoke effects for moved components.
531+
newFiber.flags |= Placement;
529532
return lastPlacedIndex;
530533
} else {
531534
// This item can stay in place.

packages/react-reconciler/src/__tests__/StrictEffectsMode-test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,4 +964,52 @@ describe('StrictEffectsMode', () => {
964964
'Child dep create',
965965
]);
966966
});
967+
968+
it('should not double invoke effects when a keyed child is moved in an array', async () => {
969+
const log = [];
970+
971+
function Item({id}) {
972+
React.useEffect(() => {
973+
log.push(`${id} effect mount`);
974+
return () => log.push(`${id} effect unmount`);
975+
}, []);
976+
977+
React.useLayoutEffect(() => {
978+
log.push(`${id} layout mount`);
979+
return () => log.push(`${id} layout unmount`);
980+
}, []);
981+
982+
return id;
983+
}
984+
985+
const root = ReactNoop.createRoot();
986+
987+
// Initial render: [A, B, C]
988+
await act(() => {
989+
root.render(
990+
<React.StrictMode>
991+
{['A', 'B', 'C'].map(id => (
992+
<Item key={id} id={id} />
993+
))}
994+
</React.StrictMode>,
995+
);
996+
});
997+
998+
log.length = 0; // clear mount logs, only care about what happens on reorder
999+
1000+
// Reorder to [C, A, B] — all elements move but none are new
1001+
await act(() => {
1002+
root.render(
1003+
<React.StrictMode>
1004+
{['C', 'A', 'B'].map(id => (
1005+
<Item key={id} id={id} />
1006+
))}
1007+
</React.StrictMode>,
1008+
);
1009+
});
1010+
1011+
// Moved elements should not have their effects re-run.
1012+
// Only DOM placement happens; no mount/unmount of effects.
1013+
expect(log).toEqual([]);
1014+
});
9671015
});

0 commit comments

Comments
 (0)