From bb176e57aae1e87f6a13550a7ed03830976ed2cc Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 14 Dec 2024 00:12:12 -0500 Subject: [PATCH 1/3] Mark hydrated components in tertiary color (green) --- .../src/ReactFiberCommitWork.js | 88 +++++++++++++++++++ .../src/ReactFiberPerformanceTrack.js | 26 +++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 83922deed4b2..71b000af0bc3 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -52,6 +52,7 @@ import { enableLegacyHidden, disableLegacyMode, enableComponentPerformanceTrack, + enablePostpone, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -97,6 +98,7 @@ import { FormReset, Cloned, PerformedWork, + ForceClientRender, } from './ReactFiberFlags'; import { commitStartTime, @@ -113,6 +115,7 @@ import { import { logComponentRender, logComponentEffect, + logSuspenseBoundaryClientRendered, } from './ReactFiberPerformanceTrack'; import {ConcurrentMode, NoMode, ProfileMode} from './ReactTypeOfMode'; import {deferHiddenCallbacks} from './ReactFiberClassUpdateQueue'; @@ -142,6 +145,8 @@ import { suspendResource, resetFormInstance, registerSuspenseInstanceRetry, + isSuspenseInstanceFallback, + getSuspenseInstanceFallbackErrorDetails, } from './ReactFiberConfig'; import { captureCommitPhaseError, @@ -2689,6 +2694,8 @@ function recursivelyTraversePassiveMountEffects( } } +let inHydratedSubtree = false; + function commitPassiveMountOnFiber( finishedRoot: FiberRoot, finishedWork: Fiber, @@ -2713,6 +2720,7 @@ function commitPassiveMountOnFiber( finishedWork, ((finishedWork.actualStartTime: any): number), endTime, + inHydratedSubtree, ); } @@ -2741,6 +2749,17 @@ function commitPassiveMountOnFiber( } case HostRoot: { const prevEffectDuration = pushNestedEffectDurations(); + + const wasInHydratedSubtree = inHydratedSubtree; + if (enableProfilerTimer && enableComponentPerformanceTrack) { + // Detect if this was a hydration commit by look at if the previous state was + // dehydrated and this wasn't a forced client render. + inHydratedSubtree = + finishedWork.alternate !== null && + (finishedWork.alternate.memoizedState: RootState).isDehydrated && + (finishedWork.flags & ForceClientRender) === NoFlags; + } + recursivelyTraversePassiveMountEffects( finishedRoot, finishedWork, @@ -2748,6 +2767,11 @@ function commitPassiveMountOnFiber( committedTransitions, endTime, ); + + if (enableProfilerTimer && enableComponentPerformanceTrack) { + inHydratedSubtree = wasInHydratedSubtree; + } + if (flags & Passive) { let previousCache: Cache | null = null; if (finishedWork.alternate !== null) { @@ -2841,6 +2865,68 @@ function commitPassiveMountOnFiber( } break; } + case SuspenseComponent: { + const wasInHydratedSubtree = inHydratedSubtree; + if (enableProfilerTimer && enableComponentPerformanceTrack) { + const prevState: SuspenseState | null = + finishedWork.alternate !== null + ? finishedWork.alternate.memoizedState + : null; + const nextState: SuspenseState | null = finishedWork.memoizedState; + if ( + prevState !== null && + prevState.dehydrated !== null && + (nextState === null || nextState.dehydrated === null) + ) { + const suspenseInstance: SuspenseInstance = prevState.dehydrated; + // This was dehydrated but is no longer dehydrated. We may have now either hydrated it + // or client rendered it. + const deletions = finishedWork.deletions; + if ( + deletions !== null && + deletions.length > 0 && + deletions[0].tag === DehydratedFragment + ) { + // This was an abandoned hydration that deleted the dehydrated fragment. That means we + // are not hydrating this Suspense boundary. + inHydratedSubtree = false; + if ( + enablePostpone && + isSuspenseInstanceFallback(suspenseInstance) && + getSuspenseInstanceFallbackErrorDetails(suspenseInstance) + .digest === 'POSTPONE' + ) { + // Client Rendered Intentionally. Don't log it as an error. + } else { + const startTime: number = (finishedWork.actualStartTime: any); + logSuspenseBoundaryClientRendered( + finishedWork, + startTime, + endTime, + ); + } + } else { + // If any children committed they were hydrated. + inHydratedSubtree = true; + } + } else { + inHydratedSubtree = false; + } + } + + recursivelyTraversePassiveMountEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions, + endTime, + ); + + if (enableProfilerTimer && enableComponentPerformanceTrack) { + inHydratedSubtree = wasInHydratedSubtree; + } + break; + } case LegacyHiddenComponent: { if (enableLegacyHidden) { recursivelyTraversePassiveMountEffects( @@ -3074,6 +3160,7 @@ export function reconnectPassiveEffects( finishedWork, ((finishedWork.actualStartTime: any): number), endTime, + inHydratedSubtree, ); } @@ -3317,6 +3404,7 @@ function commitAtomicPassiveEffects( finishedWork, ((finishedWork.actualStartTime: any): number), endTime, + inHydratedSubtree, ); } diff --git a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js index d94fd5ee480b..371e9df899aa 100644 --- a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js +++ b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js @@ -123,6 +123,7 @@ export function logComponentRender( fiber: Fiber, startTime: number, endTime: number, + wasHydrated: boolean, ): void { const name = getComponentNameFromFiber(fiber); if (name === null) { @@ -138,11 +139,17 @@ export function logComponentRender( } reusableComponentDevToolDetails.color = selfTime < 0.5 - ? 'primary-light' + ? wasHydrated + ? 'tertiary-light' + : 'primary-light' : selfTime < 10 - ? 'primary' + ? wasHydrated + ? 'tertiary' + : 'primary' : selfTime < 100 - ? 'primary-dark' + ? wasHydrated + ? 'tertiary-dark' + : 'primary-dark' : 'error'; reusableComponentOptions.start = startTime; reusableComponentOptions.end = endTime; @@ -150,6 +157,19 @@ export function logComponentRender( } } +export function logSuspenseBoundaryClientRendered( + fiber: Fiber, + startTime: number, + endTime: number, +): void { + if (supportsUserTiming) { + reusableComponentDevToolDetails.color = 'error'; + reusableComponentOptions.start = startTime; + reusableComponentOptions.end = endTime; + performance.measure('Suspense', reusableComponentOptions); + } +} + export function logComponentEffect( fiber: Fiber, startTime: number, From 487dffd90b3b5e3503e15da4b822ef7c2a07f68e Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 17 Dec 2024 23:50:43 -0500 Subject: [PATCH 2/3] Collect hydrationErrors on the boundary so we can log the reason why we client rendered --- .../src/ReactFiberBeginWork.js | 1 + .../src/ReactFiberCommitWork.js | 17 +++------ .../src/ReactFiberCompleteWork.js | 10 ++++-- .../src/ReactFiberHydrationContext.js | 11 ++++-- .../src/ReactFiberPerformanceTrack.js | 35 ++++++++++++++++--- .../src/ReactFiberSuspenseComponent.js | 3 ++ 6 files changed, 55 insertions(+), 22 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 632b2df70c09..78dda8e7060c 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -1937,6 +1937,7 @@ const SUSPENDED_MARKER: SuspenseState = { dehydrated: null, treeContext: null, retryLane: NoLane, + hydrationErrors: null, }; function mountSuspenseOffscreenState(renderLanes: Lanes): OffscreenState { diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 71b000af0bc3..43b726e98100 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -52,7 +52,6 @@ import { enableLegacyHidden, disableLegacyMode, enableComponentPerformanceTrack, - enablePostpone, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -145,8 +144,6 @@ import { suspendResource, resetFormInstance, registerSuspenseInstanceRetry, - isSuspenseInstanceFallback, - getSuspenseInstanceFallbackErrorDetails, } from './ReactFiberConfig'; import { captureCommitPhaseError, @@ -2878,7 +2875,6 @@ function commitPassiveMountOnFiber( prevState.dehydrated !== null && (nextState === null || nextState.dehydrated === null) ) { - const suspenseInstance: SuspenseInstance = prevState.dehydrated; // This was dehydrated but is no longer dehydrated. We may have now either hydrated it // or client rendered it. const deletions = finishedWork.deletions; @@ -2890,19 +2886,16 @@ function commitPassiveMountOnFiber( // This was an abandoned hydration that deleted the dehydrated fragment. That means we // are not hydrating this Suspense boundary. inHydratedSubtree = false; - if ( - enablePostpone && - isSuspenseInstanceFallback(suspenseInstance) && - getSuspenseInstanceFallbackErrorDetails(suspenseInstance) - .digest === 'POSTPONE' - ) { - // Client Rendered Intentionally. Don't log it as an error. - } else { + const hydrationErrors = prevState.hydrationErrors; + // If there were no hydration errors, that suggests that this was an intentional client + // rendered boundary. Such as postpone. + if (hydrationErrors !== null) { const startTime: number = (finishedWork.actualStartTime: any); logSuspenseBoundaryClientRendered( finishedWork, startTime, endTime, + hydrationErrors, ); } } else { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index b302b498fa14..f53b31398029 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -926,9 +926,13 @@ function completeDehydratedSuspenseBoundary( // Successfully completed this tree. If this was a forced client render, // there may have been recoverable errors during first hydration // attempt. If so, add them to a queue so we can log them in the - // commit phase. - upgradeHydrationErrorsToRecoverable(); - + // commit phase. We also add them to prev state so we can get to them + // from the Suspense Boundary. + const hydrationErrors = upgradeHydrationErrorsToRecoverable(); + if (current !== null && current.memoizedState !== null) { + const prevState: SuspenseState = current.memoizedState; + prevState.hydrationErrors = hydrationErrors; + } // Fall through to normal Suspense path return true; } diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index 1d2f69852a9d..b4d948e73527 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -280,6 +280,7 @@ function tryHydrateSuspense(fiber: Fiber, nextInstance: any) { dehydrated: suspenseInstance, treeContext: getSuspendedTreeContext(), retryLane: OffscreenLane, + hydrationErrors: null, }; fiber.memoizedState = suspenseState; // Store the dehydrated fragment as a child fiber. @@ -701,14 +702,18 @@ function resetHydrationState(): void { didSuspendOrErrorDEV = false; } -export function upgradeHydrationErrorsToRecoverable(): void { - if (hydrationErrors !== null) { +export function upgradeHydrationErrorsToRecoverable(): Array< + CapturedValue, +> | null { + const queuedErrors = hydrationErrors; + if (queuedErrors !== null) { // Successfully completed a forced client render. The errors that occurred // during the hydration attempt are now recovered. We will log them in // commit phase, once the entire tree has finished. - queueRecoverableErrors(hydrationErrors); + queueRecoverableErrors(queuedErrors); hydrationErrors = null; } + return queuedErrors; } function getIsHydrating(): boolean { diff --git a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js index 371e9df899aa..30ed5d5259c9 100644 --- a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js +++ b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js @@ -11,6 +11,8 @@ import type {Fiber} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane'; +import type {CapturedValue} from './ReactCapturedValue'; + import getComponentNameFromFiber from './getComponentNameFromFiber'; import { @@ -161,12 +163,37 @@ export function logSuspenseBoundaryClientRendered( fiber: Fiber, startTime: number, endTime: number, + errors: Array>, ): void { if (supportsUserTiming) { - reusableComponentDevToolDetails.color = 'error'; - reusableComponentOptions.start = startTime; - reusableComponentOptions.end = endTime; - performance.measure('Suspense', reusableComponentOptions); + const properties = []; + if (__DEV__) { + for (let i = 0; i < errors.length; i++) { + const capturedValue = errors[i]; + const error = capturedValue.value; + const message = + typeof error === 'object' && + error !== null && + typeof error.message === 'string' + ? // eslint-disable-next-line react-internal/safe-string-coercion + String(error.message) + : // eslint-disable-next-line react-internal/safe-string-coercion + String(error); + properties.push(['Error', message]); + } + } + performance.measure('Suspense', { + start: startTime, + end: endTime, + detail: { + devtools: { + color: 'error', + track: COMPONENTS_TRACK, + tooltipText: 'Hydration failed', + properties, + }, + }, + }); } } diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js index 1f558a44903e..16a376fe6c7a 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js @@ -12,6 +12,7 @@ import type {Fiber} from './ReactInternalTypes'; import type {SuspenseInstance} from './ReactFiberConfig'; import type {Lane} from './ReactFiberLane'; import type {TreeContext} from './ReactFiberTreeContext'; +import type {CapturedValue} from './ReactCapturedValue'; import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags'; import {NoFlags, DidCapture} from './ReactFiberFlags'; @@ -49,6 +50,8 @@ export type SuspenseState = { // OffscreenLane is the default for dehydrated boundaries. // NoLane is the default for normal boundaries, which turns into "normal" pri. retryLane: Lane, + // Stashed Errors that happened while attempting to hydrate this boundary. + hydrationErrors: Array> | null, }; export type SuspenseListTailMode = 'collapsed' | 'hidden' | void; From 8a5241e91831af0a5b0cd71de3ab8d69bad9e86e Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 18 Dec 2024 13:08:02 -0500 Subject: [PATCH 3/3] Log recoverable errors when the recovery succeeds This might be either due to concurrent errors recovering from a sync render or hydration errors. --- .../src/ReactFiberPerformanceTrack.js | 44 ++++++++++++++++++- .../src/ReactFiberWorkLoop.js | 15 +++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js index 30ed5d5259c9..61bfd5cf7f84 100644 --- a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js +++ b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js @@ -434,6 +434,48 @@ export function logSuspendedWithDelayPhase( } } +export function logRecoveredRenderPhase( + startTime: number, + endTime: number, + lanes: Lanes, + recoverableErrors: Array>, + hydrationFailed: boolean, +): void { + if (supportsUserTiming) { + const properties = []; + if (__DEV__) { + for (let i = 0; i < recoverableErrors.length; i++) { + const capturedValue = recoverableErrors[i]; + const error = capturedValue.value; + const message = + typeof error === 'object' && + error !== null && + typeof error.message === 'string' + ? // eslint-disable-next-line react-internal/safe-string-coercion + String(error.message) + : // eslint-disable-next-line react-internal/safe-string-coercion + String(error); + properties.push(['Recoverable Error', message]); + } + } + performance.measure('Recovered', { + start: startTime, + end: endTime, + detail: { + devtools: { + color: 'primary-dark', + track: reusableLaneDevToolDetails.track, + trackGroup: LANES_TRACK_GROUP, + tooltipText: hydrationFailed + ? 'Hydration Failed' + : 'Recovered after Error', + properties, + }, + }, + }); + } +} + export function logErroredRenderPhase( startTime: number, endTime: number, @@ -443,7 +485,7 @@ export function logErroredRenderPhase( reusableLaneDevToolDetails.color = 'error'; reusableLaneOptions.start = startTime; reusableLaneOptions.end = endTime; - performance.measure('Errored Render', reusableLaneOptions); + performance.measure('Errored', reusableLaneOptions); } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index a71eb543cfe5..69b12e3900c1 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -23,6 +23,7 @@ import type { } from './ReactFiberTracingMarkerComponent'; import type {OffscreenInstance} from './ReactFiberActivityComponent'; import type {Resource} from './ReactFiberConfig'; +import type {RootState} from './ReactFiberRoot'; import { enableCreateEventHandleAPI, @@ -60,6 +61,7 @@ import { logRenderPhase, logInterruptedRenderPhase, logSuspendedRenderPhase, + logRecoveredRenderPhase, logErroredRenderPhase, logInconsistentRender, logSuspendedWithDelayPhase, @@ -3184,6 +3186,19 @@ function commitRootImpl( completedRenderEndTime, lanes, ); + } else if (recoverableErrors !== null) { + const hydrationFailed = + finishedWork !== null && + finishedWork.alternate !== null && + (finishedWork.alternate.memoizedState: RootState).isDehydrated && + (finishedWork.flags & ForceClientRender) !== NoFlags; + logRecoveredRenderPhase( + completedRenderStartTime, + completedRenderEndTime, + lanes, + recoverableErrors, + hydrationFailed, + ); } else { logRenderPhase(completedRenderStartTime, completedRenderEndTime, lanes); }