Skip to content

Commit 3fbd47b

Browse files
authored
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.
1 parent 930ce7c commit 3fbd47b

File tree

2 files changed

+208
-6
lines changed

2 files changed

+208
-6
lines changed

packages/react-server/src/ReactFlightServer.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ export function resolveModelToJSON(
460460
const newSegment = createSegment(request, () => value);
461461
const ping = newSegment.ping;
462462
x.then(ping, ping);
463-
return serializeByValueID(newSegment.id);
463+
return serializeByRefID(newSegment.id);
464464
} else {
465465
// Something errored. Don't bother encoding anything up to here.
466466
throw x;
@@ -708,6 +708,7 @@ function flushCompletedChunks(request: Request): void {
708708
break;
709709
}
710710
}
711+
moduleChunks.splice(0, i);
711712
// Next comes model data.
712713
const jsonChunks = request.completedJSONChunks;
713714
i = 0;

packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js

Lines changed: 206 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,28 +64,34 @@ describe('ReactFlightDOM', () => {
6464
};
6565
}
6666

67-
function block(render, load) {
67+
function moduleReference(moduleExport) {
6868
const idx = webpackModuleIdx++;
6969
webpackModules[idx] = {
70-
d: render,
70+
d: moduleExport,
7171
};
7272
webpackMap['path/' + idx] = {
7373
id: '' + idx,
7474
chunks: [],
7575
name: 'd',
7676
};
77+
const MODULE_TAG = Symbol.for('react.module.reference');
78+
return {$$typeof: MODULE_TAG, name: 'path/' + idx};
79+
}
80+
81+
function block(render, load) {
7782
if (load === undefined) {
7883
return () => {
79-
return ReactTransportDOMServerRuntime.serverBlockNoData('path/' + idx);
84+
return ReactTransportDOMServerRuntime.serverBlockNoData(
85+
moduleReference(render),
86+
);
8087
};
8188
}
8289
return function(...args) {
8390
const curriedLoad = () => {
8491
return load(...args);
8592
};
86-
const MODULE_TAG = Symbol.for('react.module.reference');
8793
return ReactTransportDOMServerRuntime.serverBlock(
88-
{$$typeof: MODULE_TAG, name: 'path/' + idx},
94+
moduleReference(render),
8995
curriedLoad,
9096
);
9197
};
@@ -314,6 +320,9 @@ describe('ReactFlightDOM', () => {
314320
return 'data';
315321
}
316322
function DelayedText({children}, data) {
323+
if (data !== 'data') {
324+
throw new Error('No data');
325+
}
317326
return <Text>{children}</Text>;
318327
}
319328
const loadBlock = block(DelayedText, load);
@@ -477,4 +486,196 @@ describe('ReactFlightDOM', () => {
477486
'<p>Game over</p>', // TODO: should not have message in prod.
478487
);
479488
});
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+
});
480681
});

0 commit comments

Comments
 (0)