@samitouri / QOS-React-2 / commits / 1e9eb95db5

[Fiber] Mark cascading updates (#31866)

A common source of performance problems is due to cascading renders from calling `setState` in `useLayoutEffect` or `useEffect`. This marks the entry from the update to when we start the render as red and `"Cascade"` to highlight this. <img width="964" alt="Screenshot 2024-12-19 at 10 54 59 PM" src="https://github.com/user-attachments/assets/2bfa91e6-1dc1-4b7f-a659-50aaf2a97e83" /> In addition to this case, there's another case where you call `setState` multiple times in the same event causing multiple renders. This might be due to multiple `flushSync`, or spawned a microtasks from a `useLayoutEffect`. In theory it could also be from a microtask scheduled after the first `setState`. This one we can only detect if it's from an event that has a `window.event` since otherwise it's hard to know if we're still in the same event. <img width="1210" alt="Screenshot 2024-12-19 at 11 38 44 PM" src="https://github.com/user-attachments/assets/ee188bc4-8ebb-4e95-b5a5-4d724856c27d" /> I decided against making a ping in a microtask considered a cascade. Because that should ideally be using the Suspense Optimization and so wouldn't be considered multi-pass. <img width="1284" alt="Screenshot 2024-12-19 at 11 07 30 PM" src="https://github.com/user-attachments/assets/2d173750-a475-41a0-b6cf-679d15c4ca97" /> We might consider making the whole render phase and maybe commit phase red but that should maybe reserved for actual errors. The "Blocked" phase really represents the `setState` and so will have the stack trace of the first update.

Sebastian Markbåge committed Jan 2, 2025 at 13:04 UTC 1e9eb95db5b3a2064ecc26915a4e640b3a9bdaf5
4 files changed +47 -19
packages/react-reconciler/src/ReactFiberPerformanceTrack.js
+13 -6
@@ -276,11 +276,15 @@ export function logBlockingStart(
276 eventTime: number,
277 eventType: null | string,
278 eventIsRepeat: boolean,
279 + isSpawnedUpdate: boolean,
280 renderStartTime: number,
281 lanes: Lanes,
282 ): void {
283 if (supportsUserTiming) {
284 reusableLaneDevToolDetails.track = 'Blocking';
285 + // If a blocking update was spawned within render or an effect, that's considered a cascading render.
286 + // If you have a second blocking update within the same event, that suggests multiple flushSync or
287 + // setState in a microtask which is also considered a cascade.
288 if (eventTime > 0 && eventType !== null) {
289 // Log the time from the event timeStamp until we called setState.
290 reusableLaneDevToolDetails.color = eventIsRepeat
@@ -295,14 +299,17 @@ export function logBlockingStart(
299 }
300 if (updateTime > 0) {
301 // Log the time from when we called setState until we started rendering.
298 - reusableLaneDevToolDetails.color = includesOnlyHydrationOrOffscreenLanes(
299 - lanes,
300 - )
301 - ? 'tertiary-light'
302 - : 'primary-light';
302 + reusableLaneDevToolDetails.color = isSpawnedUpdate
303 + ? 'error'
304 + : includesOnlyHydrationOrOffscreenLanes(lanes)
305 + ? 'tertiary-light'
306 + : 'primary-light';
307 reusableLaneOptions.start = updateTime;
308 reusableLaneOptions.end = renderStartTime;
305 - performance.measure('Blocked', reusableLaneOptions);
309 + performance.measure(
310 + isSpawnedUpdate ? 'Cascade' : 'Blocked',
311 + reusableLaneOptions,
312 + );
313 }
314 }
315 }
packages/react-reconciler/src/ReactFiberRootScheduler.js
+18 -8
@@ -128,12 +128,12 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
128 // We're inside an `act` scope.
129 if (!didScheduleMicrotask_act) {
130 didScheduleMicrotask_act = true;
131 - scheduleImmediateTask(processRootScheduleInMicrotask);
131 + scheduleImmediateRootScheduleTask();
132 }
133 } else {
134 if (!didScheduleMicrotask) {
135 didScheduleMicrotask = true;
136 - scheduleImmediateTask(processRootScheduleInMicrotask);
136 + scheduleImmediateRootScheduleTask();
137 }
138 }
139
@@ -229,13 +229,17 @@ function flushSyncWorkAcrossRoots_impl(
229 isFlushingWork = false;
230 }
231
232 -function processRootScheduleInMicrotask() {
232 +function processRootScheduleInImmediateTask() {
233 if (enableProfilerTimer && enableComponentPerformanceTrack) {
234 // Track the currently executing event if there is one so we can ignore this
235 // event when logging events.
236 trackSchedulerEvent();
237 }
238
239 + processRootScheduleInMicrotask();
240 +}
241 +
242 +function processRootScheduleInMicrotask() {
243 // This function is always called inside a microtask. It should never be
244 // called synchronously.
245 didScheduleMicrotask = false;
@@ -558,7 +562,7 @@ function cancelCallback(callbackNode: mixed) {
562 }
563 }
564
561 -function scheduleImmediateTask(cb: () => mixed) {
565 +function scheduleImmediateRootScheduleTask() {
566 if (__DEV__ && ReactSharedInternals.actQueue !== null) {
567 // Special case: Inside an `act` scope, we push microtasks to the fake `act`
568 // callback queue. This is because we currently support calling `act`
@@ -566,7 +570,7 @@ function scheduleImmediateTask(cb: () => mixed) {
570 // that you always await the result so that the microtasks have a chance to
571 // run. But it hasn't happened yet.
572 ReactSharedInternals.actQueue.push(() => {
569 - cb();
573 + processRootScheduleInMicrotask();
574 return null;
575 });
576 }
@@ -588,14 +592,20 @@ function scheduleImmediateTask(cb: () => mixed) {
592 // wrong semantically but it prevents an infinite loop. The bug is
593 // Safari's, not ours, so we just do our best to not crash even though
594 // the behavior isn't completely correct.
591 - Scheduler_scheduleCallback(ImmediateSchedulerPriority, cb);
595 + Scheduler_scheduleCallback(
596 + ImmediateSchedulerPriority,
597 + processRootScheduleInImmediateTask,
598 + );
599 return;
600 }
594 - cb();
601 + processRootScheduleInMicrotask();
602 });
603 } else {
604 // If microtasks are not supported, use Scheduler.
598 - Scheduler_scheduleCallback(ImmediateSchedulerPriority, cb);
605 + Scheduler_scheduleCallback(
606 + ImmediateSchedulerPriority,
607 + processRootScheduleInImmediateTask,
608 + );
609 }
610 }
611
packages/react-reconciler/src/ReactFiberWorkLoop.js
+4 -5
@@ -236,6 +236,7 @@ import {
236 blockingEventTime,
237 blockingEventType,
238 blockingEventIsRepeat,
239 + blockingSpawnedUpdate,
240 blockingSuspendedTime,
241 transitionClampTime,
242 transitionStartTime,
@@ -1664,11 +1665,8 @@ export function flushSyncWork(): boolean {
1665
1666 export function isAlreadyRendering(): boolean {
1667 // Used by the renderer to print a warning if certain APIs are called from
1667 - // the wrong context.
1668 - return (
1669 - __DEV__ &&
1670 - (executionContext & (RenderContext | CommitContext)) !== NoContext
1671 - );
1668 + // the wrong context, and for profiling warnings.
1669 + return (executionContext & (RenderContext | CommitContext)) !== NoContext;
1670 }
1671
1672 export function isInvalidExecutionContextForEventFunction(): boolean {
@@ -1797,6 +1795,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1795 clampedEventTime,
1796 blockingEventType,
1797 blockingEventIsRepeat,
1798 + blockingSpawnedUpdate,
1799 renderStartTime,
1800 lanes,
1801 );
packages/react-reconciler/src/ReactProfilerTimer.js
+12
@@ -30,6 +30,8 @@ import {
30 enableComponentPerformanceTrack,
31 } from 'shared/ReactFeatureFlags';
32
33 +import {isAlreadyRendering} from './ReactFiberWorkLoop';
34 +
35 // Intentionally not named imports because Rollup would use dynamic dispatch for
36 // CommonJS interop named imports.
37 import * as Scheduler from 'scheduler';
@@ -50,6 +52,7 @@ export let blockingUpdateTime: number = -1.1; // First sync setState scheduled.
52 export let blockingEventTime: number = -1.1; // Event timeStamp of the first setState.
53 export let blockingEventType: null | string = null; // Event type of the first setState.
54 export let blockingEventIsRepeat: boolean = false;
55 +export let blockingSpawnedUpdate: boolean = false;
56 export let blockingSuspendedTime: number = -1.1;
57 // TODO: This should really be one per Transition lane.
58 export let transitionClampTime: number = -0;
@@ -78,6 +81,9 @@ export function startUpdateTimerByLane(lane: Lane): void {
81 if (isSyncLane(lane) || isBlockingLane(lane)) {
82 if (blockingUpdateTime < 0) {
83 blockingUpdateTime = now();
84 + if (isAlreadyRendering()) {
85 + blockingSpawnedUpdate = true;
86 + }
87 const newEventTime = resolveEventTimeStamp();
88 const newEventType = resolveEventType();
89 if (
@@ -85,6 +91,11 @@ export function startUpdateTimerByLane(lane: Lane): void {
91 newEventType !== blockingEventType
92 ) {
93 blockingEventIsRepeat = false;
94 + } else if (newEventType !== null) {
95 + // If this is a second update in the same event, we treat it as a spawned update.
96 + // This might be a microtask spawned from useEffect, multiple flushSync or
97 + // a setState in a microtask spawned after the first setState. Regardless it's bad.
98 + blockingSpawnedUpdate = true;
99 }
100 blockingEventTime = newEventTime;
101 blockingEventType = newEventType;
@@ -141,6 +152,7 @@ export function clearBlockingTimers(): void {
152 blockingUpdateTime = -1.1;
153 blockingSuspendedTime = -1.1;
154 blockingEventIsRepeat = true;
155 + blockingSpawnedUpdate = false;
156 }
157
158 export function startAsyncTransitionTimer(): void {