@samitouri / QOS-React / commits / b4fe1e6c7e

Log the time until the Animation finishes as "Animating" (#34538)

Stacked on #34522. <img width="1025" height="200" alt="Screenshot 2025-09-19 at 6 37 28 PM" src="https://github.com/user-attachments/assets/f25900f6-6503-48b1-876d-bd6697a29c6f" /> 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.

Sebastian Markbåge committed Sep 20, 2025 at 11:10 UTC b4fe1e6c7eb3807afbbdad7bac4cc6bb2ad7efaf
8 files changed +193 -19
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+4
@@ -2100,6 +2100,7 @@ export function startViewTransition(
2100 passiveCallback: () => mixed,
2101 errorCallback: mixed => void,
2102 blockedCallback: string => void, // Profiling-only
2103 + finishedAnimation: () => void, // Profiling-only
2104 ): null | RunningViewTransition {
2105 const ownerDocument: Document =
2106 rootContainer.nodeType === DOCUMENT_NODE
@@ -2302,6 +2303,9 @@ export function startViewTransition(
2303 // $FlowFixMe[prop-missing]
2304 ownerDocument.__reactViewTransition = null;
2305 }
2306 + if (enableProfilerTimer) {
2307 + finishedAnimation();
2308 + }
2309 passiveCallback();
2310 });
2311 return transition;
packages/react-native-renderer/src/ReactFiberConfigNative.js
+1
@@ -674,6 +674,7 @@ export function startViewTransition(
674 passiveCallback: () => mixed,
675 errorCallback: mixed => void,
676 blockedCallback: string => void, // Profiling-only
677 + finishedAnimation: () => void, // Profiling-only
678 ): null | RunningViewTransition {
679 mutationCallback();
680 layoutCallback();
packages/react-noop-renderer/src/createReactNoop.js
+2
@@ -860,6 +860,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
860 spawnedWorkCallback: () => void,
861 passiveCallback: () => mixed,
862 errorCallback: mixed => void,
863 + blockedCallback: string => void, // Profiling-only
864 + finishedAnimation: () => void, // Profiling-only
865 ): null | RunningViewTransition {
866 mutationCallback();
867 layoutCallback();
packages/react-reconciler/src/ReactFiberLane.js
+18
@@ -73,6 +73,8 @@ const TransitionLane12: Lane = /* */ 0b0000000000010000000
73 const TransitionLane13: Lane = /* */ 0b0000000000100000000000000000000;
74 const TransitionLane14: Lane = /* */ 0b0000000001000000000000000000000;
75
76 +export const SomeTransitionLane: Lane = TransitionLane1;
77 +
78 const TransitionUpdateLanes =
79 TransitionLane1 |
80 TransitionLane2 |
@@ -633,6 +635,22 @@ export function includesTransitionLane(lanes: Lanes): boolean {
635 return (lanes & TransitionLanes) !== NoLanes;
636 }
637
638 +export function includesRetryLane(lanes: Lanes): boolean {
639 + return (lanes & RetryLanes) !== NoLanes;
640 +}
641 +
642 +export function includesIdleGroupLanes(lanes: Lanes): boolean {
643 + return (
644 + (lanes &
645 + (SelectiveHydrationLane |
646 + IdleHydrationLane |
647 + IdleLane |
648 + OffscreenLane |
649 + DeferredLane)) !==
650 + NoLanes
651 + );
652 +}
653 +
654 export function includesOnlyHydrationLanes(lanes: Lanes): boolean {
655 return (lanes & HydrationLanes) === lanes;
656 }
packages/react-reconciler/src/ReactFiberPerformanceTrack.js
+2 -2
@@ -1458,7 +1458,7 @@ export function logAnimatingPhase(
1458 endTime,
1459 currentTrack,
1460 LANES_TRACK_GROUP,
1461 - 'secondary',
1461 + 'secondary-dark',
1462 ),
1463 );
1464 } else {
@@ -1468,7 +1468,7 @@ export function logAnimatingPhase(
1468 endTime,
1469 currentTrack,
1470 LANES_TRACK_GROUP,
1471 - 'secondary',
1471 + 'secondary-dark',
1472 );
1473 }
1474 }
packages/react-reconciler/src/ReactFiberWorkLoop.js
+137 -17
@@ -179,6 +179,8 @@ import {
179 includesOnlyTransitions,
180 includesBlockingLane,
181 includesTransitionLane,
182 + includesRetryLane,
183 + includesIdleGroupLanes,
184 includesExpiredLane,
185 getNextLanes,
186 getEntangledLanes,
@@ -201,6 +203,9 @@ import {
203 includesOnlyViewTransitionEligibleLanes,
204 isGestureRender,
205 GestureLane,
206 + SomeTransitionLane,
207 + SomeRetryLane,
208 + IdleLane,
209 } from './ReactFiberLane';
210 import {
211 DiscreteEventPriority,
@@ -292,6 +297,8 @@ import {
297 clearTransitionTimers,
298 clampBlockingTimers,
299 clampTransitionTimers,
300 + clampRetryTimers,
301 + clampIdleTimers,
302 markNestedUpdateScheduled,
303 renderStartTime,
304 commitStartTime,
@@ -312,6 +319,11 @@ import {
319 resetCommitErrors,
320 PINGED_UPDATE,
321 SPAWNED_UPDATE,
322 + startAnimating,
323 + stopAnimating,
324 + animatingLanes,
325 + retryClampTime,
326 + idleClampTime,
327 } from './ReactProfilerTimer';
328
329 // DEV stuff
@@ -1426,6 +1438,7 @@ function finishConcurrentRender(
1438 // immediately, wait for more data to arrive.
1439 // TODO: Combine retry throttling with Suspensey commits. Right now they
1440 // run one after the other.
1441 + pendingEffectsLanes = lanes;
1442 root.timeoutHandle = scheduleTimeout(
1443 commitRootWhenReady.bind(
1444 null,
@@ -1539,6 +1552,7 @@ function commitRootWhenReady(
1552 // Not yet ready to commit. Delay the commit until the renderer notifies
1553 // us that it's ready. This will be canceled if we start work on the
1554 // root again.
1555 + pendingEffectsLanes = lanes;
1556 root.cancelPendingCommit = schedulePendingCommit(
1557 commitRoot.bind(
1558 null,
@@ -1889,6 +1903,12 @@ function finalizeRender(lanes: Lanes, finalizationTime: number): void {
1903 if (includesTransitionLane(lanes)) {
1904 clampTransitionTimers(finalizationTime);
1905 }
1906 + if (includesRetryLane(lanes)) {
1907 + clampRetryTimers(finalizationTime);
1908 + }
1909 + if (includesIdleGroupLanes(lanes)) {
1910 + clampIdleTimers(finalizationTime);
1911 + }
1912 }
1913 }
1914
@@ -1939,6 +1959,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1959 }
1960 finalizeRender(workInProgressRootRenderLanes, renderStartTime);
1961 }
1962 + const previousUpdateTask = workInProgressUpdateTask;
1963
1964 workInProgressUpdateTask = null;
1965 if (includesSyncLane(lanes) || includesBlockingLane(lanes)) {
@@ -1951,18 +1972,30 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1972 blockingEventTime >= 0 && blockingEventTime < blockingClampTime
1973 ? blockingClampTime
1974 : blockingEventTime;
1975 + const clampedRenderStartTime = // Clamp the suspended time to the first event/update.
1976 + clampedEventTime >= 0
1977 + ? clampedEventTime
1978 + : clampedUpdateTime >= 0
1979 + ? clampedUpdateTime
1980 + : renderStartTime;
1981 if (blockingSuspendedTime >= 0) {
1955 - setCurrentTrackFromLanes(lanes);
1982 + setCurrentTrackFromLanes(SyncLane);
1983 logSuspendedWithDelayPhase(
1984 blockingSuspendedTime,
1958 - // Clamp the suspended time to the first event/update.
1959 - clampedEventTime >= 0
1960 - ? clampedEventTime
1961 - : clampedUpdateTime >= 0
1962 - ? clampedUpdateTime
1963 - : renderStartTime,
1985 + clampedRenderStartTime,
1986 lanes,
1965 - workInProgressUpdateTask,
1987 + previousUpdateTask,
1988 + );
1989 + } else if (
1990 + includesSyncLane(animatingLanes) ||
1991 + includesBlockingLane(animatingLanes)
1992 + ) {
1993 + // If this lane is still animating, log the time from previous render finishing to now as animating.
1994 + setCurrentTrackFromLanes(SyncLane);
1995 + logAnimatingPhase(
1996 + blockingClampTime,
1997 + clampedRenderStartTime,
1998 + previousUpdateTask,
1999 );
2000 }
2001 logBlockingStart(
@@ -1994,19 +2027,29 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
2027 transitionEventTime >= 0 && transitionEventTime < transitionClampTime
2028 ? transitionClampTime
2029 : transitionEventTime;
2030 + const clampedRenderStartTime =
2031 + // Clamp the suspended time to the first event/update.
2032 + clampedEventTime >= 0
2033 + ? clampedEventTime
2034 + : clampedUpdateTime >= 0
2035 + ? clampedUpdateTime
2036 + : renderStartTime;
2037 if (transitionSuspendedTime >= 0) {
1998 - setCurrentTrackFromLanes(lanes);
2038 + setCurrentTrackFromLanes(SomeTransitionLane);
2039 logSuspendedWithDelayPhase(
2040 transitionSuspendedTime,
2001 - // Clamp the suspended time to the first event/update.
2002 - clampedEventTime >= 0
2003 - ? clampedEventTime
2004 - : clampedUpdateTime >= 0
2005 - ? clampedUpdateTime
2006 - : renderStartTime,
2041 + clampedRenderStartTime,
2042 lanes,
2043 workInProgressUpdateTask,
2044 );
2045 + } else if (includesTransitionLane(animatingLanes)) {
2046 + // If this lane is still animating, log the time from previous render finishing to now as animating.
2047 + setCurrentTrackFromLanes(SomeTransitionLane);
2048 + logAnimatingPhase(
2049 + transitionClampTime,
2050 + clampedRenderStartTime,
2051 + previousUpdateTask,
2052 + );
2053 }
2054 logTransitionStart(
2055 clampedStartTime,
@@ -2022,6 +2065,20 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
2065 );
2066 clearTransitionTimers();
2067 }
2068 + if (includesRetryLane(lanes)) {
2069 + if (includesRetryLane(animatingLanes)) {
2070 + // If this lane is still animating, log the time from previous render finishing to now as animating.
2071 + setCurrentTrackFromLanes(SomeRetryLane);
2072 + logAnimatingPhase(retryClampTime, renderStartTime, previousUpdateTask);
2073 + }
2074 + }
2075 + if (includesIdleGroupLanes(lanes)) {
2076 + if (includesIdleGroupLanes(animatingLanes)) {
2077 + // If this lane is still animating, log the time from previous render finishing to now as animating.
2078 + setCurrentTrackFromLanes(IdleLane);
2079 + logAnimatingPhase(idleClampTime, renderStartTime, previousUpdateTask);
2080 + }
2081 + }
2082 }
2083
2084 const timeoutHandle = root.timeoutHandle;
@@ -2038,6 +2095,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
2095 cancelPendingCommit();
2096 }
2097
2098 + pendingEffectsLanes = NoLanes;
2099 +
2100 resetWorkInProgressStack();
2101 workInProgressRoot = root;
2102 const rootWorkInProgress = createWorkInProgress(root.current, null);
@@ -3592,6 +3651,9 @@ function commitRoot(
3651
3652 pendingEffectsStatus = PENDING_MUTATION_PHASE;
3653 if (enableViewTransition && willStartViewTransition) {
3654 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
3655 + startAnimating(lanes);
3656 + }
3657 pendingViewTransition = startViewTransition(
3658 suspendedState,
3659 root.containerInfo,
@@ -3603,6 +3665,15 @@ function commitRoot(
3665 flushPassiveEffects,
3666 reportViewTransitionError,
3667 enableProfilerTimer ? suspendedViewTransition : (null: any),
3668 + enableProfilerTimer
3669 + ? // This callback fires after "pendingEffects" so we need to snapshot the arguments.
3670 + finishedViewTransition.bind(
3671 + null,
3672 + lanes,
3673 + // TODO: Use a ViewTransition Task
3674 + __DEV__ ? workInProgressUpdateTask : null,
3675 + )
3676 + : (null: any),
3677 );
3678 } else {
3679 // Flush synchronously.
@@ -3634,13 +3705,62 @@ function suspendedViewTransition(reason: string): void {
3705 commitEndTime,
3706 commitErrors,
3707 pendingDelayedCommitReason === ABORTED_VIEW_TRANSITION_COMMIT,
3637 - workInProgressUpdateTask,
3708 + workInProgressUpdateTask, // TODO: Use a ViewTransition Task and this is not safe to read in this phase.
3709 );
3710 pendingSuspendedViewTransitionReason = reason;
3711 pendingSuspendedCommitReason = reason;
3712 }
3713 }
3714
3715 +function finishedViewTransition(
3716 + lanes: Lanes,
3717 + task: null | ConsoleTask, // DEV-only
3718 +): void {
3719 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
3720 + if ((animatingLanes & lanes) === NoLanes) {
3721 + // Was already stopped by some other action or maybe other root.
3722 + return;
3723 + }
3724 + stopAnimating(lanes);
3725 + // If an affected track isn't in the middle of rendering or committing, log from the previous
3726 + // finished render until the end of the animation.
3727 + if (
3728 + (includesSyncLane(lanes) || includesBlockingLane(lanes)) &&
3729 + !includesSyncLane(workInProgressRootRenderLanes) &&
3730 + !includesBlockingLane(workInProgressRootRenderLanes) &&
3731 + !includesSyncLane(pendingEffectsLanes) &&
3732 + !includesBlockingLane(pendingEffectsLanes)
3733 + ) {
3734 + setCurrentTrackFromLanes(SyncLane);
3735 + logAnimatingPhase(blockingClampTime, now(), task);
3736 + }
3737 + if (
3738 + includesTransitionLane(lanes) &&
3739 + !includesTransitionLane(workInProgressRootRenderLanes) &&
3740 + !includesTransitionLane(pendingEffectsLanes)
3741 + ) {
3742 + setCurrentTrackFromLanes(SomeTransitionLane);
3743 + logAnimatingPhase(transitionClampTime, now(), task);
3744 + }
3745 + if (
3746 + includesRetryLane(lanes) &&
3747 + !includesRetryLane(workInProgressRootRenderLanes) &&
3748 + !includesRetryLane(pendingEffectsLanes)
3749 + ) {
3750 + setCurrentTrackFromLanes(SomeRetryLane);
3751 + logAnimatingPhase(retryClampTime, now(), task);
3752 + }
3753 + if (
3754 + includesIdleGroupLanes(lanes) &&
3755 + !includesIdleGroupLanes(workInProgressRootRenderLanes) &&
3756 + !includesIdleGroupLanes(pendingEffectsLanes)
3757 + ) {
3758 + setCurrentTrackFromLanes(IdleLane);
3759 + logAnimatingPhase(idleClampTime, now(), task);
3760 + }
3761 + }
3762 +}
3763 +
3764 function flushAfterMutationEffects(): void {
3765 if (pendingEffectsStatus !== PENDING_AFTER_MUTATION_PHASE) {
3766 return;
@@ -3715,7 +3835,7 @@ function flushLayoutEffects(): void {
3835 commitEndTime, // The start is the end of the first commit part.
3836 commitStartTime, // The end is the start of the second commit part.
3837 suspendedViewTransitionReason,
3718 - workInProgressUpdateTask,
3838 + workInProgressUpdateTask, // TODO: Use a ViewTransition Task and this is not safe to read in this phase.
3839 );
3840 }
3841 }
packages/react-reconciler/src/ReactProfilerTimer.js
+28
@@ -22,6 +22,7 @@ import {
22 includesTransitionLane,
23 includesBlockingLane,
24 includesSyncLane,
25 + NoLanes,
26 } from './ReactFiberLane';
27
28 import {resolveEventType, resolveEventTimeStamp} from './ReactFiberConfig';
@@ -88,6 +89,11 @@ export let transitionEventType: null | string = null; // Event type of the first
89 export let transitionEventIsRepeat: boolean = false;
90 export let transitionSuspendedTime: number = -1.1;
91
92 +export let retryClampTime: number = -0;
93 +export let idleClampTime: number = -0;
94 +
95 +export let animatingLanes: Lanes = NoLanes;
96 +
97 export let yieldReason: SuspendedReason = (0: any);
98 export let yieldStartTime: number = -1.1; // The time when we yielded to the event loop
99
@@ -306,6 +312,20 @@ export function clampTransitionTimers(finalTime: number): void {
312 transitionClampTime = finalTime;
313 }
314
315 +export function clampRetryTimers(finalTime: number): void {
316 + if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
317 + return;
318 + }
319 + retryClampTime = finalTime;
320 +}
321 +
322 +export function clampIdleTimers(finalTime: number): void {
323 + if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
324 + return;
325 + }
326 + idleClampTime = finalTime;
327 +}
328 +
329 export function pushNestedEffectDurations(): number {
330 if (!enableProfilerTimer || !enableProfilerCommitHooks) {
331 return 0;
@@ -578,3 +598,11 @@ export function transferActualDuration(fiber: Fiber): void {
598 child = child.sibling;
599 }
600 }
601 +
602 +export function startAnimating(lanes: Lanes): void {
603 + animatingLanes |= lanes;
604 +}
605 +
606 +export function stopAnimating(lanes: Lanes): void {
607 + animatingLanes &= ~lanes;
608 +}
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+1
@@ -424,6 +424,7 @@ export function startViewTransition(
424 passiveCallback: () => mixed,
425 errorCallback: mixed => void,
426 blockedCallback: string => void, // Profiling-only
427 + finishedAnimation: () => void, // Profiling-only
428 ): null | RunningViewTransition {
429 mutationCallback();
430 layoutCallback();