@samitouri / QOS-React-2 / commits / 17520b6381

[Fiber] Mark hydrated components in tertiary color (green) (#31829)

This is a follow up to #31752. This keeps track in the commit phase whether this subtree was hydrated. If it was, then we mark those components in the Components track as green. Just like the phase itself is marked as green. If the boundary client rendered we instead mark it as "errored" and its children given the plain primary render color (blue). I also collect the hydration error for this case so we can include its message in the details view. (Unfortunately this doesn't support newlines atm.) Most of the time this happens in separate commits for each boundary but it is possible to force a client render in the same pass as a hydration. Such as if an update flows into a boundary that has been put into fallback state after it was initially attempted. <img width="1487" alt="Screenshot 2024-12-18 at 12 06 54 AM" src="https://github.com/user-attachments/assets/74c57291-4d11-414c-9751-3dac3285a89a" />

Sebastian Markbåge committed Dec 18, 2024 at 23:53 UTC 17520b638190a20e745fe53299813b29b52dfc4c
7 files changed +208 -10
packages/react-reconciler/src/ReactFiberBeginWork.js
+1
@@ -1933,6 +1933,7 @@ const SUSPENDED_MARKER: SuspenseState = {
1933 dehydrated: null,
1934 treeContext: null,
1935 retryLane: NoLane,
1936 + hydrationErrors: null,
1937 };
1938
1939 function mountSuspenseOffscreenState(renderLanes: Lanes): OffscreenState {
packages/react-reconciler/src/ReactFiberCommitWork.js
+81
@@ -97,6 +97,7 @@ import {
97 FormReset,
98 Cloned,
99 PerformedWork,
100 + ForceClientRender,
101 } from './ReactFiberFlags';
102 import {
103 commitStartTime,
@@ -113,6 +114,7 @@ import {
114 import {
115 logComponentRender,
116 logComponentEffect,
117 + logSuspenseBoundaryClientRendered,
118 } from './ReactFiberPerformanceTrack';
119 import {ConcurrentMode, NoMode, ProfileMode} from './ReactTypeOfMode';
120 import {deferHiddenCallbacks} from './ReactFiberClassUpdateQueue';
@@ -2689,6 +2691,8 @@ function recursivelyTraversePassiveMountEffects(
2691 }
2692 }
2693
2694 +let inHydratedSubtree = false;
2695 +
2696 function commitPassiveMountOnFiber(
2697 finishedRoot: FiberRoot,
2698 finishedWork: Fiber,
@@ -2713,6 +2717,7 @@ function commitPassiveMountOnFiber(
2717 finishedWork,
2718 ((finishedWork.actualStartTime: any): number),
2719 endTime,
2720 + inHydratedSubtree,
2721 );
2722 }
2723
@@ -2741,6 +2746,17 @@ function commitPassiveMountOnFiber(
2746 }
2747 case HostRoot: {
2748 const prevEffectDuration = pushNestedEffectDurations();
2749 +
2750 + const wasInHydratedSubtree = inHydratedSubtree;
2751 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
2752 + // Detect if this was a hydration commit by look at if the previous state was
2753 + // dehydrated and this wasn't a forced client render.
2754 + inHydratedSubtree =
2755 + finishedWork.alternate !== null &&
2756 + (finishedWork.alternate.memoizedState: RootState).isDehydrated &&
2757 + (finishedWork.flags & ForceClientRender) === NoFlags;
2758 + }
2759 +
2760 recursivelyTraversePassiveMountEffects(
2761 finishedRoot,
2762 finishedWork,
@@ -2748,6 +2764,11 @@ function commitPassiveMountOnFiber(
2764 committedTransitions,
2765 endTime,
2766 );
2767 +
2768 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
2769 + inHydratedSubtree = wasInHydratedSubtree;
2770 + }
2771 +
2772 if (flags & Passive) {
2773 let previousCache: Cache | null = null;
2774 if (finishedWork.alternate !== null) {
@@ -2841,6 +2862,64 @@ function commitPassiveMountOnFiber(
2862 }
2863 break;
2864 }
2865 + case SuspenseComponent: {
2866 + const wasInHydratedSubtree = inHydratedSubtree;
2867 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
2868 + const prevState: SuspenseState | null =
2869 + finishedWork.alternate !== null
2870 + ? finishedWork.alternate.memoizedState
2871 + : null;
2872 + const nextState: SuspenseState | null = finishedWork.memoizedState;
2873 + if (
2874 + prevState !== null &&
2875 + prevState.dehydrated !== null &&
2876 + (nextState === null || nextState.dehydrated === null)
2877 + ) {
2878 + // This was dehydrated but is no longer dehydrated. We may have now either hydrated it
2879 + // or client rendered it.
2880 + const deletions = finishedWork.deletions;
2881 + if (
2882 + deletions !== null &&
2883 + deletions.length > 0 &&
2884 + deletions[0].tag === DehydratedFragment
2885 + ) {
2886 + // This was an abandoned hydration that deleted the dehydrated fragment. That means we
2887 + // are not hydrating this Suspense boundary.
2888 + inHydratedSubtree = false;
2889 + const hydrationErrors = prevState.hydrationErrors;
2890 + // If there were no hydration errors, that suggests that this was an intentional client
2891 + // rendered boundary. Such as postpone.
2892 + if (hydrationErrors !== null) {
2893 + const startTime: number = (finishedWork.actualStartTime: any);
2894 + logSuspenseBoundaryClientRendered(
2895 + finishedWork,
2896 + startTime,
2897 + endTime,
2898 + hydrationErrors,
2899 + );
2900 + }
2901 + } else {
2902 + // If any children committed they were hydrated.
2903 + inHydratedSubtree = true;
2904 + }
2905 + } else {
2906 + inHydratedSubtree = false;
2907 + }
2908 + }
2909 +
2910 + recursivelyTraversePassiveMountEffects(
2911 + finishedRoot,
2912 + finishedWork,
2913 + committedLanes,
2914 + committedTransitions,
2915 + endTime,
2916 + );
2917 +
2918 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
2919 + inHydratedSubtree = wasInHydratedSubtree;
2920 + }
2921 + break;
2922 + }
2923 case LegacyHiddenComponent: {
2924 if (enableLegacyHidden) {
2925 recursivelyTraversePassiveMountEffects(
@@ -3074,6 +3153,7 @@ export function reconnectPassiveEffects(
3153 finishedWork,
3154 ((finishedWork.actualStartTime: any): number),
3155 endTime,
3156 + inHydratedSubtree,
3157 );
3158 }
3159
@@ -3317,6 +3397,7 @@ function commitAtomicPassiveEffects(
3397 finishedWork,
3398 ((finishedWork.actualStartTime: any): number),
3399 endTime,
3400 + inHydratedSubtree,
3401 );
3402 }
3403
packages/react-reconciler/src/ReactFiberCompleteWork.js
+7 -3
@@ -926,9 +926,13 @@ function completeDehydratedSuspenseBoundary(
926 // Successfully completed this tree. If this was a forced client render,
927 // there may have been recoverable errors during first hydration
928 // attempt. If so, add them to a queue so we can log them in the
929 - // commit phase.
930 - upgradeHydrationErrorsToRecoverable();
931 -
929 + // commit phase. We also add them to prev state so we can get to them
930 + // from the Suspense Boundary.
931 + const hydrationErrors = upgradeHydrationErrorsToRecoverable();
932 + if (current !== null && current.memoizedState !== null) {
933 + const prevState: SuspenseState = current.memoizedState;
934 + prevState.hydrationErrors = hydrationErrors;
935 + }
936 // Fall through to normal Suspense path
937 return true;
938 }
packages/react-reconciler/src/ReactFiberHydrationContext.js
+8 -3
@@ -280,6 +280,7 @@ function tryHydrateSuspense(fiber: Fiber, nextInstance: any) {
280 dehydrated: suspenseInstance,
281 treeContext: getSuspendedTreeContext(),
282 retryLane: OffscreenLane,
283 + hydrationErrors: null,
284 };
285 fiber.memoizedState = suspenseState;
286 // Store the dehydrated fragment as a child fiber.
@@ -701,14 +702,18 @@ function resetHydrationState(): void {
702 didSuspendOrErrorDEV = false;
703 }
704
704 -export function upgradeHydrationErrorsToRecoverable(): void {
705 - if (hydrationErrors !== null) {
705 +export function upgradeHydrationErrorsToRecoverable(): Array<
706 + CapturedValue<mixed>,
707 +> | null {
708 + const queuedErrors = hydrationErrors;
709 + if (queuedErrors !== null) {
710 // Successfully completed a forced client render. The errors that occurred
711 // during the hydration attempt are now recovered. We will log them in
712 // commit phase, once the entire tree has finished.
709 - queueRecoverableErrors(hydrationErrors);
713 + queueRecoverableErrors(queuedErrors);
714 hydrationErrors = null;
715 }
716 + return queuedErrors;
717 }
718
719 function getIsHydrating(): boolean {
packages/react-reconciler/src/ReactFiberPerformanceTrack.js
+93 -4
@@ -11,6 +11,8 @@ import type {Fiber} from './ReactInternalTypes';
11
12 import type {Lanes} from './ReactFiberLane';
13
14 +import type {CapturedValue} from './ReactCapturedValue';
15 +
16 import getComponentNameFromFiber from './getComponentNameFromFiber';
17
18 import {
@@ -123,6 +125,7 @@ export function logComponentRender(
125 fiber: Fiber,
126 startTime: number,
127 endTime: number,
128 + wasHydrated: boolean,
129 ): void {
130 const name = getComponentNameFromFiber(fiber);
131 if (name === null) {
@@ -138,11 +141,17 @@ export function logComponentRender(
141 }
142 reusableComponentDevToolDetails.color =
143 selfTime < 0.5
141 - ? 'primary-light'
144 + ? wasHydrated
145 + ? 'tertiary-light'
146 + : 'primary-light'
147 : selfTime < 10
143 - ? 'primary'
148 + ? wasHydrated
149 + ? 'tertiary'
150 + : 'primary'
151 : selfTime < 100
145 - ? 'primary-dark'
152 + ? wasHydrated
153 + ? 'tertiary-dark'
154 + : 'primary-dark'
155 : 'error';
156 reusableComponentOptions.start = startTime;
157 reusableComponentOptions.end = endTime;
@@ -150,6 +159,44 @@ export function logComponentRender(
159 }
160 }
161
162 +export function logSuspenseBoundaryClientRendered(
163 + fiber: Fiber,
164 + startTime: number,
165 + endTime: number,
166 + errors: Array<CapturedValue<mixed>>,
167 +): void {
168 + if (supportsUserTiming) {
169 + const properties = [];
170 + if (__DEV__) {
171 + for (let i = 0; i < errors.length; i++) {
172 + const capturedValue = errors[i];
173 + const error = capturedValue.value;
174 + const message =
175 + typeof error === 'object' &&
176 + error !== null &&
177 + typeof error.message === 'string'
178 + ? // eslint-disable-next-line react-internal/safe-string-coercion
179 + String(error.message)
180 + : // eslint-disable-next-line react-internal/safe-string-coercion
181 + String(error);
182 + properties.push(['Error', message]);
183 + }
184 + }
185 + performance.measure('Suspense', {
186 + start: startTime,
187 + end: endTime,
188 + detail: {
189 + devtools: {
190 + color: 'error',
191 + track: COMPONENTS_TRACK,
192 + tooltipText: 'Hydration failed',
193 + properties,
194 + },
195 + },
196 + });
197 + }
198 +}
199 +
200 export function logComponentEffect(
201 fiber: Fiber,
202 startTime: number,
@@ -387,6 +434,48 @@ export function logSuspendedWithDelayPhase(
434 }
435 }
436
437 +export function logRecoveredRenderPhase(
438 + startTime: number,
439 + endTime: number,
440 + lanes: Lanes,
441 + recoverableErrors: Array<CapturedValue<mixed>>,
442 + hydrationFailed: boolean,
443 +): void {
444 + if (supportsUserTiming) {
445 + const properties = [];
446 + if (__DEV__) {
447 + for (let i = 0; i < recoverableErrors.length; i++) {
448 + const capturedValue = recoverableErrors[i];
449 + const error = capturedValue.value;
450 + const message =
451 + typeof error === 'object' &&
452 + error !== null &&
453 + typeof error.message === 'string'
454 + ? // eslint-disable-next-line react-internal/safe-string-coercion
455 + String(error.message)
456 + : // eslint-disable-next-line react-internal/safe-string-coercion
457 + String(error);
458 + properties.push(['Recoverable Error', message]);
459 + }
460 + }
461 + performance.measure('Recovered', {
462 + start: startTime,
463 + end: endTime,
464 + detail: {
465 + devtools: {
466 + color: 'primary-dark',
467 + track: reusableLaneDevToolDetails.track,
468 + trackGroup: LANES_TRACK_GROUP,
469 + tooltipText: hydrationFailed
470 + ? 'Hydration Failed'
471 + : 'Recovered after Error',
472 + properties,
473 + },
474 + },
475 + });
476 + }
477 +}
478 +
479 export function logErroredRenderPhase(
480 startTime: number,
481 endTime: number,
@@ -396,7 +485,7 @@ export function logErroredRenderPhase(
485 reusableLaneDevToolDetails.color = 'error';
486 reusableLaneOptions.start = startTime;
487 reusableLaneOptions.end = endTime;
399 - performance.measure('Errored Render', reusableLaneOptions);
488 + performance.measure('Errored', reusableLaneOptions);
489 }
490 }
491
packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+3
@@ -12,6 +12,7 @@ import type {Fiber} from './ReactInternalTypes';
12 import type {SuspenseInstance} from './ReactFiberConfig';
13 import type {Lane} from './ReactFiberLane';
14 import type {TreeContext} from './ReactFiberTreeContext';
15 +import type {CapturedValue} from './ReactCapturedValue';
16
17 import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags';
18 import {NoFlags, DidCapture} from './ReactFiberFlags';
@@ -49,6 +50,8 @@ export type SuspenseState = {
50 // OffscreenLane is the default for dehydrated boundaries.
51 // NoLane is the default for normal boundaries, which turns into "normal" pri.
52 retryLane: Lane,
53 + // Stashed Errors that happened while attempting to hydrate this boundary.
54 + hydrationErrors: Array<CapturedValue<mixed>> | null,
55 };
56
57 export type SuspenseListTailMode = 'collapsed' | 'hidden' | void;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+15
@@ -23,6 +23,7 @@ import type {
23 } from './ReactFiberTracingMarkerComponent';
24 import type {OffscreenInstance} from './ReactFiberActivityComponent';
25 import type {Resource} from './ReactFiberConfig';
26 +import type {RootState} from './ReactFiberRoot';
27
28 import {
29 enableCreateEventHandleAPI,
@@ -59,6 +60,7 @@ import {
60 logRenderPhase,
61 logInterruptedRenderPhase,
62 logSuspendedRenderPhase,
63 + logRecoveredRenderPhase,
64 logErroredRenderPhase,
65 logInconsistentRender,
66 logSuspendedWithDelayPhase,
@@ -3183,6 +3185,19 @@ function commitRootImpl(
3185 completedRenderEndTime,
3186 lanes,
3187 );
3188 + } else if (recoverableErrors !== null) {
3189 + const hydrationFailed =
3190 + finishedWork !== null &&
3191 + finishedWork.alternate !== null &&
3192 + (finishedWork.alternate.memoizedState: RootState).isDehydrated &&
3193 + (finishedWork.flags & ForceClientRender) !== NoFlags;
3194 + logRecoveredRenderPhase(
3195 + completedRenderStartTime,
3196 + completedRenderEndTime,
3197 + lanes,
3198 + recoverableErrors,
3199 + hydrationFailed,
3200 + );
3201 } else {
3202 logRenderPhase(completedRenderStartTime, completedRenderEndTime, lanes);
3203 }