Add eager alternate.stateNode cleanup (#33161)

This is a fix for a problem where React retains shadow nodes longer than
it needs to. The behaviour is shown in React Native test:
https://github.com/facebook/react-native/blob/main/packages/react-native/src/private/__tests__/utilities/__tests__/ShadowNodeReferenceCounter-itest.js#L169

When React commits a new shadow tree, old shadow nodes are stored inside
`fiber.alternate.stateNode`. This is not cleared up until React clones
the node again. This may be problematic if mutation deletes a subtree,
in that case `fiber.alternate.stateNode` will retain entire subtree
until next update. In case of image nodes, this means retaining entire
images.

So when React goes from revision A: `<View><View /></View>` to revision
B: `<View />`, `fiber.alternate.stateNode` will be pointing to Shadow
Node that represents revision A..

![image](https://github.com/user-attachments/assets/076b677e-d152-4763-8c9d-4f923212b424)

To fix this, this PR adds a new feature flag
`enableEagerAlternateStateNodeCleanup`. When enabled,
`alternate.stateNode` is proactively pointed towards finishedWork's
stateNode, releasing resources sooner.

I have verified this fixes the issue [demonstrated by React Native
tests](https://github.com/facebook/react-native/blob/main/packages/react-native/src/private/__tests__/utilities/__tests__/ShadowNodeReferenceCounter-itest.js#L169).
All existing React tests pass when the flag is enabled.
This commit is contained in:
Samuel Susla
2025-07-15 15:24:50 -04:00
committed by Jack Pope
parent a24654e65b
commit 2cd3c424ea
9 changed files with 25 additions and 0 deletions
+15
View File
@@ -57,6 +57,7 @@ import {
enableComponentPerformanceTrack,
enableViewTransition,
enableFragmentRefs,
enableEagerAlternateStateNodeCleanup,
} from 'shared/ReactFeatureFlags';
import {
FunctionComponent,
@@ -1947,6 +1948,20 @@ function commitMutationEffectsOnFiber(
}
}
}
} else {
if (enableEagerAlternateStateNodeCleanup) {
if (supportsPersistence) {
if (finishedWork.alternate !== null) {
// `finishedWork.alternate.stateNode` is pointing to a stale shadow
// node at this point, retaining it and its subtree. To reclaim
// memory, point `alternate.stateNode` to new shadow node. This
// prevents shadow node from staying in memory longer than it
// needs to. The correct behaviour of this is checked by test in
// React Native: ShadowNodeReferenceCounter-itest.js#L150
finishedWork.alternate.stateNode = finishedWork.stateNode;
}
}
}
}
break;
}
+2
View File
@@ -135,6 +135,8 @@ export const enableShallowPropDiffing = false;
export const enableSiblingPrerendering = true;
export const enableEagerAlternateStateNodeCleanup = true;
/**
* Enables an expiration time for retry lanes to avoid starvation.
*/
@@ -22,6 +22,7 @@ export const enableObjectFiber = __VARIANT__;
export const enableHiddenSubtreeInsertionEffectCleanup = __VARIANT__;
export const enablePersistedModeClonedFlag = __VARIANT__;
export const enableShallowPropDiffing = __VARIANT__;
export const enableEagerAlternateStateNodeCleanup = __VARIANT__;
export const passChildrenWhenCloningPersistedNodes = __VARIANT__;
export const enableSiblingPrerendering = __VARIANT__;
export const enableUseEffectCRUDOverload = __VARIANT__;
@@ -25,6 +25,7 @@ export const {
enablePersistedModeClonedFlag,
enableShallowPropDiffing,
enableUseEffectCRUDOverload,
enableEagerAlternateStateNodeCleanup,
passChildrenWhenCloningPersistedNodes,
enableSiblingPrerendering,
enableFastAddPropertiesInDiffing,
@@ -49,6 +49,7 @@ export const enableSchedulingProfiler = __PROFILE__;
export const enableComponentPerformanceTrack = false;
export const enableScopeAPI = false;
export const enableShallowPropDiffing = false;
export const enableEagerAlternateStateNodeCleanup = false;
export const enableSuspenseAvoidThisFallback = false;
export const enableSuspenseCallback = false;
export const enableTaint = true;
@@ -65,6 +65,7 @@ export const enableShallowPropDiffing = false;
export const enableSiblingPrerendering = true;
export const enableUseEffectCRUDOverload = false;
export const enableEagerAlternateStateNodeCleanup = false;
export const enableYieldingBeforePassive = true;
@@ -47,6 +47,7 @@ export const enableSchedulingProfiler = __PROFILE__;
export const enableComponentPerformanceTrack = false;
export const enableScopeAPI = false;
export const enableShallowPropDiffing = false;
export const enableEagerAlternateStateNodeCleanup = false;
export const enableSuspenseAvoidThisFallback = false;
export const enableSuspenseCallback = false;
export const enableTaint = true;
@@ -74,6 +74,7 @@ export const enableShallowPropDiffing = false;
export const enableSiblingPrerendering = true;
export const enableUseEffectCRUDOverload = false;
export const enableEagerAlternateStateNodeCleanup = false;
export const enableHydrationLaneScheduling = true;
@@ -110,6 +110,8 @@ export const disableLegacyMode = true;
export const enableShallowPropDiffing = false;
export const enableEagerAlternateStateNodeCleanup = false;
export const enableLazyPublicInstanceInFabric = false;
export const enableSwipeTransition = false;