@samitouri / QOS-React / commits / 0b78161d7d

[Fiber] Highlight a Component with Deeply Equal Props in the Performance Track (#33660)

Stacked on #33658 and #33659. If we detect that a component is receiving only deeply equal objects, then we highlight it as potentially problematic and worth looking into. <img width="1055" alt="Screenshot 2025-06-27 at 4 15 28 PM" src="https://github.com/user-attachments/assets/e96c6a05-7fff-4fd7-b59a-36ed79f8e609" /> It's fairly conservative and can bail out for a number of reasons: - We only log it on the first parent that triggered this case since other children could be indirect causes. - If children has changed then we bail out since this component will rerender anyway. This means that it won't warn for a lot of cases that receive plain DOM children since the DOM children won't themselves get logged. - If the component's total render time including children is 100ms or less then we skip warning because rerendering might not be a big deal. - We don't warn if you have shallow equality but could memoize the JSX element itself since we don't typically recommend that and React Compiler doesn't do that. It only warns if you have nested objects too. - If the depth of the objects is deeper than like the 3 levels that we print diffs for then we wouldn't warn since we don't know if they were equal (although we might still warn on a child). - If the component had any updates scheduled on itself (e.g. setState) then we don't warn since it would rerender anyway. This should really consider Context updates too but we don't do that atm. Technically you should still memoize the incoming props even if you also had unrelated updates since it could apply to deeper bailouts.

Sebastian Markbåge committed Jul 2, 2025 at 17:33 UTC 0b78161d7d76b7fb9786f25dd222010b9e417191
3 files changed +84 -7
packages/react-reconciler/src/ReactFiberCommitWork.js
+15
@@ -143,6 +143,8 @@ import {
143 logComponentUnmount,
144 logComponentReappeared,
145 logComponentDisappeared,
146 + pushDeepEquality,
147 + popDeepEquality,
148 } from './ReactFiberPerformanceTrack';
149 import {ConcurrentMode, NoMode, ProfileMode} from './ReactTypeOfMode';
150 import {deferHiddenCallbacks} from './ReactFiberClassUpdateQueue';
@@ -3489,6 +3491,7 @@ function commitPassiveMountOnFiber(
3491 const prevEffectStart = pushComponentEffectStart();
3492 const prevEffectDuration = pushComponentEffectDuration();
3493 const prevEffectErrors = pushComponentEffectErrors();
3494 + const prevDeepEquality = pushDeepEquality();
3495
3496 const isViewTransitionEligible = enableViewTransition
3497 ? includesOnlyViewTransitionEligibleLanes(committedLanes)
@@ -3533,6 +3536,7 @@ function commitPassiveMountOnFiber(
3536 ((finishedWork.actualStartTime: any): number),
3537 endTime,
3538 inHydratedSubtree,
3539 + committedLanes,
3540 );
3541 }
3542
@@ -3577,6 +3581,7 @@ function commitPassiveMountOnFiber(
3581 ((finishedWork.actualStartTime: any): number),
3582 endTime,
3583 inHydratedSubtree,
3584 + committedLanes,
3585 );
3586 }
3587 }
@@ -4079,6 +4084,7 @@ function commitPassiveMountOnFiber(
4084 popComponentEffectStart(prevEffectStart);
4085 popComponentEffectDuration(prevEffectDuration);
4086 popComponentEffectErrors(prevEffectErrors);
4087 + popDeepEquality(prevDeepEquality);
4088 }
4089
4090 function recursivelyTraverseReconnectPassiveEffects(
@@ -4140,6 +4146,8 @@ export function reconnectPassiveEffects(
4146 const prevEffectStart = pushComponentEffectStart();
4147 const prevEffectDuration = pushComponentEffectDuration();
4148 const prevEffectErrors = pushComponentEffectErrors();
4149 + const prevDeepEquality = pushDeepEquality();
4150 +
4151 // If this component rendered in Profiling mode (DEV or in Profiler component) then log its
4152 // render time. We do this after the fact in the passive effect to avoid the overhead of this
4153 // getting in the way of the render characteristics and avoid the overhead of unwinding
@@ -4156,6 +4164,7 @@ export function reconnectPassiveEffects(
4164 ((finishedWork.actualStartTime: any): number),
4165 endTime,
4166 inHydratedSubtree,
4167 + committedLanes,
4168 );
4169 }
4170
@@ -4340,6 +4349,7 @@ export function reconnectPassiveEffects(
4349 popComponentEffectStart(prevEffectStart);
4350 popComponentEffectDuration(prevEffectDuration);
4351 popComponentEffectErrors(prevEffectErrors);
4352 + popDeepEquality(prevDeepEquality);
4353 }
4354
4355 function recursivelyTraverseAtomicPassiveEffects(
@@ -4389,6 +4399,8 @@ function commitAtomicPassiveEffects(
4399 committedTransitions: Array<Transition> | null,
4400 endTime: number, // Profiling-only. The start time of the next Fiber or root completion.
4401 ) {
4402 + const prevDeepEquality = pushDeepEquality();
4403 +
4404 // If this component rendered in Profiling mode (DEV or in Profiler component) then log its
4405 // render time. A render can happen even if the subtree is offscreen.
4406 if (
@@ -4403,6 +4415,7 @@ function commitAtomicPassiveEffects(
4415 ((finishedWork.actualStartTime: any): number),
4416 endTime,
4417 inHydratedSubtree,
4418 + committedLanes,
4419 );
4420 }
4421
@@ -4453,6 +4466,8 @@ function commitAtomicPassiveEffects(
4466 break;
4467 }
4468 }
4469 +
4470 + popDeepEquality(prevDeepEquality);
4471 }
4472
4473 export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
packages/react-reconciler/src/ReactFiberPerformanceTrack.js
+49 -3
@@ -24,6 +24,7 @@ import {
24 includesOnlyHydrationLanes,
25 includesOnlyOffscreenLanes,
26 includesOnlyHydrationOrOffscreenLanes,
27 + includesSomeLane,
28 } from './ReactFiberLane';
29
30 import {
@@ -104,6 +105,7 @@ function logComponentTrigger(
105 reusableComponentOptions.start = startTime;
106 reusableComponentOptions.end = endTime;
107 reusableComponentDevToolDetails.color = 'warning';
108 + reusableComponentDevToolDetails.tooltipText = trigger;
109 reusableComponentDevToolDetails.properties = null;
110 const debugTask = fiber._debugTask;
111 if (__DEV__ && debugTask) {
@@ -153,11 +155,30 @@ export function logComponentDisappeared(
155 logComponentTrigger(fiber, startTime, endTime, 'Disconnect');
156 }
157
158 +let alreadyWarnedForDeepEquality = false;
159 +
160 +export function pushDeepEquality(): boolean {
161 + if (__DEV__) {
162 + // If this is true then we don't reset it to false because we're tracking if any
163 + // parent already warned about having deep equality props in this subtree.
164 + return alreadyWarnedForDeepEquality;
165 + }
166 + return false;
167 +}
168 +
169 +export function popDeepEquality(prev: boolean): void {
170 + if (__DEV__) {
171 + alreadyWarnedForDeepEquality = prev;
172 + }
173 +}
174 +
175 const reusableComponentDevToolDetails = {
176 color: 'primary',
177 properties: (null: null | Array<[string, string]>),
178 + tooltipText: '',
179 track: COMPONENTS_TRACK,
180 };
181 +
182 const reusableComponentOptions = {
183 start: -0,
184 end: -0,
@@ -168,11 +189,17 @@ const reusableComponentOptions = {
189
190 const resuableChangedPropsEntry = ['Changed Props', ''];
191
192 +const DEEP_EQUALITY_WARNING =
193 + 'This component received deeply equal props. It might benefit from useMemo or the React Compiler in its owner.';
194 +
195 +const reusableDeeplyEqualPropsEntry = ['Changed Props', DEEP_EQUALITY_WARNING];
196 +
197 export function logComponentRender(
198 fiber: Fiber,
199 startTime: number,
200 endTime: number,
201 wasHydrated: boolean,
202 + committedLanes: Lanes,
203 ): void {
204 const name = getComponentNameFromFiber(fiber);
205 if (name === null) {
@@ -211,17 +238,36 @@ export function logComponentRender(
238 ) {
239 // If this is an update, we'll diff the props and emit which ones changed.
240 const properties: Array<[string, string]> = [resuableChangedPropsEntry];
214 - addObjectDiffToProperties(
241 + const isDeeplyEqual = addObjectDiffToProperties(
242 alternate.memoizedProps,
243 props,
244 properties,
245 0,
246 );
247 if (properties.length > 1) {
248 + if (
249 + isDeeplyEqual &&
250 + !alreadyWarnedForDeepEquality &&
251 + !includesSomeLane(alternate.lanes, committedLanes) &&
252 + (fiber.actualDuration: any) > 100
253 + ) {
254 + alreadyWarnedForDeepEquality = true;
255 + // This is the first component in a subtree which rerendered with deeply equal props
256 + // and didn't have its own work scheduled and took a non-trivial amount of time.
257 + // We highlight this for further inspection.
258 + // Note that we only consider this case if properties.length > 1 which it will only
259 + // be if we have emitted any diffs. We'd only emit diffs if there were any nested
260 + // equal objects. Therefore, we don't warn for simple shallow equality.
261 + properties[0] = reusableDeeplyEqualPropsEntry;
262 + reusableComponentDevToolDetails.color = 'warning';
263 + reusableComponentDevToolDetails.tooltipText = DEEP_EQUALITY_WARNING;
264 + } else {
265 + reusableComponentDevToolDetails.color = color;
266 + reusableComponentDevToolDetails.tooltipText = name;
267 + }
268 + reusableComponentDevToolDetails.properties = properties;
269 reusableComponentOptions.start = startTime;
270 reusableComponentOptions.end = endTime;
223 - reusableComponentDevToolDetails.color = color;
224 - reusableComponentDevToolDetails.properties = properties;
271 debugTask.run(
272 // $FlowFixMe[method-unbinding]
273 performance.measure.bind(
packages/shared/ReactPerformanceTrackProperties.js
+20 -4
@@ -161,7 +161,13 @@ export function addValueToProperties(
161 if (value.status === 'fulfilled') {
162 // Print the inner value
163 const idx = properties.length;
164 - addValueToProperties(propertyName, value.value, properties, indent);
164 + addValueToProperties(
165 + propertyName,
166 + value.value,
167 + properties,
168 + indent,
169 + prefix,
170 + );
171 if (properties.length > idx) {
172 // Wrap the value or type in Promise descriptor.
173 const insertedEntry = properties[idx];
@@ -177,6 +183,7 @@ export function addValueToProperties(
183 value.reason,
184 properties,
185 indent,
186 + prefix,
187 );
188 if (properties.length > idx) {
189 // Wrap the value or type in Promise descriptor.
@@ -242,13 +249,15 @@ export function addObjectDiffToProperties(
249 next: Object,
250 properties: Array<[string, string]>,
251 indent: number,
245 -): void {
252 +): boolean {
253 // Note: We diff even non-owned properties here but things that are shared end up just the same.
254 // If a property is added or removed, we just emit the property name and omit the value it had.
255 // Mainly for performance. We need to minimize to only relevant information.
256 + let isDeeplyEqual = true;
257 for (const key in prev) {
258 if (!(key in next)) {
259 properties.push([REMOVED + '\xa0\xa0'.repeat(indent) + key, '\u2026']);
260 + isDeeplyEqual = false;
261 }
262 }
263 for (const key in next) {
@@ -262,6 +271,7 @@ export function addObjectDiffToProperties(
271 // elsewhere but still mark it as a cause of render.
272 const line = '\xa0\xa0'.repeat(indent) + key;
273 properties.push([REMOVED + line, '\u2026'], [ADDED + line, '\u2026']);
274 + isDeeplyEqual = false;
275 continue;
276 }
277 if (indent >= 3) {
@@ -286,6 +296,7 @@ export function addObjectDiffToProperties(
296 const line = '\xa0\xa0'.repeat(indent) + key;
297 const desc = '<' + typeName + ' \u2026 />';
298 properties.push([REMOVED + line, desc], [ADDED + line, desc]);
299 + isDeeplyEqual = false;
300 continue;
301 }
302 } else {
@@ -304,13 +315,15 @@ export function addObjectDiffToProperties(
315 ];
316 properties.push(entry);
317 const prevLength = properties.length;
307 - addObjectDiffToProperties(
318 + const nestedEqual = addObjectDiffToProperties(
319 prevValue,
320 nextValue,
321 properties,
322 indent + 1,
323 );
313 - if (prevLength === properties.length) {
324 + if (!nestedEqual) {
325 + isDeeplyEqual = false;
326 + } else if (prevLength === properties.length) {
327 // Nothing notably changed inside the nested object. So this is only a change in reference
328 // equality. Let's note it.
329 entry[1] =
@@ -349,9 +362,12 @@ export function addObjectDiffToProperties(
362 // Otherwise, emit the change in property and the values.
363 addValueToProperties(key, prevValue, properties, indent, REMOVED);
364 addValueToProperties(key, nextValue, properties, indent, ADDED);
365 + isDeeplyEqual = false;
366 }
367 } else {
368 properties.push([ADDED + '\xa0\xa0'.repeat(indent) + key, '\u2026']);
369 + isDeeplyEqual = false;
370 }
371 }
372 + return isDeeplyEqual;
373 }