Skip to content

Commit 1e9eb95

Browse files
authored
[Fiber] Mark cascading updates (facebook#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.
1 parent fe21c94 commit 1e9eb95

File tree

4 files changed

+47
-19
lines changed

4 files changed

+47
-19
lines changed

packages/react-reconciler/src/ReactFiberPerformanceTrack.js

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,15 @@ export function logBlockingStart(
276276
eventTime: number,
277277
eventType: null | string,
278278
eventIsRepeat: boolean,
279+
isSpawnedUpdate: boolean,
279280
renderStartTime: number,
280281
lanes: Lanes,
281282
): void {
282283
if (supportsUserTiming) {
283284
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.
284288
if (eventTime > 0 && eventType !== null) {
285289
// Log the time from the event timeStamp until we called setState.
286290
reusableLaneDevToolDetails.color = eventIsRepeat
@@ -295,14 +299,17 @@ export function logBlockingStart(
295299
}
296300
if (updateTime > 0) {
297301
// 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';
303307
reusableLaneOptions.start = updateTime;
304308
reusableLaneOptions.end = renderStartTime;
305-
performance.measure('Blocked', reusableLaneOptions);
309+
performance.measure(
310+
isSpawnedUpdate ? 'Cascade' : 'Blocked',
311+
reusableLaneOptions,
312+
);
306313
}
307314
}
308315
}

packages/react-reconciler/src/ReactFiberRootScheduler.js

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,12 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
128128
// We're inside an `act` scope.
129129
if (!didScheduleMicrotask_act) {
130130
didScheduleMicrotask_act = true;
131-
scheduleImmediateTask(processRootScheduleInMicrotask);
131+
scheduleImmediateRootScheduleTask();
132132
}
133133
} else {
134134
if (!didScheduleMicrotask) {
135135
didScheduleMicrotask = true;
136-
scheduleImmediateTask(processRootScheduleInMicrotask);
136+
scheduleImmediateRootScheduleTask();
137137
}
138138
}
139139

@@ -229,13 +229,17 @@ function flushSyncWorkAcrossRoots_impl(
229229
isFlushingWork = false;
230230
}
231231

232-
function processRootScheduleInMicrotask() {
232+
function processRootScheduleInImmediateTask() {
233233
if (enableProfilerTimer && enableComponentPerformanceTrack) {
234234
// Track the currently executing event if there is one so we can ignore this
235235
// event when logging events.
236236
trackSchedulerEvent();
237237
}
238238

239+
processRootScheduleInMicrotask();
240+
}
241+
242+
function processRootScheduleInMicrotask() {
239243
// This function is always called inside a microtask. It should never be
240244
// called synchronously.
241245
didScheduleMicrotask = false;
@@ -558,15 +562,15 @@ function cancelCallback(callbackNode: mixed) {
558562
}
559563
}
560564

561-
function scheduleImmediateTask(cb: () => mixed) {
565+
function scheduleImmediateRootScheduleTask() {
562566
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
563567
// Special case: Inside an `act` scope, we push microtasks to the fake `act`
564568
// callback queue. This is because we currently support calling `act`
565569
// without awaiting the result. The plan is to deprecate that, and require
566570
// that you always await the result so that the microtasks have a chance to
567571
// run. But it hasn't happened yet.
568572
ReactSharedInternals.actQueue.push(() => {
569-
cb();
573+
processRootScheduleInMicrotask();
570574
return null;
571575
});
572576
}
@@ -588,14 +592,20 @@ function scheduleImmediateTask(cb: () => mixed) {
588592
// wrong semantically but it prevents an infinite loop. The bug is
589593
// Safari's, not ours, so we just do our best to not crash even though
590594
// the behavior isn't completely correct.
591-
Scheduler_scheduleCallback(ImmediateSchedulerPriority, cb);
595+
Scheduler_scheduleCallback(
596+
ImmediateSchedulerPriority,
597+
processRootScheduleInImmediateTask,
598+
);
592599
return;
593600
}
594-
cb();
601+
processRootScheduleInMicrotask();
595602
});
596603
} else {
597604
// If microtasks are not supported, use Scheduler.
598-
Scheduler_scheduleCallback(ImmediateSchedulerPriority, cb);
605+
Scheduler_scheduleCallback(
606+
ImmediateSchedulerPriority,
607+
processRootScheduleInImmediateTask,
608+
);
599609
}
600610
}
601611

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ import {
236236
blockingEventTime,
237237
blockingEventType,
238238
blockingEventIsRepeat,
239+
blockingSpawnedUpdate,
239240
blockingSuspendedTime,
240241
transitionClampTime,
241242
transitionStartTime,
@@ -1664,11 +1665,8 @@ export function flushSyncWork(): boolean {
16641665

16651666
export function isAlreadyRendering(): boolean {
16661667
// 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;
16721670
}
16731671

16741672
export function isInvalidExecutionContextForEventFunction(): boolean {
@@ -1797,6 +1795,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
17971795
clampedEventTime,
17981796
blockingEventType,
17991797
blockingEventIsRepeat,
1798+
blockingSpawnedUpdate,
18001799
renderStartTime,
18011800
lanes,
18021801
);

packages/react-reconciler/src/ReactProfilerTimer.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
enableComponentPerformanceTrack,
3131
} from 'shared/ReactFeatureFlags';
3232

33+
import {isAlreadyRendering} from './ReactFiberWorkLoop';
34+
3335
// Intentionally not named imports because Rollup would use dynamic dispatch for
3436
// CommonJS interop named imports.
3537
import * as Scheduler from 'scheduler';
@@ -50,6 +52,7 @@ export let blockingUpdateTime: number = -1.1; // First sync setState scheduled.
5052
export let blockingEventTime: number = -1.1; // Event timeStamp of the first setState.
5153
export let blockingEventType: null | string = null; // Event type of the first setState.
5254
export let blockingEventIsRepeat: boolean = false;
55+
export let blockingSpawnedUpdate: boolean = false;
5356
export let blockingSuspendedTime: number = -1.1;
5457
// TODO: This should really be one per Transition lane.
5558
export let transitionClampTime: number = -0;
@@ -78,13 +81,21 @@ export function startUpdateTimerByLane(lane: Lane): void {
7881
if (isSyncLane(lane) || isBlockingLane(lane)) {
7982
if (blockingUpdateTime < 0) {
8083
blockingUpdateTime = now();
84+
if (isAlreadyRendering()) {
85+
blockingSpawnedUpdate = true;
86+
}
8187
const newEventTime = resolveEventTimeStamp();
8288
const newEventType = resolveEventType();
8389
if (
8490
newEventTime !== blockingEventTime ||
8591
newEventType !== blockingEventType
8692
) {
8793
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;
8899
}
89100
blockingEventTime = newEventTime;
90101
blockingEventType = newEventType;
@@ -141,6 +152,7 @@ export function clearBlockingTimers(): void {
141152
blockingUpdateTime = -1.1;
142153
blockingSuspendedTime = -1.1;
143154
blockingEventIsRepeat = true;
155+
blockingSpawnedUpdate = false;
144156
}
145157

146158
export function startAsyncTransitionTimer(): void {

0 commit comments

Comments
 (0)