@samitouri / QOS-React / commits / 84af9085c1

Log Performance Track Entries for View Transitions (#34510)

Stacked on #34509. View Transitions introduces a bunch of new types of gaps in the commit phase which needs to be logged differently in the performance track. One thing that can happen is that a `flushSync` update forces the View Transition to abort before it has started if it happens in the gap before the transition is ready. In that case we log "Interrupted View Transition". Otherwise, when we're done in `startViewTransition` there's some work to finalize the animations before the `ready` calllback. This is logged as "Starting Animation". Then there's a gap before the passive effects fire which we log as "Animating". This can be long unless they're forced to flush early e.g. due to another lane updating. The "Animating" track should then pick up which doesn't do yet. This one is tricky because this is after the actual commit phase and needs to be interrupted by new renders which themselves can be suspended on the animation finshing. This PR is just a subset of all the cases. Will need a lot more work. <img width="679" height="161" alt="Screenshot 2025-09-16 at 10 19 06 PM" src="https://github.com/user-attachments/assets/0407372d-aaed-41f5-a262-059b2686ae87" />

Sebastian Markbåge committed Sep 17, 2025 at 13:06 UTC 84af9085c11411e44cc5e5aee6cf00c02a78986e
3 files changed +161 -31
packages/react-reconciler/src/ReactFiberPerformanceTrack.js
+82 -4
@@ -1320,6 +1320,7 @@ export function logCommitPhase(
1320 startTime: number,
1321 endTime: number,
1322 errors: null | Array<CapturedValue<mixed>>,
1323 + abortedViewTransition: boolean,
1324 debugTask: null | ConsoleTask,
1325 ): void {
1326 if (errors !== null) {
@@ -1335,22 +1336,24 @@ export function logCommitPhase(
1336 // $FlowFixMe[method-unbinding]
1337 console.timeStamp.bind(
1338 console,
1338 - 'Commit',
1339 + abortedViewTransition
1340 + ? 'Commit Interrupted View Transition'
1341 + : 'Commit',
1342 startTime,
1343 endTime,
1344 currentTrack,
1345 LANES_TRACK_GROUP,
1343 - 'secondary-dark',
1346 + abortedViewTransition ? 'error' : 'secondary-dark',
1347 ),
1348 );
1349 } else {
1350 console.timeStamp(
1348 - 'Commit',
1351 + abortedViewTransition ? 'Commit Interrupted View Transition' : 'Commit',
1352 startTime,
1353 endTime,
1354 currentTrack,
1355 LANES_TRACK_GROUP,
1353 - 'secondary-dark',
1356 + abortedViewTransition ? 'error' : 'secondary-dark',
1357 );
1358 }
1359 }
@@ -1392,6 +1395,81 @@ export function logPaintYieldPhase(
1395 }
1396 }
1397
1398 +export function logStartViewTransitionYieldPhase(
1399 + startTime: number,
1400 + endTime: number,
1401 + abortedViewTransition: boolean,
1402 + debugTask: null | ConsoleTask,
1403 +): void {
1404 + if (supportsUserTiming) {
1405 + if (endTime <= startTime) {
1406 + return;
1407 + }
1408 + if (__DEV__ && debugTask) {
1409 + debugTask.run(
1410 + // $FlowFixMe[method-unbinding]
1411 + console.timeStamp.bind(
1412 + console,
1413 + abortedViewTransition
1414 + ? 'Interrupted View Transition'
1415 + : 'Starting Animation',
1416 + startTime,
1417 + endTime,
1418 + currentTrack,
1419 + LANES_TRACK_GROUP,
1420 + abortedViewTransition ? 'error' : 'secondary-light',
1421 + ),
1422 + );
1423 + } else {
1424 + console.timeStamp(
1425 + abortedViewTransition
1426 + ? 'Interrupted View Transition'
1427 + : 'Starting Animation',
1428 + startTime,
1429 + endTime,
1430 + currentTrack,
1431 + LANES_TRACK_GROUP,
1432 + abortedViewTransition ? ' error' : 'secondary-light',
1433 + );
1434 + }
1435 + }
1436 +}
1437 +
1438 +export function logAnimatingPhase(
1439 + startTime: number,
1440 + endTime: number,
1441 + debugTask: null | ConsoleTask,
1442 +): void {
1443 + if (supportsUserTiming) {
1444 + if (endTime <= startTime) {
1445 + return;
1446 + }
1447 + if (__DEV__ && debugTask) {
1448 + debugTask.run(
1449 + // $FlowFixMe[method-unbinding]
1450 + console.timeStamp.bind(
1451 + console,
1452 + 'Animating',
1453 + startTime,
1454 + endTime,
1455 + currentTrack,
1456 + LANES_TRACK_GROUP,
1457 + 'secondary',
1458 + ),
1459 + );
1460 + } else {
1461 + console.timeStamp(
1462 + 'Animating',
1463 + startTime,
1464 + endTime,
1465 + currentTrack,
1466 + LANES_TRACK_GROUP,
1467 + 'secondary',
1468 + );
1469 + }
1470 + }
1471 +}
1472 +
1473 export function logPassiveCommitPhase(
1474 startTime: number,
1475 endTime: number,
packages/react-reconciler/src/ReactFiberRootScheduler.js
+2 -1
@@ -41,6 +41,7 @@ import {
41 NoContext,
42 RenderContext,
43 flushPendingEffects,
44 + flushPendingEffectsDelayed,
45 getExecutionContext,
46 getWorkInProgressRoot,
47 getWorkInProgressRootRenderLanes,
@@ -542,7 +543,7 @@ function performWorkOnRootViaSchedulerTask(
543 // Flush any pending passive effects before deciding which lanes to work on,
544 // in case they schedule additional work.
545 const originalCallbackNode = root.callbackNode;
545 - const didFlushPassiveEffects = flushPendingEffects(true);
546 + const didFlushPassiveEffects = flushPendingEffectsDelayed();
547 if (didFlushPassiveEffects) {
548 // Something in the passive effect phase may have canceled the current task.
549 // Check if the task node for this root was changed.
packages/react-reconciler/src/ReactFiberWorkLoop.js
+77 -26
@@ -83,6 +83,8 @@ import {
83 logSuspendedCommitPhase,
84 logCommitPhase,
85 logPaintYieldPhase,
86 + logStartViewTransitionYieldPhase,
87 + logAnimatingPhase,
88 logPassiveCommitPhase,
89 logYieldTime,
90 logActionYieldTime,
@@ -674,6 +676,11 @@ const IMMEDIATE_COMMIT = 0;
676 const SUSPENDED_COMMIT = 1;
677 const THROTTLED_COMMIT = 2;
678
679 +type DelayedCommitReason = 0 | 1 | 2 | 3;
680 +const ABORTED_VIEW_TRANSITION_COMMIT = 1;
681 +const DELAYED_PASSIVE_COMMIT = 2;
682 +const ANIMATION_STARTED_COMMIT = 3;
683 +
684 const NO_PENDING_EFFECTS = 0;
685 const PENDING_MUTATION_PHASE = 1;
686 const PENDING_LAYOUT_PHASE = 2;
@@ -696,6 +703,7 @@ let pendingViewTransitionEvents: Array<(types: Array<string>) => void> | null =
703 let pendingTransitionTypes: null | TransitionTypes = null;
704 let pendingDidIncludeRenderPhaseUpdate: boolean = false;
705 let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only
706 +let pendingDelayedCommitReason: DelayedCommitReason = IMMEDIATE_COMMIT; // Profiling-only
707
708 // Use these to prevent an infinite loop of nested updates
709 const NESTED_UPDATE_LIMIT = 50;
@@ -3436,6 +3444,7 @@ function commitRoot(
3444 if (enableProfilerTimer) {
3445 pendingEffectsRenderEndTime = completedRenderEndTime;
3446 pendingSuspendedCommitReason = suspendedCommitReason;
3447 + pendingDelayedCommitReason = IMMEDIATE_COMMIT;
3448 }
3449
3450 if (enableGestureTransition && isGestureRender(lanes)) {
@@ -3495,7 +3504,10 @@ function commitRoot(
3504 // event when logging events.
3505 trackSchedulerEvent();
3506 }
3498 - flushPassiveEffects(true);
3507 + if (pendingDelayedCommitReason === IMMEDIATE_COMMIT) {
3508 + pendingDelayedCommitReason = DELAYED_PASSIVE_COMMIT;
3509 + }
3510 + flushPassiveEffects();
3511 // This render triggered passive effects: release the root cache pool
3512 // *after* passive effects fire to avoid freeing a cache pool that may
3513 // be referenced by a node in the tree (HostRoot, Cache boundary etc)
@@ -3736,6 +3748,23 @@ function flushLayoutEffects(): void {
3748 ReactSharedInternals.T = prevTransition;
3749 }
3750 }
3751 +
3752 + const completedRenderEndTime = pendingEffectsRenderEndTime;
3753 + const suspendedCommitReason = pendingSuspendedCommitReason;
3754 +
3755 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
3756 + recordCommitEndTime();
3757 + logCommitPhase(
3758 + suspendedCommitReason === IMMEDIATE_COMMIT
3759 + ? completedRenderEndTime
3760 + : commitStartTime,
3761 + commitEndTime,
3762 + commitErrors,
3763 + pendingDelayedCommitReason === ABORTED_VIEW_TRANSITION_COMMIT,
3764 + workInProgressUpdateTask,
3765 + );
3766 + }
3767 +
3768 pendingEffectsStatus = PENDING_AFTER_MUTATION_PHASE;
3769 }
3770
@@ -3748,6 +3777,25 @@ function flushSpawnedWork(): void {
3777 ) {
3778 return;
3779 }
3780 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
3781 + // If we didn't skip the after mutation phase, when is means we started an animation.
3782 + const startedAnimation = pendingEffectsStatus === PENDING_SPAWNED_WORK;
3783 + if (startedAnimation) {
3784 + const startViewTransitionStartTime = commitEndTime;
3785 + // Update the new commitEndTime to when we started the animation.
3786 + recordCommitEndTime();
3787 + logStartViewTransitionYieldPhase(
3788 + startViewTransitionStartTime,
3789 + commitEndTime,
3790 + pendingDelayedCommitReason === ABORTED_VIEW_TRANSITION_COMMIT,
3791 + workInProgressUpdateTask, // TODO: Use a ViewTransition Task.
3792 + );
3793 + if (pendingDelayedCommitReason !== ABORTED_VIEW_TRANSITION_COMMIT) {
3794 + pendingDelayedCommitReason = ANIMATION_STARTED_COMMIT;
3795 + }
3796 + }
3797 + }
3798 +
3799 pendingEffectsStatus = NO_PENDING_EFFECTS;
3800
3801 pendingViewTransition = null; // The view transition has now fully started.
@@ -3759,22 +3807,8 @@ function flushSpawnedWork(): void {
3807 const root = pendingEffectsRoot;
3808 const finishedWork = pendingFinishedWork;
3809 const lanes = pendingEffectsLanes;
3762 - const completedRenderEndTime = pendingEffectsRenderEndTime;
3810 const recoverableErrors = pendingRecoverableErrors;
3811 const didIncludeRenderPhaseUpdate = pendingDidIncludeRenderPhaseUpdate;
3765 - const suspendedCommitReason = pendingSuspendedCommitReason;
3766 -
3767 - if (enableProfilerTimer && enableComponentPerformanceTrack) {
3768 - recordCommitEndTime();
3769 - logCommitPhase(
3770 - suspendedCommitReason === IMMEDIATE_COMMIT
3771 - ? completedRenderEndTime
3772 - : commitStartTime,
3773 - commitEndTime,
3774 - commitErrors,
3775 - workInProgressUpdateTask,
3776 - );
3777 - }
3812
3813 const passiveSubtreeMask =
3814 enableViewTransition && includesOnlyViewTransitionEligibleLanes(lanes)
@@ -4141,7 +4175,14 @@ function releaseRootPooledCache(root: FiberRoot, remainingLanes: Lanes) {
4175
4176 let didWarnAboutInterruptedViewTransitions = false;
4177
4144 -export function flushPendingEffects(wasDelayedCommit?: boolean): boolean {
4178 +export function flushPendingEffectsDelayed(): boolean {
4179 + if (pendingDelayedCommitReason === IMMEDIATE_COMMIT) {
4180 + pendingDelayedCommitReason = DELAYED_PASSIVE_COMMIT;
4181 + }
4182 + return flushPendingEffects();
4183 +}
4184 +
4185 +export function flushPendingEffects(): boolean {
4186 // Returns whether passive effects were flushed.
4187 if (enableViewTransition && pendingViewTransition !== null) {
4188 // If we forced a flush before the View Transition full started then we skip it.
@@ -4159,6 +4200,7 @@ export function flushPendingEffects(wasDelayedCommit?: boolean): boolean {
4200 }
4201 }
4202 pendingViewTransition = null;
4203 + pendingDelayedCommitReason = ABORTED_VIEW_TRANSITION_COMMIT;
4204 }
4205 flushGestureMutations();
4206 flushGestureAnimations();
@@ -4166,10 +4208,10 @@ export function flushPendingEffects(wasDelayedCommit?: boolean): boolean {
4208 flushLayoutEffects();
4209 // Skip flushAfterMutation if we're forcing this early.
4210 flushSpawnedWork();
4169 - return flushPassiveEffects(wasDelayedCommit);
4211 + return flushPassiveEffects();
4212 }
4213
4172 -function flushPassiveEffects(wasDelayedCommit?: boolean): boolean {
4214 +function flushPassiveEffects(): boolean {
4215 if (pendingEffectsStatus !== PENDING_PASSIVE_PHASE) {
4216 return false;
4217 }
@@ -4194,7 +4236,7 @@ function flushPassiveEffects(wasDelayedCommit?: boolean): boolean {
4236 try {
4237 setCurrentUpdatePriority(priority);
4238 ReactSharedInternals.T = null;
4197 - return flushPassiveEffectsImpl(wasDelayedCommit);
4239 + return flushPassiveEffectsImpl();
4240 } finally {
4241 setCurrentUpdatePriority(previousPriority);
4242 ReactSharedInternals.T = prevTransition;
@@ -4206,7 +4248,7 @@ function flushPassiveEffects(wasDelayedCommit?: boolean): boolean {
4248 }
4249 }
4250
4209 -function flushPassiveEffectsImpl(wasDelayedCommit: void | boolean) {
4251 +function flushPassiveEffectsImpl() {
4252 // Cache and clear the transitions flag
4253 const transitions = pendingPassiveTransitions;
4254 pendingPassiveTransitions = null;
@@ -4246,12 +4288,21 @@ function flushPassiveEffectsImpl(wasDelayedCommit: void | boolean) {
4288 if (enableProfilerTimer && enableComponentPerformanceTrack) {
4289 resetCommitErrors();
4290 passiveEffectStartTime = now();
4249 - logPaintYieldPhase(
4250 - commitEndTime,
4251 - passiveEffectStartTime,
4252 - !!wasDelayedCommit,
4253 - workInProgressUpdateTask,
4254 - );
4291 + if (pendingDelayedCommitReason === ANIMATION_STARTED_COMMIT) {
4292 + // The animation was started, so we've been animating since that happened.
4293 + logAnimatingPhase(
4294 + commitEndTime,
4295 + passiveEffectStartTime,
4296 + workInProgressUpdateTask, // TODO: Use a ViewTransition Task
4297 + );
4298 + } else {
4299 + logPaintYieldPhase(
4300 + commitEndTime,
4301 + passiveEffectStartTime,
4302 + pendingDelayedCommitReason === DELAYED_PASSIVE_COMMIT,
4303 + workInProgressUpdateTask,
4304 + );
4305 + }
4306 }
4307
4308 if (enableSchedulingProfiler) {