@samitouri / QOS-React / commits / 13645d224d

Deal with fallback content in Partial Hydration (#14884)

* Replace SSR fallback content with new suspense content * The three states of a Dehydrated Suspense This introduces three states for dehydrated suspense boundaries Pending - This means that the tree is still waiting for additional data or to be populated with its final content. Fallback - This means that the tree has entered a permanent fallback state and no more data from the server is to be expected. This means that the client should take over and try to render instead. The fallback nodes will be deleted. Normal - The node has entered its final content and is now ready to be hydrated. * Rename retryTimedOutBoundary to resolveRetryThenable This doesn't just retry. It assumes that resolving a thenable means that it is ok to clear it from the thenable cache. We'll reuse the retryTimedOutBoundary logic separately. * Register a callback to be fired when a boundary changes away from pending It's now possible to switch from a pending state to either hydrating or replacing the content.

Sebastian Markbåge committed Feb 19, 2019 at 13:07 UTC 13645d224d66dc7d295c5e65dae4b2bb62ee174d
11 files changed +334 -35
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+224
@@ -634,4 +634,228 @@ describe('ReactDOMServerPartialHydration', () => {
634 expect(ref.current).toBe(span);
635 expect(container.textContent).toBe('Hi');
636 });
637 +
638 + it('replaces the fallback with client content if it is not rendered by the server', async () => {
639 + let suspend = false;
640 + let promise = new Promise(resolvePromise => {});
641 + let ref = React.createRef();
642 +
643 + function Child() {
644 + if (suspend) {
645 + throw promise;
646 + } else {
647 + return 'Hello';
648 + }
649 + }
650 +
651 + function App() {
652 + return (
653 + <div>
654 + <Suspense fallback="Loading...">
655 + <span ref={ref}>
656 + <Child />
657 + </span>
658 + </Suspense>
659 + </div>
660 + );
661 + }
662 +
663 + // First we render the final HTML. With the streaming renderer
664 + // this may have suspense points on the server but here we want
665 + // to test the completed HTML. Don't suspend on the server.
666 + suspend = true;
667 + let finalHTML = ReactDOMServer.renderToString(<App />);
668 + let container = document.createElement('div');
669 + container.innerHTML = finalHTML;
670 +
671 + expect(container.getElementsByTagName('span').length).toBe(0);
672 +
673 + // On the client we have the data available quickly for some reason.
674 + suspend = false;
675 + let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
676 + root.render(<App />);
677 + jest.runAllTimers();
678 +
679 + expect(container.textContent).toBe('Hello');
680 +
681 + let span = container.getElementsByTagName('span')[0];
682 + expect(ref.current).toBe(span);
683 + });
684 +
685 + it('waits for pending content to come in from the server and then hydrates it', async () => {
686 + let suspend = false;
687 + let promise = new Promise(resolvePromise => {});
688 + let ref = React.createRef();
689 +
690 + function Child() {
691 + if (suspend) {
692 + throw promise;
693 + } else {
694 + return 'Hello';
695 + }
696 + }
697 +
698 + function App() {
699 + return (
700 + <div>
701 + <Suspense fallback="Loading...">
702 + <span ref={ref}>
703 + <Child />
704 + </span>
705 + </Suspense>
706 + </div>
707 + );
708 + }
709 +
710 + // We're going to simulate what Fizz will do during streaming rendering.
711 +
712 + // First we generate the HTML of the loading state.
713 + suspend = true;
714 + let loadingHTML = ReactDOMServer.renderToString(<App />);
715 + // Then we generate the HTML of the final content.
716 + suspend = false;
717 + let finalHTML = ReactDOMServer.renderToString(<App />);
718 +
719 + let container = document.createElement('div');
720 + container.innerHTML = loadingHTML;
721 +
722 + let suspenseNode = container.firstChild.firstChild;
723 + expect(suspenseNode.nodeType).toBe(8);
724 + // Put the suspense node in hydration state.
725 + suspenseNode.data = '$?';
726 +
727 + // This will simulates new content streaming into the document and
728 + // replacing the fallback with final content.
729 + function streamInContent() {
730 + let temp = document.createElement('div');
731 + temp.innerHTML = finalHTML;
732 + let finalSuspenseNode = temp.firstChild.firstChild;
733 + let fallbackContent = suspenseNode.nextSibling;
734 + let finalContent = finalSuspenseNode.nextSibling;
735 + suspenseNode.parentNode.replaceChild(finalContent, fallbackContent);
736 + suspenseNode.data = '$';
737 + if (suspenseNode._reactRetry) {
738 + suspenseNode._reactRetry();
739 + }
740 + }
741 +
742 + // We're still showing a fallback.
743 + expect(container.getElementsByTagName('span').length).toBe(0);
744 +
745 + // Attempt to hydrate the content.
746 + suspend = false;
747 + let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
748 + root.render(<App />);
749 + jest.runAllTimers();
750 +
751 + // We're still loading because we're waiting for the server to stream more content.
752 + expect(container.textContent).toBe('Loading...');
753 +
754 + // The server now updates the content in place in the fallback.
755 + streamInContent();
756 +
757 + // The final HTML is now in place.
758 + expect(container.textContent).toBe('Hello');
759 + let span = container.getElementsByTagName('span')[0];
760 +
761 + // But it is not yet hydrated.
762 + expect(ref.current).toBe(null);
763 +
764 + jest.runAllTimers();
765 +
766 + // Now it's hydrated.
767 + expect(ref.current).toBe(span);
768 + });
769 +
770 + it('handles an error on the client if the server ends up erroring', async () => {
771 + let suspend = false;
772 + let promise = new Promise(resolvePromise => {});
773 + let ref = React.createRef();
774 +
775 + function Child() {
776 + if (suspend) {
777 + throw promise;
778 + } else {
779 + throw new Error('Error Message');
780 + }
781 + }
782 +
783 + class ErrorBoundary extends React.Component {
784 + state = {error: null};
785 + static getDerivedStateFromError(error) {
786 + return {error};
787 + }
788 + render() {
789 + if (this.state.error) {
790 + return <div ref={ref}>{this.state.error.message}</div>;
791 + }
792 + return this.props.children;
793 + }
794 + }
795 +
796 + function App() {
797 + return (
798 + <ErrorBoundary>
799 + <div>
800 + <Suspense fallback="Loading...">
801 + <span ref={ref}>
802 + <Child />
803 + </span>
804 + </Suspense>
805 + </div>
806 + </ErrorBoundary>
807 + );
808 + }
809 +
810 + // We're going to simulate what Fizz will do during streaming rendering.
811 +
812 + // First we generate the HTML of the loading state.
813 + suspend = true;
814 + let loadingHTML = ReactDOMServer.renderToString(<App />);
815 +
816 + let container = document.createElement('div');
817 + container.innerHTML = loadingHTML;
818 +
819 + let suspenseNode = container.firstChild.firstChild;
820 + expect(suspenseNode.nodeType).toBe(8);
821 + // Put the suspense node in hydration state.
822 + suspenseNode.data = '$?';
823 +
824 + // This will simulates the server erroring and putting the fallback
825 + // as the final state.
826 + function streamInError() {
827 + suspenseNode.data = '$!';
828 + if (suspenseNode._reactRetry) {
829 + suspenseNode._reactRetry();
830 + }
831 + }
832 +
833 + // We're still showing a fallback.
834 + expect(container.getElementsByTagName('span').length).toBe(0);
835 +
836 + // Attempt to hydrate the content.
837 + suspend = false;
838 + let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
839 + root.render(<App />);
840 + jest.runAllTimers();
841 +
842 + // We're still loading because we're waiting for the server to stream more content.
843 + expect(container.textContent).toBe('Loading...');
844 +
845 + // The server now updates the content in place in the fallback.
846 + streamInError();
847 +
848 + // The server errored, but we still haven't hydrated. We don't know if the
849 + // client will succeed yet, so we still show the loading state.
850 + expect(container.textContent).toBe('Loading...');
851 + expect(ref.current).toBe(null);
852 +
853 + jest.runAllTimers();
854 +
855 + // Hydrating should've generated an error and replaced the suspense boundary.
856 + expect(container.textContent).toBe('Error Message');
857 +
858 + let div = container.getElementsByTagName('div')[0];
859 + expect(ref.current).toBe(div);
860 + });
861 });
packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js
+3 -1
@@ -94,6 +94,8 @@ describe('ReactDOMServerSuspense', () => {
94 );
95 const e = c.children[0];
96
97 - expect(e.innerHTML).toBe('<div>Children</div><div>Fallback</div>');
97 + expect(e.innerHTML).toBe(
98 + '<div>Children</div><!--$!--><div>Fallback</div><!--/$-->',
99 + );
100 });
101 });
packages/react-dom/src/client/ReactDOMHostConfig.js
+29 -4
@@ -56,7 +56,7 @@ export type Props = {
56 export type Container = Element | Document;
57 export type Instance = Element;
58 export type TextInstance = Text;
59 -export type SuspenseInstance = Comment;
59 +export type SuspenseInstance = Comment & {_reactRetry?: () => void};
60 export type HydratableInstance = Instance | TextInstance | SuspenseInstance;
61 export type PublicInstance = Element | Text;
62 type HostContextDev = {
@@ -89,6 +89,8 @@ if (__DEV__) {
89
90 const SUSPENSE_START_DATA = '$';
91 const SUSPENSE_END_DATA = '/$';
92 +const SUSPENSE_PENDING_START_DATA = '$?';
93 +const SUSPENSE_FALLBACK_START_DATA = '$!';
94
95 const STYLE = 'style';
96
@@ -458,7 +460,11 @@ export function clearSuspenseBoundary(
460 } else {
461 depth--;
462 }
461 - } else if (data === SUSPENSE_START_DATA) {
463 + } else if (
464 + data === SUSPENSE_START_DATA ||
465 + data === SUSPENSE_PENDING_START_DATA ||
466 + data === SUSPENSE_FALLBACK_START_DATA
467 + ) {
468 depth++;
469 }
470 }
@@ -554,6 +560,21 @@ export function canHydrateSuspenseInstance(
560 return ((instance: any): SuspenseInstance);
561 }
562
563 +export function isSuspenseInstancePending(instance: SuspenseInstance) {
564 + return instance.data === SUSPENSE_PENDING_START_DATA;
565 +}
566 +
567 +export function isSuspenseInstanceFallback(instance: SuspenseInstance) {
568 + return instance.data === SUSPENSE_FALLBACK_START_DATA;
569 +}
570 +
571 +export function registerSuspenseInstanceRetry(
572 + instance: SuspenseInstance,
573 + callback: () => void,
574 +) {
575 + instance._reactRetry = callback;
576 +}
577 +
578 export function getNextHydratableSibling(
579 instance: HydratableInstance,
580 ): null | HydratableInstance {
@@ -565,7 +586,9 @@ export function getNextHydratableSibling(
586 node.nodeType !== TEXT_NODE &&
587 (!enableSuspenseServerRenderer ||
588 node.nodeType !== COMMENT_NODE ||
568 - (node: any).data !== SUSPENSE_START_DATA)
589 + ((node: any).data !== SUSPENSE_START_DATA &&
590 + (node: any).data !== SUSPENSE_PENDING_START_DATA &&
591 + (node: any).data !== SUSPENSE_FALLBACK_START_DATA))
592 ) {
593 node = node.nextSibling;
594 }
@@ -583,7 +606,9 @@ export function getFirstHydratableChild(
606 next.nodeType !== TEXT_NODE &&
607 (!enableSuspenseServerRenderer ||
608 next.nodeType !== COMMENT_NODE ||
586 - (next: any).data !== SUSPENSE_START_DATA)
609 + ((next: any).data !== SUSPENSE_START_DATA &&
610 + (next: any).data !== SUSPENSE_FALLBACK_START_DATA &&
611 + (next: any).data !== SUSPENSE_PENDING_START_DATA))
612 ) {
613 next = next.nextSibling;
614 }
packages/react-dom/src/server/ReactPartialRenderer.js
+2 -2
@@ -835,6 +835,7 @@ class ReactDOMServerRenderer {
835 'suspense fallback not found, something is broken',
836 );
837 this.stack.push(fallbackFrame);
838 + out[this.suspenseDepth] += '<!--$!-->';
839 // Skip flushing output since we're switching to the fallback
840 continue;
841 } else {
@@ -996,8 +997,7 @@ class ReactDOMServerRenderer {
997 children: fallbackChildren,
998 childIndex: 0,
999 context: context,
999 - footer: '',
1000 - out: '',
1000 + footer: '<!--/$-->',
1001 };
1002 const frame: Frame = {
1003 fallbackFrame,
packages/react-reconciler/src/ReactFiberBeginWork.js
+41 -8
@@ -84,7 +84,11 @@ import {
84 import {
85 shouldSetTextContent,
86 shouldDeprioritizeSubtree,
87 + isSuspenseInstancePending,
88 + isSuspenseInstanceFallback,
89 + registerSuspenseInstanceRetry,
90 } from './ReactFiberHostConfig';
91 +import type {SuspenseInstance} from './ReactFiberHostConfig';
92 import {pushHostContext, pushHostContainer} from './ReactFiberHostContext';
93 import {
94 pushProvider,
@@ -129,6 +133,7 @@ import {
133 createWorkInProgress,
134 isSimpleFunctionComponent,
135 } from './ReactFiber';
136 +import {retryTimedOutBoundary} from './ReactFiberScheduler';
137
138 const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
139
@@ -1637,10 +1642,21 @@ function updateDehydratedSuspenseComponent(
1642 workInProgress.expirationTime = Never;
1643 return null;
1644 }
1645 + if ((workInProgress.effectTag & DidCapture) !== NoEffect) {
1646 + // Something suspended. Leave the existing children in place.
1647 + // TODO: In non-concurrent mode, should we commit the nodes we have hydrated so far?
1648 + workInProgress.child = null;
1649 + return null;
1650 + }
1651 // We use childExpirationTime to indicate that a child might depend on context, so if
1652 // any context has changed, we need to treat is as if the input might have changed.
1653 const hasContextChanged = current.childExpirationTime >= renderExpirationTime;
1643 - if (didReceiveUpdate || hasContextChanged) {
1654 + const suspenseInstance = (current.stateNode: SuspenseInstance);
1655 + if (
1656 + didReceiveUpdate ||
1657 + hasContextChanged ||
1658 + isSuspenseInstanceFallback(suspenseInstance)
1659 + ) {
1660 // This boundary has changed since the first render. This means that we are now unable to
1661 // hydrate it. We might still be able to hydrate it using an earlier expiration time but
1662 // during this render we can't. Instead, we're going to delete the whole subtree and
@@ -1648,6 +1664,10 @@ function updateDehydratedSuspenseComponent(
1664 // or fallback. The real Suspense boundary will suspend for a while so we have some time
1665 // to ensure it can produce real content, but all state and pending events will be lost.
1666
1667 + // Alternatively, this boundary is in a permanent fallback state. In this case, we'll never
1668 + // get an update and we'll never be able to hydrate the final content. Let's just try the
1669 + // client side render instead.
1670 +
1671 // Detach from the current dehydrated boundary.
1672 current.alternate = null;
1673 workInProgress.alternate = null;
@@ -1677,8 +1697,26 @@ function updateDehydratedSuspenseComponent(
1697 workInProgress.effectTag |= Placement;
1698 // Retry as a real Suspense component.
1699 return updateSuspenseComponent(null, workInProgress, renderExpirationTime);
1680 - }
1681 - if ((workInProgress.effectTag & DidCapture) === NoEffect) {
1700 + } else if (isSuspenseInstancePending(suspenseInstance)) {
1701 + // This component is still pending more data from the server, so we can't hydrate its
1702 + // content. We treat it as if this component suspended itself. It might seem as if
1703 + // we could just try to render it client-side instead. However, this will perform a
1704 + // lot of unnecessary work and is unlikely to complete since it often will suspend
1705 + // on missing data anyway. Additionally, the server might be able to render more
1706 + // than we can on the client yet. In that case we'd end up with more fallback states
1707 + // on the client than if we just leave it alone. If the server times out or errors
1708 + // these should update this boundary to the permanent Fallback state instead.
1709 + // Mark it as having captured (i.e. suspended).
1710 + workInProgress.effectTag |= DidCapture;
1711 + // Leave the children in place. I.e. empty.
1712 + workInProgress.child = null;
1713 + // Register a callback to retry this boundary once the server has sent the result.
1714 + registerSuspenseInstanceRetry(
1715 + suspenseInstance,
1716 + retryTimedOutBoundary.bind(null, current),
1717 + );
1718 + return null;
1719 + } else {
1720 // This is the first attempt.
1721 reenterHydrationStateFromDehydratedSuspenseInstance(workInProgress);
1722 const nextProps = workInProgress.pendingProps;
@@ -1690,11 +1728,6 @@ function updateDehydratedSuspenseComponent(
1728 renderExpirationTime,
1729 );
1730 return workInProgress.child;
1693 - } else {
1694 - // Something suspended. Leave the existing children in place.
1695 - // TODO: In non-concurrent mode, should we commit the nodes we have hydrated so far?
1696 - workInProgress.child = null;
1697 - return null;
1731 }
1732 }
1733
packages/react-reconciler/src/ReactFiberCommitWork.js
+2 -2
@@ -94,7 +94,7 @@ import {
94 import {
95 captureCommitPhaseError,
96 requestCurrentTime,
97 - retryTimedOutBoundary,
97 + resolveRetryThenable,
98 } from './ReactFiberScheduler';
99 import {
100 NoEffect as NoHookEffect,
@@ -1232,7 +1232,7 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void {
1232 }
1233 thenables.forEach(thenable => {
1234 // Memoize using the boundary fiber to prevent redundant listeners.
1235 - let retry = retryTimedOutBoundary.bind(null, finishedWork, thenable);
1235 + let retry = resolveRetryThenable.bind(null, finishedWork, thenable);
1236 if (enableSchedulerTracing) {
1237 retry = Schedule_tracing_wrap(retry);
1238 }
packages/react-reconciler/src/ReactFiberCompleteWork.js
+6 -1
@@ -710,7 +710,12 @@ function completeWork(
710 const nextDidTimeout = nextState !== null;
711 const prevDidTimeout = current !== null && current.memoizedState !== null;
712
713 - if (current !== null && !nextDidTimeout && prevDidTimeout) {
713 + if (current === null) {
714 + // In cases where we didn't find a suitable hydration boundary we never
715 + // downgraded this to a DehydratedSuspenseComponent, but we still need to
716 + // pop the hydration state since we might be inside the insertion tree.
717 + popHydrationState(workInProgress);
718 + } else if (!nextDidTimeout && prevDidTimeout) {
719 // We just switched from the fallback to the normal children. Delete
720 // the fallback.
721 // TODO: Would it be better to store the fallback fragment on
packages/react-reconciler/src/ReactFiberScheduler.js
+16 -11
@@ -1698,7 +1698,20 @@ function pingSuspendedRoot(
1698 }
1699 }
1700
1701 -function retryTimedOutBoundary(boundaryFiber: Fiber, thenable: Thenable) {
1701 +function retryTimedOutBoundary(boundaryFiber: Fiber) {
1702 + const currentTime = requestCurrentTime();
1703 + const retryTime = computeExpirationForFiber(currentTime, boundaryFiber);
1704 + const root = scheduleWorkToRoot(boundaryFiber, retryTime);
1705 + if (root !== null) {
1706 + markPendingPriorityLevel(root, retryTime);
1707 + const rootExpirationTime = root.expirationTime;
1708 + if (rootExpirationTime !== NoWork) {
1709 + requestWork(root, rootExpirationTime);
1710 + }
1711 + }
1712 +}
1713 +
1714 +function resolveRetryThenable(boundaryFiber: Fiber, thenable: Thenable) {
1715 // The boundary fiber (a Suspense component) previously timed out and was
1716 // rendered in its fallback state. One of the promises that suspended it has
1717 // resolved, which means at least part of the tree was likely unblocked. Try
@@ -1729,16 +1742,7 @@ function retryTimedOutBoundary(boundaryFiber: Fiber, thenable: Thenable) {
1742 retryCache.delete(thenable);
1743 }
1744
1732 - const currentTime = requestCurrentTime();
1733 - const retryTime = computeExpirationForFiber(currentTime, boundaryFiber);
1734 - const root = scheduleWorkToRoot(boundaryFiber, retryTime);
1735 - if (root !== null) {
1736 - markPendingPriorityLevel(root, retryTime);
1737 - const rootExpirationTime = root.expirationTime;
1738 - if (rootExpirationTime !== NoWork) {
1739 - requestWork(root, rootExpirationTime);
1740 - }
1741 - }
1745 + retryTimedOutBoundary(boundaryFiber);
1746 }
1747
1748 function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null {
@@ -2589,6 +2593,7 @@ export {
2593 renderDidError,
2594 pingSuspendedRoot,
2595 retryTimedOutBoundary,
2596 + resolveRetryThenable,
2597 markLegacyErrorBoundaryAsFailed,
2598 isAlreadyFailedLegacyErrorBoundary,
2599 scheduleWork,
packages/react-reconciler/src/ReactFiberUnwindWork.js
+2 -6
@@ -66,7 +66,7 @@ import {
66 markLegacyErrorBoundaryAsFailed,
67 isAlreadyFailedLegacyErrorBoundary,
68 pingSuspendedRoot,
69 - retryTimedOutBoundary,
69 + resolveRetryThenable,
70 } from './ReactFiberScheduler';
71
72 import invariant from 'shared/invariant';
@@ -372,11 +372,7 @@ function throwException(
372 // Memoize using the boundary fiber to prevent redundant listeners.
373 if (!retryCache.has(thenable)) {
374 retryCache.add(thenable);
375 - let retry = retryTimedOutBoundary.bind(
376 - null,
377 - workInProgress,
378 - thenable,
379 - );
375 + let retry = resolveRetryThenable.bind(null, workInProgress, thenable);
376 if (enableSchedulerTracing) {
377 retry = Schedule_tracing_wrap(retry);
378 }
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+6
@@ -107,6 +107,12 @@ export const canHydrateInstance = $$$hostConfig.canHydrateInstance;
107 export const canHydrateTextInstance = $$$hostConfig.canHydrateTextInstance;
108 export const canHydrateSuspenseInstance =
109 $$$hostConfig.canHydrateSuspenseInstance;
110 +export const isSuspenseInstancePending =
111 + $$$hostConfig.isSuspenseInstancePending;
112 +export const isSuspenseInstanceFallback =
113 + $$$hostConfig.isSuspenseInstanceFallback;
114 +export const registerSuspenseInstanceRetry =
115 + $$$hostConfig.registerSuspenseInstanceRetry;
116 export const getNextHydratableSibling = $$$hostConfig.getNextHydratableSibling;
117 export const getFirstHydratableChild = $$$hostConfig.getFirstHydratableChild;
118 export const hydrateInstance = $$$hostConfig.hydrateInstance;
packages/shared/HostConfigWithNoHydration.js
+3
@@ -27,6 +27,9 @@ export const supportsHydration = false;
27 export const canHydrateInstance = shim;
28 export const canHydrateTextInstance = shim;
29 export const canHydrateSuspenseInstance = shim;
30 +export const isSuspenseInstancePending = shim;
31 +export const isSuspenseInstanceFallback = shim;
32 +export const registerSuspenseInstanceRetry = shim;
33 export const getNextHydratableSibling = shim;
34 export const getFirstHydratableChild = shim;
35 export const hydrateInstance = shim;