From 8ea8d838ec1e7f151503c716e587470e6f35b24a Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 6 Mar 2019 21:15:09 -0800 Subject: [PATCH 1/4] Transform invariant to custom error type This transforms calls to the invariant module: ```js invariant(condition, 'A %s message that contains %s', adj, noun); ``` Into throw statements: ```js if (!condition) { if (__DEV__) { throw ReactError(`A ${adj} message that contains ${noun}`); } else { throw ReactErrorProd(ERR_CODE, [adj, noun]); } } ``` The only thing ReactError does is return an error whose name is set to "Invariant Violation" to match the existing behavior. ReactErrorProd is a special version used in production that throws a minified error code, with a link to see to expanded form. This replaces the reactProdInvariant module. As a next step, I would like to replace our use of the invariant module for user facing errors by transforming normal Error constructors to ReactError and ReactErrorProd. (We can continue using invariant for internal React errors that are meant to be unreachable, which was the original purpose of invariant.) --- packages/shared/ReactError.js | 19 +++ packages/shared/ReactErrorProd.js | 27 ++++ .../__tests__/ReactError-test.internal.js | 72 ++++++++++ ...nal.js => ReactErrorProd-test.internal.js} | 12 +- packages/shared/forks/invariant.www.js | 8 -- packages/shared/invariant.js | 37 +---- packages/shared/reactProdInvariant.js | 43 ------ scripts/error-codes/README.md | 29 ++-- .../minify-error-messages.js.snap | 96 +++++++++++++ .../__tests__/minify-error-messages.js | 85 +++++++++++ .../replace-invariant-error-codes-test.js | 102 ------------- scripts/error-codes/extract-errors.js | 13 +- scripts/error-codes/minify-error-messages.js | 132 +++++++++++++++++ .../replace-invariant-error-codes.js | 135 ------------------ scripts/jest/preprocessor.js | 2 +- scripts/rollup/build.js | 4 +- scripts/rollup/forks.js | 12 -- 17 files changed, 468 insertions(+), 360 deletions(-) create mode 100644 packages/shared/ReactError.js create mode 100644 packages/shared/ReactErrorProd.js create mode 100644 packages/shared/__tests__/ReactError-test.internal.js rename packages/shared/__tests__/{reactProdInvariant-test.internal.js => ReactErrorProd-test.internal.js} (88%) delete mode 100644 packages/shared/forks/invariant.www.js delete mode 100644 packages/shared/reactProdInvariant.js create mode 100644 scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap create mode 100644 scripts/error-codes/__tests__/minify-error-messages.js delete mode 100644 scripts/error-codes/__tests__/replace-invariant-error-codes-test.js create mode 100644 scripts/error-codes/minify-error-messages.js delete mode 100644 scripts/error-codes/replace-invariant-error-codes.js diff --git a/packages/shared/ReactError.js b/packages/shared/ReactError.js new file mode 100644 index 0000000000000..2c493f7d81cdc --- /dev/null +++ b/packages/shared/ReactError.js @@ -0,0 +1,19 @@ +/** + * 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. + * + */ + +// Do not require this module directly! Use a normal error constructor with +// template literal strings. The messages will be converted to ReactError during +// build, and in production they will be minified. + +function ReactError(message) { + const error = new Error(message); + error.name = 'Invariant Violation'; + return error; +} + +export default ReactError; diff --git a/packages/shared/ReactErrorProd.js b/packages/shared/ReactErrorProd.js new file mode 100644 index 0000000000000..50084ef510025 --- /dev/null +++ b/packages/shared/ReactErrorProd.js @@ -0,0 +1,27 @@ +/** + * 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. + * + */ + +// Do not require this module directly! Use a normal error constructor with +// template literal strings. The messages will be converted to ReactError during +// build, and in production they will be minified. + +function ReactErrorProd(code, args) { + let url = 'https://reactjs.org/docs/error-decoder.html?invariant=' + code; + if (args !== undefined) { + for (let i = 0; i < args.length; i++) { + url += '&args[]=' + encodeURIComponent(args[i]); + } + } + return new Error( + `Minified React error #${code}; visit ${url} for the full message or ` + + 'use the non-minified dev environment for full errors and additional ' + + 'helpful warnings. ', + ); +} + +export default ReactErrorProd; diff --git a/packages/shared/__tests__/ReactError-test.internal.js b/packages/shared/__tests__/ReactError-test.internal.js new file mode 100644 index 0000000000000..a8b9bcb557ae5 --- /dev/null +++ b/packages/shared/__tests__/ReactError-test.internal.js @@ -0,0 +1,72 @@ +/** + * 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'; + +let React; +let ReactDOM; + +describe('ReactError', () => { + let globalErrorMock; + + beforeEach(() => { + if (!__DEV__) { + // In production, our Jest environment overrides the global Error + // class in order to decode error messages automatically. However + // this is a single test where we actually *don't* want to decode + // them. So we assert that the OriginalError exists, and temporarily + // set the global Error object back to it. + globalErrorMock = global.Error; + global.Error = globalErrorMock.OriginalError; + expect(typeof global.Error).toBe('function'); + } + jest.resetModules(); + React = require('react'); + ReactDOM = require('react-dom'); + }); + + afterEach(() => { + if (!__DEV__) { + global.Error = globalErrorMock; + } + }); + + if (__DEV__) { + it('should throw errors whose name is "Invariant Violation"', () => { + let error; + try { + React.useState(); + } catch (e) { + error = e; + } + expect(error.name).toEqual('Invariant Violation'); + }); + } else { + it('should error with minified error code', () => { + expect(() => ReactDOM.render('Hi', null)).toThrowError( + 'Minified React error #200; visit ' + + 'https://reactjs.org/docs/error-decoder.html?invariant=200' + + ' for the full message or use the non-minified dev environment' + + ' for full errors and additional helpful warnings.', + ); + }); + it('should serialize arguments', () => { + function Oops() { + return; + } + Oops.displayName = '#wtf'; + const container = document.createElement('div'); + expect(() => ReactDOM.render(, container)).toThrowError( + 'Minified React error #152; visit ' + + 'https://reactjs.org/docs/error-decoder.html?invariant=152&args[]=%23wtf' + + ' for the full message or use the non-minified dev environment' + + ' for full errors and additional helpful warnings.', + ); + }); + } +}); diff --git a/packages/shared/__tests__/reactProdInvariant-test.internal.js b/packages/shared/__tests__/ReactErrorProd-test.internal.js similarity index 88% rename from packages/shared/__tests__/reactProdInvariant-test.internal.js rename to packages/shared/__tests__/ReactErrorProd-test.internal.js index 2fe334aa90e35..1d09c2fc64d7f 100644 --- a/packages/shared/__tests__/reactProdInvariant-test.internal.js +++ b/packages/shared/__tests__/ReactErrorProd-test.internal.js @@ -8,9 +8,9 @@ */ 'use strict'; -let reactProdInvariant; +let ReactErrorProd; -describe('reactProdInvariant', () => { +describe('ReactErrorProd', () => { let globalErrorMock; beforeEach(() => { @@ -25,7 +25,7 @@ describe('reactProdInvariant', () => { expect(typeof global.Error).toBe('function'); } jest.resetModules(); - reactProdInvariant = require('shared/reactProdInvariant').default; + ReactErrorProd = require('shared/ReactErrorProd').default; }); afterEach(() => { @@ -36,7 +36,7 @@ describe('reactProdInvariant', () => { it('should throw with the correct number of `%s`s in the URL', () => { expect(function() { - reactProdInvariant(124, 'foo', 'bar'); + throw ReactErrorProd('124', ['foo', 'bar']); }).toThrowError( 'Minified React error #124; visit ' + 'https://reactjs.org/docs/error-decoder.html?invariant=124&args[]=foo&args[]=bar' + @@ -45,7 +45,7 @@ describe('reactProdInvariant', () => { ); expect(function() { - reactProdInvariant(20); + throw ReactErrorProd('20'); }).toThrowError( 'Minified React error #20; visit ' + 'https://reactjs.org/docs/error-decoder.html?invariant=20' + @@ -54,7 +54,7 @@ describe('reactProdInvariant', () => { ); expect(function() { - reactProdInvariant(77, '
', '&?bar'); + throw ReactErrorProd('77', ['
', '&?bar']); }).toThrowError( 'Minified React error #77; visit ' + 'https://reactjs.org/docs/error-decoder.html?invariant=77&args[]=%3Cdiv%3E&args[]=%26%3Fbar' + diff --git a/packages/shared/forks/invariant.www.js b/packages/shared/forks/invariant.www.js deleted file mode 100644 index acae3cb10d36a..0000000000000 --- a/packages/shared/forks/invariant.www.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * 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. - */ - -export default require('invariant'); diff --git a/packages/shared/invariant.js b/packages/shared/invariant.js index 8f47ab0dccf73..747961671f1ea 100644 --- a/packages/shared/invariant.js +++ b/packages/shared/invariant.js @@ -17,38 +17,9 @@ * will remain to ensure logic does not differ in production. */ -let validateFormat = () => {}; - -if (__DEV__) { - validateFormat = function(format) { - if (format === undefined) { - throw new Error('invariant requires an error message argument'); - } - }; -} - export default function invariant(condition, format, a, b, c, d, e, f) { - validateFormat(format); - - if (!condition) { - let error; - if (format === undefined) { - error = new Error( - 'Minified exception occurred; use the non-minified dev environment ' + - 'for the full error message and additional helpful warnings.', - ); - } else { - const args = [a, b, c, d, e, f]; - let argIndex = 0; - error = new Error( - format.replace(/%s/g, function() { - return args[argIndex++]; - }), - ); - error.name = 'Invariant Violation'; - } - - error.framesToPop = 1; // we don't care about invariant's own frame - throw error; - } + throw new Error( + 'Internal React error: invariant() is meant to be replaced at compile ' + + 'time. There is no runtime version.', + ); } diff --git a/packages/shared/reactProdInvariant.js b/packages/shared/reactProdInvariant.js deleted file mode 100644 index 482b2e342ef48..0000000000000 --- a/packages/shared/reactProdInvariant.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * 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. - * - * @flow - */ - -// Relying on the `invariant()` implementation lets us -// preserve the format and params in the www builds. -import invariant from 'shared/invariant'; - -/** - * 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: string): void { - const argCount = arguments.length - 1; - let url = 'https://reactjs.org/docs/error-decoder.html?invariant=' + code; - for (let argIdx = 0; argIdx < argCount; argIdx++) { - url += '&args[]=' + encodeURIComponent(arguments[argIdx + 1]); - } - // Rename it so that our build transform doesn't attempt - // to replace this invariant() call with reactProdInvariant(). - const i = invariant; - i( - false, - // The error code is intentionally part of the message (and - // not the format argument) so that we could deduplicate - // different errors in logs based on the code. - 'Minified React error #' + - code + - '; visit %s ' + - 'for the full message or use the non-minified dev environment ' + - 'for full errors and additional helpful warnings. ', - url, - ); -} - -export default reactProdInvariant; diff --git a/scripts/error-codes/README.md b/scripts/error-codes/README.md index 4e34a82a271d5..2f39c9097ee1e 100644 --- a/scripts/error-codes/README.md +++ b/scripts/error-codes/README.md @@ -1,14 +1,17 @@ -The error code system substitutes React's invariant error messages with error IDs to provide a better debugging support in production. Check out the blog post [here](https://reactjs.org/blog/2016/07/11/introducing-reacts-error-code-system.html). +The error code system substitutes React's error messages with error IDs to +provide a better debugging support in production. Check out the blog post +[here](https://reactjs.org/blog/2016/07/11/introducing-reacts-error-code-system.html). -## Note for cutting a new React release -1. For each release, we run `yarn build -- --extract-errors` to update the error codes before calling `yarn build`. The build step uses `codes.json` for a production (minified) build; there should be no warning like `Error message "foo" cannot be found` for a successful release. -2. The updated `codes.json` file should be synced back to the master branch. The error decoder page in our documentation site uses `codes.json` from master; if the json file has been updated, the docs site should also be rebuilt (`rake copy_error_codes` is included in the default `rake release` task). -3. Be certain to run `yarn build -- --extract-errors` directly in the release branch (if not master) to ensure the correct error codes are generated. These error messages might be changed/removed before cutting a new release, and we don't want to add intermediate/temporary error messages to `codes.json`. However, if a PR changes an existing error message and there's a specific production test (which is rare), it's ok to update `codes.json` for that. Please use `yarn build -- --extract-errors` and don't edit the file manually. - -## Structure -The error code system consists of 5 parts: -- [`codes.json`](https://github.com/facebook/react/blob/master/scripts/error-codes/codes.json) contains the mapping from IDs to error messages. This file is generated by the Gulp plugin and is used by both the Babel plugin and the error decoder page in our documentation. This file is append-only, which means an existing code in the file will never be changed/removed. -- [`extract-errors.js`](https://github.com/facebook/react/blob/master/scripts/error-codes/extract-errors.js) is an node script that traverses our codebase and updates `codes.json`. Use it by calling `yarn build -- --extract-errors`. -- [`replace-invariant-error-codes.js`](https://github.com/facebook/react/blob/master/scripts/error-codes/replace-invariant-error-codes.js) is a Babel pass that rewrites error messages to IDs for a production (minified) build. -- [`reactProdInvariant.js`](https://github.com/facebook/react/blob/master/src/shared/utils/reactProdInvariant.js) is the replacement for `invariant` in production. This file gets imported by the Babel plugin and should _not_ be used manually. -- [`ErrorDecoderComponent`](https://github.com/facebook/react/blob/master/docs/_js/ErrorDecoderComponent.js) is a React component that lives at https://reactjs.org/docs/error-decoder.html. This page takes parameters like `?invariant=109&args[]=Foo` and displays a corresponding error message. Our documentation site's [`Rakefile`](https://github.com/facebook/react/blob/master/docs/Rakefile#L64-L69) has a task (`bundle exec rake copy_error_codes`) for adding the latest `codes.json` to the error decoder page. This task is included in the default `bundle exec rake release` task. +- [`codes.json`](https://github.com/facebook/react/blob/master/scripts/error-codes/codes.json) + contains the mapping from IDs to error messages. This file is generated by the + Gulp plugin and is used by both the Babel plugin and the error decoder page in + our documentation. This file is append-only, which means an existing code in + the file will never be changed/removed. +- [`extract-errors.js`](https://github.com/facebook/react/blob/master/scripts/error-codes/extract-errors.js) + is an node script that traverses our codebase and updates `codes.json`. You + can test it by running `yarn build -- --extract-errors`, but you should only + commit changes to this file when running a release. (The release tool will + perform this step automatically.) +- [`minify-error-codes`](https://github.com/facebook/react/blob/master/scripts/error-codes/minify-error-codes) + is a Babel pass that rewrites error messages to IDs for a production + (minified) build. diff --git a/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap b/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap new file mode 100644 index 0000000000000..b2be00ab35237 --- /dev/null +++ b/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap @@ -0,0 +1,96 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`error transform should correctly transform invariants that are not in the error codes map 1`] = ` +"import _ReactError from 'shared/ReactError'; + +import invariant from 'shared/invariant'; +(function () { + if (!condition) { + throw _ReactError(\`This is not a real error message.\`); + } +})();" +`; + +exports[`error transform should handle escaped characters 1`] = ` +"import _ReactError from 'shared/ReactError'; + +import invariant from 'shared/invariant'; +(function () { + if (!condition) { + throw _ReactError(\`What's up?\`); + } +})();" +`; + +exports[`error transform should only add \`ReactError\` and \`ReactErrorProd\` once each 1`] = ` +"import _ReactErrorProd from 'shared/ReactErrorProd'; +import _ReactError from 'shared/ReactError'; + +import invariant from 'shared/invariant'; +(function () { + if (!condition) { + if (__DEV__) { + throw _ReactError(\`Do not override existing functions.\`); + } else { + throw _ReactErrorProd('16'); + } + } +})(); +(function () { + if (!condition) { + if (__DEV__) { + throw _ReactError(\`Do not override existing functions.\`); + } else { + throw _ReactErrorProd('16'); + } + } +})();" +`; + +exports[`error transform should replace simple invariant calls 1`] = ` +"import _ReactErrorProd from 'shared/ReactErrorProd'; +import _ReactError from 'shared/ReactError'; + +import invariant from 'shared/invariant'; +(function () { + if (!condition) { + if (__DEV__) { + throw _ReactError(\`Do not override existing functions.\`); + } else { + throw _ReactErrorProd('16'); + } + } +})();" +`; + +exports[`error transform should support invariant calls with a concatenated template string and args 1`] = ` +"import _ReactErrorProd from 'shared/ReactErrorProd'; +import _ReactError from 'shared/ReactError'; + +import invariant from 'shared/invariant'; +(function () { + if (!condition) { + if (__DEV__) { + throw _ReactError(\`Expected a component class, got \${Foo}.\${Bar}\`); + } else { + throw _ReactErrorProd('18', [Foo, Bar]); + } + } +})();" +`; + +exports[`error transform should support invariant calls with args 1`] = ` +"import _ReactErrorProd from 'shared/ReactErrorProd'; +import _ReactError from 'shared/ReactError'; + +import invariant from 'shared/invariant'; +(function () { + if (!condition) { + if (__DEV__) { + throw _ReactError(\`Expected \${foo} target to be an array; got \${bar}\`); + } else { + throw _ReactErrorProd('7', [foo, bar]); + } + } +})();" +`; diff --git a/scripts/error-codes/__tests__/minify-error-messages.js b/scripts/error-codes/__tests__/minify-error-messages.js new file mode 100644 index 0000000000000..f9a799af4528d --- /dev/null +++ b/scripts/error-codes/__tests__/minify-error-messages.js @@ -0,0 +1,85 @@ +/** + * 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. + */ +/* eslint-disable quotes */ +'use strict'; + +let babel = require('babel-core'); +let devExpressionWithCodes = require('../minify-error-messages'); + +function transform(input) { + return babel.transform(input, { + plugins: [devExpressionWithCodes], + }).code; +} + +let oldEnv; + +describe('error transform', () => { + beforeEach(() => { + oldEnv = process.env.NODE_ENV; + process.env.NODE_ENV = ''; + }); + + afterEach(() => { + process.env.NODE_ENV = oldEnv; + }); + + it('should replace simple invariant calls', () => { + expect( + transform(` +import invariant from 'shared/invariant'; +invariant(condition, 'Do not override existing functions.'); +`) + ).toMatchSnapshot(); + }); + + it('should only add `ReactError` and `ReactErrorProd` once each', () => { + expect( + transform(` +import invariant from 'shared/invariant'; +invariant(condition, 'Do not override existing functions.'); +invariant(condition, 'Do not override existing functions.'); +`) + ).toMatchSnapshot(); + }); + + it('should support invariant calls with args', () => { + expect( + transform(` +import invariant from 'shared/invariant'; +invariant(condition, 'Expected %s target to be an array; got %s', foo, bar); +`) + ).toMatchSnapshot(); + }); + + it('should support invariant calls with a concatenated template string and args', () => { + expect( + transform(` +import invariant from 'shared/invariant'; +invariant(condition, 'Expected a component class, ' + 'got %s.' + '%s', Foo, Bar); +`) + ).toMatchSnapshot(); + }); + + it('should correctly transform invariants that are not in the error codes map', () => { + expect( + transform(` +import invariant from 'shared/invariant'; +invariant(condition, 'This is not a real error message.'); +`) + ).toMatchSnapshot(); + }); + + it('should handle escaped characters', () => { + expect( + transform(` +import invariant from 'shared/invariant'; +invariant(condition, 'What\\'s up?'); +`) + ).toMatchSnapshot(); + }); +}); diff --git a/scripts/error-codes/__tests__/replace-invariant-error-codes-test.js b/scripts/error-codes/__tests__/replace-invariant-error-codes-test.js deleted file mode 100644 index 48a6663a90f13..0000000000000 --- a/scripts/error-codes/__tests__/replace-invariant-error-codes-test.js +++ /dev/null @@ -1,102 +0,0 @@ -/** - * 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. - */ -/* eslint-disable quotes */ -'use strict'; - -let babel = require('babel-core'); -let devExpressionWithCodes = require('../replace-invariant-error-codes'); - -function transform(input) { - return babel.transform(input, { - plugins: [devExpressionWithCodes], - }).code; -} - -function compare(input, output) { - const compiled = transform(input); - expect(compiled).toEqual(output); -} - -let oldEnv; - -describe('error codes transform', () => { - beforeEach(() => { - oldEnv = process.env.NODE_ENV; - process.env.NODE_ENV = ''; - }); - - afterEach(() => { - process.env.NODE_ENV = oldEnv; - }); - - it('should replace simple invariant calls', () => { - compare( - "import invariant from 'shared/invariant';\n" + - "invariant(condition, 'Do not override existing functions.');", - "import _prodInvariant from 'shared/reactProdInvariant';\n" + - "import invariant from 'shared/invariant';\n" + - '!condition ? ' + - '__DEV__ ? ' + - "invariant(false, 'Do not override existing functions.') : " + - `_prodInvariant('16') : void 0;` - ); - }); - - it('should only add `reactProdInvariant` once', () => { - const expectedInvariantTransformResult = - '!condition ? ' + - '__DEV__ ? ' + - "invariant(false, 'Do not override existing functions.') : " + - `_prodInvariant('16') : void 0;`; - - compare( - `import invariant from 'shared/invariant'; -invariant(condition, 'Do not override existing functions.'); -invariant(condition, 'Do not override existing functions.');`, - `import _prodInvariant from 'shared/reactProdInvariant'; -import invariant from 'shared/invariant'; -${expectedInvariantTransformResult} -${expectedInvariantTransformResult}` - ); - }); - - it('should support invariant calls with args', () => { - compare( - "import invariant from 'shared/invariant';\n" + - "invariant(condition, 'Expected %s target to be an array; got %s', 'foo', 'bar');", - "import _prodInvariant from 'shared/reactProdInvariant';\n" + - "import invariant from 'shared/invariant';\n" + - '!condition ? ' + - '__DEV__ ? ' + - "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( - "import invariant from 'shared/invariant';\n" + - "invariant(condition, 'Expected a component class, ' + 'got %s.' + '%s', 'Foo', 'Bar');", - "import _prodInvariant from 'shared/reactProdInvariant';\n" + - "import invariant from 'shared/invariant';\n" + - '!condition ? ' + - '__DEV__ ? ' + - "invariant(false, 'Expected a component class, got %s.%s', 'Foo', 'Bar') : " + - `_prodInvariant('18', 'Foo', 'Bar') : void 0;` - ); - }); - - it('should correctly transform invariants that are not in the error codes map', () => { - compare( - "import invariant from 'shared/invariant';\n" + - "invariant(condition, 'This is not a real error message.');", - "import _prodInvariant from 'shared/reactProdInvariant';\n" + - "import invariant from 'shared/invariant';\n" + - "!condition ? invariant(false, 'This is not a real error message.') : void 0;" - ); - }); -}); diff --git a/scripts/error-codes/extract-errors.js b/scripts/error-codes/extract-errors.js index 1a5def2658670..248a1ccae9de4 100644 --- a/scripts/error-codes/extract-errors.js +++ b/scripts/error-codes/extract-errors.js @@ -76,17 +76,20 @@ module.exports = function(opts) { // error messages can be concatenated (`+`) at runtime, so here's a // trivial partial evaluator that interprets the literal value const errorMsgLiteral = evalToString(node.arguments[1]); - if (existingErrorMap.hasOwnProperty(errorMsgLiteral)) { - return; - } - - existingErrorMap[errorMsgLiteral] = '' + currentID++; + addtoErrorMap(errorMsgLiteral); } }, }, }); } + function addtoErrorMap(errorMsgLiteral) { + if (existingErrorMap.hasOwnProperty(errorMsgLiteral)) { + return; + } + existingErrorMap[errorMsgLiteral] = '' + currentID++; + } + function flush(cb) { fs.writeFileSync( errorMapFilePath, diff --git a/scripts/error-codes/minify-error-messages.js b/scripts/error-codes/minify-error-messages.js new file mode 100644 index 0000000000000..e3ccb0b1718ac --- /dev/null +++ b/scripts/error-codes/minify-error-messages.js @@ -0,0 +1,132 @@ +/** + * 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. + */ +'use strict'; + +const fs = require('fs'); +const evalToString = require('../shared/evalToString'); +const invertObject = require('./invertObject'); + +module.exports = function(babel) { + const t = babel.types; + + const DEV_EXPRESSION = t.identifier('__DEV__'); + + return { + visitor: { + CallExpression(path, file) { + const node = path.node; + if (path.get('callee').isIdentifier({name: 'invariant'})) { + // Turns this code: + // + // invariant(condition, 'A %s message that contains %s', adj, noun); + // + // into this: + // + // if (!condition) { + // if (__DEV__) { + // throw ReactError(`A ${adj} message that contains ${noun}`); + // } else { + // throw ReactErrorProd(ERR_CODE, [adj, noun]); + // } + // } + // + // where ERR_CODE 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`. + const condition = node.arguments[0]; + const errorMsgLiteral = evalToString(node.arguments[1]); + const errorMsgExpressions = Array.from(node.arguments.slice(2)); + const errorMsgQuasis = errorMsgLiteral + .split('%s') + .map(raw => t.templateElement({raw, cooked: String.raw({raw})})); + + // Import ReactError + const reactErrorIdentfier = file.addImport( + 'shared/ReactError', + 'default', + 'ReactError' + ); + + // Outputs: + // throw ReactError(`A ${adj} message that contains ${noun}`); + const devThrow = t.throwStatement( + t.callExpression(reactErrorIdentfier, [ + t.templateLiteral(errorMsgQuasis, errorMsgExpressions), + ]) + ); + + // Avoid caching because we write it as we go. + const existingErrorMap = JSON.parse( + fs.readFileSync(__dirname + '/codes.json', 'utf-8') + ); + const errorMap = invertObject(existingErrorMap); + + const prodErrorId = errorMap[errorMsgLiteral]; + if (prodErrorId === undefined) { + // There is no error code for this message. We use a lint rule to + // enforce that messages can be minified, so assume this is + // intentional and exit gracefully. + // + // Outputs: + // if (!condition) { + // throw ReactError(`A ${adj} message that contains ${noun}`); + // } + path.replaceWith( + t.ifStatement( + t.unaryExpression('!', condition), + t.blockStatement([devThrow]) + ) + ); + return; + } + + // Import ReactErrorProd + const reactErrorProdIdentfier = file.addImport( + 'shared/ReactErrorProd', + 'default', + 'ReactErrorProd' + ); + + // Outputs: + // throw ReactErrorProd(ERR_CODE, [adj, noun]); + const prodThrow = t.throwStatement( + t.callExpression( + reactErrorProdIdentfier, + [ + t.stringLiteral(prodErrorId), + errorMsgExpressions.length > 0 + ? t.arrayExpression(errorMsgExpressions) + : undefined, + ].filter(arg => arg !== undefined) + ) + ); + + // Outputs: + // if (!condition) { + // if (__DEV__) { + // throw ReactError(`A ${adj} message that contains ${noun}`); + // } else { + // throw ReactErrorProd(ERR_CODE, [adj, noun]); + // } + // } + path.replaceWith( + t.ifStatement( + t.unaryExpression('!', condition), + t.blockStatement([ + t.ifStatement( + DEV_EXPRESSION, + t.blockStatement([devThrow]), + t.blockStatement([prodThrow]) + ), + ]) + ) + ); + } + }, + }, + }; +}; diff --git a/scripts/error-codes/replace-invariant-error-codes.js b/scripts/error-codes/replace-invariant-error-codes.js deleted file mode 100644 index c143a76ded45e..0000000000000 --- a/scripts/error-codes/replace-invariant-error-codes.js +++ /dev/null @@ -1,135 +0,0 @@ -/** - * 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. - */ -'use strict'; - -const fs = require('fs'); -const evalToString = require('../shared/evalToString'); -const invertObject = require('./invertObject'); - -module.exports = function(babel) { - const t = babel.types; - - const SEEN_SYMBOL = Symbol('replace-invariant-error-codes.seen'); - - // Generate a hygienic identifier - function getProdInvariantIdentifier(path, file, localState) { - if (!localState.prodInvariantIdentifier) { - localState.prodInvariantIdentifier = file.addImport( - 'shared/reactProdInvariant', - 'default', - 'prodInvariant' - ); - } - return localState.prodInvariantIdentifier; - } - - const DEV_EXPRESSION = t.identifier('__DEV__'); - - return { - pre() { - this.prodInvariantIdentifier = null; - }, - - visitor: { - CallExpression: { - exit(path, file) { - const node = path.node; - // Ignore if it's already been processed - if (node[SEEN_SYMBOL]) { - return; - } - // Insert `import PROD_INVARIANT from 'reactProdInvariant';` - // before all `invariant()` calls. - 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://reactjs.org/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. - const condition = node.arguments[0]; - const errorMsgLiteral = evalToString(node.arguments[1]); - - const devInvariant = t.callExpression( - node.callee, - [ - t.booleanLiteral(false), - t.stringLiteral(errorMsgLiteral), - ].concat(node.arguments.slice(2)) - ); - - devInvariant[SEEN_SYMBOL] = true; - - // Avoid caching because we write it as we go. - const existingErrorMap = JSON.parse( - fs.readFileSync(__dirname + '/codes.json', 'utf-8') - ); - const errorMap = invertObject(existingErrorMap); - - const localInvariantId = getProdInvariantIdentifier( - path, - file, - this - ); - const prodErrorId = errorMap[errorMsgLiteral]; - let body = null; - - if (prodErrorId === undefined) { - // The error wasn't found in the map. - // This is only expected to occur on master since we extract codes before releases. - // Keep the original invariant. - body = t.expressionStatement(devInvariant); - } else { - const prodInvariant = t.callExpression( - localInvariantId, - [t.stringLiteral(prodErrorId)].concat(node.arguments.slice(2)) - ); - prodInvariant[SEEN_SYMBOL] = true; - // The error was found in the map. - // Switch between development and production versions depending on the env. - body = t.ifStatement( - DEV_EXPRESSION, - t.blockStatement([t.expressionStatement(devInvariant)]), - t.blockStatement([t.expressionStatement(prodInvariant)]) - ); - } - - path.replaceWith( - t.ifStatement( - t.unaryExpression('!', condition), - t.blockStatement([body]) - ) - ); - } - }, - }, - }, - }; -}; diff --git a/scripts/jest/preprocessor.js b/scripts/jest/preprocessor.js index 631625a37582d..3a7b1448e505b 100644 --- a/scripts/jest/preprocessor.js +++ b/scripts/jest/preprocessor.js @@ -15,7 +15,7 @@ const pathToBabel = path.join( 'package.json' ); const pathToBabelPluginDevWithCode = require.resolve( - '../error-codes/replace-invariant-error-codes' + '../error-codes/minify-error-messages' ); const pathToBabelPluginWrapWarning = require.resolve( '../babel/wrap-warning-with-env-check' diff --git a/scripts/rollup/build.js b/scripts/rollup/build.js index 44e5a65691537..d21e58599ece4 100644 --- a/scripts/rollup/build.js +++ b/scripts/rollup/build.js @@ -113,7 +113,7 @@ function getBabelConfig(updateBabelOptions, bundleType, filename) { return Object.assign({}, options, { plugins: options.plugins.concat([ // Minify invariant messages - require('../error-codes/replace-invariant-error-codes'), + require('../error-codes/minify-error-messages'), // Wrap warning() calls in a __DEV__ check so they are stripped from production. require('../babel/wrap-warning-with-env-check'), ]), @@ -141,7 +141,7 @@ function getBabelConfig(updateBabelOptions, bundleType, filename) { // Use object-assign polyfill in open source path.resolve('./scripts/babel/transform-object-assign-require'), // Minify invariant messages - require('../error-codes/replace-invariant-error-codes'), + require('../error-codes/minify-error-messages'), // Wrap warning() calls in a __DEV__ check so they are stripped from production. require('../babel/wrap-warning-with-env-check'), ]), diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index 9d028262ba425..c93ac87559aaa 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -180,18 +180,6 @@ const forks = Object.freeze({ return 'scheduler/src/forks/SchedulerHostConfig.default'; }, - // This logic is forked on www to fork the formatting function. - 'shared/invariant': (bundleType, entry) => { - switch (bundleType) { - case FB_WWW_DEV: - case FB_WWW_PROD: - case FB_WWW_PROFILING: - return 'shared/forks/invariant.www.js'; - default: - return null; - } - }, - // This logic is forked on www to ignore some warnings. 'shared/lowPriorityWarning': (bundleType, entry) => { switch (bundleType) { From cbd75401ddd38699017b8884539a639a753dd87d Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 11 Mar 2019 13:37:59 -0700 Subject: [PATCH 2/4] Use numbers instead of strings for error codes --- .../__snapshots__/minify-error-messages.js.snap | 10 +++++----- scripts/error-codes/minify-error-messages.js | 5 +++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap b/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap index b2be00ab35237..9d1ee9678af84 100644 --- a/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap +++ b/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap @@ -32,7 +32,7 @@ import invariant from 'shared/invariant'; if (__DEV__) { throw _ReactError(\`Do not override existing functions.\`); } else { - throw _ReactErrorProd('16'); + throw _ReactErrorProd(16); } } })(); @@ -41,7 +41,7 @@ import invariant from 'shared/invariant'; if (__DEV__) { throw _ReactError(\`Do not override existing functions.\`); } else { - throw _ReactErrorProd('16'); + throw _ReactErrorProd(16); } } })();" @@ -57,7 +57,7 @@ import invariant from 'shared/invariant'; if (__DEV__) { throw _ReactError(\`Do not override existing functions.\`); } else { - throw _ReactErrorProd('16'); + throw _ReactErrorProd(16); } } })();" @@ -73,7 +73,7 @@ import invariant from 'shared/invariant'; if (__DEV__) { throw _ReactError(\`Expected a component class, got \${Foo}.\${Bar}\`); } else { - throw _ReactErrorProd('18', [Foo, Bar]); + throw _ReactErrorProd(18, [Foo, Bar]); } } })();" @@ -89,7 +89,7 @@ import invariant from 'shared/invariant'; if (__DEV__) { throw _ReactError(\`Expected \${foo} target to be an array; got \${bar}\`); } else { - throw _ReactErrorProd('7', [foo, bar]); + throw _ReactErrorProd(7, [foo, bar]); } } })();" diff --git a/scripts/error-codes/minify-error-messages.js b/scripts/error-codes/minify-error-messages.js index e3ccb0b1718ac..dd4570b9235fe 100644 --- a/scripts/error-codes/minify-error-messages.js +++ b/scripts/error-codes/minify-error-messages.js @@ -65,7 +65,7 @@ module.exports = function(babel) { ); const errorMap = invertObject(existingErrorMap); - const prodErrorId = errorMap[errorMsgLiteral]; + let prodErrorId = errorMap[errorMsgLiteral]; if (prodErrorId === undefined) { // There is no error code for this message. We use a lint rule to // enforce that messages can be minified, so assume this is @@ -83,6 +83,7 @@ module.exports = function(babel) { ); return; } + prodErrorId = parseInt(prodErrorId, 10); // Import ReactErrorProd const reactErrorProdIdentfier = file.addImport( @@ -97,7 +98,7 @@ module.exports = function(babel) { t.callExpression( reactErrorProdIdentfier, [ - t.stringLiteral(prodErrorId), + t.numericLiteral(prodErrorId), errorMsgExpressions.length > 0 ? t.arrayExpression(errorMsgExpressions) : undefined, From 89083cf2e691bf32d5623ab64b0d00c83ca02086 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 18 Mar 2019 13:37:49 -0700 Subject: [PATCH 3/4] Use arguments instead of an array I wasn't sure about this part so I asked Sebastian, and his rationale was that using arguments will make ReactErrorProd slightly slower, but using an array will likely make all the functions that throw slightly slower to compile, so it's hard to say which way is better. But since ReactErrorProd is in an error path, and fewer bytes is generally better, no array is good. --- packages/shared/ReactErrorProd.js | 8 +++----- .../__tests__/ReactErrorProd-test.internal.js | 6 +++--- .../minify-error-messages.js.snap | 4 ++-- scripts/error-codes/minify-error-messages.js | 19 +++++++------------ 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/packages/shared/ReactErrorProd.js b/packages/shared/ReactErrorProd.js index 50084ef510025..3b3996e1366dc 100644 --- a/packages/shared/ReactErrorProd.js +++ b/packages/shared/ReactErrorProd.js @@ -10,12 +10,10 @@ // template literal strings. The messages will be converted to ReactError during // build, and in production they will be minified. -function ReactErrorProd(code, args) { +function ReactErrorProd(code) { let url = 'https://reactjs.org/docs/error-decoder.html?invariant=' + code; - if (args !== undefined) { - for (let i = 0; i < args.length; i++) { - url += '&args[]=' + encodeURIComponent(args[i]); - } + for (let i = 1; i < arguments.length; i++) { + url += '&args[]=' + encodeURIComponent(arguments[i]); } return new Error( `Minified React error #${code}; visit ${url} for the full message or ` + diff --git a/packages/shared/__tests__/ReactErrorProd-test.internal.js b/packages/shared/__tests__/ReactErrorProd-test.internal.js index 1d09c2fc64d7f..b8984d13d52ae 100644 --- a/packages/shared/__tests__/ReactErrorProd-test.internal.js +++ b/packages/shared/__tests__/ReactErrorProd-test.internal.js @@ -36,7 +36,7 @@ describe('ReactErrorProd', () => { it('should throw with the correct number of `%s`s in the URL', () => { expect(function() { - throw ReactErrorProd('124', ['foo', 'bar']); + throw ReactErrorProd(124, 'foo', 'bar'); }).toThrowError( 'Minified React error #124; visit ' + 'https://reactjs.org/docs/error-decoder.html?invariant=124&args[]=foo&args[]=bar' + @@ -45,7 +45,7 @@ describe('ReactErrorProd', () => { ); expect(function() { - throw ReactErrorProd('20'); + throw ReactErrorProd(20); }).toThrowError( 'Minified React error #20; visit ' + 'https://reactjs.org/docs/error-decoder.html?invariant=20' + @@ -54,7 +54,7 @@ describe('ReactErrorProd', () => { ); expect(function() { - throw ReactErrorProd('77', ['
', '&?bar']); + throw ReactErrorProd(77, '
', '&?bar'); }).toThrowError( 'Minified React error #77; visit ' + 'https://reactjs.org/docs/error-decoder.html?invariant=77&args[]=%3Cdiv%3E&args[]=%26%3Fbar' + diff --git a/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap b/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap index 9d1ee9678af84..5227bf089c455 100644 --- a/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap +++ b/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap @@ -73,7 +73,7 @@ import invariant from 'shared/invariant'; if (__DEV__) { throw _ReactError(\`Expected a component class, got \${Foo}.\${Bar}\`); } else { - throw _ReactErrorProd(18, [Foo, Bar]); + throw _ReactErrorProd(18, Foo, Bar); } } })();" @@ -89,7 +89,7 @@ import invariant from 'shared/invariant'; if (__DEV__) { throw _ReactError(\`Expected \${foo} target to be an array; got \${bar}\`); } else { - throw _ReactErrorProd(7, [foo, bar]); + throw _ReactErrorProd(7, foo, bar); } } })();" diff --git a/scripts/error-codes/minify-error-messages.js b/scripts/error-codes/minify-error-messages.js index dd4570b9235fe..8a55ce4d4bbc5 100644 --- a/scripts/error-codes/minify-error-messages.js +++ b/scripts/error-codes/minify-error-messages.js @@ -30,7 +30,7 @@ module.exports = function(babel) { // if (__DEV__) { // throw ReactError(`A ${adj} message that contains ${noun}`); // } else { - // throw ReactErrorProd(ERR_CODE, [adj, noun]); + // throw ReactErrorProd(ERR_CODE, adj, noun); // } // } // @@ -93,17 +93,12 @@ module.exports = function(babel) { ); // Outputs: - // throw ReactErrorProd(ERR_CODE, [adj, noun]); + // throw ReactErrorProd(ERR_CODE, adj, noun); const prodThrow = t.throwStatement( - t.callExpression( - reactErrorProdIdentfier, - [ - t.numericLiteral(prodErrorId), - errorMsgExpressions.length > 0 - ? t.arrayExpression(errorMsgExpressions) - : undefined, - ].filter(arg => arg !== undefined) - ) + t.callExpression(reactErrorProdIdentfier, [ + t.numericLiteral(prodErrorId), + ...errorMsgExpressions, + ]) ); // Outputs: @@ -111,7 +106,7 @@ module.exports = function(babel) { // if (__DEV__) { // throw ReactError(`A ${adj} message that contains ${noun}`); // } else { - // throw ReactErrorProd(ERR_CODE, [adj, noun]); + // throw ReactErrorProd(ERR_CODE, adj, noun); // } // } path.replaceWith( From 8e7cb774633847f48748389a67dd2ea48132f578 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 18 Mar 2019 13:38:11 -0700 Subject: [PATCH 4/4] Casing nit --- scripts/error-codes/extract-errors.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/error-codes/extract-errors.js b/scripts/error-codes/extract-errors.js index 248a1ccae9de4..39e70391481a1 100644 --- a/scripts/error-codes/extract-errors.js +++ b/scripts/error-codes/extract-errors.js @@ -76,14 +76,14 @@ module.exports = function(opts) { // error messages can be concatenated (`+`) at runtime, so here's a // trivial partial evaluator that interprets the literal value const errorMsgLiteral = evalToString(node.arguments[1]); - addtoErrorMap(errorMsgLiteral); + addToErrorMap(errorMsgLiteral); } }, }, }); } - function addtoErrorMap(errorMsgLiteral) { + function addToErrorMap(errorMsgLiteral) { if (existingErrorMap.hasOwnProperty(errorMsgLiteral)) { return; }