From b4fe1e6c7eb3807afbbdad7bac4cc6bb2ad7efaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sat, 20 Sep 2025 11:10:42 -0400 Subject: [PATCH] Log the time until the Animation finishes as "Animating" (#34538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #34522. Screenshot 2025-09-19 at 6 37 28 PM We already cover the time between "Starting Animation" and "Remaining Effects" as "Animating". However, if the effects are forced then we can still be animating after that. This fills in that gap. This also fills in the gap if another render starts before the animation finishes on the same track. It'll mark the blank space between the previous render finishing and the next render starting as "Animating". This should correspond roughly to the native "Animations" track. --- .../src/client/ReactFiberConfigDOM.js | 4 + .../src/ReactFiberConfigNative.js | 1 + .../src/createReactNoop.js | 2 + .../react-reconciler/src/ReactFiberLane.js | 18 ++ .../src/ReactFiberPerformanceTrack.js | 4 +- .../src/ReactFiberWorkLoop.js | 154 ++++++++++++++++-- .../src/ReactProfilerTimer.js | 28 ++++ .../src/ReactFiberConfigTestHost.js | 1 + 8 files changed, 193 insertions(+), 19 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 5967d18a59..d7ca1951e0 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 3e6ea65db3..12b256e016 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 eeaa43627c..db69232d12 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/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js index 3248556fbe..3e4f22b854 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 | @@ -633,6 +635,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/ReactFiberPerformanceTrack.js b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js index 31d90ee6a2..c19ef704b2 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', ); } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 7ccc8ad458..7003d25aff 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, @@ -201,6 +203,9 @@ import { includesOnlyViewTransitionEligibleLanes, isGestureRender, GestureLane, + SomeTransitionLane, + SomeRetryLane, + IdleLane, } from './ReactFiberLane'; import { DiscreteEventPriority, @@ -292,6 +297,8 @@ import { clearTransitionTimers, clampBlockingTimers, clampTransitionTimers, + clampRetryTimers, + clampIdleTimers, markNestedUpdateScheduled, renderStartTime, commitStartTime, @@ -312,6 +319,11 @@ import { resetCommitErrors, PINGED_UPDATE, SPAWNED_UPDATE, + startAnimating, + stopAnimating, + animatingLanes, + retryClampTime, + idleClampTime, } from './ReactProfilerTimer'; // DEV stuff @@ -1426,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, @@ -1539,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, @@ -1889,6 +1903,12 @@ function finalizeRender(lanes: Lanes, finalizationTime: number): void { if (includesTransitionLane(lanes)) { clampTransitionTimers(finalizationTime); } + if (includesRetryLane(lanes)) { + clampRetryTimers(finalizationTime); + } + if (includesIdleGroupLanes(lanes)) { + clampIdleTimers(finalizationTime); + } } } @@ -1939,6 +1959,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { } finalizeRender(workInProgressRootRenderLanes, renderStartTime); } + const previousUpdateTask = workInProgressUpdateTask; workInProgressUpdateTask = null; if (includesSyncLane(lanes) || includesBlockingLane(lanes)) { @@ -1951,18 +1972,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( @@ -1994,19 +2027,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, @@ -2022,6 +2065,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; @@ -2038,6 +2095,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { cancelPendingCommit(); } + pendingEffectsLanes = NoLanes; + resetWorkInProgressStack(); workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null); @@ -3592,6 +3651,9 @@ function commitRoot( pendingEffectsStatus = PENDING_MUTATION_PHASE; if (enableViewTransition && willStartViewTransition) { + if (enableProfilerTimer && enableComponentPerformanceTrack) { + startAnimating(lanes); + } pendingViewTransition = startViewTransition( suspendedState, root.containerInfo, @@ -3603,6 +3665,15 @@ function commitRoot( flushPassiveEffects, reportViewTransitionError, enableProfilerTimer ? suspendedViewTransition : (null: any), + enableProfilerTimer + ? // 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 { // Flush synchronously. @@ -3634,13 +3705,62 @@ 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, + 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 { if (pendingEffectsStatus !== PENDING_AFTER_MUTATION_PHASE) { return; @@ -3715,7 +3835,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. ); } } diff --git a/packages/react-reconciler/src/ReactProfilerTimer.js b/packages/react-reconciler/src/ReactProfilerTimer.js index 62b76ee6a4..0891a4f5f5 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'; @@ -88,6 +89,11 @@ 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 animatingLanes: Lanes = NoLanes; + export let yieldReason: SuspendedReason = (0: any); export let yieldStartTime: number = -1.1; // The time when we yielded to the event loop @@ -306,6 +312,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; @@ -578,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 004e9ae365..b5bddad8e6 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();