diff --git a/packages/react-dev-utils/FlowTypecheckPlugin.js b/packages/react-dev-utils/FlowTypecheckPlugin.js new file mode 100644 index 00000000000..7062a350174 --- /dev/null +++ b/packages/react-dev-utils/FlowTypecheckPlugin.js @@ -0,0 +1,220 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const chalk = require('chalk'); +const childProcess = require('child_process'); +const flowBinPath = require('flow-bin'); + +function exec(command, args, options) { + return new Promise((resolve, reject) => { + var stdout = new Buffer(''); + var stderr = new Buffer(''); + var oneTimeProcess = childProcess.spawn(command, args, options); + oneTimeProcess.stdout.on('data', chunk => { + stdout = Buffer.concat([stdout, chunk]); + }); + oneTimeProcess.stderr.on('data', chunk => { + stderr = Buffer.concat([stderr, chunk]); + }); + oneTimeProcess.on('error', error => reject(error)); + oneTimeProcess.on('exit', code => { + switch (code) { + case 0: { + return resolve(stdout); + } + default: { + return reject(new Error(Buffer.concat([stdout, stderr]).toString())); + } + } + }); + }); +} + +function createVersionWarning(flowVersion) { + return 'Flow: ' + + chalk.red( + chalk.bold( + `Your global flow version is incompatible with this tool. +To fix warning, uninstall it or run \`npm install -g flow-bin@${flowVersion}\`.` + ) + ); +} + +function formatFlowErrors(error) { + return error + .toString() + .split('\n') + .filter(line => { + return !(/flow is still initializing/.test(line) || + /Found \d+ error/.test(line) || + /The flow server is not responding/.test(line) || + /Going to launch a new one/.test(line) || + /The flow server is not responding/.test(line) || + /Spawned flow server/.test(line) || + /Logs will go to/.test(line) || + /version didn't match the client's/.test(line)); + }) + .map(line => line.replace(/^Error:\s*/, '')) + .join('\n'); +} + +function getFlowVersion(global) { + return exec(global ? 'flow' : flowBinPath, ['version', '--json']) + .then(data => JSON.parse(data.toString('utf8')).semver || '0.0.0') + .catch(() => null); +} + +class FlowTypecheckPlugin { + constructor() { + this.shouldRun = false; + this.flowStarted = false; + this.flowStarting = null; + + this.flowVersion = require(path.join( + __dirname, + 'package.json' + )).dependencies['flow-bin']; + } + + startFlow(cwd) { + if (this.flowStarted) { + return Promise.resolve(); + } + if (this.flowStarting != null) { + return this.flowStarting.then(err => { + // We need to do it like this because of unhandled rejections + // ... basically, we can't actually reject a promise unless someone + // has it handled -- which is only the case when we're returned from here + if (err != null) { + throw err; + } + }); + } + console.log(chalk.cyan('Starting the flow server ...')); + const flowConfigPath = path.join(cwd, '.flowconfig'); + let delegate; + this.flowStarting = new Promise(resolve => { + delegate = resolve; + }); + return getFlowVersion(true) + .then(globalVersion => { + if (globalVersion === null) return; + return getFlowVersion(false).then(ourVersion => { + if (globalVersion !== ourVersion) { + return Promise.reject('__FLOW_VERSION_MISMATCH__'); + } + }); + }) + .then( + () => new Promise(resolve => { + fs.access(flowConfigPath, err => { + if (err) { + resolve(exec(flowBinPath, ['init'], { cwd })); + } else { + resolve(); + } + }); + }) + ) + .then(() => exec(flowBinPath, ['stop'], { + cwd, + })) + .then(() => exec(flowBinPath, ['start'], { cwd }).catch(err => { + if ( + typeof err.message === 'string' && + err.message.indexOf('There is already a server running') !== -1 + ) { + return true; + } else { + throw err; + } + })) + .then(() => { + this.flowStarted = true; + delegate(); + this.flowStarting = null; + }) + .catch(err => { + delegate(err); + this.flowStarting = null; + throw err; + }); + } + + apply(compiler) { + compiler.plugin('compile', () => { + this.shouldRun = false; + }); + + compiler.plugin('compilation', compilation => { + compilation.plugin('normal-module-loader', (loaderContext, module) => { + if ( + this.shouldRun || + module.resource.indexOf('node_modules') !== -1 || + !/[.]js(x)?$/.test(module.resource) + ) { + return; + } + const contents = loaderContext.fs.readFileSync(module.resource, 'utf8'); + if ( + /^\s*\/\/.*@flow/.test(contents) || /^\s*\/\*.*@flow/.test(contents) + ) { + this.shouldRun = true; + } + }); + }); + + // Run lint checks + compiler.plugin('emit', (compilation, callback) => { + if (!this.shouldRun) { + callback(); + return; + } + const cwd = compiler.options.context; + const first = this.flowStarting == null && !this.flowStarted; + this.startFlow(cwd) + .then(() => { + if (first) { + console.log( + chalk.yellow( + 'Flow is initializing, ' + + chalk.bold('this might take a while...') + ) + ); + } else { + console.log('Running flow...'); + } + exec(flowBinPath, ['status', '--color=always'], { cwd }) + .then(() => { + callback(); + }) + .catch(e => { + compilation.warnings.push(formatFlowErrors(e)); + callback(); + }); + }) + .catch(e => { + if (e === '__FLOW_VERSION_MISMATCH__') { + compilation.warnings.push(createVersionWarning(this.flowVersion)); + } else { + compilation.warnings.push( + 'Flow: Type checking has been disabled due to an error in Flow.' + ); + } + callback(); + }); + }); + } +} + +module.exports = FlowTypecheckPlugin; diff --git a/packages/react-dev-utils/formatWebpackMessages.js b/packages/react-dev-utils/formatWebpackMessages.js index 098c48657e2..a2ecdbe3cc1 100644 --- a/packages/react-dev-utils/formatWebpackMessages.js +++ b/packages/react-dev-utils/formatWebpackMessages.js @@ -117,7 +117,9 @@ function formatWebpackMessages(json) { return 'Error in ' + formatMessage(message); }); var formattedWarnings = json.warnings.map(function(message) { - return 'Warning in ' + formatMessage(message); + var formattedMessage = formatMessage(message); + if (/^Flow: /.test(message)) return formattedMessage; + return 'Warning in ' + formattedMessage; }); var result = { errors: formattedErrors, diff --git a/packages/react-dev-utils/package.json b/packages/react-dev-utils/package.json index 64037886a0e..7ca9d4ccb7f 100644 --- a/packages/react-dev-utils/package.json +++ b/packages/react-dev-utils/package.json @@ -16,6 +16,7 @@ "clearConsole.js", "crashOverlay.js", "FileSizeReporter.js", + "FlowTypecheckPlugin.js", "formatWebpackMessages.js", "getProcessForPort.js", "InterpolateHtmlPlugin.js", @@ -31,6 +32,7 @@ "chalk": "1.1.3", "escape-string-regexp": "1.0.5", "filesize": "3.3.0", + "flow-bin": "0.45.0", "gzip-size": "3.0.0", "html-entities": "1.2.0", "opn": "4.0.2", diff --git a/packages/react-scripts/config/webpack.config.dev.js b/packages/react-scripts/config/webpack.config.dev.js index 7244ff79671..1840751d8ce 100644 --- a/packages/react-scripts/config/webpack.config.dev.js +++ b/packages/react-scripts/config/webpack.config.dev.js @@ -16,6 +16,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin'); const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin'); const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin'); +var FlowTypecheckPlugin = require('react-dev-utils/FlowTypecheckPlugin'); const getClientEnvironment = require('./env'); const paths = require('./paths'); @@ -241,6 +242,8 @@ module.exports = { // makes the discovery automatic so you don't have to restart. // See https://github.com/facebookincubator/create-react-app/issues/186 new WatchMissingNodeModulesPlugin(paths.appNodeModules), + // Run Flow on files with the @flow header + new FlowTypecheckPlugin(), ], // Some libraries import Node modules but don't use them in the browser. // Tell Webpack to provide empty mocks for them so importing them works. diff --git a/packages/react-scripts/config/webpack.config.prod.js b/packages/react-scripts/config/webpack.config.prod.js index 342cc34d9c3..89af42f3bde 100644 --- a/packages/react-scripts/config/webpack.config.prod.js +++ b/packages/react-scripts/config/webpack.config.prod.js @@ -16,6 +16,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const ExtractTextPlugin = require('extract-text-webpack-plugin'); const ManifestPlugin = require('webpack-manifest-plugin'); const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin'); +var FlowTypecheckPlugin = require('react-dev-utils/FlowTypecheckPlugin'); const paths = require('./paths'); const getClientEnvironment = require('./env'); @@ -277,6 +278,8 @@ module.exports = { new ManifestPlugin({ fileName: 'asset-manifest.json', }), + // Run Flow on files with the @flow header + new FlowTypecheckPlugin(), ], // Some libraries import Node modules but don't use them in the browser. // Tell Webpack to provide empty mocks for them so importing them works. diff --git a/packages/react-scripts/scripts/utils/createWebpackCompiler.js b/packages/react-scripts/scripts/utils/createWebpackCompiler.js index a333165b082..2f6f0cac2b3 100644 --- a/packages/react-scripts/scripts/utils/createWebpackCompiler.js +++ b/packages/react-scripts/scripts/utils/createWebpackCompiler.js @@ -112,6 +112,12 @@ module.exports = function createWebpackCompiler(config, onReadyCallback) { chalk.yellow('/* eslint-disable */') + ' to ignore all warnings in a file.' ); + // Teach some Flow tricks. + console.log( + 'Use ' + + chalk.yellow('// $FlowFixMe') + + ' to ignore flow-related warnings on the next line.' + ); } }); diff --git a/packages/react-scripts/template/README.md b/packages/react-scripts/template/README.md index 0cda7e071f6..50a6a9b0426 100644 --- a/packages/react-scripts/template/README.md +++ b/packages/react-scripts/template/README.md @@ -646,24 +646,21 @@ We suggest the following approach: Here is an example of adding a [customized Bootstrap](https://medium.com/@tacomanator/customizing-create-react-app-aa9ffb88165) that follows these steps. -## Adding Flow +## Using Flow Flow is a static type checker that helps you write code with fewer bugs. Check out this [introduction to using static types in JavaScript](https://medium.com/@preethikasireddy/why-use-static-types-in-javascript-part-1-8382da1e0adb) if you are new to this concept. -Recent versions of [Flow](http://flowtype.org/) work with Create React App projects out of the box. +Flow typing is supported out of the box. All you have to do is add the `/* @flow */` comment on top of files you +want to typecheck. If no `.flowconfig` is present, one will be generated for you. -To add Flow to a Create React App project, follow these steps: +Flow errors will show up alongside ESLint errors as you work on your application. -1. Run `npm install --save-dev flow-bin` (or `yarn add --dev flow-bin`). -2. Add `"flow": "flow"` to the `scripts` section of your `package.json`. -3. Run `npm run flow -- init` (or `yarn flow -- init`) to create a [`.flowconfig` file](https://flowtype.org/docs/advanced-configuration.html) in the root directory. -4. Add `// @flow` to any files you want to type check (for example, to `src/App.js`). +>Note: If your global flow installation version differs from react-scripts's flow version, you may experience slower compilation times while going back and forth between your app and your IDE (that may use your global flow). Ensure you have the same `flow-bin` version [here](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-dev-utils/package.json). -Now you can run `npm run flow` (or `yarn flow`) to check the files for type errors. You can optionally use an IDE like [Nuclide](https://nuclide.io/docs/languages/flow/) for a better integrated experience. -In the future we plan to integrate it into Create React App even more closely. To learn more about Flow, check out [its documentation](https://flowtype.org/). +You may also want to learn how to use definitions from [flow-typed](https://github.com/flowtype/flow-typed). ## Adding Custom Environment Variables diff --git a/tasks/e2e-simple.sh b/tasks/e2e-simple.sh index 59cccd88898..8859b6f3502 100755 --- a/tasks/e2e-simple.sh +++ b/tasks/e2e-simple.sh @@ -227,6 +227,7 @@ exists build/static/js/*.js exists build/static/css/*.css exists build/static/media/*.svg exists build/favicon.ico +test ! -e .flowconfig # Run tests with CI flag CI=true npm test @@ -239,6 +240,16 @@ npm start -- --smoke-test # Test environment handling verify_env_url +# Test optional flow enabling +cp src/App.js src/App.backup.js +echo "/* @flow */" > src/App.js +cat src/App.backup.js >> src/App.js +npm start -- --smoke-test +test -e .flowconfig +rm src/App.js +cp src/App.backup.js src/App.js +rm src/App.backup.js + # ****************************************************************************** # Finally, let's check that everything still works after ejecting. # ****************************************************************************** @@ -275,5 +286,14 @@ npm start -- --smoke-test # Test environment handling verify_env_url +# Test optional flow enabling +cp src/App.js src/App.backup.js +cp .gitignore .gitignore.backup +echo "/* @flow */" > src/App.js +cat src/App.backup.js >> src/App.js +npm start -- --smoke-test +cp src/App.backup.js src/App.js +rm src/App.backup.js + # Cleanup cleanup