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();