[lexical] Chore: Change $getTextNodeOffset invariant to warn in prod (error in __DEV__) (#7397)

This commit is contained in:
Bob Ippolito
2025-03-31 09:53:13 -07:00
committed by GitHub
parent 8fc84876d9
commit 1bafc3cdf5
9 changed files with 330 additions and 153 deletions

View File

@ -161,6 +161,7 @@
"@lexical/yjs": ["../lexical-yjs/src/index.ts"], "@lexical/yjs": ["../lexical-yjs/src/index.ts"],
"shared/canUseDOM": ["../shared/src/canUseDOM.ts"], "shared/canUseDOM": ["../shared/src/canUseDOM.ts"],
"shared/caretFromPoint": ["../shared/src/caretFromPoint.ts"], "shared/caretFromPoint": ["../shared/src/caretFromPoint.ts"],
"shared/devInvariant": ["../shared/src/devInvariant.ts"],
"shared/environment": ["../shared/src/environment.ts"], "shared/environment": ["../shared/src/environment.ts"],
"shared/formatDevErrorMessage": [ "shared/formatDevErrorMessage": [
"../shared/src/formatDevErrorMessage.ts" "../shared/src/formatDevErrorMessage.ts"
@ -168,6 +169,9 @@
"shared/formatProdErrorMessage": [ "shared/formatProdErrorMessage": [
"../shared/src/formatProdErrorMessage.ts" "../shared/src/formatProdErrorMessage.ts"
], ],
"shared/formatProdWarningMessage": [
"../shared/src/formatProdWarningMessage.ts"
],
"shared/invariant": ["../shared/src/invariant.ts"], "shared/invariant": ["../shared/src/invariant.ts"],
"shared/normalizeClassNames": ["../shared/src/normalizeClassNames.ts"], "shared/normalizeClassNames": ["../shared/src/normalizeClassNames.ts"],
"shared/react-test-utils": ["../shared/src/react-test-utils.ts"], "shared/react-test-utils": ["../shared/src/react-test-utils.ts"],

View File

@ -7,6 +7,7 @@
*/ */
import type {LexicalNode, NodeKey} from '../LexicalNode'; import type {LexicalNode, NodeKey} from '../LexicalNode';
import devInvariant from 'shared/devInvariant';
import invariant from 'shared/invariant'; import invariant from 'shared/invariant';
import {$getRoot, $isRootOrShadowRoot} from '../LexicalUtils'; import {$getRoot, $isRootOrShadowRoot} from '../LexicalUtils';
@ -890,7 +891,7 @@ export function $getTextPointCaret(
/** /**
* Get a normalized offset into a TextNode given a numeric offset or a * Get a normalized offset into a TextNode given a numeric offset or a
* direction for which end of the string to use. Throws if the offset * direction for which end of the string to use. Throws in dev if the offset
* is not in the bounds of the text content size. * is not in the bounds of the text content size.
* *
* @param origin a TextNode * @param origin a TextNode
@ -902,14 +903,19 @@ export function $getTextNodeOffset(
offset: number | CaretDirection, offset: number | CaretDirection,
): number { ): number {
const size = origin.getTextContentSize(); const size = origin.getTextContentSize();
const numericOffset = let numericOffset =
offset === 'next' ? size : offset === 'previous' ? 0 : offset; offset === 'next' ? size : offset === 'previous' ? 0 : offset;
invariant( if (numericOffset < 0 || numericOffset > size) {
numericOffset >= 0 && numericOffset <= size, devInvariant(
'$getTextNodeOffset: invalid offset %s for size %s', false,
String(offset), '$getTextNodeOffset: invalid offset %s for size %s at key %s',
String(size), String(offset),
); String(size),
origin.getKey(),
);
// Clamp invalid offsets in prod
numericOffset = numericOffset < 0 ? 0 : size;
}
return numericOffset; return numericOffset;
} }

View File

@ -0,0 +1,21 @@
/**
* 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.
*
*/
export default function devInvariant(
cond?: boolean,
message?: string,
...args: string[]
): void {
if (cond) {
return;
}
throw new Error(
args.reduce((msg, arg) => msg.replace('%s', String(arg)), message || ''),
);
}

View File

@ -0,0 +1,26 @@
/**
* 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.
*
*/
/**
* If `!cond` throw in `__DEV__` like an invariant and warn in prod
*/
export default function devInvariant(
cond?: boolean,
message?: string,
...args: string[]
): void {
if (cond) {
return;
}
throw new Error(
'Internal Lexical error: devInvariant() is meant to be replaced at compile ' +
'time. There is no runtime version. Error: ' +
message,
);
}

View File

@ -0,0 +1,24 @@
/**
* 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.
*
*/
export default function prodWarningMessage(
code: string,
...args: string[]
): void {
const url = new URL('https://lexical.dev/docs/error');
const params = new URLSearchParams();
params.append('code', code);
for (const arg of args) {
params.append('v', arg);
}
url.search = params.toString();
console.warn(
`Minified Lexical warning #${code}; visit ${url.toString()} for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`,
);
}

View File

@ -53,10 +53,10 @@ function fmt(strings: TemplateStringsArray, ...keys: unknown[]) {
.replace(/.use strict.;\n/g, '') .replace(/.use strict.;\n/g, '')
.replace(/var _[^;]+;\n/g, '') .replace(/var _[^;]+;\n/g, '')
.replace(/function _interopRequireDefault\([^)]*\) {[^;]+?;[\s\n]*}\n/g, '') .replace(/function _interopRequireDefault\([^)]*\) {[^;]+?;[\s\n]*}\n/g, '')
.replace(/_format(Dev|Prod)ErrorMessage\d+/g, 'format$1ErrorMessage') .replace(/_format(Dev|Prod)(Error|Warning)Message\d+/g, 'format$1$2Message')
.replace( .replace(
/\(0,\s*format(Dev|Prod)ErrorMessage\.default\)/g, /\(0,\s*format(Dev|Prod)(Error|Warning)Message\.default\)/g,
'format$1ErrorMessage', 'format$1$2Message',
) )
.trim(); .trim();
return prettier.format(before, { return prettier.format(before, {
@ -98,77 +98,155 @@ async function expectTransform(opts) {
} }
describe('transform-error-messages', () => { describe('transform-error-messages', () => {
describe('{extractCodes: true, noMinify: false}', () => { describe('invariant', () => {
const opts = {extractCodes: true, noMinify: false}; describe('{extractCodes: true, noMinify: false}', () => {
it('inserts known and extracts unknown message codes', async () => { const opts = {extractCodes: true, noMinify: false};
await expectTransform({ it('inserts known and extracts unknown message codes', async () => {
codeBefore: ` await expectTransform({
codeBefore: `
invariant(condition, ${JSON.stringify(NEW_MSG)}); invariant(condition, ${JSON.stringify(NEW_MSG)});
invariant(condition, ${JSON.stringify(KNOWN_MSG)}, adj, noun); invariant(condition, ${JSON.stringify(KNOWN_MSG)}, adj, noun);
`, `,
codeExpect: ` codeExpect: `
if (!condition) { if (!condition) {
formatProdErrorMessage(1); formatProdErrorMessage(1);
} }
if (!condition) { if (!condition) {
formatProdErrorMessage(0, adj, noun); formatProdErrorMessage(0, adj, noun);
}`, }`,
messageMapBefore: KNOWN_MSG_MAP, messageMapBefore: KNOWN_MSG_MAP,
messageMapExpect: NEW_MSG_MAP, messageMapExpect: NEW_MSG_MAP,
opts, opts,
});
}); });
}); });
}); describe('{extractCodes: true, noMinify: true}', () => {
describe('{extractCodes: true, noMinify: true}', () => { const opts = {extractCodes: true, noMinify: true};
const opts = {extractCodes: true, noMinify: true}; it('inserts known and extracts unknown message codes', async () => {
it('inserts known and extracts unknown message codes', async () => { await expectTransform({
await expectTransform({ codeBefore: `
codeBefore: `
invariant(condition, ${JSON.stringify(NEW_MSG)}); invariant(condition, ${JSON.stringify(NEW_MSG)});
invariant(condition, ${JSON.stringify(KNOWN_MSG)}, adj, noun); invariant(condition, ${JSON.stringify(KNOWN_MSG)}, adj, noun);
`, `,
codeExpect: ` codeExpect: `
if (!condition) { if (!condition) {
formatDevErrorMessage(\`A new invariant\`); formatDevErrorMessage(\`A new invariant\`);
} }
if (!condition) { if (!condition) {
formatDevErrorMessage(\`A \${adj} message that contains \${noun}\`); formatDevErrorMessage(\`A \${adj} message that contains \${noun}\`);
}`, }`,
messageMapBefore: KNOWN_MSG_MAP, messageMapBefore: KNOWN_MSG_MAP,
messageMapExpect: NEW_MSG_MAP, messageMapExpect: NEW_MSG_MAP,
opts, opts,
});
}); });
}); });
}); describe('{extractCodes: false, noMinify: false}', () => {
describe('{extractCodes: false, noMinify: false}', () => { const opts = {extractCodes: false, noMinify: false};
const opts = {extractCodes: false, noMinify: false}; it('inserts known message', async () => {
it('inserts known message', async () => { await expectTransform({
await expectTransform({ codeBefore: `invariant(condition, ${JSON.stringify(
codeBefore: `invariant(condition, ${JSON.stringify( KNOWN_MSG,
KNOWN_MSG, )}, adj, noun)`,
)}, adj, noun)`, codeExpect: `
codeExpect: `
if (!condition) { if (!condition) {
formatProdErrorMessage(0, adj, noun); formatProdErrorMessage(0, adj, noun);
} }
`, `,
messageMapBefore: KNOWN_MSG_MAP, messageMapBefore: KNOWN_MSG_MAP,
messageMapExpect: KNOWN_MSG_MAP, messageMapExpect: KNOWN_MSG_MAP,
opts, opts,
});
}); });
}); it('inserts warning comment for unknown messages', async () => {
it('inserts warning comment for unknown messages', async () => { await expectTransform({
await expectTransform({ codeBefore: `invariant(condition, ${JSON.stringify(NEW_MSG)})`,
codeBefore: `invariant(condition, ${JSON.stringify(NEW_MSG)})`, codeExpect: `
codeExpect: `
/*FIXME (minify-errors-in-prod): Unminified error message in production build!*/ /*FIXME (minify-errors-in-prod): Unminified error message in production build!*/
if (!condition) { if (!condition) {
formatDevErrorMessage(\`A new invariant\`); formatDevErrorMessage(\`A new invariant\`);
} }
`, `,
messageMapBefore: KNOWN_MSG_MAP, messageMapBefore: KNOWN_MSG_MAP,
messageMapExpect: KNOWN_MSG_MAP, messageMapExpect: KNOWN_MSG_MAP,
opts, opts,
});
});
});
});
describe('devInvariant', () => {
describe('{extractCodes: true, noMinify: false}', () => {
const opts = {extractCodes: true, noMinify: false};
it('inserts known and extracts unknown message codes', async () => {
await expectTransform({
codeBefore: `
devInvariant(condition, ${JSON.stringify(NEW_MSG)});
devInvariant(condition, ${JSON.stringify(KNOWN_MSG)}, adj, noun);
`,
codeExpect: `
if (!condition) {
formatProdWarningMessage(1);
}
if (!condition) {
formatProdWarningMessage(0, adj, noun);
}`,
messageMapBefore: KNOWN_MSG_MAP,
messageMapExpect: NEW_MSG_MAP,
opts,
});
});
});
describe('{extractCodes: true, noMinify: true}', () => {
const opts = {extractCodes: true, noMinify: true};
it('inserts known and extracts unknown message codes', async () => {
await expectTransform({
codeBefore: `
devInvariant(condition, ${JSON.stringify(NEW_MSG)});
devInvariant(condition, ${JSON.stringify(KNOWN_MSG)}, adj, noun);
`,
codeExpect: `
if (!condition) {
formatDevErrorMessage(\`A new invariant\`);
}
if (!condition) {
formatDevErrorMessage(\`A \${adj} message that contains \${noun}\`);
}`,
messageMapBefore: KNOWN_MSG_MAP,
messageMapExpect: NEW_MSG_MAP,
opts,
});
});
});
describe('{extractCodes: false, noMinify: false}', () => {
const opts = {extractCodes: false, noMinify: false};
it('inserts known message', async () => {
await expectTransform({
codeBefore: `devInvariant(condition, ${JSON.stringify(
KNOWN_MSG,
)}, adj, noun)`,
codeExpect: `
if (!condition) {
formatProdWarningMessage(0, adj, noun);
}
`,
messageMapBefore: KNOWN_MSG_MAP,
messageMapExpect: KNOWN_MSG_MAP,
opts,
});
});
it('inserts warning comment for unknown messages', async () => {
await expectTransform({
codeBefore: `devInvariant(condition, ${JSON.stringify(NEW_MSG)})`,
codeExpect: `
/*FIXME (minify-errors-in-prod): Unminified error message in production build!*/
if (!condition) {
formatDevErrorMessage(\`A new invariant\`);
}
`,
messageMapBefore: KNOWN_MSG_MAP,
messageMapExpect: KNOWN_MSG_MAP,
opts,
});
}); });
}); });
}); });

View File

@ -50,6 +50,19 @@ function getErrorMap(filepath) {
* @property {boolean} noMinify * @property {boolean} noMinify
*/ */
const invariantExpressions = [
{
dev: 'formatDevErrorMessage',
name: 'invariant',
prod: 'formatProdErrorMessage',
},
{
dev: 'formatDevErrorMessage',
name: 'devInvariant',
prod: 'formatProdWarningMessage',
},
];
/** /**
* @param {import('@babel/core')} babel * @param {import('@babel/core')} babel
* @param {Partial<TransformErrorMessagesOptions>} opts * @param {Partial<TransformErrorMessagesOptions>} opts
@ -66,120 +79,117 @@ module.exports = function (babel, opts) {
const node = path.node; const node = path.node;
const {extractCodes, noMinify} = const {extractCodes, noMinify} =
/** @type Partial<TransformErrorMessagesOptions> */ (file.opts); /** @type Partial<TransformErrorMessagesOptions> */ (file.opts);
if (path.get('callee').isIdentifier({name: 'invariant'})) { for (const {name, dev, prod} of invariantExpressions) {
// Turns this code: 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(
'invariant() 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: // invariant(condition, 'A %s message that contains %s', adj, noun);
// if (!condition) { //
// formatDevErrorMessage(`A ${adj} message that contains ${noun}`); // into something equivalent to this:
// }
const formatDevErrorMessageIdentifier =
helperModuleImports.addDefault(
path,
'shared/formatDevErrorMessage',
{
nameHint: 'formatDevErrorMessage',
},
);
callExpression = t.callExpression(formatDevErrorMessageIdentifier, [
devMessage,
]);
} else {
// Error minification enabled for this build.
// //
// Outputs:
// if (!condition) { // if (!condition) {
// formatProdErrorMessage(ERR_CODE, adj, noun) // if (__DEV__ || ERR_CODE === undefined) {
// formatDevErrorMessage(`A ${adj} message that contains ${noun}`);
// } else {
// formatProdErrorMessage(ERR_CODE, adj, noun)
// };
// } // }
//
// Import ReactErrorProd // where ERR_CODE is an error code: a unique identifier (a number
const formatProdErrorMessageIdentifier = // string) that references a verbose error message. The mapping is
helperModuleImports.addDefault( // stored in `scripts/error-codes/codes.json`.
path, const condition = node.arguments[0];
'shared/formatProdErrorMessage', const errorMsgLiteral = evalToString(node.arguments[1]);
{ const errorMsgExpressions = Array.from(node.arguments.slice(2));
nameHint: 'formatProdErrorMessage', const errorMsgQuasis = errorMsgLiteral
}, .split('%s')
.map((raw) =>
t.templateElement({cooked: String.raw({raw}), raw}),
); );
// Outputs: // Outputs:
// formatProdErrorMessage(ERR_CODE, adj, noun); // `A ${adj} message that contains ${noun}`;
callExpression = t.callExpression( const devMessage = t.templateLiteral(
formatProdErrorMessageIdentifier, errorMsgQuasis,
[t.numericLiteral(prodErrorId), ...errorMsgExpressions], errorMsgExpressions,
); );
}
parentStatementPath.replaceWith( const parentStatementPath = path.parentPath;
t.ifStatement( if (parentStatementPath.type !== 'ExpressionStatement') {
t.unaryExpression('!', condition), throw path.buildCodeFrameError(
t.blockStatement([t.expressionStatement(callExpression)]), `${name}() cannot be called from expression context. Move the call to its own statement.`,
), );
); }
if (!noMinify && prodErrorId === undefined) { // We extract the prodErrorId even if we are not using it
// There is no error code for this message. Add an inline comment // so we can extractCodes in a non-production build.
// that flags this as an unminified error. This allows the build let prodErrorId = errorMap.getOrAddToErrorMap(
// to proceed, while also allowing a post-build linter to detect it. errorMsgLiteral,
// extractCodes,
// 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!',
); );
/** @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;
} }
} }
}, },

View File

@ -162,6 +162,7 @@
"@lexical/yjs": ["./packages/lexical-yjs/src/index.ts"], "@lexical/yjs": ["./packages/lexical-yjs/src/index.ts"],
"shared/canUseDOM": ["./packages/shared/src/canUseDOM.ts"], "shared/canUseDOM": ["./packages/shared/src/canUseDOM.ts"],
"shared/caretFromPoint": ["./packages/shared/src/caretFromPoint.ts"], "shared/caretFromPoint": ["./packages/shared/src/caretFromPoint.ts"],
"shared/devInvariant": ["./packages/shared/src/devInvariant.ts"],
"shared/environment": ["./packages/shared/src/environment.ts"], "shared/environment": ["./packages/shared/src/environment.ts"],
"shared/formatDevErrorMessage": [ "shared/formatDevErrorMessage": [
"./packages/shared/src/formatDevErrorMessage.ts" "./packages/shared/src/formatDevErrorMessage.ts"
@ -169,6 +170,9 @@
"shared/formatProdErrorMessage": [ "shared/formatProdErrorMessage": [
"./packages/shared/src/formatProdErrorMessage.ts" "./packages/shared/src/formatProdErrorMessage.ts"
], ],
"shared/formatProdWarningMessage": [
"./packages/shared/src/formatProdWarningMessage.ts"
],
"shared/invariant": ["./packages/shared/src/invariant.ts"], "shared/invariant": ["./packages/shared/src/invariant.ts"],
"shared/normalizeClassNames": [ "shared/normalizeClassNames": [
"./packages/shared/src/normalizeClassNames.ts" "./packages/shared/src/normalizeClassNames.ts"

View File

@ -170,6 +170,7 @@
"@lexical/yjs": ["./packages/lexical-yjs/src/index.ts"], "@lexical/yjs": ["./packages/lexical-yjs/src/index.ts"],
"shared/canUseDOM": ["./packages/shared/src/canUseDOM.ts"], "shared/canUseDOM": ["./packages/shared/src/canUseDOM.ts"],
"shared/caretFromPoint": ["./packages/shared/src/caretFromPoint.ts"], "shared/caretFromPoint": ["./packages/shared/src/caretFromPoint.ts"],
"shared/devInvariant": ["./packages/shared/src/devInvariant.ts"],
"shared/environment": ["./packages/shared/src/environment.ts"], "shared/environment": ["./packages/shared/src/environment.ts"],
"shared/formatDevErrorMessage": [ "shared/formatDevErrorMessage": [
"./packages/shared/src/formatDevErrorMessage.ts" "./packages/shared/src/formatDevErrorMessage.ts"
@ -177,6 +178,9 @@
"shared/formatProdErrorMessage": [ "shared/formatProdErrorMessage": [
"./packages/shared/src/formatProdErrorMessage.ts" "./packages/shared/src/formatProdErrorMessage.ts"
], ],
"shared/formatProdWarningMessage": [
"./packages/shared/src/formatProdWarningMessage.ts"
],
"shared/invariant": ["./packages/shared/src/invariant.ts"], "shared/invariant": ["./packages/shared/src/invariant.ts"],
"shared/normalizeClassNames": [ "shared/normalizeClassNames": [
"./packages/shared/src/normalizeClassNames.ts" "./packages/shared/src/normalizeClassNames.ts"