diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 8b82caac6bc749..09ea1515c99972 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -69,6 +69,31 @@ describe('ReactFlightDOMBrowser', () => { } } + function makeDelayedText(Model) { + 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]; + } + it('should resolve HTML using W3C streams', async () => { function Text({children}) { return {children}; @@ -174,36 +199,11 @@ describe('ReactFlightDOMBrowser', () => { 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(); + const [Friends, resolveFriends] = makeDelayedText(Text); + const [Name, resolveName] = makeDelayedText(Text); + const [Posts, resolvePosts] = makeDelayedText(Text); + const [Photos, resolvePhotos] = makeDelayedText(Text); + const [Games, , rejectGames] = makeDelayedText(Text); // View function ProfileDetails({avatar}) { @@ -340,4 +340,117 @@ describe('ReactFlightDOMBrowser', () => { expect(reportedErrors).toEqual([]); }); + + it('should close the stream upon completion when rendering to W3C streams', async () => { + const {Suspense} = React; + + // Model + function Text({children}) { + return children; + } + + const [Friends, resolveFriends] = makeDelayedText(Text); + const [Name, resolveName] = makeDelayedText(Text); + const [Posts, resolvePosts] = makeDelayedText(Text); + const [Photos, resolvePhotos] = makeDelayedText(Text); + + // View + function ProfileDetails({avatar}) { + return ( +
+ :name: + {avatar} +
+ ); + } + function ProfileSidebar({friends}) { + return ( +
+ :photos: + {friends} +
+ ); + } + function ProfilePosts({posts}) { + return
{posts}
; + } + + function ProfileContent() { + return ( + + :avatar:} /> + (loading sidebar)

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

}> + :posts:} /> +
+
+ ); + } + + const model = { + rootContent: , + }; + + const stream = ReactServerDOMWriter.renderToReadableStream( + model, + webpackMap, + ); + + const reader = stream.getReader(); + const decoder = new TextDecoder(); + + let flightResponse = ''; + let isDone = false; + + reader.read().then(function progress({done, value}) { + if (done) { + isDone = true; + return; + } + + flightResponse += decoder.decode(value); + + return reader.read().then(progress); + }); + + // Advance time enough to trigger a nested fallback. + jest.advanceTimersByTime(500); + + await act(async () => {}); + + expect(flightResponse).toContain('(loading everything)'); + expect(flightResponse).toContain('(loading sidebar)'); + expect(flightResponse).toContain('(loading posts)'); + expect(flightResponse).not.toContain(':friends:'); + expect(flightResponse).not.toContain(':name:'); + + await act(async () => { + resolveFriends(); + }); + + expect(flightResponse).toContain(':friends:'); + + await act(async () => { + resolveName(); + }); + + expect(flightResponse).toContain(':name:'); + + await act(async () => { + resolvePhotos(); + }); + + expect(flightResponse).toContain(':photos:'); + + await act(async () => { + resolvePosts(); + }); + + expect(flightResponse).toContain(':posts:'); + + // Final pending chunk is written; stream should be closed. + expect(isDone).toBeTruthy(); + }); }); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index e46aee9a45cd23..39fcdf31682055 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -1950,6 +1950,10 @@ export function startFlowing(request: Request, destination: Destination): void { if (request.status === CLOSED) { return; } + if (request.destination !== null) { + // We're already flowing. + return; + } request.destination = destination; try { flushCompletedQueues(request, destination); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 052fa730fd91e0..5ad7ed2ea041c6 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -790,6 +790,10 @@ export function startFlowing(request: Request, destination: Destination): void { if (request.status === CLOSED) { return; } + if (request.destination !== null) { + // We're already flowing. + return; + } request.destination = destination; try { flushCompletedChunks(request, destination);