Skip to content

Commit ebce981

Browse files
committed
refactor didSuspend to didSuspendOrError
the orignial behavior applied the hydration warning bailout to error paths only. originally I moved it to Suspense paths only but this commit restores it to both paths and renames the marker function as didThrow rather than didSuspend The logic here is that for either case if we get a mismatch in hydration we want to warm up components but effectively consider the hydration for this boundary halted
1 parent 158f216 commit ebce981

File tree

5 files changed

+215
-28
lines changed

5 files changed

+215
-28
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
let JSDOM;
1313
let Stream;
1414
let Scheduler;
15+
let Suspense;
1516
let React;
1617
let ReactDOMClient;
1718
let ReactDOMFizzServer;
@@ -28,6 +29,7 @@ describe('ReactDOMFizzServerHydrationWarning', () => {
2829
JSDOM = require('jsdom').JSDOM;
2930
Scheduler = require('scheduler');
3031
React = require('react');
32+
Suspense = React.Suspense;
3133
ReactDOMClient = require('react-dom/client');
3234
if (__EXPERIMENTAL__) {
3335
ReactDOMFizzServer = require('react-dom/server');
@@ -58,6 +60,18 @@ describe('ReactDOMFizzServerHydrationWarning', () => {
5860
});
5961
});
6062

63+
function normalizeCodeLocInfo(strOrErr) {
64+
if (strOrErr && strOrErr.replace) {
65+
return strOrErr.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(
66+
m,
67+
name,
68+
) {
69+
return '\n in ' + name + ' (at **)';
70+
});
71+
}
72+
return strOrErr;
73+
}
74+
6175
async function act(callback) {
6276
await callback();
6377
// Await one turn around the event loop.
@@ -702,4 +716,169 @@ describe('ReactDOMFizzServerHydrationWarning', () => {
702716
</div>,
703717
);
704718
});
719+
720+
// @gate experimental && enableClientRenderFallbackOnTextMismatch && enableClientRenderFallbackOnHydrationMismatch
721+
it('#24384: Suspending should halt hydration warnings while still allowing siblings to warm up', async () => {
722+
const makeApp = () => {
723+
let resolve, resolved;
724+
const promise = new Promise(r => {
725+
resolve = () => {
726+
resolved = true;
727+
return r();
728+
};
729+
});
730+
function ComponentThatSuspends() {
731+
if (!resolved) {
732+
throw promise;
733+
}
734+
return <p>A</p>;
735+
}
736+
737+
const App = ({text}) => {
738+
return (
739+
<div>
740+
<Suspense fallback={<h1>Loading...</h1>}>
741+
<ComponentThatSuspends />
742+
<h2 name={text}>{text}</h2>
743+
</Suspense>
744+
</div>
745+
);
746+
};
747+
748+
return [App, resolve];
749+
};
750+
751+
const [ServerApp, serverResolve] = makeApp();
752+
await act(async () => {
753+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
754+
<ServerApp text="initial" />,
755+
);
756+
pipe(writable);
757+
});
758+
await act(() => {
759+
serverResolve();
760+
});
761+
762+
expect(getVisibleChildren(container)).toEqual(
763+
<div>
764+
<p>A</p>
765+
<h2 name="initial">initial</h2>
766+
</div>,
767+
);
768+
769+
// the client app is rendered with an intentionally incorrect text. The still Suspended component causes
770+
// hydration to fail silently (allowing for cache warming but otherwise skipping this boundary) until it
771+
// resolves.
772+
const [ClientApp, clientResolve] = makeApp();
773+
ReactDOMClient.hydrateRoot(container, <ClientApp text="replaced" />, {
774+
onRecoverableError(error) {
775+
Scheduler.unstable_yieldValue(
776+
'Logged recoverable error: ' + error.message,
777+
);
778+
},
779+
});
780+
Scheduler.unstable_flushAll();
781+
782+
expect(getVisibleChildren(container)).toEqual(
783+
<div>
784+
<p>A</p>
785+
<h2 name="initial">initial</h2>
786+
</div>,
787+
);
788+
789+
// Now that the boundary resolves to it's children the hydration completes and discovers that there is a mismatch requiring
790+
// client-side rendering.
791+
await clientResolve();
792+
expect(() => {
793+
expect(Scheduler).toFlushAndYield([
794+
'Logged recoverable error: Text content does not match server-rendered HTML.',
795+
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
796+
]);
797+
}).toErrorDev(
798+
'Warning: Prop `name` did not match. Server: "initial" Client: "replaced"',
799+
);
800+
expect(getVisibleChildren(container)).toEqual(
801+
<div>
802+
<p>A</p>
803+
<h2 name="replaced">replaced</h2>
804+
</div>,
805+
);
806+
807+
expect(Scheduler).toFlushAndYield([]);
808+
});
809+
810+
// @gate experimental && enableClientRenderFallbackOnTextMismatch && enableClientRenderFallbackOnHydrationMismatch
811+
it('only warns once on hydration mismatch while within a suspense boundary', async () => {
812+
const originalConsoleError = console.error;
813+
const mockError = jest.fn();
814+
console.error = (...args) => {
815+
mockError(...args.map(normalizeCodeLocInfo));
816+
};
817+
818+
const App = ({text}) => {
819+
return (
820+
<div>
821+
<Suspense fallback={<h1>Loading...</h1>}>
822+
<h2>{text}</h2>
823+
<h2>{text}</h2>
824+
<h2>{text}</h2>
825+
</Suspense>
826+
</div>
827+
);
828+
};
829+
830+
try {
831+
await act(async () => {
832+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
833+
<App text="initial" />,
834+
);
835+
pipe(writable);
836+
});
837+
838+
expect(getVisibleChildren(container)).toEqual(
839+
<div>
840+
<h2>initial</h2>
841+
<h2>initial</h2>
842+
<h2>initial</h2>
843+
</div>,
844+
);
845+
846+
ReactDOMClient.hydrateRoot(container, <App text="replaced" />, {
847+
onRecoverableError(error) {
848+
Scheduler.unstable_yieldValue(
849+
'Logged recoverable error: ' + error.message,
850+
);
851+
},
852+
});
853+
expect(Scheduler).toFlushAndYield([
854+
'Logged recoverable error: Text content does not match server-rendered HTML.',
855+
'Logged recoverable error: Text content does not match server-rendered HTML.',
856+
'Logged recoverable error: Text content does not match server-rendered HTML.',
857+
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
858+
]);
859+
860+
expect(getVisibleChildren(container)).toEqual(
861+
<div>
862+
<h2>replaced</h2>
863+
<h2>replaced</h2>
864+
<h2>replaced</h2>
865+
</div>,
866+
);
867+
868+
expect(Scheduler).toFlushAndYield([]);
869+
expect(mockError.mock.calls.length).toBe(1);
870+
expect(mockError.mock.calls[0]).toEqual([
871+
'Warning: Text content did not match. Server: "%s" Client: "%s"%s',
872+
'initial',
873+
'replaced',
874+
'\n' +
875+
' in h2 (at **)\n' +
876+
' in Suspense (at **)\n' +
877+
' in div (at **)\n' +
878+
' in App (at **)',
879+
]);
880+
} finally {
881+
console.error = originalConsoleError;
882+
}
883+
});
705884
});

