@mui/internal-babel-plugin-minify-errors 2.0.8-canary.2 → 2.0.8-canary.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +223 -146
  2. package/index.test.js +161 -59
  3. package/package.json +9 -9
package/index.js CHANGED
@@ -26,136 +26,203 @@ function pathToNodeImportSpecifier(importPath) {
26
26
 
27
27
  const COMMENT_OPT_IN_MARKER = 'minify-error';
28
28
  const COMMENT_OPT_OUT_MARKER = 'minify-error-disabled';
29
+ const SUPPORTED_ERROR_CONSTRUCTORS = new Set(['Error', 'TypeError']);
29
30
 
30
31
  /**
31
32
  * @typedef {import('@babel/core')} babel
32
33
  */
33
34
 
34
35
  /**
35
- * @typedef {babel.PluginPass & {updatedErrorCodes?: boolean, formatErrorMessageIdentifier?: babel.types.Identifier}} PluginState
36
36
  * @typedef {'annotate' | 'throw' | 'write'} MissingError
37
+ * @typedef {babel.PluginPass & {formatErrorMessageIdentifier?: babel.types.Identifier, processedNodes?: WeakSet<babel.types.Node>}} PluginState
37
38
  * @typedef {{
38
- * errorCodesPath: string,
39
- * missingError: MissingError,
39
+ * errorCodesPath?: string,
40
40
  * runtimeModule?: string,
41
41
  * detection?: 'opt-in' | 'opt-out',
42
- * outExtension?: string
42
+ * outExtension?: string,
43
+ * collectErrors?: Set<string | Error>
43
44
  * }} Options
44
45
  */
45
46
 
46
47
  /**
47
- * Extracts the message and expressions from a node.
48
+ * `collectErrors` - When provided, the plugin collects error messages into this Set
49
+ * instead of transforming the code. The caller typically passes the same Set instance
50
+ * across multiple plugin invocations (e.g., when processing multiple files), and the
51
+ * plugin is expected to mutate the Set by adding entries during traversal.
52
+ */
53
+
54
+ /**
55
+ * Checks if a node is `process.env.NODE_ENV` using Babel types.
48
56
  * @param {babel.types} t
49
57
  * @param {babel.types.Node} node
50
- * @returns {{ message: string, expressions: babel.types.Expression[] } | null}
58
+ * @returns {boolean}
59
+ */
60
+ function isProcessEnvNodeEnv(t, node) {
61
+ return (
62
+ t.isMemberExpression(node) &&
63
+ t.isMemberExpression(node.object) &&
64
+ t.isIdentifier(node.object.object, { name: 'process' }) &&
65
+ t.isIdentifier(node.object.property, { name: 'env' }) &&
66
+ t.isIdentifier(node.property, { name: 'NODE_ENV' })
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Checks if a binary expression compares `process.env.NODE_ENV` with a value using the given operator.
72
+ * Handles both `process.env.NODE_ENV op value` and `value op process.env.NODE_ENV`.
73
+ * @param {babel.types} t
74
+ * @param {babel.types.BinaryExpression} node
75
+ * @param {string} operator
76
+ * @param {string} value
77
+ * @returns {boolean}
78
+ */
79
+ function isNodeEnvComparison(t, node, operator, value) {
80
+ if (node.operator !== operator) {
81
+ return false;
82
+ }
83
+ return (
84
+ (isProcessEnvNodeEnv(t, node.left) && t.isStringLiteral(node.right, { value })) ||
85
+ (t.isStringLiteral(node.left, { value }) && isProcessEnvNodeEnv(t, node.right))
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Checks if the given path is inside a dev-only branch
91
+ * (e.g. `if (process.env.NODE_ENV !== 'production') { ... }`).
92
+ * Errors inside such branches are already stripped in production,
93
+ * so minification is unnecessary.
94
+ * @param {babel.types} t
95
+ * @param {babel.NodePath} path
96
+ * @returns {boolean}
97
+ */
98
+ function isInsideDevOnlyBranch(t, path) {
99
+ let current = path;
100
+ while (current.parentPath) {
101
+ const parent = current.parentPath;
102
+ if (parent.isIfStatement()) {
103
+ const isInConsequent = current.key === 'consequent';
104
+ const isInAlternate = current.key === 'alternate';
105
+ if ((isInConsequent || isInAlternate) && t.isBinaryExpression(parent.node.test)) {
106
+ const operator = isInConsequent ? '!==' : '===';
107
+ if (isNodeEnvComparison(t, parent.node.test, operator, 'production')) {
108
+ return true;
109
+ }
110
+ }
111
+ }
112
+ current = current.parentPath;
113
+ }
114
+ return false;
115
+ }
116
+
117
+ /**
118
+ * @typedef {{ path: babel.NodePath<babel.types.Expression>, message: string, expressions: babel.types.Expression[] }} ExtractedMessage
119
+ */
120
+
121
+ /**
122
+ * Extracts the message and expressions from a path.
123
+ * @param {babel.types} t
124
+ * @param {babel.NodePath<babel.types.ArgumentPlaceholder | babel.types.SpreadElement | babel.types.Expression>} path
125
+ * @returns {ExtractedMessage | null}
51
126
  */
52
- function extractMessage(t, node) {
53
- if (t.isTemplateLiteral(node)) {
127
+ function extractMessage(t, path) {
128
+ if (path.isSpreadElement() || path.isArgumentPlaceholder()) {
129
+ return null;
130
+ }
131
+ if (path.isTemplateLiteral()) {
54
132
  return {
55
- message: node.quasis.map((quasi) => quasi.value.cooked).join('%s'),
56
- expressions: node.expressions.map((expression) => {
133
+ path,
134
+ message: path.node.quasis.map((quasi) => quasi.value.cooked).join('%s'),
135
+ expressions: path.node.expressions.map((expression) => {
57
136
  if (t.isExpression(expression)) {
58
137
  return expression;
59
138
  }
60
- throw new Error('Can only evaluate javascript template literals.');
139
+ throw path.buildCodeFrameError('Can only evaluate javascript template literals.');
61
140
  }),
62
141
  };
63
142
  }
64
- if (t.isStringLiteral(node)) {
65
- return { message: node.value, expressions: [] };
143
+ if (path.isStringLiteral()) {
144
+ return { path, message: path.node.value, expressions: [] };
66
145
  }
67
- if (t.isBinaryExpression(node) && node.operator === '+') {
68
- const left = extractMessage(t, node.left);
69
- const right = extractMessage(t, node.right);
70
- if (!left || !right) {
71
- return null;
146
+ if (path.isBinaryExpression() && path.node.operator === '+') {
147
+ const leftPath = path.get('left');
148
+ if (leftPath.isExpression()) {
149
+ const left = extractMessage(t, leftPath);
150
+ const right = extractMessage(t, path.get('right'));
151
+ if (!left || !right) {
152
+ return null;
153
+ }
154
+ return {
155
+ path,
156
+ message: left.message + right.message,
157
+ expressions: [...left.expressions, ...right.expressions],
158
+ };
72
159
  }
73
- return {
74
- message: left.message + right.message,
75
- expressions: [...left.expressions, ...right.expressions],
76
- };
77
160
  }
78
161
  return null;
79
162
  }
80
163
 
81
164
  /**
82
- * Handles unminifyable errors based on the missingError option.
83
- * @param {MissingError} missingError
84
- * @param {babel.NodePath} path
165
+ * @param {babel.types} t
166
+ * @param {babel.NodePath<babel.types.NewExpression>} newExpressionPath
167
+ * @param {'opt-in' | 'opt-out'} detection
168
+ * @returns {null | babel.NodePath<babel.types.ArgumentPlaceholder | babel.types.SpreadElement | babel.types.Expression>}
85
169
  */
86
- function handleUnminifyableError(missingError, path) {
87
- switch (missingError) {
88
- case 'annotate':
89
- path.addComment(
90
- 'leading',
91
- ' FIXME (minify-errors-in-prod): Unminifyable error in production! ',
92
- );
170
+ function findMessageNode(t, newExpressionPath, detection) {
171
+ const callee = newExpressionPath.get('callee');
172
+ if (!callee.isIdentifier() || !SUPPORTED_ERROR_CONSTRUCTORS.has(callee.node.name)) {
173
+ return null;
174
+ }
175
+
176
+ if (isInsideDevOnlyBranch(t, newExpressionPath)) {
177
+ return null;
178
+ }
179
+
180
+ switch (detection) {
181
+ case 'opt-in': {
182
+ if (
183
+ !newExpressionPath.node.leadingComments?.some((comment) =>
184
+ comment.value.includes(COMMENT_OPT_IN_MARKER),
185
+ )
186
+ ) {
187
+ return null;
188
+ }
93
189
  break;
94
- case 'throw':
95
- throw new Error(
96
- 'Unminifyable error. You can only use literal strings and template strings as error messages.',
97
- );
98
- case 'write':
190
+ }
191
+ case 'opt-out': {
192
+ if (
193
+ newExpressionPath.node.leadingComments?.some((comment) =>
194
+ comment.value.includes(COMMENT_OPT_OUT_MARKER),
195
+ )
196
+ ) {
197
+ return null;
198
+ }
199
+
99
200
  break;
100
- default:
101
- throw new Error(`Unknown missingError option: ${missingError}`);
201
+ }
202
+ default: {
203
+ throw new Error(`Unknown detection option: ${detection}`);
204
+ }
102
205
  }
206
+
207
+ const messagePath = newExpressionPath.get('arguments')[0];
208
+
209
+ return messagePath ?? null;
103
210
  }
104
211
 
105
212
  /**
106
213
  * Transforms the error message node.
107
214
  * @param {babel.types} t
108
- * @param {babel.NodePath} path
109
- * @param {babel.types.Expression} messageNode
215
+ * @param {ExtractedMessage} extracted
216
+ * @param {number} errorCode
110
217
  * @param {PluginState} state
111
- * @param {Map<string, number>} errorCodesLookup
112
- * @param {MissingError} missingError
113
218
  * @param {string} runtimeModule
114
219
  * @param {string} outExtension
115
- * @returns {babel.types.Expression | null}
220
+ * @returns {babel.types.Expression}
116
221
  */
117
- function transformMessage(
118
- t,
119
- path,
120
- messageNode,
121
- state,
122
- errorCodesLookup,
123
- missingError,
124
- runtimeModule,
125
- outExtension,
126
- ) {
127
- const message = extractMessage(t, messageNode);
128
- if (!message) {
129
- handleUnminifyableError(missingError, path);
130
- return null;
131
- }
132
-
133
- let errorCode = errorCodesLookup.get(message.message);
134
- if (errorCode === undefined) {
135
- switch (missingError) {
136
- case 'annotate':
137
- path.addComment(
138
- 'leading',
139
- ' FIXME (minify-errors-in-prod): Unminified error message in production build! ',
140
- );
141
- return null;
142
- case 'throw':
143
- throw new Error(
144
- `Missing error code for message '${message.message}'. Did you forget to run \`pnpm extract-error-codes\` first?`,
145
- );
146
- case 'write':
147
- errorCode = errorCodesLookup.size + 1;
148
- errorCodesLookup.set(message.message, errorCode);
149
- state.updatedErrorCodes = true;
150
- break;
151
- default:
152
- throw new Error(`Unknown missingError option: ${missingError}`);
153
- }
154
- }
155
-
222
+ function transformMessage(t, extracted, errorCode, state, runtimeModule, outExtension) {
156
223
  if (!state.formatErrorMessageIdentifier) {
157
224
  state.formatErrorMessageIdentifier = helperModuleImports.addDefault(
158
- path,
225
+ extracted.path,
159
226
  transformExtension(resolveRuntimeModule(runtimeModule, state), outExtension),
160
227
  { nameHint: '_formatErrorMessage' },
161
228
  );
@@ -170,10 +237,10 @@ function transformMessage(
170
237
  ),
171
238
  t.stringLiteral('production'),
172
239
  ),
173
- messageNode,
240
+ extracted.path.node,
174
241
  t.callExpression(t.cloneNode(state.formatErrorMessageIdentifier, true), [
175
242
  t.numericLiteral(errorCode),
176
- ...message.expressions,
243
+ ...extracted.expressions,
177
244
  ]),
178
245
  );
179
246
  }
@@ -240,99 +307,109 @@ module.exports = function plugin(
240
307
  { types: t },
241
308
  {
242
309
  errorCodesPath,
243
- missingError = 'annotate',
244
310
  runtimeModule = '#formatErrorMessage',
245
311
  detection = 'opt-in',
246
312
  outExtension = '.js',
313
+ collectErrors,
247
314
  },
248
315
  ) {
249
- if (!errorCodesPath) {
250
- throw new Error('errorCodesPath is required.');
251
- }
316
+ /** @type {Map<string, number>} */
317
+ let errorCodesLookup;
252
318
 
253
- const errorCodesContent = fs.readFileSync(errorCodesPath, 'utf8');
254
- const errorCodes = JSON.parse(errorCodesContent);
319
+ if (collectErrors) {
320
+ errorCodesLookup = new Map();
321
+ } else {
322
+ if (!errorCodesPath) {
323
+ throw new Error('errorCodesPath is required.');
324
+ }
255
325
 
256
- const errorCodesLookup = new Map(
257
- Object.entries(errorCodes).map(([key, value]) => [value, Number(key)]),
258
- );
326
+ const errorCodesContent = fs.readFileSync(errorCodesPath, 'utf8');
327
+ const errorCodes = JSON.parse(errorCodesContent);
328
+
329
+ errorCodesLookup = new Map(
330
+ Object.entries(errorCodes).map(([key, value]) => [value, Number(key)]),
331
+ );
332
+ }
259
333
 
260
334
  return {
261
335
  name: '@mui/internal-babel-plugin-minify-errors',
262
336
  visitor: {
263
337
  NewExpression(newExpressionPath, state) {
264
- if (!newExpressionPath.get('callee').isIdentifier({ name: 'Error' })) {
338
+ // Initialize the WeakSet lazily to track processed nodes
339
+ state.processedNodes ??= new WeakSet();
340
+
341
+ // Skip if we've already processed this node. This can happen when Babel
342
+ // visits the same node multiple times due to configuration or plugin
343
+ // interactions (e.g., @babel/preset-env with modules: 'commonjs' combined
344
+ // with React.forwardRef causes double visitation).
345
+ if (state.processedNodes.has(newExpressionPath.node)) {
346
+ return;
347
+ }
348
+
349
+ // Mark this node as processed before transforming
350
+ state.processedNodes.add(newExpressionPath.node);
351
+
352
+ const messagePath = findMessageNode(t, newExpressionPath, detection);
353
+
354
+ if (!messagePath) {
355
+ // Not an error, or not eligible for minification
265
356
  return;
266
357
  }
267
358
 
268
- switch (detection) {
269
- case 'opt-in': {
270
- if (
271
- !newExpressionPath.node.leadingComments?.some((comment) =>
272
- comment.value.includes(COMMENT_OPT_IN_MARKER),
273
- )
274
- ) {
275
- return;
276
- }
277
- newExpressionPath.node.leadingComments = newExpressionPath.node.leadingComments.filter(
278
- (comment) => !comment.value.includes(COMMENT_OPT_IN_MARKER),
359
+ if (!collectErrors && newExpressionPath.node.leadingComments) {
360
+ newExpressionPath.node.leadingComments = newExpressionPath.node.leadingComments.filter(
361
+ (comment) =>
362
+ !comment.value.includes(COMMENT_OPT_IN_MARKER) &&
363
+ !comment.value.includes(COMMENT_OPT_OUT_MARKER),
364
+ );
365
+ }
366
+
367
+ const extracted = extractMessage(t, messagePath);
368
+
369
+ if (!extracted) {
370
+ if (collectErrors) {
371
+ // Mutates the caller's Set
372
+ collectErrors.add(
373
+ messagePath.buildCodeFrameError(
374
+ 'Unminifyable error. You can only use literal strings and template strings as error messages.',
375
+ ),
376
+ );
377
+ } else {
378
+ newExpressionPath.addComment(
379
+ 'leading',
380
+ ' FIXME (minify-errors-in-prod): Unminifyable error in production! ',
279
381
  );
280
- break;
281
- }
282
- case 'opt-out': {
283
- if (
284
- newExpressionPath.node.leadingComments?.some((comment) =>
285
- comment.value.includes(COMMENT_OPT_OUT_MARKER),
286
- )
287
- ) {
288
- newExpressionPath.node.leadingComments =
289
- newExpressionPath.node.leadingComments.filter(
290
- (comment) => !comment.value.includes(COMMENT_OPT_OUT_MARKER),
291
- );
292
- return;
293
- }
294
-
295
- break;
296
- }
297
- default: {
298
- throw new Error(`Unknown detection option: ${detection}`);
299
382
  }
383
+ return;
300
384
  }
301
385
 
302
- const messagePath = newExpressionPath.get('arguments')[0];
303
- if (!messagePath) {
386
+ const errorCode = errorCodesLookup.get(extracted.message);
387
+
388
+ if (collectErrors) {
389
+ // Mutates the caller's Set
390
+ collectErrors.add(extracted.message);
304
391
  return;
305
392
  }
306
393
 
307
- const messageNode = messagePath.node;
308
- if (t.isSpreadElement(messageNode) || t.isArgumentPlaceholder(messageNode)) {
309
- handleUnminifyableError(missingError, newExpressionPath);
394
+ if (errorCode === undefined) {
395
+ newExpressionPath.addComment(
396
+ 'leading',
397
+ ' FIXME (minify-errors-in-prod): Unminified error message in production build! ',
398
+ );
310
399
  return;
311
400
  }
312
401
 
313
402
  const transformedMessage = transformMessage(
314
403
  t,
315
- newExpressionPath,
316
- messageNode,
404
+ extracted,
405
+ errorCode,
317
406
  state,
318
- errorCodesLookup,
319
- missingError,
320
407
  runtimeModule,
321
408
  outExtension,
322
409
  );
323
410
 
324
- if (transformedMessage) {
325
- messagePath.replaceWith(transformedMessage);
326
- }
411
+ messagePath.replaceWith(transformedMessage);
327
412
  },
328
413
  },
329
- post() {
330
- if (missingError === 'write' && this.updatedErrorCodes) {
331
- const invertedErrorCodes = Object.fromEntries(
332
- Array.from(errorCodesLookup, ([key, value]) => [value, key]),
333
- );
334
- fs.writeFileSync(errorCodesPath, `${JSON.stringify(invertedErrorCodes, null, 2)}\n`);
335
- }
336
- },
337
414
  };
338
415
  };
package/index.test.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import * as os from 'os';
3
+ import { transformSync } from '@babel/core';
4
4
  import { pluginTester } from 'babel-plugin-tester';
5
- import { expect } from 'chai';
5
+ import { expect, describe, it } from 'vitest';
6
+ import * as babel from '@babel/core';
6
7
  import plugin from './index';
7
8
 
8
- const temporaryErrorCodesPath = path.join(os.tmpdir(), 'error-codes.json');
9
9
  const fixturePath = path.resolve(__dirname, './__fixtures__');
10
10
 
11
11
  /**
@@ -35,6 +35,15 @@ pluginTester({
35
35
  fixture: path.join(fixturePath, 'literal', 'input.js'),
36
36
  output: readOutputFixtureSync('literal', 'output.js'),
37
37
  },
38
+ {
39
+ title: 'type-error',
40
+ pluginOptions: {
41
+ errorCodesPath: path.join(fixturePath, 'type-error', 'error-codes.json'),
42
+ runtimeModule: '@mui/utils/formatMuiErrorMessage',
43
+ },
44
+ fixture: path.join(fixturePath, 'type-error', 'input.js'),
45
+ output: readOutputFixtureSync('type-error', 'output.js'),
46
+ },
38
47
  {
39
48
  title: 'interpolation',
40
49
  pluginOptions: {
@@ -53,19 +62,6 @@ pluginTester({
53
62
  fixture: path.join(fixturePath, 'no-error-code-annotation', 'input.js'),
54
63
  output: readOutputFixtureSync('no-error-code-annotation', 'output.js'),
55
64
  },
56
- {
57
- title: 'can throw on missing error codes',
58
- // babel prefixes with filename.
59
- // We're only interested in the message.
60
- error:
61
- /: Missing error code for message 'missing'. Did you forget to run `pnpm extract-error-codes` first\?/,
62
- fixture: path.join(fixturePath, 'no-error-code-throw', 'input.js'),
63
- pluginOptions: {
64
- errorCodesPath: path.join(fixturePath, 'no-error-code-throw', 'error-codes.json'),
65
- missingError: 'throw',
66
- runtimeModule: '@mui/utils/formatMuiErrorMessage',
67
- },
68
- },
69
65
  {
70
66
  title: 'annotates unminifyable errors',
71
67
  pluginOptions: {
@@ -76,50 +72,10 @@ pluginTester({
76
72
  output: readOutputFixtureSync('unminifyable-annotation', 'output.js'),
77
73
  },
78
74
  {
79
- title: 'can throw on unminifyable errors',
80
- // babel prefixes with filename.
81
- // We're only interested in the message.
82
- error:
83
- /: Unminifyable error. You can only use literal strings and template strings as error messages./,
84
- fixture: path.join(fixturePath, 'unminifyable-throw', 'input.js'),
85
- pluginOptions: {
86
- errorCodesPath: path.join(fixturePath, 'unminifyable-throw', 'error-codes.json'),
87
- missingError: 'throw',
88
- runtimeModule: '@mui/utils/formatMuiErrorMessage',
89
- },
90
- },
91
- {
92
- title: 'can extract errors',
93
- fixture: path.join(fixturePath, 'error-code-extraction', 'input.js'),
75
+ title: 'collects unminifyable errors as Error objects without throwing',
76
+ fixture: path.join(fixturePath, 'unminifyable-collect', 'input.js'),
94
77
  pluginOptions: {
95
- errorCodesPath: temporaryErrorCodesPath,
96
- missingError: 'write',
97
- runtimeModule: '@mui/utils/formatMuiErrorMessage',
98
- },
99
- output: readOutputFixtureSync('error-code-extraction', 'output.js'),
100
- setup() {
101
- fs.copyFileSync(
102
- path.join(fixturePath, 'error-code-extraction', 'error-codes.before.json'),
103
- temporaryErrorCodesPath,
104
- );
105
-
106
- return function teardown() {
107
- try {
108
- const actualErrorCodes = JSON.parse(
109
- fs.readFileSync(temporaryErrorCodesPath, { encoding: 'utf8' }),
110
- );
111
- const expectedErrorCodes = JSON.parse(
112
- fs.readFileSync(
113
- path.join(fixturePath, 'error-code-extraction', 'error-codes.after.json'),
114
- 'utf-8',
115
- ),
116
- );
117
-
118
- expect(actualErrorCodes).to.deep.equal(expectedErrorCodes);
119
- } finally {
120
- fs.unlinkSync(temporaryErrorCodesPath);
121
- }
122
- };
78
+ collectErrors: new Set(),
123
79
  },
124
80
  },
125
81
  {
@@ -166,5 +122,151 @@ pluginTester({
166
122
  fixture: path.join(fixturePath, 'custom-runtime-imports-recursive', 'input.js'),
167
123
  output: readOutputFixtureSync('custom-runtime-imports-recursive', 'output.js'),
168
124
  },
125
+ {
126
+ title: 'skips errors inside dev-only branches',
127
+ pluginOptions: {
128
+ errorCodesPath: path.join(fixturePath, 'dev-only-branch', 'error-codes.json'),
129
+ runtimeModule: '@mui/utils/formatMuiErrorMessage',
130
+ detection: 'opt-out',
131
+ },
132
+ fixture: path.join(fixturePath, 'dev-only-branch', 'input.js'),
133
+ output: readOutputFixtureSync('dev-only-branch', 'output.js'),
134
+ },
169
135
  ],
170
136
  });
137
+
138
+ describe('collectErrors', () => {
139
+ it('collects error messages into the provided Set without transforming code', () => {
140
+ const errors = new Set();
141
+ const code = [
142
+ 'throw /* minify-error */ new Error("first error");',
143
+ // eslint-disable-next-line no-template-curly-in-string
144
+ 'throw /* minify-error */ new Error(`second ${x} error`);',
145
+ ].join('\n');
146
+
147
+ transformSync(code, {
148
+ filename: '/test/file.js',
149
+ plugins: [[plugin, { collectErrors: errors }]],
150
+ configFile: false,
151
+ babelrc: false,
152
+ });
153
+
154
+ expect(errors).toEqual(new Set(['first error', 'second %s error']));
155
+ });
156
+
157
+ it('collects Error objects for unminifyable errors', () => {
158
+ const errors = new Set();
159
+ const code = [
160
+ 'throw /* minify-error */ new Error(foo);',
161
+ 'throw /* minify-error */ new Error(...bar);',
162
+ ].join('\n');
163
+
164
+ transformSync(code, {
165
+ filename: '/test/file.js',
166
+ plugins: [[plugin, { collectErrors: errors }]],
167
+ configFile: false,
168
+ babelrc: false,
169
+ });
170
+
171
+ const collected = Array.from(errors);
172
+ expect(collected).toHaveLength(2);
173
+ expect(collected[0]).toBeInstanceOf(Error);
174
+ expect(collected[0].message).toMatch(
175
+ /Unminifyable error. You can only use literal strings and template strings as error messages./,
176
+ );
177
+ expect(collected[1]).toBeInstanceOf(Error);
178
+ expect(collected[1].message).toMatch(
179
+ /Unminifyable error. You can only use literal strings and template strings as error messages./,
180
+ );
181
+ });
182
+
183
+ it('continues collection past unminifyable errors', () => {
184
+ const errors = new Set();
185
+ const code = [
186
+ 'throw /* minify-error */ new Error(foo);',
187
+ 'throw /* minify-error */ new Error("valid error message");',
188
+ ].join('\n');
189
+
190
+ transformSync(code, {
191
+ filename: '/test/file.js',
192
+ plugins: [[plugin, { collectErrors: errors }]],
193
+ configFile: false,
194
+ babelrc: false,
195
+ });
196
+
197
+ const collected = Array.from(errors);
198
+ expect(collected).toHaveLength(2);
199
+ expect(collected[0]).toBeInstanceOf(Error);
200
+ expect(collected[1]).toBe('valid error message');
201
+ });
202
+
203
+ it('respects detection option when collecting errors', () => {
204
+ const errors = new Set();
205
+ const code = ['throw new Error("opted-in error");', 'throw new Error("not collected");'].join(
206
+ '\n',
207
+ );
208
+
209
+ transformSync(code, {
210
+ filename: '/test/file.js',
211
+ plugins: [[plugin, { collectErrors: errors, detection: 'opt-out' }]],
212
+ configFile: false,
213
+ babelrc: false,
214
+ });
215
+
216
+ expect(errors).toEqual(new Set(['opted-in error', 'not collected']));
217
+ });
218
+ });
219
+
220
+ describe('minify-errors double-visitation fix', () => {
221
+ // Separate test for the double-visitation bug fix
222
+ // This test uses @babel/core directly because it requires specific preset configuration
223
+ // that triggers the double-visitation issue (preset-env with modules: 'commonjs' + React.forwardRef)
224
+ it('handles double visitation with preset-env commonjs modules', () => {
225
+ // This pattern (React.forwardRef) combined with @babel/preset-env modules: 'commonjs'
226
+ // causes Babel to visit the same NewExpression node multiple times.
227
+ // Without the fix, this would result in both a FIXME annotation AND a minified error.
228
+ //
229
+ // NOTE: We use detection: 'opt-out' to properly trigger the bug, because with opt-in,
230
+ // the /* minify-error */ comment gets removed on the first pass, which masks the issue.
231
+ const input = `
232
+ import * as React from 'react';
233
+
234
+ export const Component = React.forwardRef(function Component(props, ref) {
235
+ if (!props.store) {
236
+ throw new Error('Component requires a store prop');
237
+ }
238
+ return <div ref={ref}>{props.children}</div>;
239
+ });
240
+ `;
241
+
242
+ const result = babel.transformSync(input, {
243
+ filename: path.join(fixturePath, 'commonjs-double-visit', 'test.js'),
244
+ configFile: false,
245
+ babelrc: false,
246
+ presets: [
247
+ ['@babel/preset-env', { modules: 'commonjs' }],
248
+ ['@babel/preset-react', { runtime: 'automatic' }],
249
+ ],
250
+ plugins: [
251
+ [
252
+ plugin,
253
+ {
254
+ errorCodesPath: path.join(fixturePath, 'commonjs-double-visit', 'error-codes.json'),
255
+ runtimeModule: '@mui/utils/formatMuiErrorMessage',
256
+ detection: 'opt-out', // Use opt-out to properly trigger the bug
257
+ },
258
+ ],
259
+ ],
260
+ });
261
+
262
+ // Key assertions:
263
+ // 1. Output should NOT contain FIXME annotation (which would indicate improper double processing)
264
+ expect(result?.code).not.toContain('FIXME');
265
+
266
+ // 2. Output should contain the properly minified error with NODE_ENV conditional
267
+ expect(result?.code).toContain('process.env.NODE_ENV !== "production"');
268
+
269
+ // 3. Output should contain the error code call (the import name varies based on babel helper)
270
+ expect(result?.code).toMatch(/_formatMuiErrorMessage.*\(1\)/);
271
+ });
272
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-babel-plugin-minify-errors",
3
- "version": "2.0.8-canary.2",
3
+ "version": "2.0.8-canary.20",
4
4
  "author": "MUI Team",
5
5
  "description": "This is an internal package not meant for general use.",
6
6
  "repository": {
@@ -17,22 +17,22 @@
17
17
  "url": "https://opencollective.com/mui-org"
18
18
  },
19
19
  "dependencies": {
20
- "@babel/helper-module-imports": "^7.27.1",
20
+ "@babel/helper-module-imports": "^7.28.6",
21
21
  "find-package-json": "^1.2.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@babel/core": "^7.22.0",
25
- "@types/babel__core": "^7.20.5",
26
- "@types/babel__helper-module-imports": "^7.18.3",
27
- "@types/find-package-json": "^1.2.6",
28
- "babel-plugin-tester": "^12.0.0",
29
- "chai": "^4.5.0"
24
+ "@babel/core": "7.28.6",
25
+ "@types/babel__core": "7.20.5",
26
+ "@types/babel__helper-module-imports": "7.18.3",
27
+ "@types/find-package-json": "1.2.7",
28
+ "babel-plugin-tester": "12.0.0"
30
29
  },
31
30
  "peerDependencies": {
32
31
  "@babel/core": "7"
33
32
  },
34
33
  "sideEffects": false,
35
34
  "type": "commonjs",
35
+ "main": "./index.js",
36
36
  "exports": {
37
37
  ".": "./index.js"
38
38
  },
@@ -42,6 +42,6 @@
42
42
  "publishConfig": {
43
43
  "access": "public"
44
44
  },
45
- "gitSha": "8391aed9c1a47151a520e3bfea6db8c730fdadd3",
45
+ "gitSha": "2836960cc7e128ab529942129c49aeffb8a0b76d",
46
46
  "scripts": {}
47
47
  }