diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 71affb7ca7a44..0ad2635ed48b1 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -1933,6 +1933,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 83922deed4b26..43b726e981007 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -97,6 +97,7 @@ import { FormReset, Cloned, PerformedWork, + ForceClientRender, } from './ReactFiberFlags'; import { commitStartTime, @@ -113,6 +114,7 @@ import { import { logComponentRender, logComponentEffect, + logSuspenseBoundaryClientRendered, } from './ReactFiberPerformanceTrack'; import {ConcurrentMode, NoMode, ProfileMode} from './ReactTypeOfMode'; import {deferHiddenCallbacks} from './ReactFiberClassUpdateQueue'; @@ -2689,6 +2691,8 @@ function recursivelyTraversePassiveMountEffects( } } +let inHydratedSubtree = false; + function commitPassiveMountOnFiber( finishedRoot: FiberRoot, finishedWork: Fiber, @@ -2713,6 +2717,7 @@ function commitPassiveMountOnFiber( finishedWork, ((finishedWork.actualStartTime: any): number), endTime, + inHydratedSubtree, ); } @@ -2741,6 +2746,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 +2764,11 @@ function commitPassiveMountOnFiber( committedTransitions, endTime, ); + + if (enableProfilerTimer && enableComponentPerformanceTrack) { + inHydratedSubtree = wasInHydratedSubtree; + } + if (flags & Passive) { let previousCache: Cache | null = null; if (finishedWork.alternate !== null) { @@ -2841,6 +2862,64 @@ 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) + ) { + // 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; + 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 { + // 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 +3153,7 @@ export function reconnectPassiveEffects( finishedWork, ((finishedWork.actualStartTime: any): number), endTime, + inHydratedSubtree, ); } @@ -3317,6 +3397,7 @@ function commitAtomicPassiveEffects( finishedWork, ((finishedWork.actualStartTime: any): number), endTime, + inHydratedSubtree, ); } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index b302b498fa14d..f53b313980295 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 1d2f69852a9db..b4d948e735276 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 d94fd5ee480b3..61bfd5cf7f844 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 { @@ -123,6 +125,7 @@ export function logComponentRender( fiber: Fiber, startTime: number, endTime: number, + wasHydrated: boolean, ): void { const name = getComponentNameFromFiber(fiber); if (name === null) { @@ -138,11 +141,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 +159,44 @@ export function logComponentRender( } } +export function logSuspenseBoundaryClientRendered( + fiber: Fiber, + startTime: number, + endTime: number, + errors: Array>, +): void { + if (supportsUserTiming) { + 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, + }, + }, + }); + } +} + export function logComponentEffect( fiber: Fiber, startTime: number, @@ -387,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, @@ -396,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/ReactFiberSuspenseComponent.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js index 1f558a44903e2..16a376fe6c7ae 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; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 7377f605a8811..2e8b443d22caa 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, @@ -59,6 +60,7 @@ import { logRenderPhase, logInterruptedRenderPhase, logSuspendedRenderPhase, + logRecoveredRenderPhase, logErroredRenderPhase, logInconsistentRender, logSuspendedWithDelayPhase, @@ -3183,6 +3185,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); }