diff --git a/development/webpack/utils/loaders/reactCompilerLoader.ts b/development/webpack/utils/loaders/reactCompilerLoader.ts deleted file mode 100644 index 9edd16594498..000000000000 --- a/development/webpack/utils/loaders/reactCompilerLoader.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - type ReactCompilerLoaderOption, - defineReactCompilerLoaderOption, - reactCompilerLoader, -} from 'react-compiler-webpack'; -import type { Logger } from 'babel-plugin-react-compiler'; - -/** - * React Compiler logger that tracks compilation statistics - */ -class ReactCompilerLogger { - private compiledCount = 0; - - private skippedCount = 0; - - private errorCount = 0; - - private todoCount = 0; - - private compiledFiles: string[] = []; - - private skippedFiles: string[] = []; - - private errorFiles: string[] = []; - - private todoFiles: string[] = []; - - logEvent( - filename: string | null, - event: { kind: string; detail: { options: { category: string } } }, - ) { - if (filename === null) { - return; - } - const { options: errorDetails } = event.detail ?? {}; - switch (event.kind) { - case 'CompileSuccess': - this.compiledCount++; - this.compiledFiles.push(filename); - console.log(`āœ… Compiled: ${filename}`); - break; - case 'CompileSkip': - this.skippedCount++; - this.skippedFiles.push(filename); - break; - case 'CompileError': - // This error is thrown for syntax that is not yet supported by the React Compiler. - // We count these separately as "unsupported" errors, since there's no actionable fix we can apply. - if (errorDetails?.category === 'Todo') { - this.todoCount++; - this.todoFiles.push(filename); - break; - } - this.errorCount++; - this.errorFiles.push(filename); - console.error( - `āŒ React Compiler error in ${filename}: ${errorDetails ? JSON.stringify(errorDetails) : 'Unknown error'}`, - ); - break; - default: - break; - } - } - - getStats() { - return { - compiled: this.compiledCount, - skipped: this.skippedCount, - errors: this.errorCount, - unsupported: this.todoCount, - total: - this.compiledCount + - this.skippedCount + - this.errorCount + - this.todoCount, - compiledFiles: this.compiledFiles, - skippedFiles: this.skippedFiles, - errorFiles: this.errorFiles, - unsupportedFiles: this.todoFiles, - }; - } - - logSummary() { - const stats = this.getStats(); - console.log('\nšŸ“Š React Compiler Statistics:'); - console.log(` āœ… Compiled: ${stats.compiled} files`); - console.log(` ā­ļø Skipped: ${stats.skipped} files`); - console.log(` āŒ Errors: ${stats.errors} files`); - console.log(` šŸ” Unsupported: ${stats.unsupported} files`); - console.log(` šŸ“¦ Total processed: ${stats.total} files`); - } - - /** - * Reset all statistics. Should be called after each build in watch mode - * to prevent accumulation across rebuilds. - */ - reset() { - this.compiledCount = 0; - this.skippedCount = 0; - this.errorCount = 0; - this.todoCount = 0; - this.compiledFiles = []; - this.skippedFiles = []; - this.errorFiles = []; - this.todoFiles = []; - } -} - -const reactCompilerLogger = new ReactCompilerLogger(); - -/** - * Get the React Compiler logger singleton instance to access statistics. - */ -export function getReactCompilerLogger(): ReactCompilerLogger { - return reactCompilerLogger; -} - -/** - * Get the React Compiler loader. - * - * @param target - The target version of the React Compiler. - * @param verbose - Whether to enable verbose mode. - * @param debug - The debug level to use. - * - 'all': Fail build on and display debug information for all compilation errors. - * - 'critical': Fail build on and display debug information only for critical compilation errors. - * - 'none': Prevent build from failing. - * @returns The React Compiler loader object with the loader and configured options. - */ -export const getReactCompilerLoader = ( - target: ReactCompilerLoaderOption['target'], - verbose: boolean, - debug: 'all' | 'critical' | 'none', -) => { - const reactCompilerOptions = { - target, - logger: verbose ? (reactCompilerLogger as Logger) : undefined, - panicThreshold: debug === 'none' ? undefined : `${debug}_errors`, - } as const satisfies ReactCompilerLoaderOption; - - return { - loader: reactCompilerLoader, - options: defineReactCompilerLoaderOption(reactCompilerOptions), - }; -}; diff --git a/development/webpack/utils/plugins/ReactCompilerPlugin/index.ts b/development/webpack/utils/plugins/ReactCompilerPlugin/index.ts index 231b0b595529..9b6dfcb5c1f9 100644 --- a/development/webpack/utils/plugins/ReactCompilerPlugin/index.ts +++ b/development/webpack/utils/plugins/ReactCompilerPlugin/index.ts @@ -1,13 +1,168 @@ import type { Compiler } from 'webpack'; -import { getReactCompilerLogger } from '../../loaders/reactCompilerLoader'; +import { + defineReactCompilerLoaderOption, + reactCompilerLoader, + type ReactCompilerLoaderOption, +} from 'react-compiler-webpack'; +import { validate } from 'schema-utils'; +import { schema } from './schema'; +import { ReactCompilerLogger } from './logger'; +const NAME = 'ReactCompilerPlugin'; + +/** + * Default test pattern for matching React component files. + * Matches .js, .jsx, .ts, .tsx, .mjs, .mts files, + * excluding test, stories, and container files. + */ +const DEFAULT_TEST = + /(?:.(?!\.(?:test|stories|container)))+\.(?:m?[jt]s|[jt]sx)$/u; + +/** + * Default options for the ReactCompilerPlugin. + */ +const defaultOptions = { + verbose: false, + debug: 'none', + test: DEFAULT_TEST, +} as const; + +/** + * Webpack plugin that integrates React Compiler with logging and path filtering. + * + * This plugin provides: + * - Automatic setup of the React Compiler webpack loader + * - Configurable include/exclude patterns for file filtering + * - Compilation statistics tracking and reporting + * - Schema validation for plugin options + * - Full support for all babel-plugin-react-compiler options + * + * @example + * // Basic usage + * const reactCompilerPlugin = new ReactCompilerPlugin({ + * target: '17', + * verbose: true, + * debug: 'critical', + * include: /ui\/components/, + * exclude: /\.test\./, + * }); + * + * // Add to webpack config + * module.exports = { + * plugins: [reactCompilerPlugin], + * module: { + * rules: [reactCompilerPlugin.getLoaderRule()], + * }, + * }; + * @example + * // With gating + * const reactCompilerPlugin = new ReactCompilerPlugin({ + * target: '18', + * gating: { + * source: 'my-gating-module', + * importSpecifierName: 'isCompilerEnabled', + * }, + * }); + * @example + * // With compilation mode + * const reactCompilerPlugin = new ReactCompilerPlugin({ + * target: '19', + * compilationMode: 'annotation', // Only compile annotated functions + * }); + */ export class ReactCompilerPlugin { + private readonly options: ReactCompilerLoaderOption; + + private readonly logger: ReactCompilerLogger; + + /** + * Default test pattern for matching React component files. + * Exposed for use in custom configurations. + */ + static readonly defaultTest = DEFAULT_TEST; + + constructor(options: ReactCompilerLoaderOption) { + validate(schema, options, { name: NAME }); + + this.options = { + ...defaultOptions, + ...options, + }; + this.logger = new ReactCompilerLogger(); + } + + /** + * Returns the webpack loader configuration for the React Compiler. + * Use this in your module.rules array. + * + * @returns A webpack RuleSetRule configured for React Compiler + */ + getLoaderRule() { + const { + target, + verbose, + debug, + include, + exclude, + test, + // Compiler options + compilationMode, + gating, + dynamicGating, + noEmit, + eslintSuppressionRules, + flowSuppressions, + ignoreUseNoForget, + customOptOutDirectives, + sources, + enableReanimatedCheck, + // Babel options + babelTransformOptions, + } = this.options; + + const reactCompilerOptions = defineReactCompilerLoaderOption({ + target, + logger: verbose ? this.logger : undefined, + panicThreshold: debug === 'none' ? undefined : `${debug}_errors`, + // Pass through additional compiler options if provided + ...(compilationMode !== undefined && { compilationMode }), + ...(gating !== undefined && { gating }), + ...(dynamicGating !== undefined && { dynamicGating }), + ...(noEmit !== undefined && { noEmit }), + ...(eslintSuppressionRules !== undefined && { eslintSuppressionRules }), + ...(flowSuppressions !== undefined && { flowSuppressions }), + ...(ignoreUseNoForget !== undefined && { ignoreUseNoForget }), + ...(customOptOutDirectives !== undefined && { customOptOutDirectives }), + ...(sources !== undefined && { sources }), + ...(enableReanimatedCheck !== undefined && { enableReanimatedCheck }), + ...(babelTransformOptions !== undefined && { + babelTransFormOpt: babelTransformOptions, + }), + }); + + return { + test, + ...(include !== undefined && { include }), + ...(exclude !== undefined && { exclude }), + use: [ + { + loader: reactCompilerLoader, + options: reactCompilerOptions, + }, + ], + }; + } + + /** + * Webpack plugin apply method. + * Hooks into the compilation lifecycle to log summaries and reset stats. + * + * @param compiler - The webpack compiler instance + */ apply(compiler: Compiler): void { compiler.hooks.afterEmit.tap(ReactCompilerPlugin.name, () => { - const logger = getReactCompilerLogger(); - logger.logSummary(); - // Reset statistics after logging to prevent accumulation in watch mode - logger.reset(); + this.logger.logSummary(); + this.logger.reset(); }); } } diff --git a/development/webpack/utils/plugins/ReactCompilerPlugin/logger.ts b/development/webpack/utils/plugins/ReactCompilerPlugin/logger.ts new file mode 100644 index 000000000000..82c76bff7369 --- /dev/null +++ b/development/webpack/utils/plugins/ReactCompilerPlugin/logger.ts @@ -0,0 +1,161 @@ +import type { LoggerEvent, Logger } from 'babel-plugin-react-compiler'; + +// Re-export Logger type for convenience +export type { Logger } from 'babel-plugin-react-compiler'; + +/** + * Logger for tracking React Compiler compilation statistics. + * Implements the `Logger` interface from babel-plugin-react-compiler. + * + * Tracks: + * - Successful compilations (CompileSuccess events) + * - Skipped files (CompileSkip events) + * - Compilation errors (CompileError events) + * - Unsupported syntax (CompileError events with 'Todo' category) + */ +export class ReactCompilerLogger implements Logger { + private compiledCount = 0; + + private skippedCount = 0; + + private errorCount = 0; + + private todoCount = 0; + + private compiledFiles: string[] = []; + + private skippedFiles: string[] = []; + + private errorFiles: string[] = []; + + private todoFiles: string[] = []; + + /** + * Log a compilation event from the React Compiler. + * This method implements the `Logger.logEvent` interface. + * + * @param filename - The path of the file being compiled + * @param event - The compilation event from the React Compiler + */ + logEvent(filename: string | null, event: LoggerEvent): void { + if (filename === null) { + return; + } + + switch (event.kind) { + case 'CompileSuccess': + this.compiledCount++; + this.compiledFiles.push(filename); + console.log(`āœ… Compiled: ${filename}`); + break; + + case 'CompileSkip': + this.skippedCount++; + this.skippedFiles.push(filename); + break; + + case 'CompileError': { + const { detail } = event; + // Check if the error is a "Todo" category (unsupported syntax) + // This error is thrown for syntax that is not yet supported by the React Compiler. + // We count these separately as "unsupported" errors, since there's no actionable fix. + const category = + 'category' in detail ? detail.category : detail.options?.category; + if (category === 'Todo') { + this.todoCount++; + this.todoFiles.push(filename); + break; + } + this.errorCount++; + this.errorFiles.push(filename); + console.error( + `āŒ React Compiler error in ${filename}: ${ + detail + ? JSON.stringify( + 'reason' in detail ? detail.reason : detail, + null, + 2, + ) + : 'Unknown error' + }`, + ); + break; + } + + case 'CompileDiagnostic': + // Diagnostics are informational, we don't track them in stats + // but could log them in verbose mode if needed + break; + + case 'PipelineError': + // Pipeline errors are internal compiler errors + this.errorCount++; + this.errorFiles.push(filename); + console.error( + `āŒ React Compiler pipeline error in ${filename}: ${event.data}`, + ); + break; + + case 'Timing': + case 'AutoDepsDecorations': + case 'AutoDepsEligible': + // These are informational events, not tracked in basic stats + break; + + default: + // Exhaustive check - TypeScript will error if we miss a case + break; + } + } + + /** + * Get the current compilation statistics. + * + * @returns Object containing counts and file lists for each category + */ + getStats() { + return { + compiled: this.compiledCount, + skipped: this.skippedCount, + errors: this.errorCount, + unsupported: this.todoCount, + total: + this.compiledCount + + this.skippedCount + + this.errorCount + + this.todoCount, + compiledFiles: this.compiledFiles, + skippedFiles: this.skippedFiles, + errorFiles: this.errorFiles, + unsupportedFiles: this.todoFiles, + }; + } + + /** + * Log a summary of compilation statistics to the console. + */ + logSummary(): void { + const stats = this.getStats(); + console.log('\nšŸ“Š React Compiler Statistics:'); + console.log(` āœ… Compiled: ${stats.compiled} files`); + console.log(` ā­ļø Skipped: ${stats.skipped} files`); + console.log(` āŒ Errors: ${stats.errors} files`); + console.log(` šŸ” Unsupported: ${stats.unsupported} files`); + console.log(` šŸ“¦ Total processed: ${stats.total} files`); + } + + /** + * Reset all statistics to initial state. + * Called at the start of each compilation to ensure accurate per-build stats. + */ + reset(): void { + this.compiledCount = 0; + this.skippedCount = 0; + this.errorCount = 0; + this.todoCount = 0; + this.compiledFiles = []; + this.skippedFiles = []; + this.errorFiles = []; + this.todoFiles = []; + } +} diff --git a/development/webpack/utils/plugins/ReactCompilerPlugin/schema.ts b/development/webpack/utils/plugins/ReactCompilerPlugin/schema.ts new file mode 100644 index 000000000000..9033b7e393cb --- /dev/null +++ b/development/webpack/utils/plugins/ReactCompilerPlugin/schema.ts @@ -0,0 +1,239 @@ +import type { ExtendedJSONSchema } from 'json-schema-to-ts'; + +/** + * Reusable schema for webpack RuleSetCondition. + * Supports string, RegExp, array, function, or logical condition objects. + */ +const ruleSetConditionSchema = { + oneOf: [ + { type: 'string' }, + { instanceof: 'RegExp', tsType: 'RegExp' }, + { + type: 'array', + items: { + oneOf: [ + { type: 'string' }, + { instanceof: 'RegExp', tsType: 'RegExp' }, + ], + }, + }, + { instanceof: 'Function', tsType: '((value: string) => boolean)' }, + { + type: 'object', + properties: { + and: { type: 'array' }, + or: { type: 'array' }, + not: {}, + }, + additionalProperties: false, + }, + ], +} as const; + +/** + * Schema for ExternalFunction. + */ +const externalFunctionSchema = { + type: 'object', + required: ['source', 'importSpecifierName'], + properties: { + source: { + description: 'Module source path for the external function.', + type: 'string', + }, + importSpecifierName: { + description: 'Name of the import specifier.', + type: 'string', + }, + }, + additionalProperties: false, +} as const; + +/** + * Schema for DynamicGatingOptions. + */ +const dynamicGatingSchema = { + type: 'object', + required: ['source'], + properties: { + source: { + description: 'Module source path for dynamic gating.', + type: 'string', + }, + }, + additionalProperties: false, +} as const; + +/** + * Schema for MetaInternalTarget (internal use only). + */ +const metaInternalTargetSchema = { + type: 'object', + required: ['kind', 'runtimeModule'], + properties: { + kind: { + type: 'string', + const: 'donotuse_meta_internal', + }, + runtimeModule: { + description: 'Custom runtime module path.', + type: 'string', + }, + }, + additionalProperties: false, +} as const; + +/** + * JSON Schema for validating ReactCompilerPlugin options. + * Uses schema-utils for runtime validation of plugin configuration. + * + * This schema validates options that map to: + * - `ReactCompilerLoaderOption` from react-compiler-webpack + * - `PluginOptions` from babel-plugin-react-compiler + * - Webpack RuleSetRule conditions (test, include, exclude) + */ +export const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + required: ['target'], + properties: { + // Core compiler options + target: { + description: + 'The target React version for the compiler. Should match the React version used in your project.', + oneOf: [ + { + type: 'string', + enum: ['17', '18', '19'], + }, + metaInternalTargetSchema, + ], + }, + verbose: { + description: + 'Enable verbose logging of compilation events. When enabled, logs each successful compilation and errors to console, and prints a summary after each build.', + type: 'boolean', + default: false, + }, + debug: { + description: + "Debug level for build failure behavior. 'all': Fail on all errors. 'critical': Fail only on critical errors. 'none': Never fail. Maps to panicThreshold internally.", + type: 'string', + enum: ['all', 'critical', 'none'], + default: 'none', + }, + compilationMode: { + description: + "Compilation mode for the React Compiler. 'syntax': Only compile functions with explicit directive. 'infer': Infer memoization based on heuristics. 'annotation': Compile functions with annotations. 'all': Compile all functions.", + type: 'string', + enum: ['syntax', 'infer', 'annotation', 'all'], + }, + + // Gating options + gating: { + description: + 'Gating function configuration. When set, the compiler will use this function to gate compiled output.', + oneOf: [externalFunctionSchema, { type: 'null' }], + }, + dynamicGating: { + description: + 'Dynamic gating options. Allows runtime gating of compiled code.', + oneOf: [dynamicGatingSchema, { type: 'null' }], + }, + + // Emit options + noEmit: { + description: + 'When true, the compiler will not emit any output. Useful for validation-only runs.', + type: 'boolean', + default: false, + }, + + // Suppression options + eslintSuppressionRules: { + description: + 'ESLint suppression rules to recognize. Array of ESLint rule names that should be treated as React Compiler suppressions.', + oneOf: [ + { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + }, + { type: 'null' }, + ], + }, + flowSuppressions: { + description: 'Enable Flow suppression comment recognition.', + type: 'boolean', + default: false, + }, + ignoreUseNoForget: { + description: + 'Ignore "use no forget" directive. When true, the compiler will process functions even if they have this directive.', + type: 'boolean', + default: false, + }, + customOptOutDirectives: { + description: + 'Custom opt-out directives. Array of custom directive strings that should disable compilation.', + oneOf: [ + { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + }, + { type: 'null' }, + ], + }, + + // Source filtering + sources: { + description: + 'Source file patterns to include. Array of glob patterns or a function that returns true for files to compile.', + oneOf: [ + { + type: 'array', + items: { type: 'string' }, + }, + { + instanceof: 'Function', + tsType: '((filename: string) => boolean)', + }, + { type: 'null' }, + ], + }, + + // Library compatibility + enableReanimatedCheck: { + description: 'Enable Reanimated library compatibility check.', + type: 'boolean', + default: false, + }, + + // Babel options + babelTransformOptions: { + description: + 'Babel transform options to pass to the underlying Babel transform. Allows customization of the Babel compilation process.', + type: 'object', + additionalProperties: true, + }, + + // Webpack RuleSetRule options + include: { + description: + 'Regex pattern or patterns to include files for compilation. Files must match at least one pattern to be processed.', + ...ruleSetConditionSchema, + }, + exclude: { + description: + 'Regex pattern or patterns to exclude files from compilation. Files matching any pattern will be skipped.', + ...ruleSetConditionSchema, + }, + test: { + description: + 'Test pattern for matching files to be processed by the loader. Defaults to match .js, .jsx, .ts, .tsx, .mjs, .mts files, excluding test, stories, and container files.', + ...ruleSetConditionSchema, + }, + }, + additionalProperties: false, +} satisfies ExtendedJSONSchema>; diff --git a/development/webpack/webpack.config.ts b/development/webpack/webpack.config.ts index 093cf8b0319e..c790a03b0ee7 100644 --- a/development/webpack/webpack.config.ts +++ b/development/webpack/webpack.config.ts @@ -34,9 +34,9 @@ import { transformManifest } from './utils/plugins/ManifestPlugin/helpers'; import { parseArgv, getDryRunMessage } from './utils/cli'; import { getCodeFenceLoader } from './utils/loaders/codeFenceLoader'; import { getSwcLoader } from './utils/loaders/swcLoader'; -import { getReactCompilerLoader } from './utils/loaders/reactCompilerLoader'; import { getVariables } from './utils/config'; import { ManifestPlugin } from './utils/plugins/ManifestPlugin'; +import { ReactCompilerPlugin } from './utils/plugins/ReactCompilerPlugin'; import { getLatestCommit } from './utils/git'; const buildTypes = loadBuildTypesConfig(); @@ -95,6 +95,12 @@ const cache = args.cache // #region plugins const commitHash = isDevelopment ? getLatestCommit().hash() : null; +const reactCompilerPlugin = new ReactCompilerPlugin({ + target: '17', + verbose: args.reactCompilerVerbose, + debug: args.reactCompilerDebug, + include: UI_DIR_RE, +}); const plugins: WebpackPluginInstance[] = [ // HtmlBundlerPlugin treats HTML files as entry points new HtmlBundlerPlugin({ @@ -191,6 +197,7 @@ const plugins: WebpackPluginInstance[] = [ : []), ], }), + reactCompilerPlugin, ]; // MV2 requires self-injection if (MANIFEST_VERSION === 2) { @@ -213,12 +220,6 @@ if (args.progress) { const { ProgressPlugin } = require('webpack'); plugins.push(new ProgressPlugin()); } -if (args.reactCompilerVerbose) { - const { - ReactCompilerPlugin, - } = require('./utils/plugins/ReactCompilerPlugin'); - plugins.push(new ReactCompilerPlugin()); -} // #endregion plugins @@ -227,11 +228,6 @@ const tsxLoader = getSwcLoader('typescript', true, safeVariables, swcConfig); const jsxLoader = getSwcLoader('ecmascript', true, safeVariables, swcConfig); const npmLoader = getSwcLoader('ecmascript', false, {}, swcConfig); const cjsLoader = getSwcLoader('ecmascript', false, {}, swcConfig, 'commonjs'); -const reactCompilerLoader = getReactCompilerLoader( - '17', - args.reactCompilerVerbose, - args.reactCompilerDebug, -); const config = { entry, @@ -334,11 +330,7 @@ const config = { dependency: 'url', type: 'asset/resource', }, - { - test: /^(?!.*\.(?:test|stories|container)\.)(?:.*)\.(?:m?[jt]s|[jt]sx)$/u, - include: UI_DIR_RE, - use: [reactCompilerLoader], - }, + reactCompilerPlugin.getLoaderRule(), // own typescript, and own typescript with jsx { test: /\.(?:ts|mts|tsx)$/u,