From 3fbd47b86285b6b7bdeab66d29c85951a84d4525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 30 Oct 2020 20:19:46 -0400 Subject: [PATCH] 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. --- .../react-server/src/ReactFlightServer.js | 3 +- .../src/__tests__/ReactFlightDOM-test.js | 211 +++++++++++++++++- 2 files changed, 208 insertions(+), 6 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index e82171d24de5..97bee6c04c60 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -460,7 +460,7 @@ export function resolveModelToJSON( const newSegment = createSegment(request, () => value); const ping = newSegment.ping; x.then(ping, ping); - return serializeByValueID(newSegment.id); + return serializeByRefID(newSegment.id); } else { // Something errored. Don't bother encoding anything up to here. throw x; @@ -708,6 +708,7 @@ function flushCompletedChunks(request: Request): void { break; } } + moduleChunks.splice(0, i); // Next comes model data. const jsonChunks = request.completedJSONChunks; i = 0; diff --git a/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js index ebabf4e75f5f..8b690219ec6e 100644 --- a/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -64,28 +64,34 @@ describe('ReactFlightDOM', () => { }; } - function block(render, load) { + function moduleReference(moduleExport) { const idx = webpackModuleIdx++; webpackModules[idx] = { - d: render, + d: moduleExport, }; webpackMap['path/' + idx] = { id: '' + idx, chunks: [], name: 'd', }; + const MODULE_TAG = Symbol.for('react.module.reference'); + return {$$typeof: MODULE_TAG, name: 'path/' + idx}; + } + + function block(render, load) { if (load === undefined) { return () => { - return ReactTransportDOMServerRuntime.serverBlockNoData('path/' + idx); + return ReactTransportDOMServerRuntime.serverBlockNoData( + moduleReference(render), + ); }; } return function(...args) { const curriedLoad = () => { return load(...args); }; - const MODULE_TAG = Symbol.for('react.module.reference'); return ReactTransportDOMServerRuntime.serverBlock( - {$$typeof: MODULE_TAG, name: 'path/' + idx}, + moduleReference(render), curriedLoad, ); }; @@ -314,6 +320,9 @@ describe('ReactFlightDOM', () => { return 'data'; } function DelayedText({children}, data) { + if (data !== 'data') { + throw new Error('No data'); + } return {children}; } const loadBlock = block(DelayedText, load); @@ -477,4 +486,196 @@ describe('ReactFlightDOM', () => { '

Game over

', // TODO: should not have message in prod. ); }); + + // @gate experimental + it('should progressively reveal server components', async () => { + const {Suspense} = React; + + // Client Components + + class ErrorBoundary extends React.Component { + state = {hasError: false, error: null}; + static getDerivedStateFromError(error) { + return { + hasError: true, + error, + }; + } + render() { + if (this.state.hasError) { + return this.props.fallback(this.state.error); + } + return this.props.children; + } + } + + function MyErrorBoundary({children}) { + return ( +

{e.message}

}> + {children} +
+ ); + } + + function Placeholder({children, fallback}) { + return {children}; + } + + // Model + function Text({children}) { + return children; + } + + function makeDelayedText() { + let error, _resolve, _reject; + let promise = new Promise((resolve, reject) => { + _resolve = () => { + promise = null; + resolve(); + }; + _reject = e => { + error = e; + promise = null; + reject(e); + }; + }); + function DelayedText({children}, data) { + if (promise) { + throw promise; + } + if (error) { + throw error; + } + return {children}; + } + return [DelayedText, _resolve, _reject]; + } + + const [Friends, resolveFriends] = makeDelayedText(); + const [Name, resolveName] = makeDelayedText(); + const [Posts, resolvePosts] = makeDelayedText(); + const [Photos, resolvePhotos] = makeDelayedText(); + const [Games, , rejectGames] = makeDelayedText(); + + // View + function ProfileDetails({avatar}) { + return ( +
+ :name: + {avatar} +
+ ); + } + function ProfileSidebar({friends}) { + return ( +
+ :photos: + {friends} +
+ ); + } + function ProfilePosts({posts}) { + return
{posts}
; + } + function ProfileGames({games}) { + return
{games}
; + } + + const MyErrorBoundaryClient = moduleReference(MyErrorBoundary); + const PlaceholderClient = moduleReference(Placeholder); + + function ProfileContent() { + return ( + <> + :avatar:} /> + (loading sidebar)

}> + :friends:} /> +
+ (loading posts)

}> + :posts:} /> +
+ + (loading games)

}> + :games:} /> +
+
+ + ); + } + + const model = { + rootContent: , + }; + + function ProfilePage({response}) { + return response.readRoot().rootContent; + } + + const {writable, readable} = getTestStream(); + ReactTransportDOMServer.pipeToNodeWritable(model, writable, webpackMap); + const response = ReactTransportDOMClient.createFromReadableStream(readable); + + const container = document.createElement('div'); + const root = ReactDOM.unstable_createRoot(container); + await act(async () => { + root.render( + (loading)

}> + +
, + ); + }); + expect(container.innerHTML).toBe('

(loading)

'); + + // This isn't enough to show anything. + await act(async () => { + resolveFriends(); + }); + expect(container.innerHTML).toBe('

(loading)

'); + + // We can now show the details. Sidebar and posts are still loading. + await act(async () => { + resolveName(); + }); + // Advance time enough to trigger a nested fallback. + jest.advanceTimersByTime(500); + expect(container.innerHTML).toBe( + '
:name::avatar:
' + + '

(loading sidebar)

' + + '

(loading posts)

' + + '

(loading games)

', + ); + + // Let's *fail* loading games. + await act(async () => { + rejectGames(new Error('Game over')); + }); + expect(container.innerHTML).toBe( + '
:name::avatar:
' + + '

(loading sidebar)

' + + '

(loading posts)

' + + '

Game over

', // TODO: should not have message in prod. + ); + + // We can now show the sidebar. + await act(async () => { + resolvePhotos(); + }); + expect(container.innerHTML).toBe( + '
:name::avatar:
' + + '
:photos::friends:
' + + '

(loading posts)

' + + '

Game over

', // TODO: should not have message in prod. + ); + + // Show everything. + await act(async () => { + resolvePosts(); + }); + expect(container.innerHTML).toBe( + '
:name::avatar:
' + + '
:photos::friends:
' + + '
:posts:
' + + '

Game over

', // TODO: should not have message in prod. + ); + }); });