Skip to content

Commit 8e5bb99

Browse files
committed
Minify error constructors in production
We use a script to minify our error messages in production. Each message is assigned an error code, defined in `scripts/error-codes/codes.json`. Then our build script replaces the messages with a link to our error decoder page, e.g. https://reactjs.org/docs/error-decoder.html/?invariant=92 This enables us to write helpful error messages without increasing the bundle size. Right now, the script only works for `invariant` calls. It does not work if you throw an Error object. This is an old Facebookism that we don't really need, other than the fact that our error minification script relies on it. So, I've updated the script to minify error constructors, too: Input: Error(`A ${adj} message that contains ${noun}`); Output: Error(formatProdErrorMessage(ERR_CODE, adj, noun)); It only works for constructors that are literally named Error, though we could add support for other names, too. As a next step, I will add a lint rule to enforce that errors written this way must have a corresponding error code.
1 parent 85f82fe commit 8e5bb99

File tree

9 files changed

+216
-16
lines changed

9 files changed

+216
-16
lines changed

packages/jest-react/src/JestReact.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
import {REACT_ELEMENT_TYPE, REACT_FRAGMENT_TYPE} from 'shared/ReactSymbols';
99

10-
import invariant from 'shared/invariant';
1110
import isArray from 'shared/isArray';
1211

1312
export {act} from './internalAct';
@@ -31,7 +30,7 @@ function captureAssertion(fn) {
3130
function assertYieldsWereCleared(root) {
3231
const Scheduler = root._Scheduler;
3332
const actualYields = Scheduler.unstable_clearYields();
34-
invariant(
33+
throw new Error(
3534
actualYields.length === 0,
3635
'Log of yielded values is not empty. ' +
3736
'Call expect(ReactTestRenderer).unstable_toHaveYielded(...) first.',

scripts/error-codes/__tests__/__snapshots__/transform-error-messages.js.snap

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@ if (!condition) {
1818
}"
1919
`;
2020

21+
exports[`error transform should not touch other calls or new expressions 1`] = `
22+
"new NotAnError();
23+
NotAnError();"
24+
`;
25+
26+
exports[`error transform should replace error constructors (no new) 1`] = `
27+
"import _formatProdErrorMessage from \\"shared/formatProdErrorMessage\\";
28+
Error(__DEV__ ? 'Do not override existing functions.' : _formatProdErrorMessage(16));"
29+
`;
30+
31+
exports[`error transform should replace error constructors 1`] = `
32+
"import _formatProdErrorMessage from \\"shared/formatProdErrorMessage\\";
33+
Error(__DEV__ ? 'Do not override existing functions.' : _formatProdErrorMessage(16));"
34+
`;
35+
2136
exports[`error transform should replace simple invariant calls 1`] = `
2237
"import _formatProdErrorMessage from \\"shared/formatProdErrorMessage\\";
2338
import invariant from 'shared/invariant';
@@ -29,6 +44,21 @@ if (!condition) {
2944
}"
3045
`;
3146

47+
exports[`error transform should support error constructors with concatenated messages 1`] = `
48+
"import _formatProdErrorMessage from \\"shared/formatProdErrorMessage\\";
49+
Error(__DEV__ ? \\"Expected \\" + foo + \\" target to \\" + (\\"be an array; got \\" + bar) : _formatProdErrorMessage(7, foo, bar));"
50+
`;
51+
52+
exports[`error transform should support interpolating arguments with concatenation 1`] = `
53+
"import _formatProdErrorMessage from \\"shared/formatProdErrorMessage\\";
54+
Error(__DEV__ ? 'Expected ' + foo + ' target to be an array; got ' + bar : _formatProdErrorMessage(7, foo, bar));"
55+
`;
56+
57+
exports[`error transform should support interpolating arguments with template strings 1`] = `
58+
"import _formatProdErrorMessage from \\"shared/formatProdErrorMessage\\";
59+
Error(__DEV__ ? \\"Expected \\" + foo + \\" target to be an array; got \\" + bar : _formatProdErrorMessage(7, foo, bar));"
60+
`;
61+
3262
exports[`error transform should support invariant calls with a concatenated template string and args 1`] = `
3363
"import _formatProdErrorMessage from \\"shared/formatProdErrorMessage\\";
3464
import invariant from 'shared/invariant';

scripts/error-codes/__tests__/transform-error-messages.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,53 @@ invariant(condition, 'Do not override existing functions.');
9393
)
9494
).toMatchSnapshot();
9595
});
96+
97+
it('should replace error constructors', () => {
98+
expect(
99+
transform(`
100+
new Error('Do not override existing functions.');
101+
`)
102+
).toMatchSnapshot();
103+
});
104+
105+
it('should replace error constructors (no new)', () => {
106+
expect(
107+
transform(`
108+
Error('Do not override existing functions.');
109+
`)
110+
).toMatchSnapshot();
111+
});
112+
113+
it('should not touch other calls or new expressions', () => {
114+
expect(
115+
transform(`
116+
new NotAnError();
117+
NotAnError();
118+
`)
119+
).toMatchSnapshot();
120+
});
121+
122+
it('should support interpolating arguments with template strings', () => {
123+
expect(
124+
transform(`
125+
new Error(\`Expected \${foo} target to be an array; got \${bar}\`);
126+
`)
127+
).toMatchSnapshot();
128+
});
129+
130+
it('should support interpolating arguments with concatenation', () => {
131+
expect(
132+
transform(`
133+
new Error('Expected ' + foo + ' target to be an array; got ' + bar);
134+
`)
135+
).toMatchSnapshot();
136+
});
137+
138+
it('should support error constructors with concatenated messages', () => {
139+
expect(
140+
transform(`
141+
new Error(\`Expected \${foo} target to \` + \`be an array; got \${bar}\`);
142+
`)
143+
).toMatchSnapshot();
144+
});
96145
});

