Skip to content

Commit ed5f010

Browse files
sebmarkbagelunaruan
authored andcommitted
Client render Suspense content if there's no boundary match (#16945)
Without the enableSuspenseServerRenderer flag there will never be a boundary match. Also when it is enabled, there might not be a boundary match if something was conditionally rendered by mistake. With this PR it will now client render the content of a Suspense boundary in that case and issue a DEV only hydration warning. This is the only sound semantics for this case. Unfortunately, landing this will once again break #16938. It will be less bad though because at least it'll just work by client rendering the content instead of hydrating and issue a DEV only warning. However, we must land this before enabling the enableSuspenseServerRenderer flag since it does this anyway. I did notice that we special case fallback={undefined} due to our unfortunate semantics for that. So technically a workaround that works is actually setting the fallback to undefined on the server and during hydration. Then flip it on only after hydration. That could be a workaround if you want to be able to have a Suspense boundary work only after hydration for some reason. It's kind of unfortunate but at least those semantics are internally consistent. So I added a test for that.
1 parent 9169375 commit ed5f010

File tree

4 files changed

+47
-16
lines changed

4 files changed

+47
-16
lines changed

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

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,14 +517,45 @@ describe('ReactDOMServerHydration', () => {
517517
});
518518
}
519519

520-
it('regression test: Suspense + hydration in legacy mode ', () => {
520+
it('Suspense + hydration in legacy mode', () => {
521521
const element = document.createElement('div');
522522
element.innerHTML = '<div>Hello World</div>';
523+
let div = element.firstChild;
524+
let ref = React.createRef();
525+
expect(() =>
526+
ReactDOM.hydrate(
527+
<React.Suspense fallback={null}>
528+
<div ref={ref}>Hello World</div>
529+
</React.Suspense>,
530+
element,
531+
),
532+
).toWarnDev(
533+
'Warning: Did not expect server HTML to contain a <div> in <div>.',
534+
{withoutStack: true},
535+
);
536+
537+
// The content should've been client rendered and replaced the
538+
// existing div.
539+
expect(ref.current).not.toBe(div);
540+
// The HTML should be the same though.
541+
expect(element.innerHTML).toBe('<div>Hello World</div>');
542+
});
543+
544+
it('Suspense + hydration in legacy mode with no fallback', () => {
545+
const element = document.createElement('div');
546+
element.innerHTML = '<div>Hello World</div>';
547+
let div = element.firstChild;
548+
let ref = React.createRef();
523549
ReactDOM.hydrate(
524550
<React.Suspense>
525-
<div>Hello World</div>
551+
<div ref={ref}>Hello World</div>
526552
</React.Suspense>,
527553
element,
528554
);
555+
556+
// Because this didn't have a fallback, it was hydrated as if it's
557+
// not a Suspense boundary.
558+
expect(ref.current).toBe(div);
559+
expect(element.innerHTML).toBe('<div>Hello World</div>');
529560
});
530561
});

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1592,12 +1592,12 @@ function updateSuspenseComponent(
15921592
// children. It's essentially a very basic form of re-parenting.
15931593

15941594
if (current === null) {
1595-
if (enableSuspenseServerRenderer) {
1596-
// If we're currently hydrating, try to hydrate this boundary.
1597-
// But only if this has a fallback.
1598-
if (nextProps.fallback !== undefined) {
1599-
tryToClaimNextHydratableInstance(workInProgress);
1600-
// This could've been a dehydrated suspense component.
1595+
// If we're currently hydrating, try to hydrate this boundary.
1596+
// But only if this has a fallback.
1597+
if (nextProps.fallback !== undefined) {
1598+
tryToClaimNextHydratableInstance(workInProgress);
1599+
// This could've been a dehydrated suspense component.
1600+
if (enableSuspenseServerRenderer) {
16011601
const suspenseState: null | SuspenseState =
16021602
workInProgress.memoizedState;
16031603
if (suspenseState !== null) {

packages/react-reconciler/src/ReactFiberCompleteWork.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -870,10 +870,9 @@ function completeWork(
870870
const nextDidTimeout = nextState !== null;
871871
let prevDidTimeout = false;
872872
if (current === null) {
873-
// In cases where we didn't find a suitable hydration boundary we never
874-
// put this in dehydrated mode, but we still need to pop the hydration
875-
// state since we might be inside the insertion tree.
876-
popHydrationState(workInProgress);
873+
if (workInProgress.memoizedProps.fallback !== undefined) {
874+
popHydrationState(workInProgress);
875+
}
877876
} else {
878877
const prevState: null | SuspenseState = current.memoizedState;
879878
prevDidTimeout = prevState !== null;

packages/react-reconciler/src/ReactFiberHydrationContext.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -404,10 +404,11 @@ function skipPastDehydratedSuspenseInstance(
404404
let suspenseState: null | SuspenseState = fiber.memoizedState;
405405
let suspenseInstance: null | SuspenseInstance =
406406
suspenseState !== null ? suspenseState.dehydrated : null;
407-
if (suspenseInstance === null) {
408-
// This Suspense boundary was hydrated without a match.
409-
return nextHydratableInstance;
410-
}
407+
invariant(
408+
suspenseInstance,
409+
'Expected to have a hydrated suspense instance. ' +
410+
'This error is likely caused by a bug in React. Please file an issue.',
411+
);
411412
return getNextHydratableInstanceAfterSuspenseInstance(suspenseInstance);
412413
}
413414

0 commit comments

Comments
 (0)