@samitouri / QOS-React-2 / commits / ed5f010ae5

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.

Sebastian Markbåge committed Oct 16, 2019 at 16:23 UTC ed5f010ae51db1544ce92e1a5105e870b5a5098e
4 files changed +47 -16
packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js
+33 -2
@@ -517,14 +517,45 @@ describe('ReactDOMServerHydration', () => {
517 });
518 }
519
520 - it('regression test: Suspense + hydration in legacy mode ', () => {
520 + it('Suspense + hydration in legacy mode', () => {
521 const element = document.createElement('div');
522 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();
549 ReactDOM.hydrate(
550 <React.Suspense>
525 - <div>Hello World</div>
551 + <div ref={ref}>Hello World</div>
552 </React.Suspense>,
553 element,
554 );
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>');
560 });
561 });
packages/react-reconciler/src/ReactFiberBeginWork.js
+6 -6
@@ -1592,12 +1592,12 @@ function updateSuspenseComponent(
1592 // children. It's essentially a very basic form of re-parenting.
1593
1594 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) {
1601 const suspenseState: null | SuspenseState =
1602 workInProgress.memoizedState;
1603 if (suspenseState !== null) {
packages/react-reconciler/src/ReactFiberCompleteWork.js
+3 -4
@@ -870,10 +870,9 @@ function completeWork(
870 const nextDidTimeout = nextState !== null;
871 let prevDidTimeout = false;
872 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 + }
876 } else {
877 const prevState: null | SuspenseState = current.memoizedState;
878 prevDidTimeout = prevState !== null;
packages/react-reconciler/src/ReactFiberHydrationContext.js
+5 -4
@@ -404,10 +404,11 @@ function skipPastDehydratedSuspenseInstance(
404 let suspenseState: null | SuspenseState = fiber.memoizedState;
405 let suspenseInstance: null | SuspenseInstance =
406 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 + );
412 return getNextHydratableInstanceAfterSuspenseInstance(suspenseInstance);
413 }
414