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