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
});