[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.
This commit is contained in:
Sebastian Markbåge
2025-01-02 13:04:09 -05:00
committed by GitHub
parent fe21c947c8
commit 1e9eb95db5
4 changed files with 47 additions and 19 deletions
+13 -6
View File
@@ -276,11 +276,15 @@ export function logBlockingStart(
eventTime: number,
eventType: null | string,
eventIsRepeat: boolean,
isSpawnedUpdate: boolean,
renderStartTime: number,
lanes: Lanes,
): void {
if (supportsUserTiming) {
reusableLaneDevToolDetails.track = 'Blocking';
// If a blocking update was spawned within render or an effect, that's considered a cascading render.
// If you have a second blocking update within the same event, that suggests multiple flushSync or
// setState in a microtask which is also considered a cascade.
if (eventTime > 0 && eventType !== null) {
// Log the time from the event timeStamp until we called setState.
reusableLaneDevToolDetails.color = eventIsRepeat
@@ -295,14 +299,17 @@ export function logBlockingStart(
}
if (updateTime > 0) {
// Log the time from when we called setState until we started rendering.
reusableLaneDevToolDetails.color = includesOnlyHydrationOrOffscreenLanes(
lanes,
)
? 'tertiary-light'
: 'primary-light';
reusableLaneDevToolDetails.color = isSpawnedUpdate
? 'error'
: includesOnlyHydrationOrOffscreenLanes(lanes)
? 'tertiary-light'
: 'primary-light';
reusableLaneOptions.start = updateTime;
reusableLaneOptions.end = renderStartTime;
performance.measure('Blocked', reusableLaneOptions);
performance.measure(
isSpawnedUpdate ? 'Cascade' : 'Blocked',
reusableLaneOptions,
);
}
}
}
+18 -8
View File
@@ -128,12 +128,12 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
// We're inside an `act` scope.
if (!didScheduleMicrotask_act) {
didScheduleMicrotask_act = true;
scheduleImmediateTask(processRootScheduleInMicrotask);
scheduleImmediateRootScheduleTask();
}
} else {
if (!didScheduleMicrotask) {
didScheduleMicrotask = true;
scheduleImmediateTask(processRootScheduleInMicrotask);
scheduleImmediateRootScheduleTask();
}
}
@@ -229,13 +229,17 @@ function flushSyncWorkAcrossRoots_impl(
isFlushingWork = false;
}
function processRootScheduleInMicrotask() {
function processRootScheduleInImmediateTask() {
if (enableProfilerTimer && enableComponentPerformanceTrack) {
// Track the currently executing event if there is one so we can ignore this
// event when logging events.
trackSchedulerEvent();
}
processRootScheduleInMicrotask();
}
function processRootScheduleInMicrotask() {
// This function is always called inside a microtask. It should never be
// called synchronously.
didScheduleMicrotask = false;
@@ -558,7 +562,7 @@ function cancelCallback(callbackNode: mixed) {
}
}
function scheduleImmediateTask(cb: () => mixed) {
function scheduleImmediateRootScheduleTask() {
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
// Special case: Inside an `act` scope, we push microtasks to the fake `act`
// callback queue. This is because we currently support calling `act`
@@ -566,7 +570,7 @@ function scheduleImmediateTask(cb: () => mixed) {
// that you always await the result so that the microtasks have a chance to
// run. But it hasn't happened yet.
ReactSharedInternals.actQueue.push(() => {
cb();
processRootScheduleInMicrotask();
return null;
});
}
@@ -588,14 +592,20 @@ function scheduleImmediateTask(cb: () => mixed) {
// wrong semantically but it prevents an infinite loop. The bug is
// Safari's, not ours, so we just do our best to not crash even though
// the behavior isn't completely correct.
Scheduler_scheduleCallback(ImmediateSchedulerPriority, cb);
Scheduler_scheduleCallback(
ImmediateSchedulerPriority,
processRootScheduleInImmediateTask,
);
return;
}
cb();
processRootScheduleInMicrotask();
});
} else {
// If microtasks are not supported, use Scheduler.
Scheduler_scheduleCallback(ImmediateSchedulerPriority, cb);
Scheduler_scheduleCallback(
ImmediateSchedulerPriority,
processRootScheduleInImmediateTask,
);
}
}
+4 -5
View File
@@ -236,6 +236,7 @@ import {
blockingEventTime,
blockingEventType,
blockingEventIsRepeat,
blockingSpawnedUpdate,
blockingSuspendedTime,
transitionClampTime,
transitionStartTime,
@@ -1664,11 +1665,8 @@ export function flushSyncWork(): boolean {
export function isAlreadyRendering(): boolean {
// Used by the renderer to print a warning if certain APIs are called from
// the wrong context.
return (
__DEV__ &&
(executionContext & (RenderContext | CommitContext)) !== NoContext
);
// the wrong context, and for profiling warnings.
return (executionContext & (RenderContext | CommitContext)) !== NoContext;
}
export function isInvalidExecutionContextForEventFunction(): boolean {
@@ -1797,6 +1795,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
clampedEventTime,
blockingEventType,
blockingEventIsRepeat,
blockingSpawnedUpdate,
renderStartTime,
lanes,
);
+12
View File
@@ -30,6 +30,8 @@ import {
enableComponentPerformanceTrack,
} from 'shared/ReactFeatureFlags';
import {isAlreadyRendering} from './ReactFiberWorkLoop';
// Intentionally not named imports because Rollup would use dynamic dispatch for
// CommonJS interop named imports.
import * as Scheduler from 'scheduler';
@@ -50,6 +52,7 @@ export let blockingUpdateTime: number = -1.1; // First sync setState scheduled.
export let blockingEventTime: number = -1.1; // Event timeStamp of the first setState.
export let blockingEventType: null | string = null; // Event type of the first setState.
export let blockingEventIsRepeat: boolean = false;
export let blockingSpawnedUpdate: boolean = false;
export let blockingSuspendedTime: number = -1.1;
// TODO: This should really be one per Transition lane.
export let transitionClampTime: number = -0;
@@ -78,6 +81,9 @@ export function startUpdateTimerByLane(lane: Lane): void {
if (isSyncLane(lane) || isBlockingLane(lane)) {
if (blockingUpdateTime < 0) {
blockingUpdateTime = now();
if (isAlreadyRendering()) {
blockingSpawnedUpdate = true;
}
const newEventTime = resolveEventTimeStamp();
const newEventType = resolveEventType();
if (
@@ -85,6 +91,11 @@ export function startUpdateTimerByLane(lane: Lane): void {
newEventType !== blockingEventType
) {
blockingEventIsRepeat = false;
} else if (newEventType !== null) {
// If this is a second update in the same event, we treat it as a spawned update.
// This might be a microtask spawned from useEffect, multiple flushSync or
// a setState in a microtask spawned after the first setState. Regardless it's bad.
blockingSpawnedUpdate = true;
}
blockingEventTime = newEventTime;
blockingEventType = newEventType;
@@ -141,6 +152,7 @@ export function clearBlockingTimers(): void {
blockingUpdateTime = -1.1;
blockingSuspendedTime = -1.1;
blockingEventIsRepeat = true;
blockingSpawnedUpdate = false;
}
export function startAsyncTransitionTimer(): void {