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

199 lines
6.6 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and 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';
// @ts-check
const fs = require('fs-extra');
const ErrorMap = require('./ErrorMap');
const evalToString = require('./evalToString');
const helperModuleImports = require('@babel/helper-module-imports');
const prettier = require('prettier');
/** @type {Map<string, ErrorMap>} */
const errorMaps = new Map();
/**
* Get a module-global ErrorMap instance so that all instances of this
* plugin are working with the same data structure. Typically there is
* at most one entry in this map (`${__dirname}/codes.json`).
*
* @param {string} filepath
* @returns {ErrorMap}
*/
function getErrorMap(filepath) {
let errorMap = errorMaps.get(filepath);
if (!errorMap) {
const prettierConfig = {
...(prettier.resolveConfig.sync('./') || {}),
filepath,
};
errorMap = new ErrorMap(fs.readJsonSync(filepath), (newErrorMap) =>
fs.writeFileSync(
filepath,
prettier.format(JSON.stringify(newErrorMap), prettierConfig),
),
);
errorMaps.set(filepath, errorMap);
}
return errorMap;
}
/**
* @typedef {Object} TransformErrorMessagesOptions
* @property {string} errorCodesPath
* @property {boolean} extractCodes
* @property {boolean} noMinify
*/
const invariantExpressions = [
{
dev: 'formatDevErrorMessage',
name: 'invariant',
prod: 'formatProdErrorMessage',
},
{
dev: 'formatDevErrorMessage',
name: 'devInvariant',
prod: 'formatProdWarningMessage',
},
];
/**
* @param {import('@babel/core')} babel
* @param {Partial<TransformErrorMessagesOptions>} opts
* @returns {import('@babel/core').PluginObj}
*/
module.exports = function (babel, opts) {
const t = babel.types;
const errorMap = getErrorMap(
(opts && opts.errorCodesPath) || `${__dirname}/codes.json`,
);
return {
visitor: {
CallExpression(path, file) {
const node = path.node;
const {extractCodes, noMinify} =
/** @type Partial<TransformErrorMessagesOptions> */ (file.opts);
for (const {name, dev, prod} of invariantExpressions) {
if (path.get('callee').isIdentifier({name})) {
// Turns this code:
//
// invariant(condition, 'A %s message that contains %s', adj, noun);
//
// into something equivalent to this:
//
// if (!condition) {
// if (__DEV__ || ERR_CODE === undefined) {
// formatDevErrorMessage(`A ${adj} message that contains ${noun}`);
// } else {
// formatProdErrorMessage(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({cooked: String.raw({raw}), raw}),
);
// Outputs:
// `A ${adj} message that contains ${noun}`;
const devMessage = t.templateLiteral(
errorMsgQuasis,
errorMsgExpressions,
);
const parentStatementPath = path.parentPath;
if (parentStatementPath.type !== 'ExpressionStatement') {
throw path.buildCodeFrameError(
`${name}() cannot be called from expression context. Move the call to its own statement.`,
);
}
// We extract the prodErrorId even if we are not using it
// so we can extractCodes in a non-production build.
let prodErrorId = errorMap.getOrAddToErrorMap(
errorMsgLiteral,
extractCodes,
);
/** @type {babel.types.CallExpression} */
let callExpression;
if (noMinify || prodErrorId === undefined) {
// Error minification is disabled for this build.
//
// Outputs:
// if (!condition) {
// formatDevErrorMessage(`A ${adj} message that contains ${noun}`);
// }
const formatDevErrorMessageIdentifier =
helperModuleImports.addDefault(path, `shared/${dev}`, {
nameHint: dev,
});
callExpression = t.callExpression(
formatDevErrorMessageIdentifier,
[devMessage],
);
} else {
// Error minification enabled for this build.
//
// Outputs:
// if (!condition) {
// formatProdErrorMessage(ERR_CODE, adj, noun)
// }
// Import ReactErrorProd
const formatProdErrorMessageIdentifier =
helperModuleImports.addDefault(path, `shared/${prod}`, {
nameHint: prod,
});
// Outputs:
// formatProdErrorMessage(ERR_CODE, adj, noun);
callExpression = t.callExpression(
formatProdErrorMessageIdentifier,
[t.numericLiteral(prodErrorId), ...errorMsgExpressions],
);
}
parentStatementPath.replaceWith(
t.ifStatement(
t.unaryExpression('!', condition),
t.blockStatement([t.expressionStatement(callExpression)]),
),
);
if (!noMinify && prodErrorId === undefined) {
// There is no error code for this message. Add an inline comment
// that flags this as an unminified error. This allows the build
// to proceed, while also allowing a post-build linter to detect it.
//
// Outputs:
// /* FIXME (minify-errors-in-prod): Unminified error message in production build! */
// if (!condition) {
// formatDevErrorMessage(`A ${adj} message that contains ${noun}`);
// }
parentStatementPath.addComment(
'leading',
'FIXME (minify-errors-in-prod): Unminified error message in production build!',
);
}
return;
}
}
},
},
};
};