scripts/error-codes/codes.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,12 +287,10 @@
287287
"288": "It is not supported to run the profiling version of a renderer (for example, `react-dom/profiling`) without also replacing the `schedule/tracing` module with `schedule/tracing-profiling`. Your bundler might have a setting for aliasing both modules. Learn more at https://reactjs.org/link/profiling",
288288
"289": "Function components cannot have refs.",
289289
"290": "Element ref was specified as a string (%s) but no owner was set. This could happen for one of the following reasons:\n1. You may be adding a ref to a function component\n2. You may be adding a ref to a component that was not created inside a component's render method\n3. You have multiple copies of React loaded\nSee https://reactjs.org/link/refs-must-have-owner for more information.",
290-
"291": "Log of yielded values is not empty. Call expect(Scheduler).toHaveYielded(...) first.",
291290
"292": "The matcher `toHaveYielded` expects an instance of React Test Renderer.\n\nTry: expect(Scheduler).toHaveYielded(expectedYields)",
292291
"293": "Context can only be read while React is rendering, e.g. inside the render method or getDerivedStateFromProps.",
293292
"294": "ReactDOMServer does not yet support Suspense.",
294293
"295": "ReactDOMServer does not yet support lazy-loaded components.",
295-
"296": "Log of yielded values is not empty. Call expect(ReactTestRenderer).unstable_toHaveYielded(...) first.",
296294
"297": "The matcher `unstable_toHaveYielded` expects an instance of React Test Renderer.\n\nTry: expect(ReactTestRenderer).unstable_toHaveYielded(expectedYields)",
297295
"298": "Hooks can only be called inside the body of a function component.",
298296
"299": "createRoot(...): Target container is not a DOM element.",

scripts/error-codes/extract-errors.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const parser = require('@babel/parser');
1010
const fs = require('fs');
1111
const path = require('path');
1212
const traverse = require('@babel/traverse').default;
13-
const evalToString = require('../shared/evalToString');
13+
const {evalStringConcat} = require('../shared/evalToString');
1414
const invertObject = require('./invertObject');
1515

1616
const babylonOptions = {
@@ -75,7 +75,7 @@ module.exports = function(opts) {
7575

7676
// error messages can be concatenated (`+`) at runtime, so here's a
7777
// trivial partial evaluator that interprets the literal value
78-
const errorMsgLiteral = evalToString(node.arguments[1]);
78+
const errorMsgLiteral = evalStringConcat(node.arguments[1]);
7979
addToErrorMap(errorMsgLiteral);
8080
}
8181
},

scripts/error-codes/transform-error-messages.js

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,114 @@
77
'use strict';
88

