From 4e2367eb60b26f88819441ebc611c80590f48e1f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 16 Sep 2025 22:26:00 -0400 Subject: [PATCH 1/6] Add clamping for idle and retry groups so we can know when the previous render finished --- packages/react-reconciler/src/ReactFiberLane.js | 16 ++++++++++++++++ .../react-reconciler/src/ReactFiberWorkLoop.js | 10 ++++++++++ .../react-reconciler/src/ReactProfilerTimer.js | 17 +++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js index 3248556fbe0..1351fb6717d 100644 --- a/packages/react-reconciler/src/ReactFiberLane.js +++ b/packages/react-reconciler/src/ReactFiberLane.js @@ -633,6 +633,22 @@ export function includesTransitionLane(lanes: Lanes): boolean { return (lanes & TransitionLanes) !== NoLanes; } +export function includesRetryLane(lanes: Lanes): boolean { + return (lanes & RetryLanes) !== NoLanes; +} + +export function includesIdleGroupLanes(lanes: Lanes): boolean { + return ( + (lanes & + (SelectiveHydrationLane | + IdleHydrationLane | + IdleLane | + OffscreenLane | + DeferredLane)) !== + NoLanes + ); +} + export function includesOnlyHydrationLanes(lanes: Lanes): boolean { return (lanes & HydrationLanes) === lanes; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 7ccc8ad4580..95fdbcf0f5a 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -179,6 +179,8 @@ import { includesOnlyTransitions, includesBlockingLane, includesTransitionLane, + includesRetryLane, + includesIdleGroupLanes, includesExpiredLane, getNextLanes, getEntangledLanes, @@ -292,6 +294,8 @@ import { clearTransitionTimers, clampBlockingTimers, clampTransitionTimers, + clampRetryTimers, + clampIdleTimers, markNestedUpdateScheduled, renderStartTime, commitStartTime, @@ -1889,6 +1893,12 @@ function finalizeRender(lanes: Lanes, finalizationTime: number): void { if (includesTransitionLane(lanes)) { clampTransitionTimers(finalizationTime); } + if (includesRetryLane(lanes)) { + clampRetryTimers(finalizationTime); + } + if (includesIdleGroupLanes(lanes)) { + clampIdleTimers(finalizationTime); + } } } diff --git a/packages/react-reconciler/src/ReactProfilerTimer.js b/packages/react-reconciler/src/ReactProfilerTimer.js index 62b76ee6a40..d62fbcb6ca5 100644 --- a/packages/react-reconciler/src/ReactProfilerTimer.js +++ b/packages/react-reconciler/src/ReactProfilerTimer.js @@ -88,6 +88,9 @@ export let transitionEventType: null | string = null; // Event type of the first export let transitionEventIsRepeat: boolean = false; export let transitionSuspendedTime: number = -1.1; +export let retryClampTime: number = -0; +export let idleClampTime: number = -0; + export let yieldReason: SuspendedReason = (0: any); export let yieldStartTime: number = -1.1; // The time when we yielded to the event loop @@ -306,6 +309,20 @@ export function clampTransitionTimers(finalTime: number): void { transitionClampTime = finalTime; } +export function clampRetryTimers(finalTime: number): void { + if (!enableProfilerTimer || !enableComponentPerformanceTrack) { + return; + } + retryClampTime = finalTime; +} + +export function clampIdleTimers(finalTime: number): void { + if (!enableProfilerTimer || !enableComponentPerformanceTrack) { + return; + } + idleClampTime = finalTime; +} + export function pushNestedEffectDurations(): number { if (!enableProfilerTimer || !enableProfilerCommitHooks) { return 0; From e5eac77dfb8e17963098eba61dfa356024e75fb6 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 19 Sep 2025 18:03:50 -0400 Subject: [PATCH 2/6] Track which lanes currently have animations running on them Stops when the animation finishes which is not the same as when the passive effects commit. It can be after. --- .../src/client/ReactFiberConfigDOM.js | 4 ++++ .../src/ReactFiberConfigNative.js | 1 + packages/react-noop-renderer/src/createReactNoop.js | 2 ++ packages/react-reconciler/src/ReactFiberWorkLoop.js | 10 ++++++++++ packages/react-reconciler/src/ReactProfilerTimer.js | 11 +++++++++++ .../src/ReactFiberConfigTestHost.js | 1 + 6 files changed, 29 insertions(+) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 5967d18a59f..d7ca1951e00 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2100,6 +2100,7 @@ export function startViewTransition( passiveCallback: () => mixed, errorCallback: mixed => void, blockedCallback: string => void, // Profiling-only + finishedAnimation: () => void, // Profiling-only ): null | RunningViewTransition { const ownerDocument: Document = rootContainer.nodeType === DOCUMENT_NODE @@ -2302,6 +2303,9 @@ export function startViewTransition( // $FlowFixMe[prop-missing] ownerDocument.__reactViewTransition = null; } + if (enableProfilerTimer) { + finishedAnimation(); + } passiveCallback(); }); return transition; diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js index 3e6ea65db31..12b256e016f 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigNative.js +++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js @@ -674,6 +674,7 @@ export function startViewTransition( passiveCallback: () => mixed, errorCallback: mixed => void, blockedCallback: string => void, // Profiling-only + finishedAnimation: () => void, // Profiling-only ): null | RunningViewTransition { mutationCallback(); layoutCallback(); diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index eeaa43627ca..db69232d129 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -860,6 +860,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { spawnedWorkCallback: () => void, passiveCallback: () => mixed, errorCallback: mixed => void, + blockedCallback: string => void, // Profiling-only + finishedAnimation: () => void, // Profiling-only ): null | RunningViewTransition { mutationCallback(); layoutCallback(); diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 95fdbcf0f5a..c21b6b5c561 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -316,6 +316,8 @@ import { resetCommitErrors, PINGED_UPDATE, SPAWNED_UPDATE, + startAnimating, + stopAnimating, } from './ReactProfilerTimer'; // DEV stuff @@ -3602,6 +3604,7 @@ function commitRoot( pendingEffectsStatus = PENDING_MUTATION_PHASE; if (enableViewTransition && willStartViewTransition) { + startAnimating(lanes); pendingViewTransition = startViewTransition( suspendedState, root.containerInfo, @@ -3613,6 +3616,9 @@ function commitRoot( flushPassiveEffects, reportViewTransitionError, enableProfilerTimer ? suspendedViewTransition : (null: any), + enableProfilerTimer + ? finishedViewTransition.bind(null, lanes) + : (null: any), ); } else { // Flush synchronously. @@ -3651,6 +3657,10 @@ function suspendedViewTransition(reason: string): void { } } +function finishedViewTransition(lanes: Lanes): void { + stopAnimating(lanes); +} + function flushAfterMutationEffects(): void { if (pendingEffectsStatus !== PENDING_AFTER_MUTATION_PHASE) { return; diff --git a/packages/react-reconciler/src/ReactProfilerTimer.js b/packages/react-reconciler/src/ReactProfilerTimer.js index d62fbcb6ca5..0891a4f5f5e 100644 --- a/packages/react-reconciler/src/ReactProfilerTimer.js +++ b/packages/react-reconciler/src/ReactProfilerTimer.js @@ -22,6 +22,7 @@ import { includesTransitionLane, includesBlockingLane, includesSyncLane, + NoLanes, } from './ReactFiberLane'; import {resolveEventType, resolveEventTimeStamp} from './ReactFiberConfig'; @@ -91,6 +92,8 @@ export let transitionSuspendedTime: number = -1.1; export let retryClampTime: number = -0; export let idleClampTime: number = -0; +export let animatingLanes: Lanes = NoLanes; + export let yieldReason: SuspendedReason = (0: any); export let yieldStartTime: number = -1.1; // The time when we yielded to the event loop @@ -595,3 +598,11 @@ export function transferActualDuration(fiber: Fiber): void { child = child.sibling; } } + +export function startAnimating(lanes: Lanes): void { + animatingLanes |= lanes; +} + +export function stopAnimating(lanes: Lanes): void { + animatingLanes &= ~lanes; +} diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js index 004e9ae3657..b5bddad8e61 100644 --- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js +++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js @@ -424,6 +424,7 @@ export function startViewTransition( passiveCallback: () => mixed, errorCallback: mixed => void, blockedCallback: string => void, // Profiling-only + finishedAnimation: () => void, // Profiling-only ): null | RunningViewTransition { mutationCallback(); layoutCallback(); From 56ddc3913cd6bcb9e0c149d5f82c1f71deb4e6f7 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 19 Sep 2025 18:31:37 -0400 Subject: [PATCH 3/6] Log "Animating" track from the end of the previous render to the end of the animation This is outside of regular rendering. If we're rendering then that takes precedence. --- .../react-reconciler/src/ReactFiberLane.js | 2 + .../src/ReactFiberWorkLoop.js | 71 +++++++++++++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js index 1351fb6717d..3e4f22b8541 100644 --- a/packages/react-reconciler/src/ReactFiberLane.js +++ b/packages/react-reconciler/src/ReactFiberLane.js @@ -73,6 +73,8 @@ const TransitionLane12: Lane = /* */ 0b0000000000010000000 const TransitionLane13: Lane = /* */ 0b0000000000100000000000000000000; const TransitionLane14: Lane = /* */ 0b0000000001000000000000000000000; +export const SomeTransitionLane: Lane = TransitionLane1; + const TransitionUpdateLanes = TransitionLane1 | TransitionLane2 | diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index c21b6b5c561..76a4bbc2e1f 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -203,6 +203,9 @@ import { includesOnlyViewTransitionEligibleLanes, isGestureRender, GestureLane, + SomeTransitionLane, + SomeRetryLane, + IdleLane, } from './ReactFiberLane'; import { DiscreteEventPriority, @@ -318,6 +321,9 @@ import { SPAWNED_UPDATE, startAnimating, stopAnimating, + animatingLanes, + retryClampTime, + idleClampTime, } from './ReactProfilerTimer'; // DEV stuff @@ -3604,7 +3610,9 @@ function commitRoot( pendingEffectsStatus = PENDING_MUTATION_PHASE; if (enableViewTransition && willStartViewTransition) { - startAnimating(lanes); + if (enableProfilerTimer && enableComponentPerformanceTrack) { + startAnimating(lanes); + } pendingViewTransition = startViewTransition( suspendedState, root.containerInfo, @@ -3617,7 +3625,13 @@ function commitRoot( reportViewTransitionError, enableProfilerTimer ? suspendedViewTransition : (null: any), enableProfilerTimer - ? finishedViewTransition.bind(null, lanes) + ? // This callback fires after "pendingEffects" so we need to snapshot the arguments. + finishedViewTransition.bind( + null, + lanes, + // TODO: Use a ViewTransition Task + __DEV__ ? workInProgressUpdateTask : null, + ) : (null: any), ); } else { @@ -3650,15 +3664,60 @@ function suspendedViewTransition(reason: string): void { commitEndTime, commitErrors, pendingDelayedCommitReason === ABORTED_VIEW_TRANSITION_COMMIT, - workInProgressUpdateTask, + workInProgressUpdateTask, // TODO: Use a ViewTransition Task and this is not safe to read in this phase. ); pendingSuspendedViewTransitionReason = reason; pendingSuspendedCommitReason = reason; } } -function finishedViewTransition(lanes: Lanes): void { - stopAnimating(lanes); +function finishedViewTransition( + lanes: Lanes, + task: null | ConsoleTask, // DEV-only +): void { + if (enableProfilerTimer && enableComponentPerformanceTrack) { + if ((animatingLanes & lanes) === NoLanes) { + // Was already stopped by some other action or maybe other root. + return; + } + stopAnimating(lanes); + // If an affected track isn't in the middle of rendering or committing, log from the previous + // finished render until the end of the animation. + if ( + (includesSyncLane(lanes) || includesBlockingLane(lanes)) && + !includesSyncLane(workInProgressRootRenderLanes) && + !includesBlockingLane(workInProgressRootRenderLanes) && + !includesSyncLane(pendingEffectsLanes) && + !includesBlockingLane(pendingEffectsLanes) + ) { + setCurrentTrackFromLanes(SyncLane); + logAnimatingPhase(blockingClampTime, now(), task); + } + if ( + includesTransitionLane(lanes) && + !includesTransitionLane(workInProgressRootRenderLanes) && + !includesTransitionLane(pendingEffectsLanes) + ) { + setCurrentTrackFromLanes(SomeTransitionLane); + logAnimatingPhase(transitionClampTime, now(), task); + } + if ( + includesRetryLane(lanes) && + !includesRetryLane(workInProgressRootRenderLanes) && + !includesRetryLane(pendingEffectsLanes) + ) { + setCurrentTrackFromLanes(SomeRetryLane); + logAnimatingPhase(retryClampTime, now(), task); + } + if ( + includesIdleGroupLanes(lanes) && + !includesIdleGroupLanes(workInProgressRootRenderLanes) && + !includesIdleGroupLanes(pendingEffectsLanes) + ) { + setCurrentTrackFromLanes(IdleLane); + logAnimatingPhase(idleClampTime, now(), task); + } + } } function flushAfterMutationEffects(): void { @@ -3735,7 +3794,7 @@ function flushLayoutEffects(): void { commitEndTime, // The start is the end of the first commit part. commitStartTime, // The end is the start of the second commit part. suspendedViewTransitionReason, - workInProgressUpdateTask, + workInProgressUpdateTask, // TODO: Use a ViewTransition Task and this is not safe to read in this phase. ); } } From 06588e2972ad0e544f15e5a3e85c4a9915077370 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 19 Sep 2025 18:35:43 -0400 Subject: [PATCH 4/6] Use seondary-dark color since this is the same color the Animation track uses in Chrome --- packages/react-reconciler/src/ReactFiberPerformanceTrack.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js index 31d90ee6a21..c19ef704b20 100644 --- a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js +++ b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js @@ -1458,7 +1458,7 @@ export function logAnimatingPhase( endTime, currentTrack, LANES_TRACK_GROUP, - 'secondary', + 'secondary-dark', ), ); } else { @@ -1468,7 +1468,7 @@ export function logAnimatingPhase( endTime, currentTrack, LANES_TRACK_GROUP, - 'secondary', + 'secondary-dark', ); } } From 600760f2764b56afede93e595792f874d311895f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 19 Sep 2025 18:59:35 -0400 Subject: [PATCH 5/6] Log the time from the previous commit to the next render start as animating If we start a new render on the same track before the previous one finishes we'll start logging new renders on that track but we should first log the time between the two renders as animating. --- .../src/ReactFiberWorkLoop.js | 67 ++++++++++++++----- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 76a4bbc2e1f..5bf20eef48b 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -1957,6 +1957,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { } finalizeRender(workInProgressRootRenderLanes, renderStartTime); } + const previousUpdateTask = workInProgressUpdateTask; workInProgressUpdateTask = null; if (includesSyncLane(lanes) || includesBlockingLane(lanes)) { @@ -1969,18 +1970,30 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { blockingEventTime >= 0 && blockingEventTime < blockingClampTime ? blockingClampTime : blockingEventTime; + const clampedRenderStartTime = // Clamp the suspended time to the first event/update. + clampedEventTime >= 0 + ? clampedEventTime + : clampedUpdateTime >= 0 + ? clampedUpdateTime + : renderStartTime; if (blockingSuspendedTime >= 0) { - setCurrentTrackFromLanes(lanes); + setCurrentTrackFromLanes(SyncLane); logSuspendedWithDelayPhase( blockingSuspendedTime, - // Clamp the suspended time to the first event/update. - clampedEventTime >= 0 - ? clampedEventTime - : clampedUpdateTime >= 0 - ? clampedUpdateTime - : renderStartTime, + clampedRenderStartTime, lanes, - workInProgressUpdateTask, + previousUpdateTask, + ); + } else if ( + includesSyncLane(animatingLanes) || + includesBlockingLane(animatingLanes) + ) { + // If this lane is still animating, log the time from previous render finishing to now as animating. + setCurrentTrackFromLanes(SyncLane); + logAnimatingPhase( + blockingClampTime, + clampedRenderStartTime, + previousUpdateTask, ); } logBlockingStart( @@ -2012,19 +2025,29 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { transitionEventTime >= 0 && transitionEventTime < transitionClampTime ? transitionClampTime : transitionEventTime; + const clampedRenderStartTime = + // Clamp the suspended time to the first event/update. + clampedEventTime >= 0 + ? clampedEventTime + : clampedUpdateTime >= 0 + ? clampedUpdateTime + : renderStartTime; if (transitionSuspendedTime >= 0) { - setCurrentTrackFromLanes(lanes); + setCurrentTrackFromLanes(SomeTransitionLane); logSuspendedWithDelayPhase( transitionSuspendedTime, - // Clamp the suspended time to the first event/update. - clampedEventTime >= 0 - ? clampedEventTime - : clampedUpdateTime >= 0 - ? clampedUpdateTime - : renderStartTime, + clampedRenderStartTime, lanes, workInProgressUpdateTask, ); + } else if (includesTransitionLane(animatingLanes)) { + // If this lane is still animating, log the time from previous render finishing to now as animating. + setCurrentTrackFromLanes(SomeTransitionLane); + logAnimatingPhase( + transitionClampTime, + clampedRenderStartTime, + previousUpdateTask, + ); } logTransitionStart( clampedStartTime, @@ -2040,6 +2063,20 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { ); clearTransitionTimers(); } + if (includesRetryLane(lanes)) { + if (includesRetryLane(animatingLanes)) { + // If this lane is still animating, log the time from previous render finishing to now as animating. + setCurrentTrackFromLanes(SomeRetryLane); + logAnimatingPhase(retryClampTime, renderStartTime, previousUpdateTask); + } + } + if (includesIdleGroupLanes(lanes)) { + if (includesIdleGroupLanes(animatingLanes)) { + // If this lane is still animating, log the time from previous render finishing to now as animating. + setCurrentTrackFromLanes(IdleLane); + logAnimatingPhase(idleClampTime, renderStartTime, previousUpdateTask); + } + } } const timeoutHandle = root.timeoutHandle; From 537674fd8d764e3d5eaa953dcf6fa64e77f3ccdc Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 19 Sep 2025 19:07:53 -0400 Subject: [PATCH 6/6] Stash pendingEffectsLanes earlier In the gap when a commit is delayed, we don't know which lanes are currently pending. We can use the same flag as the ones we use when we're in the middle of commit effects by setting it earlier. This ensures that we don't write an "Animating" track during this period since it'll get some other suspended/delay track logged (for example "waiting on previous animation". --- packages/react-reconciler/src/ReactFiberWorkLoop.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 5bf20eef48b..7003d25aff6 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -1438,6 +1438,7 @@ function finishConcurrentRender( // immediately, wait for more data to arrive. // TODO: Combine retry throttling with Suspensey commits. Right now they // run one after the other. + pendingEffectsLanes = lanes; root.timeoutHandle = scheduleTimeout( commitRootWhenReady.bind( null, @@ -1551,6 +1552,7 @@ function commitRootWhenReady( // Not yet ready to commit. Delay the commit until the renderer notifies // us that it's ready. This will be canceled if we start work on the // root again. + pendingEffectsLanes = lanes; root.cancelPendingCommit = schedulePendingCommit( commitRoot.bind( null, @@ -2093,6 +2095,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { cancelPendingCommit(); } + pendingEffectsLanes = NoLanes; + resetWorkInProgressStack(); workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null);