Skip to content

Commit 17520b6

Browse files
authored
[Fiber] Mark hydrated components in tertiary color (green) (facebook#31829)
This is a follow up to facebook#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" />
1 parent 7de040c commit 17520b6

File tree

7 files changed

+208
-10
lines changed

7 files changed

+208
-10
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1933,6 +1933,7 @@ const SUSPENDED_MARKER: SuspenseState = {
19331933
dehydrated: null,
19341934
treeContext: null,
19351935
retryLane: NoLane,
1936+
hydrationErrors: null,
19361937
};
19371938

19381939
function mountSuspenseOffscreenState(renderLanes: Lanes): OffscreenState {

packages/react-reconciler/src/ReactFiberCommitWork.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import {
9797
FormReset,
9898
Cloned,
9999
PerformedWork,
100+
ForceClientRender,
100101
} from './ReactFiberFlags';
101102
import {
102103
commitStartTime,
@@ -113,6 +114,7 @@ import {
113114
import {
114115
logComponentRender,
115116
logComponentEffect,
117+
logSuspenseBoundaryClientRendered,
116118
} from './ReactFiberPerformanceTrack';
117119
import {ConcurrentMode, NoMode, ProfileMode} from './ReactTypeOfMode';
118120
import {deferHiddenCallbacks} from './ReactFiberClassUpdateQueue';
@@ -2689,6 +2691,8 @@ function recursivelyTraversePassiveMountEffects(
26892691
}
26902692
}
26912693

2694+
let inHydratedSubtree = false;
2695+
26922696
function commitPassiveMountOnFiber(
26932697
finishedRoot: FiberRoot,
26942698
finishedWork: Fiber,
@@ -2713,6 +2717,7 @@ function commitPassiveMountOnFiber(
27132717
finishedWork,
27142718
((finishedWork.actualStartTime: any): number),
27152719
endTime,
2720+
inHydratedSubtree,
27162721
);
27172722
}
27182723

@@ -2741,13 +2746,29 @@ function commitPassiveMountOnFiber(
27412746
}
27422747
case HostRoot: {
27432748
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+
27442760
recursivelyTraversePassiveMountEffects(
27452761
finishedRoot,
27462762
finishedWork,
27472763
committedLanes,
27482764
committedTransitions,
27492765
endTime,
27502766
);
2767+
2768+
if (enableProfilerTimer && enableComponentPerformanceTrack) {
2769+
inHydratedSubtree = wasInHydratedSubtree;
2770+
}
2771+
27512772
if (flags & Passive) {
27522773
let previousCache: Cache | null = null;
27532774
if (finishedWork.alternate !== null) {
@@ -2841,6 +2862,64 @@ function commitPassiveMountOnFiber(
28412862
}
28422863
break;
28432864
}
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+
}
28442923
case LegacyHiddenComponent: {
28452924
if (enableLegacyHidden) {
28462925
recursivelyTraversePassiveMountEffects(
@@ -3074,6 +3153,7 @@ export function reconnectPassiveEffects(
30743153
finishedWork,
30753154
((finishedWork.actualStartTime: any): number),
30763155
endTime,
3156+
inHydratedSubtree,
30773157
);
30783158
}
30793159

@@ -3317,6 +3397,7 @@ function commitAtomicPassiveEffects(
33173397
finishedWork,
33183398
((finishedWork.actualStartTime: any): number),
33193399
endTime,
3400+
inHydratedSubtree,
33203401
);
33213402
}
33223403

packages/react-reconciler/src/ReactFiberCompleteWork.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -926,9 +926,13 @@ function completeDehydratedSuspenseBoundary(
926926
// Successfully completed this tree. If this was a forced client render,
927927
// there may have been recoverable errors during first hydration
928928
// 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+
}
932936
// Fall through to normal Suspense path
933937
return true;
934938
}

packages/react-reconciler/src/ReactFiberHydrationContext.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ function tryHydrateSuspense(fiber: Fiber, nextInstance: any) {
280280
dehydrated: suspenseInstance,
281281
treeContext: getSuspendedTreeContext(),
282282
retryLane: OffscreenLane,
283+
hydrationErrors: null,
283284
};
284285
fiber.memoizedState = suspenseState;
285286
// Store the dehydrated fragment as a child fiber.
@@ -701,14 +702,18 @@ function resetHydrationState(): void {
701702
didSuspendOrErrorDEV = false;
702703
}
703704

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) {
706710
// Successfully completed a forced client render. The errors that occurred
707711
// during the hydration attempt are now recovered. We will log them in
708712
// commit phase, once the entire tree has finished.
709-
queueRecoverableErrors(hydrationErrors);
713+
queueRecoverableErrors(queuedErrors);
710714
hydrationErrors = null;
711715
}
716+
return queuedErrors;
712717
}
713718

714719
function getIsHydrating(): boolean {

packages/react-reconciler/src/ReactFiberPerformanceTrack.js

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type {Fiber} from './ReactInternalTypes';
1111

1212
import type {Lanes} from './ReactFiberLane';
1313

14+
import type {CapturedValue} from './ReactCapturedValue';
15+
1416
import getComponentNameFromFiber from './getComponentNameFromFiber';
1517

1618
import {
@@ -123,6 +125,7 @@ export function logComponentRender(
123125
fiber: Fiber,
124126
startTime: number,
125127
endTime: number,
128+
wasHydrated: boolean,
126129
): void {
127130
const name = getComponentNameFromFiber(fiber);
128131
if (name === null) {
@@ -138,18 +141,62 @@ export function logComponentRender(
138141
}
139142
reusableComponentDevToolDetails.color =
140143
selfTime < 0.5
141-
? 'primary-light'
144+
? wasHydrated
145+
? 'tertiary-light'
146+
: 'primary-light'
142147
: selfTime < 10
143-
? 'primary'
148+
? wasHydrated
149+
? 'tertiary'
150+
: 'primary'
144151
: selfTime < 100
145-
? 'primary-dark'
152+
? wasHydrated
153+
? 'tertiary-dark'
154+
: 'primary-dark'
146155
: 'error';
147156
reusableComponentOptions.start = startTime;
148157
reusableComponentOptions.end = endTime;
149158
performance.measure(name, reusableComponentOptions);
150159
}
151160
}
152161

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+
153200
export function logComponentEffect(
154201
fiber: Fiber,
155202
startTime: number,
@@ -387,6 +434,48 @@ export function logSuspendedWithDelayPhase(
387434
}
388435
}
389436

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+
390479
export function logErroredRenderPhase(
391480
startTime: number,
392481
endTime: number,
@@ -396,7 +485,7 @@ export function logErroredRenderPhase(
396485
reusableLaneDevToolDetails.color = 'error';
397486
reusableLaneOptions.start = startTime;
398487
reusableLaneOptions.end = endTime;
399-
performance.measure('Errored Render', reusableLaneOptions);
488+
performance.measure('Errored', reusableLaneOptions);
400489
}
401490
}
402491

packages/react-reconciler/src/ReactFiberSuspenseComponent.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {Fiber} from './ReactInternalTypes';
1212
import type {SuspenseInstance} from './ReactFiberConfig';
1313
import type {Lane} from './ReactFiberLane';
1414
import type {TreeContext} from './ReactFiberTreeContext';
15+
import type {CapturedValue} from './ReactCapturedValue';
1516

1617
import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags';
1718
import {NoFlags, DidCapture} from './ReactFiberFlags';
@@ -49,6 +50,8 @@ export type SuspenseState = {
4950
// OffscreenLane is the default for dehydrated boundaries.
5051
// NoLane is the default for normal boundaries, which turns into "normal" pri.
5152
retryLane: Lane,
53+
// Stashed Errors that happened while attempting to hydrate this boundary.
54+
hydrationErrors: Array<CapturedValue<mixed>> | null,
5255
};
5356

5457
export type SuspenseListTailMode = 'collapsed' | 'hidden' | void;

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
} from './ReactFiberTracingMarkerComponent';
2424
import type {OffscreenInstance} from './ReactFiberActivityComponent';
2525
import type {Resource} from './ReactFiberConfig';
26+
import type {RootState} from './ReactFiberRoot';
2627

2728
import {
2829
enableCreateEventHandleAPI,
@@ -59,6 +60,7 @@ import {
5960
logRenderPhase,
6061
logInterruptedRenderPhase,
6162
logSuspendedRenderPhase,
63+
logRecoveredRenderPhase,
6264
logErroredRenderPhase,
6365
logInconsistentRender,
6466
logSuspendedWithDelayPhase,
@@ -3183,6 +3185,19 @@ function commitRootImpl(
31833185
completedRenderEndTime,
31843186
lanes,
31853187
);
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+
);
31863201
} else {
31873202
logRenderPhase(completedRenderStartTime, completedRenderEndTime, lanes);
31883203
}

0 commit comments

Comments
 (0)