Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 13645d2

Browse files
authored
Deal with fallback content in Partial Hydration (facebook#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.
1 parent c506ded commit 13645d2

11 files changed

+334
-35
lines changed

packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,4 +634,228 @@ describe('ReactDOMServerPartialHydration', () => {
634634
expect(ref.current).toBe(span);
635635
expect(container.textContent).toBe('Hi');
636636
});
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+
});
637861
});

packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ describe('ReactDOMServerSuspense', () => {
9494
);
9595
const e = c.children[0];
9696

97-
expect(e.innerHTML).toBe('<div>Children</div><div>Fallback</div>');
97+
expect(e.innerHTML).toBe(
98+
'<div>Children</div><!--$!--><div>Fallback</div><!--/$-->',
99+
);
98100
});
99101
});

packages/react-dom/src/client/ReactDOMHostConfig.js

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export type Props = {
5656
export type Container = Element | Document;
5757
export type Instance = Element;
5858
export type TextInstance = Text;
59-
export type SuspenseInstance = Comment;
59+
export type SuspenseInstance = Comment & {_reactRetry?: () => void};
6060
export type HydratableInstance = Instance | TextInstance | SuspenseInstance;
6161
export type PublicInstance = Element | Text;
6262
type HostContextDev = {
@@ -89,6 +89,8 @@ if (__DEV__) {
8989

9090
const SUSPENSE_START_DATA = '$';
9191
const SUSPENSE_END_DATA = '/$';
92+
const SUSPENSE_PENDING_START_DATA = '$?';
93+
const SUSPENSE_FALLBACK_START_DATA = '$!';
9294

9395
const STYLE = 'style';
9496

@@ -458,7 +460,11 @@ export function clearSuspenseBoundary(
458460
} else {
459461
depth--;
460462
}
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+
) {
462468
depth++;
463469
}
464470
}
@@ -554,6 +560,21 @@ export function canHydrateSuspenseInstance(
554560
return ((instance: any): SuspenseInstance);
555561
}
556562

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+
557578
export function getNextHydratableSibling(
558579
instance: HydratableInstance,
559580
): null | HydratableInstance {
@@ -565,7 +586,9 @@ export function getNextHydratableSibling(
565586
node.nodeType !== TEXT_NODE &&
566587
(!enableSuspenseServerRenderer ||
567588
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))
569592
) {
570593
node = node.nextSibling;
571594
}
@@ -583,7 +606,9 @@ export function getFirstHydratableChild(
583606
next.nodeType !== TEXT_NODE &&
584607
(!enableSuspenseServerRenderer ||
585608
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))
587612
) {
588613
next = next.nextSibling;
589614
}

packages/react-dom/src/server/ReactPartialRenderer.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,7 @@ class ReactDOMServerRenderer {
835835
'suspense fallback not found, something is broken',
836836
);
837837
this.stack.push(fallbackFrame);
838+
out[this.suspenseDepth] += '<!--$!-->';
838839
// Skip flushing output since we're switching to the fallback
839840
continue;
840841
} else {
@@ -996,8 +997,7 @@ class ReactDOMServerRenderer {
996997
children: fallbackChildren,
997998
childIndex: 0,
998999
context: context,
999-
footer: '',
1000-
out: '',
1000+
footer: '<!--/$-->',
10011001
};
10021002
const frame: Frame = {
10031003
fallbackFrame,

0 commit comments

Comments
 (0)