From b204edda3aa397243faa267512811b778542f6a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sat, 20 Sep 2025 11:01:52 -0400 Subject: [PATCH 1/3] Log Custom Reason for the Suspended Commit Track (#34522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #34511. We currently log all Suspended Commit as "Suspended on Images or CSS" but it can really be other reasons too now. Like waiting on the previous View Transition. This allows the host config configure this reason. Now when one animation starts before another one finishes we log that as "Waiting for the previous Animation". Screenshot 2025-09-17 at 11 53 45 PM --- packages/react-art/src/ReactFiberConfigART.js | 4 ++ .../src/client/ReactFiberConfigDOM.js | 25 +++++++++++ .../src/ReactFiberConfigFabric.js | 7 ++++ .../src/ReactFiberConfigNative.js | 7 ++++ .../src/createReactNoop.js | 7 ++++ .../src/ReactFiberPerformanceTrack.js | 41 ++----------------- .../src/ReactFiberWorkLoop.js | 39 ++++++++---------- .../ReactFiberHostContext-test.internal.js | 3 ++ .../src/forks/ReactFiberConfig.custom.js | 1 + .../src/ReactFiberConfigTestHost.js | 7 ++++ 10 files changed, 80 insertions(+), 61 deletions(-) diff --git a/packages/react-art/src/ReactFiberConfigART.js b/packages/react-art/src/ReactFiberConfigART.js index 77aedb6ae3c7a..ca2745beea5cc 100644 --- a/packages/react-art/src/ReactFiberConfigART.js +++ b/packages/react-art/src/ReactFiberConfigART.js @@ -621,6 +621,10 @@ export function waitForCommitToBeReady(timeoutOffset) { return null; } +export function getSuspendedCommitReason(state, rootContainer) { + return null; +} + export const NotPendingTransition = null; export const HostTransitionContext: ReactContext = { $$typeof: REACT_CONTEXT_TYPE, diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 8f67e12db1e1f..5967d18a59f92 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -5965,6 +5965,7 @@ export opaque type SuspendedState = { imgBytes: number, // number of bytes we estimate needing to download suspenseyImages: Array, // instances of suspensey images (whether loaded or not) waitingForImages: boolean, // false when we're no longer blocking on images + waitingForViewTransition: boolean, unsuspend: null | (() => void), }; @@ -5976,6 +5977,7 @@ export function startSuspendingCommit(): SuspendedState { imgBytes: 0, suspenseyImages: [], waitingForImages: true, + waitingForViewTransition: false, // We use a noop function when we begin suspending because if possible we want the // waitfor step to finish synchronously. If it doesn't we'll return a function to // provide the actual unsuspend function and that will get completed when the count @@ -6123,6 +6125,7 @@ export function suspendOnActiveViewTransition( return; } state.count++; + state.waitingForViewTransition = true; const ping = onUnsuspend.bind(state); activeViewTransition.finished.then(ping, ping); } @@ -6206,6 +6209,28 @@ export function waitForCommitToBeReady( return null; } +export function getSuspendedCommitReason( + state: SuspendedState, + rootContainer: Container, +): null | string { + if (state.waitingForViewTransition) { + return 'Waiting for the previous Animation'; + } + if (state.count > 0) { + if (state.imgCount > 0) { + return 'Suspended on CSS and Images'; + } + return 'Suspended on CSS'; + } + if (state.imgCount === 1) { + return 'Suspended on an Image'; + } + if (state.imgCount > 0) { + return 'Suspended on Images'; + } + return null; +} + function checkIfFullyUnsuspended(state: SuspendedState) { if (state.count === 0 && (state.imgCount === 0 || !state.waitingForImages)) { if (state.stylesheets) { diff --git a/packages/react-native-renderer/src/ReactFiberConfigFabric.js b/packages/react-native-renderer/src/ReactFiberConfigFabric.js index 233c267a1d6a3..c030b8528b62c 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigFabric.js +++ b/packages/react-native-renderer/src/ReactFiberConfigFabric.js @@ -627,6 +627,13 @@ export function waitForCommitToBeReady( return null; } +export function getSuspendedCommitReason( + state: SuspendedState, + rootContainer: Container, +): null | string { + return null; +} + export type FragmentInstanceType = { _fragmentFiber: Fiber, _observers: null | Set, diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js index 8271a62327aea..3e6ea65db3137 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigNative.js +++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js @@ -806,6 +806,13 @@ export function waitForCommitToBeReady( return null; } +export function getSuspendedCommitReason( + state: SuspendedState, + rootContainer: Container, +): null | string { + return null; +} + export const NotPendingTransition: TransitionStatus = null; export const HostTransitionContext: ReactContext = { $$typeof: REACT_CONTEXT_TYPE, diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 7235837cd64df..eeaa43627ca4b 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -702,6 +702,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { waitForCommitToBeReady, + getSuspendedCommitReason( + state: SuspendedState, + rootContainer: Container, + ): null | string { + return null; + }, + NotPendingTransition: (null: TransitionStatus), resetFormInstance(form: Instance) {}, diff --git a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js index 67438b7f817e8..31d90ee6a2177 100644 --- a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js +++ b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js @@ -1180,45 +1180,10 @@ export function logInconsistentRender( } } -export function logSuspenseThrottlePhase( - startTime: number, - endTime: number, - debugTask: null | ConsoleTask, -): void { - // This was inside a throttled Suspense boundary commit. - if (supportsUserTiming) { - if (endTime <= startTime) { - return; - } - if (__DEV__ && debugTask) { - debugTask.run( - // $FlowFixMe[method-unbinding] - console.timeStamp.bind( - console, - 'Throttled', - startTime, - endTime, - currentTrack, - LANES_TRACK_GROUP, - 'secondary-light', - ), - ); - } else { - console.timeStamp( - 'Throttled', - startTime, - endTime, - currentTrack, - LANES_TRACK_GROUP, - 'secondary-light', - ); - } - } -} - export function logSuspendedCommitPhase( startTime: number, endTime: number, + reason: string, debugTask: null | ConsoleTask, ): void { // This means the commit was suspended on CSS or images. @@ -1233,7 +1198,7 @@ export function logSuspendedCommitPhase( // $FlowFixMe[method-unbinding] console.timeStamp.bind( console, - 'Suspended on CSS or Images', + reason, startTime, endTime, currentTrack, @@ -1243,7 +1208,7 @@ export function logSuspendedCommitPhase( ); } else { console.timeStamp( - 'Suspended on CSS or Images', + reason, startTime, endTime, currentTrack, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index b2b53281b9234..7ccc8ad45803d 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -79,7 +79,6 @@ import { logErroredRenderPhase, logInconsistentRender, logSuspendedWithDelayPhase, - logSuspenseThrottlePhase, logSuspendedCommitPhase, logSuspendedViewTransitionPhase, logCommitPhase, @@ -103,6 +102,7 @@ import { startSuspendingCommit, suspendOnActiveViewTransition, waitForCommitToBeReady, + getSuspendedCommitReason, preloadInstance, preloadResource, supportsHydration, @@ -672,12 +672,10 @@ export function getRenderTargetTime(): number { let legacyErrorBoundariesThatAlreadyFailed: Set | null = null; -type SuspendedCommitReason = 0 | 1 | 2; -const IMMEDIATE_COMMIT = 0; -const SUSPENDED_COMMIT = 1; -const THROTTLED_COMMIT = 2; +type SuspendedCommitReason = null | string; type DelayedCommitReason = 0 | 1 | 2 | 3; +const IMMEDIATE_COMMIT = 0; const ABORTED_VIEW_TRANSITION_COMMIT = 1; const DELAYED_PASSIVE_COMMIT = 2; const ANIMATION_STARTED_COMMIT = 3; @@ -703,7 +701,7 @@ let pendingViewTransitionEvents: Array<(types: Array) => void> | null = null; let pendingTransitionTypes: null | TransitionTypes = null; let pendingDidIncludeRenderPhaseUpdate: boolean = false; -let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only +let pendingSuspendedCommitReason: SuspendedCommitReason = null; // Profiling-only let pendingDelayedCommitReason: DelayedCommitReason = IMMEDIATE_COMMIT; // Profiling-only let pendingSuspendedViewTransitionReason: null | string = null; // Profiling-only @@ -1391,7 +1389,7 @@ function finishConcurrentRender( workInProgressSuspendedRetryLanes, exitStatus, null, - IMMEDIATE_COMMIT, + null, renderStartTime, renderEndTime, ); @@ -1442,7 +1440,7 @@ function finishConcurrentRender( workInProgressSuspendedRetryLanes, workInProgressRootDidSkipSuspendedSiblings, exitStatus, - THROTTLED_COMMIT, + 'Throttled', renderStartTime, renderEndTime, ), @@ -1463,7 +1461,7 @@ function finishConcurrentRender( workInProgressSuspendedRetryLanes, workInProgressRootDidSkipSuspendedSiblings, exitStatus, - IMMEDIATE_COMMIT, + null, renderStartTime, renderEndTime, ); @@ -1555,7 +1553,9 @@ function commitRootWhenReady( suspendedRetryLanes, exitStatus, suspendedState, - SUSPENDED_COMMIT, + enableProfilerTimer + ? getSuspendedCommitReason(suspendedState, root.containerInfo) + : null, completedRenderStartTime, completedRenderEndTime, ), @@ -3458,7 +3458,7 @@ function commitRoot( recoverableErrors, suspendedState, enableProfilerTimer - ? suspendedCommitReason === IMMEDIATE_COMMIT + ? suspendedCommitReason === null ? completedRenderEndTime : commitStartTime : 0, @@ -3530,16 +3530,11 @@ function commitRoot( resetCommitErrors(); recordCommitTime(); if (enableComponentPerformanceTrack) { - if (suspendedCommitReason === SUSPENDED_COMMIT) { + if (suspendedCommitReason !== null) { logSuspendedCommitPhase( completedRenderEndTime, commitStartTime, - workInProgressUpdateTask, - ); - } else if (suspendedCommitReason === THROTTLED_COMMIT) { - logSuspenseThrottlePhase( - completedRenderEndTime, - commitStartTime, + suspendedCommitReason, workInProgressUpdateTask, ); } @@ -3633,7 +3628,7 @@ function suspendedViewTransition(reason: string): void { // We'll split the commit into two phases, because we're suspended in the middle. recordCommitEndTime(); logCommitPhase( - pendingSuspendedCommitReason === IMMEDIATE_COMMIT + pendingSuspendedCommitReason === null ? pendingEffectsRenderEndTime : commitStartTime, commitEndTime, @@ -3642,7 +3637,7 @@ function suspendedViewTransition(reason: string): void { workInProgressUpdateTask, ); pendingSuspendedViewTransitionReason = reason; - pendingSuspendedCommitReason = SUSPENDED_COMMIT; + pendingSuspendedCommitReason = reason; } } @@ -3792,9 +3787,7 @@ function flushLayoutEffects(): void { if (enableProfilerTimer && enableComponentPerformanceTrack) { recordCommitEndTime(); logCommitPhase( - suspendedCommitReason === IMMEDIATE_COMMIT - ? completedRenderEndTime - : commitStartTime, + suspendedCommitReason === null ? completedRenderEndTime : commitStartTime, commitEndTime, commitErrors, pendingDelayedCommitReason === ABORTED_VIEW_TRANSITION_COMMIT, diff --git a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js index 33305f80d1750..b0e827259dc48 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js @@ -114,6 +114,9 @@ describe('ReactFiberHostContext', () => { waitForCommitToBeReady(state, timeoutOffset) { return null; }, + getSuspendedCommitReason(state, rootContainer) { + return null; + }, supportsMutation: true, }); diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index 7edea606a94c5..a6c6613eb39e9 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -99,6 +99,7 @@ export const suspendInstance = $$$config.suspendInstance; export const suspendOnActiveViewTransition = $$$config.suspendOnActiveViewTransition; export const waitForCommitToBeReady = $$$config.waitForCommitToBeReady; +export const getSuspendedCommitReason = $$$config.getSuspendedCommitReason; export const NotPendingTransition = $$$config.NotPendingTransition; export const HostTransitionContext = $$$config.HostTransitionContext; export const resetFormInstance = $$$config.resetFormInstance; diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js index 7b1477fa25602..004e9ae365752 100644 --- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js +++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js @@ -589,6 +589,13 @@ export function waitForCommitToBeReady( return null; } +export function getSuspendedCommitReason( + state: SuspendedState, + rootContainer: Container, +): null | string { + return null; +} + export const NotPendingTransition: TransitionStatus = null; export const HostTransitionContext: ReactContext = { $$typeof: REACT_CONTEXT_TYPE, 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 2/3] 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 5967d18a59f92..d7ca1951e00aa 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 3e6ea65db3137..12b256e016fe2 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 eeaa43627ca4b..db69232d1295a 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 3248556fbe077..3e4f22b854104 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 31d90ee6a2177..c19ef704b20b2 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 7ccc8ad45803d..7003d25aff63e 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 62b76ee6a407a..0891a4f5f5ee8 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 004e9ae365752..b5bddad8e6156 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 d91d28c8ba6fe7c96e651f82fc47c9d5481bf5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sat, 20 Sep 2025 11:11:27 -0400 Subject: [PATCH 3/3] Use the JSX of the ViewTransition as the Stack Trace of "Animating" Traces (#34539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #34538. Track the Task of the first ViewTransition that we detected as animating. Use this as the Task as "Starting Animation", "Animating" etc. That way you can see which ViewTransition spawned the Animation. Although it's likely to be multiple. Screenshot 2025-09-19 at 10 19 18 PM --- .../src/ReactFiberCommitViewTransitions.js | 31 ++++++++++++------ .../src/ReactFiberWorkLoop.js | 32 +++++++------------ .../src/ReactProfilerTimer.js | 9 ++++++ 3 files changed, 41 insertions(+), 31 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js index 9a75195915d53..c8e59afb88e20 100644 --- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js +++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js @@ -39,6 +39,11 @@ import { getViewTransitionName, getViewTransitionClassName, } from './ReactFiberViewTransitionComponent'; +import {trackAnimatingTask} from './ReactProfilerTimer'; +import { + enableComponentPerformanceTrack, + enableProfilerTimer, +} from 'shared/ReactFeatureFlags'; export let shouldStartViewTransition: boolean = false; @@ -101,21 +106,27 @@ export function popViewTransitionCancelableScope( let viewTransitionHostInstanceIdx = 0; -export function applyViewTransitionToHostInstances( - child: null | Fiber, +function applyViewTransitionToHostInstances( + fiber: Fiber, name: string, className: ?string, collectMeasurements: null | Array, stopAtNestedViewTransitions: boolean, ): boolean { viewTransitionHostInstanceIdx = 0; - return applyViewTransitionToHostInstancesRecursive( - child, + const inViewport = applyViewTransitionToHostInstancesRecursive( + fiber.child, name, className, collectMeasurements, stopAtNestedViewTransitions, ); + if (enableProfilerTimer && enableComponentPerformanceTrack && inViewport) { + if (fiber._debugTask != null) { + trackAnimatingTask(fiber._debugTask); + } + } + return inViewport; } function applyViewTransitionToHostInstancesRecursive( @@ -247,7 +258,7 @@ function commitAppearingPairViewTransitions(placement: Fiber): void { // We found a new appearing view transition with the same name as this deletion. // We'll transition between them. const inViewport = applyViewTransitionToHostInstances( - child.child, + child, name, className, null, @@ -284,7 +295,7 @@ export function commitEnterViewTransitions( ); if (className !== 'none') { const inViewport = applyViewTransitionToHostInstances( - placement.child, + placement, name, className, null, @@ -355,7 +366,7 @@ function commitDeletedPairViewTransitions(deletion: Fiber): void { if (className !== 'none') { // We found a new appearing view transition with the same name as this deletion. const inViewport = applyViewTransitionToHostInstances( - child.child, + child, name, className, null, @@ -406,7 +417,7 @@ export function commitExitViewTransitions(deletion: Fiber): void { ); if (className !== 'none') { const inViewport = applyViewTransitionToHostInstances( - deletion.child, + deletion, name, className, null, @@ -490,7 +501,7 @@ export function commitBeforeUpdateViewTransition( return; } applyViewTransitionToHostInstances( - current.child, + current, oldName, className, (current.memoizedState = []), @@ -518,7 +529,7 @@ export function commitNestedViewTransitions(changedParent: Fiber): void { child.flags &= ~Update; if (className !== 'none') { applyViewTransitionToHostInstances( - child.child, + child, name, className, (child.memoizedState = []), diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 7003d25aff63e..7c899f6ff899a 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -324,6 +324,7 @@ import { animatingLanes, retryClampTime, idleClampTime, + animatingTask, } from './ReactProfilerTimer'; // DEV stuff @@ -1995,7 +1996,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { logAnimatingPhase( blockingClampTime, clampedRenderStartTime, - previousUpdateTask, + animatingTask, ); } logBlockingStart( @@ -2048,7 +2049,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { logAnimatingPhase( transitionClampTime, clampedRenderStartTime, - previousUpdateTask, + animatingTask, ); } logTransitionStart( @@ -2069,14 +2070,14 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { 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); + logAnimatingPhase(retryClampTime, renderStartTime, animatingTask); } } 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); + logAnimatingPhase(idleClampTime, renderStartTime, animatingTask); } } } @@ -3667,12 +3668,7 @@ function commitRoot( 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, - ) + finishedViewTransition.bind(null, lanes) : (null: any), ); } else { @@ -3712,15 +3708,13 @@ function suspendedViewTransition(reason: string): void { } } -function finishedViewTransition( - lanes: Lanes, - task: null | ConsoleTask, // DEV-only -): void { +function finishedViewTransition(lanes: Lanes): void { if (enableProfilerTimer && enableComponentPerformanceTrack) { if ((animatingLanes & lanes) === NoLanes) { // Was already stopped by some other action or maybe other root. return; } + const task = animatingTask; 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. @@ -3835,7 +3829,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, // TODO: Use a ViewTransition Task and this is not safe to read in this phase. + animatingTask, ); } } @@ -3938,7 +3932,7 @@ function flushSpawnedWork(): void { startViewTransitionStartTime, commitEndTime, pendingDelayedCommitReason === ABORTED_VIEW_TRANSITION_COMMIT, - workInProgressUpdateTask, // TODO: Use a ViewTransition Task. + animatingTask, ); if (pendingDelayedCommitReason !== ABORTED_VIEW_TRANSITION_COMMIT) { pendingDelayedCommitReason = ANIMATION_STARTED_COMMIT; @@ -4440,11 +4434,7 @@ function flushPassiveEffectsImpl() { passiveEffectStartTime = now(); if (pendingDelayedCommitReason === ANIMATION_STARTED_COMMIT) { // The animation was started, so we've been animating since that happened. - logAnimatingPhase( - commitEndTime, - passiveEffectStartTime, - workInProgressUpdateTask, // TODO: Use a ViewTransition Task - ); + logAnimatingPhase(commitEndTime, passiveEffectStartTime, animatingTask); } else { logPaintYieldPhase( commitEndTime, diff --git a/packages/react-reconciler/src/ReactProfilerTimer.js b/packages/react-reconciler/src/ReactProfilerTimer.js index 0891a4f5f5ee8..f2cf8efdce5b2 100644 --- a/packages/react-reconciler/src/ReactProfilerTimer.js +++ b/packages/react-reconciler/src/ReactProfilerTimer.js @@ -93,6 +93,7 @@ export let retryClampTime: number = -0; export let idleClampTime: number = -0; export let animatingLanes: Lanes = NoLanes; +export let animatingTask: null | ConsoleTask = null; // First ViewTransition applying an Animation. export let yieldReason: SuspendedReason = (0: any); export let yieldStartTime: number = -1.1; // The time when we yielded to the event loop @@ -601,8 +602,16 @@ export function transferActualDuration(fiber: Fiber): void { export function startAnimating(lanes: Lanes): void { animatingLanes |= lanes; + animatingTask = null; } export function stopAnimating(lanes: Lanes): void { animatingLanes &= ~lanes; + animatingTask = null; +} + +export function trackAnimatingTask(task: ConsoleTask): void { + if (animatingTask === null) { + animatingTask = task; + } }