From be3bfa6fabccd2e76d3bb3841a67f6d8ede7892c Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 9 Nov 2019 02:56:03 +0000 Subject: [PATCH] [Flight] Basic Integration Test (#17307) * [Flight] Basic Integration Test * Just act() * Lint * Remove unnecessary acts * Use Concurrent Mode * it.experimental * Fix prod test by advancing time * Don't observe initial state --- .../__tests__/ReactFlightIntegration-test.js | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 packages/react-dom/src/__tests__/ReactFlightIntegration-test.js diff --git a/packages/react-dom/src/__tests__/ReactFlightIntegration-test.js b/packages/react-dom/src/__tests__/ReactFlightIntegration-test.js new file mode 100644 index 0000000000000..7621513830464 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactFlightIntegration-test.js @@ -0,0 +1,311 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +// Polyfills for test environment +global.ReadableStream = require('@mattiasbuelens/web-streams-polyfill/ponyfill/es6').ReadableStream; +global.TextDecoder = require('util').TextDecoder; + +// Don't wait before processing work on the server. +// TODO: we can replace this with FlightServer.act(). +global.setImmediate = cb => cb(); + +let act; +let Stream; +let React; +let ReactDOM; +let ReactFlightDOMServer; +let ReactFlightDOMClient; + +describe('ReactFlightIntegration', () => { + beforeEach(() => { + jest.resetModules(); + act = require('react-dom/test-utils').act; + Stream = require('stream'); + React = require('react'); + ReactDOM = require('react-dom'); + ReactFlightDOMServer = require('react-dom/unstable-flight-server'); + ReactFlightDOMClient = require('react-dom/unstable-flight-client'); + }); + + function getTestStream() { + let writable = new Stream.PassThrough(); + let readable = new ReadableStream({ + start(controller) { + writable.on('data', chunk => { + controller.enqueue(chunk); + }); + writable.on('end', () => { + controller.close(); + }); + }, + }); + return { + writable, + readable, + }; + } + + it.experimental('should resolve the root', async () => { + let {Suspense} = React; + + // Model + function Text({children}) { + return {children}; + } + function HTML() { + return ( +
+ hello + world +
+ ); + } + function RootModel() { + return { + html: , + }; + } + + // View + function Message({result}) { + return

; + } + function App({result}) { + return ( + Loading...}> + + + ); + } + + let {writable, readable} = getTestStream(); + ReactFlightDOMServer.pipeToNodeWritable(, writable); + let result = ReactFlightDOMClient.readFromReadableStream(readable); + + let container = document.createElement('div'); + let root = ReactDOM.createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.innerHTML).toBe( + '

helloworld

', + ); + }); + + it.experimental('should not get confused by $', async () => { + let {Suspense} = React; + + // Model + function RootModel() { + return {text: '$1'}; + } + + // View + function Message({result}) { + return

{result.model.text}

; + } + function App({result}) { + return ( + Loading...}> + + + ); + } + + let {writable, readable} = getTestStream(); + ReactFlightDOMServer.pipeToNodeWritable(, writable); + let result = ReactFlightDOMClient.readFromReadableStream(readable); + + let container = document.createElement('div'); + let root = ReactDOM.createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.innerHTML).toBe('

$1

'); + }); + + it.experimental('should progressively reveal chunks', async () => { + let {Suspense} = React; + + 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; + } + } + + // 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}) { + if (promise) { + throw promise; + } + if (error) { + throw error; + } + return {children}; + } + return [DelayedText, _resolve, _reject]; + } + + const [FriendsModel, resolveFriendsModel] = makeDelayedText(); + const [NameModel, resolveNameModel] = makeDelayedText(); + const [PostsModel, resolvePostsModel] = makeDelayedText(); + const [PhotosModel, resolvePhotosModel] = makeDelayedText(); + const [GamesModel, , rejectGamesModel] = makeDelayedText(); + function ProfileMore() { + return { + avatar: :avatar:, + friends: :friends:, + posts: :posts:, + games: :games:, + }; + } + function ProfileModel() { + return { + photos: :photos:, + name: :name:, + more: , + }; + } + + // View + function ProfileDetails({result}) { + return ( +
+ {result.model.name} + {result.model.more.avatar} +
+ ); + } + function ProfileSidebar({result}) { + return ( +
+ {result.model.photos} + {result.model.more.friends} +
+ ); + } + function ProfilePosts({result}) { + return
{result.model.more.posts}
; + } + function ProfileGames({result}) { + return
{result.model.more.games}
; + } + function ProfilePage({result}) { + return ( + <> + (loading)

}> + + (loading sidebar)

}> + +
+ (loading posts)

}> + +
+

{e.message}

}> + (loading games)

}> + +
+
+
+ + ); + } + + let {writable, readable} = getTestStream(); + ReactFlightDOMServer.pipeToNodeWritable(, writable); + let result = ReactFlightDOMClient.readFromReadableStream(readable); + + let container = document.createElement('div'); + let root = ReactDOM.createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.innerHTML).toBe('

(loading)

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

(loading)

'); + + // We can now show the details. Sidebar and posts are still loading. + await act(async () => { + resolveNameModel(); + }); + // 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 () => { + rejectGamesModel(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 () => { + resolvePhotosModel(); + }); + expect(container.innerHTML).toBe( + '
:name::avatar:
' + + '
:photos::friends:
' + + '

(loading posts)

' + + '

Game over

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

Game over

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