From 1abce1630c60b501ff187187d0bf2b5eb0d95dd7 Mon Sep 17 00:00:00 2001 From: Keyan Zhang Date: Tue, 7 Jun 2016 17:11:04 -0700 Subject: [PATCH] Add reactProdInvariant and corresponding babel rewrite pass (#6948) --- .babelrc | 1 - gulpfile.js | 2 + .../dev-expression-with-codes-test.js | 145 +++++++++++++ .../error-codes/dev-expression-with-codes.js | 190 ++++++++++++++++++ scripts/jest/preprocessor.js | 5 +- .../dom/__tests__/ReactDOMProduction-test.js | 17 +- .../__tests__/reactProdInvariant-test.js | 49 +++++ src/shared/utils/reactProdInvariant.js | 43 ++++ 8 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 scripts/error-codes/__tests__/dev-expression-with-codes-test.js create mode 100644 scripts/error-codes/dev-expression-with-codes.js create mode 100644 src/shared/utils/__tests__/reactProdInvariant-test.js create mode 100644 src/shared/utils/reactProdInvariant.js diff --git a/.babelrc b/.babelrc index 2880552916426..a976990ee9e0e 100644 --- a/.babelrc +++ b/.babelrc @@ -2,7 +2,6 @@ "presets": ["react"], "ignore": ["third_party"], "plugins": [ - "fbjs-scripts/babel-6/dev-expression", "syntax-trailing-function-commas", "babel-plugin-transform-object-rest-spread", "transform-es2015-template-literals", diff --git a/gulpfile.js b/gulpfile.js index cb287ff2aa6fd..103f608e208f9 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -16,6 +16,7 @@ var del = require('del'); var babelPluginModules = require('fbjs-scripts/babel-6/rewrite-modules'); var extractErrors = require('./scripts/error-codes/gulp-extract-errors'); +var devExpressionWithCodes = require('./scripts/error-codes/dev-expression-with-codes'); var paths = { react: { @@ -53,6 +54,7 @@ var errorCodeOpts = { var babelOpts = { plugins: [ + devExpressionWithCodes, // this pass has to run before `rewrite-modules` [babelPluginModules, {map: moduleMap}], ], }; diff --git a/scripts/error-codes/__tests__/dev-expression-with-codes-test.js b/scripts/error-codes/__tests__/dev-expression-with-codes-test.js new file mode 100644 index 0000000000000..fcc1a31513ee2 --- /dev/null +++ b/scripts/error-codes/__tests__/dev-expression-with-codes-test.js @@ -0,0 +1,145 @@ +/** + * Copyright (c) 2013-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. + */ +/* eslint-disable quotes */ +'use strict'; + +let babel = require('babel-core'); +let devExpressionWithCodes = require('../dev-expression-with-codes'); + +function transform(input) { + return babel.transform(input, { + plugins: [devExpressionWithCodes], + }).code; +} + +function compare(input, output) { + var compiled = transform(input); + expect(compiled).toEqual(output); +} + +var oldEnv; + +describe('dev-expression', function() { + beforeEach(() => { + oldEnv = process.env.NODE_ENV; + process.env.NODE_ENV = ''; + }); + + afterEach(() => { + process.env.NODE_ENV = oldEnv; + }); + + it('should replace __DEV__ in if', () => { + compare( +` +if (__DEV__) { + console.log('foo') +}`, +` +if (process.env.NODE_ENV !== 'production') { + console.log('foo'); +}` + ); + }); + + it('should replace warning calls', () => { + compare( + "warning(condition, 'a %s b', 'c');", + "process.env.NODE_ENV !== 'production' ? warning(condition, 'a %s b', 'c') : void 0;" + ); + }); + + it("should add `reactProdInvariant` when it finds `require('invariant')`", () => { + compare( +"var invariant = require('invariant');", + +`var _prodInvariant = require('reactProdInvariant'); + +var invariant = require('invariant');` + ); + }); + + it('should replace simple invariant calls', () => { + compare( + "invariant(condition, 'Do not override existing functions.');", + "var _prodInvariant = require('reactProdInvariant');\n\n" + + "!condition ? " + + "process.env.NODE_ENV !== 'production' ? " + + "invariant(false, 'Do not override existing functions.') : " + + `_prodInvariant('16') : void 0;` + ); + }); + + it("should only add `reactProdInvariant` once", () => { + var expectedInvariantTransformResult = ( + "!condition ? " + + "process.env.NODE_ENV !== 'production' ? " + + "invariant(false, 'Do not override existing functions.') : " + + `_prodInvariant('16') : void 0;` + ); + + compare( +`var invariant = require('invariant'); +invariant(condition, 'Do not override existing functions.'); +invariant(condition, 'Do not override existing functions.');`, + +`var _prodInvariant = require('reactProdInvariant'); + +var invariant = require('invariant'); +${expectedInvariantTransformResult} +${expectedInvariantTransformResult}` + ); + }); + + it('should support invariant calls with args', () => { + compare( + "invariant(condition, 'Expected %s target to be an array; got %s', 'foo', 'bar');", + "var _prodInvariant = require('reactProdInvariant');\n\n" + + "!condition ? " + + "process.env.NODE_ENV !== 'production' ? " + + "invariant(false, 'Expected %s target to be an array; got %s', 'foo', 'bar') : " + + `_prodInvariant('7', 'foo', 'bar') : void 0;` + ); + }); + + it('should support invariant calls with a concatenated template string and args', () => { + compare( + "invariant(condition, 'Expected a component class, ' + 'got %s.' + '%s', 'Foo', 'Bar');", + "var _prodInvariant = require('reactProdInvariant');\n\n" + + "!condition ? " + + "process.env.NODE_ENV !== 'production' ? " + + "invariant(false, 'Expected a component class, got %s.%s', 'Foo', 'Bar') : " + + `_prodInvariant('18', 'Foo', 'Bar') : void 0;` + ); + }); + + it('should warn in non-test envs if the error message cannot be found', () => { + spyOn(console, 'warn'); + transform("invariant(condition, 'a %s b', 'c');"); + + expect(console.warn.calls.count()).toBe(1); + expect(console.warn.calls.argsFor(0)[0]).toBe( + 'Error message "a %s b" ' + + 'cannot be found. The current React version ' + + 'and the error map are probably out of sync. ' + + 'Please run `gulp react:extract-errors` before building React.' + ); + }); + + it('should not warn in test env if the error message cannot be found', () => { + process.env.NODE_ENV = 'test'; + + spyOn(console, 'warn'); + transform("invariant(condition, 'a %s b', 'c');"); + + expect(console.warn.calls.count()).toBe(0); + + process.env.NODE_ENV = ''; + }); +}); diff --git a/scripts/error-codes/dev-expression-with-codes.js b/scripts/error-codes/dev-expression-with-codes.js new file mode 100644 index 0000000000000..9862a20f51a7b --- /dev/null +++ b/scripts/error-codes/dev-expression-with-codes.js @@ -0,0 +1,190 @@ +/** + * Copyright (c) 2013-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'; + +var evalToString = require('./evalToString'); +var existingErrorMap = require('./codes.json'); +var invertObject = require('./invertObject'); + +var errorMap = invertObject(existingErrorMap); + +module.exports = function(babel) { + var t = babel.types; + + var SEEN_SYMBOL = Symbol('dev-expression-with-codes.seen'); + + // Generate a hygienic identifier + function getProdInvariantIdentifier(path, localState) { + if (!localState.prodInvariantIdentifier) { + localState.prodInvariantIdentifier = path.scope.generateUidIdentifier('prodInvariant'); + path.scope.getProgramParent().push({ + id: localState.prodInvariantIdentifier, + init: t.callExpression( + t.identifier('require'), + [t.stringLiteral('reactProdInvariant')] + ), + }); + } + return localState.prodInvariantIdentifier; + } + + var DEV_EXPRESSION = t.binaryExpression( + '!==', + t.memberExpression( + t.memberExpression( + t.identifier('process'), + t.identifier('env'), + false + ), + t.identifier('NODE_ENV'), + false + ), + t.stringLiteral('production') + ); + + return { + pre: function() { + this.prodInvariantIdentifier = null; + }, + + visitor: { + Identifier: { + enter: function(path) { + // Do nothing when testing + if (process.env.NODE_ENV === 'test') { + return; + } + // Replace __DEV__ with process.env.NODE_ENV !== 'production' + if (path.isIdentifier({name: '__DEV__'})) { + path.replaceWith(DEV_EXPRESSION); + } + }, + }, + CallExpression: { + exit: function(path) { + var node = path.node; + // Ignore if it's already been processed + if (node[SEEN_SYMBOL]) { + return; + } + // Insert `var PROD_INVARIANT = require('reactProdInvariant');` + // before all `require('invariant')`s. + // NOTE it doesn't support ES6 imports yet. + if ( + path.get('callee').isIdentifier({name: 'require'}) && + path.get('arguments')[0] && + path.get('arguments')[0].isStringLiteral({value: 'invariant'}) + ) { + node[SEEN_SYMBOL] = true; + getProdInvariantIdentifier(path, this); + } else if (path.get('callee').isIdentifier({name: 'invariant'})) { + // Turns this code: + // + // invariant(condition, argument, 'foo', 'bar'); + // + // into this: + // + // if (!condition) { + // if ("production" !== process.env.NODE_ENV) { + // invariant(false, argument, 'foo', 'bar'); + // } else { + // PROD_INVARIANT('XYZ', 'foo', 'bar'); + // } + // } + // + // where + // - `XYZ` is an error code: a unique identifier (a number string) + // that references a verbose error message. + // The mapping is stored in `scripts/error-codes/codes.json`. + // - `PROD_INVARIANT` is the `reactProdInvariant` function that always throws with an error URL like + // http://facebook.github.io/react/docs/error-decoder.html?invariant=XYZ&args[]=foo&args[]=bar + // + // Specifically this does 3 things: + // 1. Checks the condition first, preventing an extra function call. + // 2. Adds an environment check so that verbose error messages aren't + // shipped to production. + // 3. Rewrites the call to `invariant` in production to `reactProdInvariant` + // - `reactProdInvariant` is always renamed to avoid shadowing + // The generated code is longer than the original code but will dead + // code removal in a minifier will strip that out. + var condition = node.arguments[0]; + var errorMsgLiteral = evalToString(node.arguments[1]); + + var prodErrorId = errorMap[errorMsgLiteral]; + if (prodErrorId === undefined) { + // The error cannot be found in the map. + node[SEEN_SYMBOL] = true; + if (process.env.NODE_ENV !== 'test') { + console.warn( + 'Error message "' + errorMsgLiteral + + '" cannot be found. The current React version ' + + 'and the error map are probably out of sync. ' + + 'Please run `gulp react:extract-errors` before building React.' + ); + } + return; + } + + var devInvariant = t.callExpression(node.callee, [ + t.booleanLiteral(false), + t.stringLiteral(errorMsgLiteral), + ].concat(node.arguments.slice(2))); + + devInvariant[SEEN_SYMBOL] = true; + + var localInvariantId = getProdInvariantIdentifier(path, this); + var prodInvariant = t.callExpression(localInvariantId, [ + t.stringLiteral(prodErrorId), + ].concat(node.arguments.slice(2))); + + prodInvariant[SEEN_SYMBOL] = true; + path.replaceWith(t.ifStatement( + t.unaryExpression('!', condition), + t.blockStatement([ + t.ifStatement( + DEV_EXPRESSION, + t.blockStatement([ + t.expressionStatement(devInvariant), + ]), + t.blockStatement([ + t.expressionStatement(prodInvariant), + ]) + ), + ]) + )); + } else if (path.get('callee').isIdentifier({name: 'warning'})) { + // Turns this code: + // + // warning(condition, argument, argument); + // + // into this: + // + // if ("production" !== process.env.NODE_ENV) { + // warning(condition, argument, argument); + // } + // + // The goal is to strip out warning calls entirely in production. We + // don't need the same optimizations for conditions that we use for + // invariant because we don't care about an extra call in __DEV__ + + node[SEEN_SYMBOL] = true; + path.replaceWith(t.ifStatement( + DEV_EXPRESSION, + t.blockStatement([ + t.expressionStatement( + node + ), + ]) + )); + } + }, + }, + }, + }; +}; diff --git a/scripts/jest/preprocessor.js b/scripts/jest/preprocessor.js index 0cbc46dd90c1c..3a60dfd051e6d 100644 --- a/scripts/jest/preprocessor.js +++ b/scripts/jest/preprocessor.js @@ -20,13 +20,14 @@ var createCacheKeyFunction = require('fbjs-scripts/jest/createCacheKeyFunction') // Use require.resolve to be resilient to file moves, npm updates, etc var pathToBabel = path.join(require.resolve('babel-core'), '..', 'package.json'); var pathToModuleMap = require.resolve('fbjs/module-map'); -var pathToBabelPluginDev = require.resolve('fbjs-scripts/babel-6/dev-expression'); +var pathToBabelPluginDevWithCode = require.resolve('../error-codes/dev-expression-with-codes'); var pathToBabelPluginModules = require.resolve('fbjs-scripts/babel-6/rewrite-modules'); var pathToBabelrc = path.join(__dirname, '..', '..', '.babelrc'); // TODO: make sure this stays in sync with gulpfile var babelOptions = { plugins: [ + pathToBabelPluginDevWithCode, // this pass has to run before `rewrite-modules` [babelPluginModules, { map: Object.assign( {}, @@ -68,7 +69,7 @@ module.exports = { pathToBabel, pathToBabelrc, pathToModuleMap, - pathToBabelPluginDev, + pathToBabelPluginDevWithCode, pathToBabelPluginModules, ]), }; diff --git a/src/renderers/dom/__tests__/ReactDOMProduction-test.js b/src/renderers/dom/__tests__/ReactDOMProduction-test.js index 718909ed86894..2704f15767631 100644 --- a/src/renderers/dom/__tests__/ReactDOMProduction-test.js +++ b/src/renderers/dom/__tests__/ReactDOMProduction-test.js @@ -8,7 +8,6 @@ * * @emails react-core */ - 'use strict'; describe('ReactDOMProduction', function() { @@ -87,4 +86,20 @@ describe('ReactDOMProduction', function() { expect(container.childNodes.length).toBe(0); }); + it('should throw with an error code in production', function() { + expect(function() { + var Component = React.createClass({ + render: function() { + return ['this is wrong']; + }, + }); + var container = document.createElement('div'); + ReactDOM.render(, container); + }).toThrowError( + 'Minified React error #109; visit ' + + 'http://facebook.github.io/react/docs/error-decoder.html?invariant=109&args[]=Component' + + ' for the full message or use the non-minified dev environment' + + ' for full errors and additional helpful warnings.' + ); + }); }); diff --git a/src/shared/utils/__tests__/reactProdInvariant-test.js b/src/shared/utils/__tests__/reactProdInvariant-test.js new file mode 100644 index 0000000000000..9b5f950ae65ea --- /dev/null +++ b/src/shared/utils/__tests__/reactProdInvariant-test.js @@ -0,0 +1,49 @@ +/** + * Copyright 2013-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. + * + * @emails react-core + */ +'use strict'; + +var reactProdInvariant; + +describe('reactProdInvariant', function() { + beforeEach(function() { + jest.resetModuleRegistry(); + reactProdInvariant = require('reactProdInvariant'); + }); + + it('should throw with the correct number of `%s`s in the URL', function() { + expect(function() { + reactProdInvariant(124, 'foo', 'bar'); + }).toThrowError( + 'Minified React error #124; visit ' + + 'http://facebook.github.io/react/docs/error-decoder.html?invariant=124&args[]=foo&args[]=bar' + + ' for the full message or use the non-minified dev environment' + + ' for full errors and additional helpful warnings.' + ); + + expect(function() { + reactProdInvariant(20); + }).toThrowError( + 'Minified React error #20; visit ' + + 'http://facebook.github.io/react/docs/error-decoder.html?invariant=20' + + ' for the full message or use the non-minified dev environment' + + ' for full errors and additional helpful warnings.' + ); + + expect(function() { + reactProdInvariant(77, '
', '&?bar'); + }).toThrowError( + 'Minified React error #77; visit ' + + 'http://facebook.github.io/react/docs/error-decoder.html?invariant=77&args[]=%3Cdiv%3E&args[]=%26%3Fbar' + + ' for the full message or use the non-minified dev environment' + + ' for full errors and additional helpful warnings.' + ); + }); +}); diff --git a/src/shared/utils/reactProdInvariant.js b/src/shared/utils/reactProdInvariant.js new file mode 100644 index 0000000000000..ca235bbeb9d25 --- /dev/null +++ b/src/shared/utils/reactProdInvariant.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2013-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. + * + * @providesModule reactProdInvariant + */ +'use strict'; + +/** + * WARNING: DO NOT manually require this module. + * This is a replacement for `invariant(...)` used by the error code system + * and will _only_ be required by the corresponding babel pass. + * It always throws. + */ +function reactProdInvariant(code) { + var argCount = arguments.length - 1; + + var message = ( + 'Minified React error #' + code + '; visit ' + + 'http://facebook.github.io/react/docs/error-decoder.html?invariant=' + code + ); + + for (var argIdx = 0; argIdx < argCount; argIdx++) { + message += '&args[]=' + encodeURIComponent(arguments[argIdx + 1]); + } + + message += ( + ' for the full message or use the non-minified dev environment' + + ' for full errors and additional helpful warnings.' + ); + + var error = new Error(message); + error.name = 'Invariant Violation'; + error.framesToPop = 1; // we don't care about reactProdInvariant's own frame + + throw error; +} + +module.exports = reactProdInvariant;