99
const fs = require('fs');
10-
const evalToString = require('../shared/evalToString');
10+
const {
11+
evalStringConcat,
12+
evalStringAndTemplateConcat,
13+
} = require('../shared/evalToString');
1114
const invertObject = require('./invertObject');
1215
const helperModuleImports = require('@babel/helper-module-imports');
1316

1417
const errorMap = invertObject(
1518
JSON.parse(fs.readFileSync(__dirname + '/codes.json', 'utf-8'))
1619
);
1720

21+
const SEEN_SYMBOL = Symbol('transform-error-messages.seen');
22+
1823
module.exports = function(babel) {
1924
const t = babel.types;
2025

26+
// TODO: Instead of outputting __DEV__ conditions, only apply this transform
27+
// in production.
2128
const DEV_EXPRESSION = t.identifier('__DEV__');
2229

30+
function CallOrNewExpression(path, file) {
31+
// Turns this code:
32+
//
33+
// new Error(`A ${adj} message that contains ${noun}`);
34+
//
35+
// or this code (no constructor):
36+
//
37+
// Error(`A ${adj} message that contains ${noun}`);
38+
//
39+
// into this:
40+
//
41+
// Error(
42+
// __DEV__
43+
// ? `A ${adj} message that contains ${noun}`
44+
// : formatProdErrorMessage(ERR_CODE, adj, noun)
45+
// );
46+
const node = path.node;
47+
if (node[SEEN_SYMBOL]) {
48+
return;
49+
}
50+
node[SEEN_SYMBOL] = true;
51+
52+
const errorMsgNode = node.arguments[0];
53+
if (errorMsgNode === undefined) {
54+
return;
55+
}
56+
57+
const errorMsgExpressions = [];
58+
const errorMsgLiteral = evalStringAndTemplateConcat(
59+
errorMsgNode,
60+
errorMsgExpressions
61+
);
62+
63+
let prodErrorId = errorMap[errorMsgLiteral];
64+
if (prodErrorId === undefined) {
65+
// There is no error code for this message. We use a lint rule to
66+
// enforce that messages can be minified, so assume this is
67+
// intentional and exit gracefully.
68+
return;
69+
}
70+
prodErrorId = parseInt(prodErrorId, 10);
71+
72+
// Import formatProdErrorMessage
73+
const formatProdErrorMessageIdentifier = helperModuleImports.addDefault(
74+
path,
75+
'shared/formatProdErrorMessage',
76+
{nameHint: 'formatProdErrorMessage'}
77+
);
78+
79+
// Outputs:
80+
// formatProdErrorMessage(ERR_CODE, adj, noun);
81+
const prodMessage = t.callExpression(formatProdErrorMessageIdentifier, [
82+
t.numericLiteral(prodErrorId),
83+
...errorMsgExpressions,
84+
]);
85+
86+
// Outputs:
87+
// Error(
88+
// __DEV__
89+
// ? `A ${adj} message that contains ${noun}`
90+
// : formatProdErrorMessage(ERR_CODE, adj, noun)
91+
// );
92+
path.replaceWith(t.callExpression(t.identifier('Error'), [prodMessage]));
93+
path.replaceWith(
94+
t.callExpression(t.identifier('Error'), [
95+
t.conditionalExpression(DEV_EXPRESSION, errorMsgNode, prodMessage),
96+
])
97+
);
98+
}
99+
23100
return {
24101
visitor: {
102+
NewExpression(path, file) {
103+
const noMinify = file.opts.noMinify;
104+
if (!noMinify && path.get('callee').isIdentifier({name: 'Error'})) {
105+
CallOrNewExpression(path, file);
106+
}
107+
},
108+
25109
CallExpression(path, file) {
26110
const node = path.node;
27111
const noMinify = file.opts.noMinify;
112+
113+
if (!noMinify && path.get('callee').isIdentifier({name: 'Error'})) {
114+
CallOrNewExpression(path, file);
115+
return;
116+
}
117+
28118
if (path.get('callee').isIdentifier({name: 'invariant'})) {
29119
// Turns this code:
30120
//
@@ -44,7 +134,7 @@ module.exports = function(babel) {
44134
// string) that references a verbose error message. The mapping is
45135
// stored in `scripts/error-codes/codes.json`.
46136
const condition = node.arguments[0];
47-
const errorMsgLiteral = evalToString(node.arguments[1]);
137+
const errorMsgLiteral = evalStringConcat(node.arguments[1]);
48138
const errorMsgExpressions = Array.from(node.arguments.slice(2));
49139
const errorMsgQuasis = errorMsgLiteral
50140
.split('%s')
@@ -115,7 +205,7 @@ module.exports = function(babel) {
115205
}
116206
prodErrorId = parseInt(prodErrorId, 10);
117207

118-
// Import ReactErrorProd
208+
// Import formatProdErrorMessage
119209
const formatProdErrorMessageIdentifier = helperModuleImports.addDefault(
120210
path,
121211
'shared/formatProdErrorMessage',

scripts/print-warnings/print-warnings.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const through = require('through2');
1212
const traverse = require('@babel/traverse').default;
1313
const gs = require('glob-stream');
1414

15-
const evalToString = require('../shared/evalToString');
15+
const {evalStringConcat} = require('../shared/evalToString');
1616

1717
const parserOptions = {
1818
sourceType: 'module',
@@ -64,7 +64,7 @@ function transform(file, enc, cb) {
6464
// warning messages can be concatenated (`+`) at runtime, so here's
6565
// a trivial partial evaluator that interprets the literal value
6666
try {
67-
const warningMsgLiteral = evalToString(node.arguments[0]);
67+
const warningMsgLiteral = evalStringConcat(node.arguments[0]);
6868
warnings.add(JSON.stringify(warningMsgLiteral));
6969
} catch (error) {
7070
console.error(

scripts/shared/__tests__/evalToString-test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
*/
77
'use strict';
88

9-
const evalToString = require('../evalToString');
9+
const {evalStringConcat} = require('../evalToString');
1010
const parser = require('@babel/parser');
1111

1212
const parse = source => parser.parse(`(${source});`).program.body[0].expression; // quick way to get an exp node
1313

14-
const parseAndEval = source => evalToString(parse(source));
14+
const parseAndEval = source => evalStringConcat(parse(source));
1515

1616
describe('evalToString', () => {
1717
it('should support StringLiteral', () => {

scripts/shared/evalToString.js

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99
'use strict';
1010

11-
function evalToString(ast /* : Object */) /* : string */ {
11+
function evalStringConcat(ast /* : Object */) /* : string */ {
1212
switch (ast.type) {
1313
case 'StringLiteral':
1414
case 'Literal': // ESLint
@@ -17,10 +17,44 @@ function evalToString(ast /* : Object */) /* : string */ {
1717
if (ast.operator !== '+') {
1818
throw new Error('Unsupported binary operator ' + ast.operator);
1919
}
20-
return evalToString(ast.left) + evalToString(ast.right);
20+
return evalStringConcat(ast.left) + evalStringConcat(ast.right);
2121
default:
2222
throw new Error('Unsupported type ' + ast.type);
2323
}
2424
}
25+
exports.evalStringConcat = evalStringConcat;
2526

26-
module.exports = evalToString;
27+
function evalStringAndTemplateConcat(
28+
ast /* : Object */,
29+
args /* : Array<mixed> */
30+
) /* : string */ {
31+
switch (ast.type) {
32+
case 'StringLiteral':
33+
return ast.value;
34+
case 'BinaryExpression': // `+`
35+
if (ast.operator !== '+') {
36+
throw new Error('Unsupported binary operator ' + ast.operator);
37+
}
38+
return (
39+
evalStringAndTemplateConcat(ast.left, args) +
40+
evalStringAndTemplateConcat(ast.right, args)
41+
);
42+
case 'TemplateLiteral': {
43+
let elements = [];
44+
for (let i = 0; i < ast.quasis.length; i++) {
45+
const elementNode = ast.quasis[i];
46+
if (elementNode.type !== 'TemplateElement') {
47+
throw new Error('Unsupported type ' + ast.type);
48+
}
49+
elements.push(elementNode.value.raw);
50+
}
51+
args.push(...ast.expressions);
52+
return elements.join('%s');
53+
}
54+
default:
55+
// Anything that's not a string is interpreted as an argument.
56+
args.push(ast);
57+
return '%s';
58+
}
59+
}
60+
exports.evalStringAndTemplateConcat = evalStringAndTemplateConcat;

0 commit comments

Comments
 (0)