@samitouri / QOS-React / commits / 5d04d73274

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 # Problem 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) # Fix 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.

Samuel Susla committed May 12, 2025 at 17:39 UTC 5d04d73274a884ed53106677d56dd837ae668c45
9 files changed +25
packages/react-reconciler/src/ReactFiberCommitWork.js
+15
@@ -59,6 +59,7 @@ import {
59 enableComponentPerformanceTrack,
60 enableViewTransition,
61 enableFragmentRefs,
62 + enableEagerAlternateStateNodeCleanup,
63 } from 'shared/ReactFeatureFlags';
64 import {
65 FunctionComponent,
@@ -2170,6 +2171,20 @@ function commitMutationEffectsOnFiber(
2171 }
2172 }
2173 }
2174 + } else {
2175 + if (enableEagerAlternateStateNodeCleanup) {
2176 + if (supportsPersistence) {
2177 + if (finishedWork.alternate !== null) {
2178 + // `finishedWork.alternate.stateNode` is pointing to a stale shadow
2179 + // node at this point, retaining it and its subtree. To reclaim
2180 + // memory, point `alternate.stateNode` to new shadow node. This
2181 + // prevents shadow node from staying in memory longer than it
2182 + // needs to. The correct behaviour of this is checked by test in
2183 + // React Native: ShadowNodeReferenceCounter-itest.js#L150
2184 + finishedWork.alternate.stateNode = finishedWork.stateNode;
2185 + }
2186 + }
2187 + }
2188 }
2189 break;
2190 }
packages/shared/ReactFeatureFlags.js
+2
@@ -141,6 +141,8 @@ export const enablePersistedModeClonedFlag = false;
141
142 export const enableShallowPropDiffing = false;
143
144 +export const enableEagerAlternateStateNodeCleanup = true;
145 +
146 /**
147 * Enables an expiration time for retry lanes to avoid starvation.
148 */
packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
+1
@@ -22,6 +22,7 @@ export const enableObjectFiber = __VARIANT__;
22 export const enableHiddenSubtreeInsertionEffectCleanup = __VARIANT__;
23 export const enablePersistedModeClonedFlag = __VARIANT__;
24 export const enableShallowPropDiffing = __VARIANT__;
25 +export const enableEagerAlternateStateNodeCleanup = __VARIANT__;
26 export const passChildrenWhenCloningPersistedNodes = __VARIANT__;
27 export const enableFastAddPropertiesInDiffing = __VARIANT__;
28 export const enableLazyPublicInstanceInFabric = __VARIANT__;
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -24,6 +24,7 @@ export const {
24 enableObjectFiber,
25 enablePersistedModeClonedFlag,
26 enableShallowPropDiffing,
27 + enableEagerAlternateStateNodeCleanup,
28 passChildrenWhenCloningPersistedNodes,
29 enableFastAddPropertiesInDiffing,
30 enableLazyPublicInstanceInFabric,
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -49,6 +49,7 @@ export const enableSchedulingProfiler = __PROFILE__;
49 export const enableComponentPerformanceTrack = false;
50 export const enableScopeAPI = false;
51 export const enableShallowPropDiffing = false;
52 +export const enableEagerAlternateStateNodeCleanup = false;
53 export const enableSuspenseAvoidThisFallback = false;
54 export const enableSuspenseCallback = false;
55 export const enableTaint = true;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -62,6 +62,7 @@ export const enableInfiniteRenderLoopDetection = false;
62
63 export const renameElementSymbol = true;
64 export const enableShallowPropDiffing = false;
65 +export const enableEagerAlternateStateNodeCleanup = false;
66
67 export const enableYieldingBeforePassive = true;
68
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -47,6 +47,7 @@ export const enableSchedulingProfiler = __PROFILE__;
47 export const enableComponentPerformanceTrack = false;
48 export const enableScopeAPI = false;
49 export const enableShallowPropDiffing = false;
50 +export const enableEagerAlternateStateNodeCleanup = false;
51 export const enableSuspenseAvoidThisFallback = false;
52 export const enableSuspenseCallback = false;
53 export const enableTaint = true;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -71,6 +71,7 @@ export const renameElementSymbol = false;
71
72 export const enableObjectFiber = false;
73 export const enableShallowPropDiffing = false;
74 +export const enableEagerAlternateStateNodeCleanup = false;
75
76 export const enableHydrationLaneScheduling = true;
77
packages/shared/forks/ReactFeatureFlags.www.js
+2
@@ -107,6 +107,8 @@ export const disableLegacyMode = true;
107
108 export const enableShallowPropDiffing = false;
109
110 +export const enableEagerAlternateStateNodeCleanup = false;
111 +
112 export const enableLazyPublicInstanceInFabric = false;
113
114 export const enableGestureTransition = false;