Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add reactProdInvariant and corresponding babel rewrite pass #6948

Merged
merged 11 commits into from
Jun 8, 2016
1 change: 0 additions & 1 deletion .babelrc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -53,6 +54,7 @@ var errorCodeOpts = {

var babelOpts = {
plugins: [
devExpressionWithCodes, // this pass has to run before `rewrite-modules`
[babelPluginModules, {map: moduleMap}],
],
};
Expand Down
145 changes: 145 additions & 0 deletions scripts/error-codes/__tests__/dev-expression-with-codes-test.js
Original file line number Diff line number Diff line change
@@ -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 = '';
});
});
190 changes: 190 additions & 0 deletions scripts/error-codes/dev-expression-with-codes.js
Original file line number Diff line number Diff line change
@@ -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')]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh btw, reactProdInvariant is hard-coded now. Do you think I should make it an option to the babel plugin?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably okay like this.

),
});
}
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
),
])
));
}
},
},
},
};
};
5 changes: 3 additions & 2 deletions scripts/jest/preprocessor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{},
Expand Down Expand Up @@ -68,7 +69,7 @@ module.exports = {
pathToBabel,
pathToBabelrc,
pathToModuleMap,
pathToBabelPluginDev,
pathToBabelPluginDevWithCode,
pathToBabelPluginModules,
]),
};
Loading