packages/react-reconciler/src/ReactFiberHydrationContext.new.js

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ import {queueRecoverableErrors} from './ReactFiberWorkLoop.new';
8484
let hydrationParentFiber: null | Fiber = null;
8585
let nextHydratableInstance: null | HydratableInstance = null;
8686
let isHydrating: boolean = false;
87-
let didSuspend: boolean = false;
87+
88+
// this flag allows for warning supression when we expect there to be mismatches due to
89+
// earlier mismatches or a suspended fiber.
90+
let didSuspendOrError: boolean = false;
8891

8992
// Hydration errors that were thrown inside this boundary
9093
let hydrationErrors: Array<mixed> | null = null;
@@ -99,9 +102,9 @@ function warnIfHydrating() {
99102
}
100103
}
101104

102-
export function markDidSuspendWhileHydratingDEV() {
105+
export function markDidThrowWhileHydratingDEV() {
103106
if (__DEV__) {
104-
didSuspend = true;
107+
didSuspendOrError = true;
105108
}
106109
}
107110

@@ -117,7 +120,7 @@ function enterHydrationState(fiber: Fiber): boolean {
117120
hydrationParentFiber = fiber;
118121
isHydrating = true;
119122
hydrationErrors = null;
120-
didSuspend = false;
123+
didSuspendOrError = false;
121124
return true;
122125
}
123126

@@ -135,7 +138,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
135138
hydrationParentFiber = fiber;
136139
isHydrating = true;
137140
hydrationErrors = null;
138-
didSuspend = false;
141+
didSuspendOrError = false;
139142
if (treeContext !== null) {
140143
restoreSuspendedTreeContext(fiber, treeContext);
141144
}
@@ -200,7 +203,7 @@ function deleteHydratableInstance(
200203

201204
function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) {
202205
if (__DEV__) {
203-
if (didSuspend) {
206+
if (didSuspendOrError) {
204207
// Inside a boundary that already suspended. We're currently rendering the
205208
// siblings of a suspended node. The mismatch may be due to the missing
206209
// data, so it's probably a false positive.
@@ -451,7 +454,7 @@ function prepareToHydrateHostInstance(
451454
}
452455

453456
const instance: Instance = fiber.stateNode;
454-
const shouldWarnIfMismatchDev = !didSuspend;
457+
const shouldWarnIfMismatchDev = !didSuspendOrError;
455458
const updatePayload = hydrateInstance(
456459
instance,
457460
fiber.type,
@@ -481,7 +484,7 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
481484

482485
const textInstance: TextInstance = fiber.stateNode;
483486
const textContent: string = fiber.memoizedProps;
484-
const shouldWarnIfMismatchDev = !didSuspend;
487+
const shouldWarnIfMismatchDev = !didSuspendOrError;
485488
const shouldUpdate = hydrateTextInstance(
486489
textInstance,
487490
textContent,
@@ -660,7 +663,7 @@ function resetHydrationState(): void {
660663
hydrationParentFiber = null;
661664
nextHydratableInstance = null;
662665
isHydrating = false;
663-
didSuspend = false;
666+
didSuspendOrError = false;
664667
}
665668

666669
export function upgradeHydrationErrorsToRecoverable(): void {

packages/react-reconciler/src/ReactFiberHydrationContext.old.js

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ import {queueRecoverableErrors} from './ReactFiberWorkLoop.old';
8484
let hydrationParentFiber: null | Fiber = null;
8585
let nextHydratableInstance: null | HydratableInstance = null;
8686
let isHydrating: boolean = false;
87-
let didSuspend: boolean = false;
87+
88+
// this flag allows for warning supression when we expect there to be mismatches due to
89+
// earlier mismatches or a suspended fiber.
90+
let didSuspendOrError: boolean = false;
8891

8992
// Hydration errors that were thrown inside this boundary
9093
let hydrationErrors: Array<mixed> | null = null;
@@ -99,9 +102,9 @@ function warnIfHydrating() {
99102
}
100103
}
101104

102-
export function markDidSuspendWhileHydratingDEV() {
105+
export function markDidThrowWhileHydratingDEV() {
103106
if (__DEV__) {
104-
didSuspend = true;
107+
didSuspendOrError = true;
105108
}
106109
}
107110

@@ -117,7 +120,7 @@ function enterHydrationState(fiber: Fiber): boolean {
117120
hydrationParentFiber = fiber;
118121
isHydrating = true;
119122
hydrationErrors = null;
120-
didSuspend = false;
123+
didSuspendOrError = false;
121124
return true;
122125
}
123126

@@ -135,7 +138,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
135138
hydrationParentFiber = fiber;
136139
isHydrating = true;
137140
hydrationErrors = null;
138-
didSuspend = false;
141+
didSuspendOrError = false;
139142
if (treeContext !== null) {
140143
restoreSuspendedTreeContext(fiber, treeContext);
141144
}
@@ -200,7 +203,7 @@ function deleteHydratableInstance(
200203

201204
function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) {
202205
if (__DEV__) {
203-
if (didSuspend) {
206+
if (didSuspendOrError) {
204207
// Inside a boundary that already suspended. We're currently rendering the
205208
// siblings of a suspended node. The mismatch may be due to the missing
206209
// data, so it's probably a false positive.
@@ -451,7 +454,7 @@ function prepareToHydrateHostInstance(
451454
}
452455

453456
const instance: Instance = fiber.stateNode;
454-
const shouldWarnIfMismatchDev = !didSuspend;
457+
const shouldWarnIfMismatchDev = !didSuspendOrError;
455458
const updatePayload = hydrateInstance(
456459
instance,
457460
fiber.type,
@@ -481,7 +484,7 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
481484

482485
const textInstance: TextInstance = fiber.stateNode;
483486
const textContent: string = fiber.memoizedProps;
484-
const shouldWarnIfMismatchDev = !didSuspend;
487+
const shouldWarnIfMismatchDev = !didSuspendOrError;
485488
const shouldUpdate = hydrateTextInstance(
486489
textInstance,
487490
textContent,
@@ -660,7 +663,7 @@ function resetHydrationState(): void {
660663
hydrationParentFiber = null;
661664
nextHydratableInstance = null;
662665
isHydrating = false;
663-
didSuspend = false;
666+
didSuspendOrError = false;
664667
}
665668

666669
export function upgradeHydrationErrorsToRecoverable(): void {

packages/react-reconciler/src/ReactFiberThrow.new.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ import {
8383
} from './ReactFiberLane.new';
8484
import {
8585
getIsHydrating,
86-
markDidSuspendWhileHydratingDEV,
86+
markDidThrowWhileHydratingDEV,
8787
queueHydrationError,
8888
} from './ReactFiberHydrationContext.new';
8989

@@ -453,8 +453,10 @@ function throwException(
453453
const wakeable: Wakeable = (value: any);
454454
resetSuspendedComponent(sourceFiber, rootRenderLanes);
455455

456-
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
457-
markDidSuspendWhileHydratingDEV();
456+
if (__DEV__) {
457+
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
458+
markDidThrowWhileHydratingDEV();
459+
}
458460
}
459461

460462
if (__DEV__) {
@@ -518,6 +520,7 @@ function throwException(
518520
} else {
519521
// This is a regular error, not a Suspense wakeable.
520522
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
523+
markDidThrowWhileHydratingDEV();
521524
const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
522525
// If the error was thrown during hydration, we may be able to recover by
523526
// discarding the dehydrated content and switching to a client render.

0 commit comments

Comments
 (0)