@samitouri / QOS-React-2 / commits / 3fbd47b862

Serialize pending server components by reference (lazy component) (#20137)

This now means that if a server component suspends, its value becomes a React.lazy object. I.e. the element that rendered the server component gets replaced with a lazy node. As of #19033 lazy objects can be rendered in the node position. This allows us to suspend at the location of the server component while we're waiting on its content. Now server components has the same capabilities as Blocks to progressively reveal its content.

Sebastian Markbåge committed Oct 30, 2020 at 20:19 UTC 3fbd47b86285b6b7bdeab66d29c85951a84d4525
2 files changed +208 -6
packages/react-server/src/ReactFlightServer.js
+2 -1
@@ -460,7 +460,7 @@ export function resolveModelToJSON(
460 const newSegment = createSegment(request, () => value);
461 const ping = newSegment.ping;
462 x.then(ping, ping);
463 - return serializeByValueID(newSegment.id);
463 + return serializeByRefID(newSegment.id);
464 } else {
465 // Something errored. Don't bother encoding anything up to here.
466 throw x;
@@ -708,6 +708,7 @@ function flushCompletedChunks(request: Request): void {
708 break;
709 }
710 }
711 + moduleChunks.splice(0, i);
712 // Next comes model data.
713 const jsonChunks = request.completedJSONChunks;
714 i = 0;
packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+206 -5
@@ -64,28 +64,34 @@ describe('ReactFlightDOM', () => {
64 };
65 }
66
67 - function block(render, load) {
67 + function moduleReference(moduleExport) {
68 const idx = webpackModuleIdx++;
69 webpackModules[idx] = {
70 - d: render,
70 + d: moduleExport,
71 };
72 webpackMap['path/' + idx] = {
73 id: '' + idx,
74 chunks: [],
75 name: 'd',
76 };
77 + const MODULE_TAG = Symbol.for('react.module.reference');
78 + return {$$typeof: MODULE_TAG, name: 'path/' + idx};
79 + }
80 +
81 + function block(render, load) {
82 if (load === undefined) {
83 return () => {
79 - return ReactTransportDOMServerRuntime.serverBlockNoData('path/' + idx);
84 + return ReactTransportDOMServerRuntime.serverBlockNoData(
85 + moduleReference(render),
86 + );
87 };
88 }
89 return function(...args) {
90 const curriedLoad = () => {
91 return load(...args);
92 };
86 - const MODULE_TAG = Symbol.for('react.module.reference');
93 return ReactTransportDOMServerRuntime.serverBlock(
88 - {$$typeof: MODULE_TAG, name: 'path/' + idx},
94 + moduleReference(render),
95 curriedLoad,
96 );
97 };
@@ -314,6 +320,9 @@ describe('ReactFlightDOM', () => {
320 return 'data';
321 }
322 function DelayedText({children}, data) {
323 + if (data !== 'data') {
324 + throw new Error('No data');
325 + }
326 return <Text>{children}</Text>;
327 }
328 const loadBlock = block(DelayedText, load);
@@ -477,4 +486,196 @@ describe('ReactFlightDOM', () => {
486 '<p>Game over</p>', // TODO: should not have message in prod.
487 );
488 });
489 +
490 + // @gate experimental
491 + it('should progressively reveal server components', async () => {
492 + const {Suspense} = React;
493 +
494 + // Client Components
495 +
496 + class ErrorBoundary extends React.Component {
497 + state = {hasError: false, error: null};
498 + static getDerivedStateFromError(error) {
499 + return {
500 + hasError: true,
501 + error,
502 + };
503 + }
504 + render() {
505 + if (this.state.hasError) {
506 + return this.props.fallback(this.state.error);
507 + }
508 + return this.props.children;
509 + }
510 + }
511 +
512 + function MyErrorBoundary({children}) {
513 + return (
514 + <ErrorBoundary fallback={e => <p>{e.message}</p>}>
515 + {children}
516 + </ErrorBoundary>
517 + );
518 + }
519 +
520 + function Placeholder({children, fallback}) {
521 + return <Suspense fallback={fallback}>{children}</Suspense>;
522 + }
523 +
524 + // Model
525 + function Text({children}) {
526 + return children;
527 + }
528 +
529 + function makeDelayedText() {
530 + let error, _resolve, _reject;
531 + let promise = new Promise((resolve, reject) => {
532 + _resolve = () => {
533 + promise = null;
534 + resolve();
535 + };
536 + _reject = e => {
537 + error = e;
538 + promise = null;
539 + reject(e);
540 + };
541 + });
542 + function DelayedText({children}, data) {
543 + if (promise) {
544 + throw promise;
545 + }
546 + if (error) {
547 + throw error;
548 + }
549 + return <Text>{children}</Text>;
550 + }
551 + return [DelayedText, _resolve, _reject];
552 + }
553 +
554 + const [Friends, resolveFriends] = makeDelayedText();
555 + const [Name, resolveName] = makeDelayedText();
556 + const [Posts, resolvePosts] = makeDelayedText();
557 + const [Photos, resolvePhotos] = makeDelayedText();
558 + const [Games, , rejectGames] = makeDelayedText();
559 +
560 + // View
561 + function ProfileDetails({avatar}) {
562 + return (
563 + <div>
564 + <Name>:name:</Name>
565 + {avatar}
566 + </div>
567 + );
568 + }
569 + function ProfileSidebar({friends}) {
570 + return (
571 + <div>
572 + <Photos>:photos:</Photos>
573 + {friends}
574 + </div>
575 + );
576 + }
577 + function ProfilePosts({posts}) {
578 + return <div>{posts}</div>;
579 + }
580 + function ProfileGames({games}) {
581 + return <div>{games}</div>;
582 + }
583 +
584 + const MyErrorBoundaryClient = moduleReference(MyErrorBoundary);
585 + const PlaceholderClient = moduleReference(Placeholder);
586 +
587 + function ProfileContent() {
588 + return (
589 + <>
590 + <ProfileDetails avatar={<Text>:avatar:</Text>} />
591 + <PlaceholderClient fallback={<p>(loading sidebar)</p>}>
592 + <ProfileSidebar friends={<Friends>:friends:</Friends>} />
593 + </PlaceholderClient>
594 + <PlaceholderClient fallback={<p>(loading posts)</p>}>
595 + <ProfilePosts posts={<Posts>:posts:</Posts>} />
596 + </PlaceholderClient>
597 + <MyErrorBoundaryClient>
598 + <PlaceholderClient fallback={<p>(loading games)</p>}>
599 + <ProfileGames games={<Games>:games:</Games>} />
600 + </PlaceholderClient>
601 + </MyErrorBoundaryClient>
602 + </>
603 + );
604 + }
605 +
606 + const model = {
607 + rootContent: <ProfileContent />,
608 + };
609 +
610 + function ProfilePage({response}) {
611 + return response.readRoot().rootContent;
612 + }
613 +
614 + const {writable, readable} = getTestStream();
615 + ReactTransportDOMServer.pipeToNodeWritable(model, writable, webpackMap);
616 + const response = ReactTransportDOMClient.createFromReadableStream(readable);
617 +
618 + const container = document.createElement('div');
619 + const root = ReactDOM.unstable_createRoot(container);
620 + await act(async () => {
621 + root.render(
622 + <Suspense fallback={<p>(loading)</p>}>
623 + <ProfilePage response={response} />
624 + </Suspense>,
625 + );
626 + });
627 + expect(container.innerHTML).toBe('<p>(loading)</p>');
628 +
629 + // This isn't enough to show anything.
630 + await act(async () => {
631 + resolveFriends();
632 + });
633 + expect(container.innerHTML).toBe('<p>(loading)</p>');
634 +
635 + // We can now show the details. Sidebar and posts are still loading.
636 + await act(async () => {
637 + resolveName();
638 + });
639 + // Advance time enough to trigger a nested fallback.
640 + jest.advanceTimersByTime(500);
641 + expect(container.innerHTML).toBe(
642 + '<div>:name::avatar:</div>' +
643 + '<p>(loading sidebar)</p>' +
644 + '<p>(loading posts)</p>' +
645 + '<p>(loading games)</p>',
646 + );
647 +
648 + // Let's *fail* loading games.
649 + await act(async () => {
650 + rejectGames(new Error('Game over'));
651 + });
652 + expect(container.innerHTML).toBe(
653 + '<div>:name::avatar:</div>' +
654 + '<p>(loading sidebar)</p>' +
655 + '<p>(loading posts)</p>' +
656 + '<p>Game over</p>', // TODO: should not have message in prod.
657 + );
658 +
659 + // We can now show the sidebar.
660 + await act(async () => {
661 + resolvePhotos();
662 + });
663 + expect(container.innerHTML).toBe(
664 + '<div>:name::avatar:</div>' +
665 + '<div>:photos::friends:</div>' +
666 + '<p>(loading posts)</p>' +
667 + '<p>Game over</p>', // TODO: should not have message in prod.
668 + );
669 +
670 + // Show everything.
671 + await act(async () => {
672 + resolvePosts();
673 + });
674 + expect(container.innerHTML).toBe(
675 + '<div>:name::avatar:</div>' +
676 + '<div>:photos::friends:</div>' +
677 + '<div>:posts:</div>' +
678 + '<p>Game over</p>', // TODO: should not have message in prod.
679 + );
680 + });
681 });