From 0a0c05518b785624ec69e70f6fbad5567fca080d Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 6 Apr 2026 15:51:48 -0700 Subject: [PATCH 01/23] Add variadic positional arg support to CLI utility Allow the last positional arg in a CLI config to be marked `variadic: true`, which collects all remaining positional args into a string[]. The type system correctly infers string[] for variadic args. Made-with: Cursor --- scripts/utils/CLI.ts | 29 ++++++++- tests/unit/CLIVariadicTest.ts | 113 ++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 tests/unit/CLIVariadicTest.ts diff --git a/scripts/utils/CLI.ts b/scripts/utils/CLI.ts index 49b844637ab5..0ee7c4687103 100644 --- a/scripts/utils/CLI.ts +++ b/scripts/utils/CLI.ts @@ -34,14 +34,16 @@ type StringArg = CLIArg & { /** * A positional argument is just a string arg, but also must be assigned a name which we will eventually expose the CLI consumer. + * If `variadic` is true, this must be the last positional arg and it collects all remaining positional args into a string[]. */ type PositionalArg = StringArg & { name: string; + variadic?: boolean; }; /** * This type represents the config for a CLI. - * Note: this utility does not yet support variadic args of any kind. + * The last positional arg can be marked `variadic: true` to collect all remaining positional args into a string[]. */ type CLIConfig = NonEmptyObject<{ /** @@ -87,9 +89,10 @@ type ParsedNamedArgs = { /** * Record of positional args after parsing. + * Variadic args are parsed as string[]; all others use InferStringArgParsedValue. */ type ParsedPositionalArgs = { - [K in NonNullable[number] as K['name']]: InferStringArgParsedValue; + [K in NonNullable[number] as K['name']]: K extends {variadic: true} ? string[] : InferStringArgParsedValue; }; /** @@ -217,6 +220,19 @@ class CLI { if (spec === undefined) { throw new Error(`Unexpected arg: ${rawArg}`); } + if (spec.variadic) { + // Variadic: collect this and all remaining non-flag args into an array + const collected: string[] = []; + for (let j = i; j < rawArgs.length; j++) { + const remaining = rawArgs.at(j); + if (remaining === undefined || remaining.startsWith('--')) { + break; + } + collected.push(remaining); + } + parsedPositionalArgs[spec.name as keyof typeof parsedPositionalArgs] = collected as ValueOf; + break; + } parsedPositionalArgs[spec.name as keyof typeof parsedPositionalArgs] = CLI.parseStringArg(rawArg, spec.name, spec) as ValueOf; positionalIndex++; } @@ -265,6 +281,8 @@ class CLI { if (!(spec.name in parsedPositionalArgs)) { if (spec.default !== undefined) { parsedPositionalArgs[spec.name as keyof typeof parsedPositionalArgs] = spec.default as ValueOf; + } else if (spec.variadic) { + parsedPositionalArgs[spec.name as keyof typeof parsedPositionalArgs] = [] as ValueOf; } else { throw new Error(`Missing required positional argument --${spec.name}`); } @@ -291,7 +309,12 @@ class CLI { private printHelp(): void { const {flags = {}, namedArgs = {}, positionalArgs = []} = this.config; const scriptName = process.argv.at(1) ?? 'script.ts'; - const positionalUsage = positionalArgs.map((arg) => (arg.default === undefined ? `<${arg.name}>` : `[${arg.name}]`)).join(' '); + const positionalUsage = positionalArgs + .map((arg) => { + const label = arg.variadic ? `${arg.name}...` : arg.name; + return arg.default === undefined ? `<${label}>` : `[${label}]`; + }) + .join(' '); const namedArgUsage = Object.keys(namedArgs) .map((key) => `[--${key} ]`) .join(' '); diff --git a/tests/unit/CLIVariadicTest.ts b/tests/unit/CLIVariadicTest.ts new file mode 100644 index 000000000000..ab70039c8369 --- /dev/null +++ b/tests/unit/CLIVariadicTest.ts @@ -0,0 +1,113 @@ +import CLI from '../../scripts/utils/CLI'; + +const originalArgv = process.argv; +beforeEach(() => { + jest.spyOn(process, 'exit').mockImplementation(() => undefined as never); +}); + +afterEach(() => { + process.argv = originalArgv; + jest.restoreAllMocks(); +}); + +function setArgv(...args: string[]) { + process.argv = ['node', 'script.ts', ...args]; +} + +describe('CLI variadic positional args', () => { + it('collects multiple positional args into a string[]', () => { + setArgv('check', 'file1.ts', 'file2.tsx', 'file3.jsx'); + + const cli = new CLI({ + positionalArgs: [ + { + name: 'command', + description: 'Command to run', + }, + { + name: 'files', + description: 'Files to check', + variadic: true, + default: [], + }, + ], + }); + + expect(cli.positionalArgs.command).toBe('check'); + expect(cli.positionalArgs.files).toEqual(['file1.ts', 'file2.tsx', 'file3.jsx']); + }); + + it('returns an empty array when no variadic args are provided', () => { + setArgv('check'); + + const cli = new CLI({ + positionalArgs: [ + { + name: 'command', + description: 'Command to run', + }, + { + name: 'files', + description: 'Files to check', + variadic: true, + default: [], + }, + ], + }); + + expect(cli.positionalArgs.command).toBe('check'); + expect(cli.positionalArgs.files).toEqual([]); + }); + + it('works with flags alongside variadic args', () => { + setArgv('--verbose', 'check', 'file1.ts', 'file2.ts'); + + const cli = new CLI({ + flags: { + verbose: {description: 'Enable verbose output'}, + }, + positionalArgs: [ + { + name: 'command', + description: 'Command to run', + }, + { + name: 'files', + description: 'Files to check', + variadic: true, + default: [], + }, + ], + }); + + expect(cli.flags.verbose).toBe(true); + expect(cli.positionalArgs.command).toBe('check'); + expect(cli.positionalArgs.files).toEqual(['file1.ts', 'file2.ts']); + }); + + it('works with named args alongside variadic args', () => { + setArgv('check', '--remote', 'upstream', 'file1.ts'); + + const cli = new CLI({ + namedArgs: { + remote: {description: 'Git remote', default: 'origin'}, + }, + positionalArgs: [ + { + name: 'command', + description: 'Command to run', + }, + { + name: 'files', + description: 'Files to check', + variadic: true, + default: [], + }, + ], + }); + + expect(cli.namedArgs.remote).toBe('upstream'); + expect(cli.positionalArgs.command).toBe('check'); + expect(cli.positionalArgs.files).toEqual(['file1.ts']); + }); +}); From 3561670de7e7da7196396804f5c7531e5ca14c51 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 6 Apr 2026 15:52:48 -0700 Subject: [PATCH 02/23] Add getChangedFilesWithStatus() with paginated GitHub API support New method returns {filename, status}[] using paginated GitHub API in CI and git diff locally. Refactors getChangedFileNames() to be a lightweight wrapper around it, fixing a pagination bug where large PRs could have files silently dropped. Made-with: Cursor --- scripts/utils/Git.ts | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/scripts/utils/Git.ts b/scripts/utils/Git.ts index 523ef31cf8c0..aff0adcae7f6 100644 --- a/scripts/utils/Git.ts +++ b/scripts/utils/Git.ts @@ -458,22 +458,42 @@ class Git { } } - static async getChangedFileNames(fromRef: string, toRef?: string, shouldIncludeUntrackedFiles = false): Promise { + /** + * Get changed files with their status (added, modified, removed, renamed). + * In CI, uses the GitHub API with pagination for accuracy. + * Locally, uses git diff against the provided ref. + */ + static async getChangedFilesWithStatus( + fromRef: string, + toRef?: string, + shouldIncludeUntrackedFiles = false, + ): Promise> { if (IS_CI) { - const {data: changedFiles} = await GitHubUtils.octokit.pulls.listFiles({ + const files = await GitHubUtils.paginate(GitHubUtils.octokit.pulls.listFiles, { owner: CONST.GITHUB_OWNER, repo: CONST.APP_REPO, // eslint-disable-next-line @typescript-eslint/naming-convention pull_number: context.payload.pull_request?.number ?? 0, + // eslint-disable-next-line @typescript-eslint/naming-convention + per_page: 100, }); - return changedFiles.map((file) => file.filename); + return files.map((file) => ({ + filename: file.filename, + status: file.status as 'added' | 'modified' | 'removed' | 'renamed', + })); } - // Get the diff output and check status const diffResult = this.diff(fromRef, toRef, undefined, shouldIncludeUntrackedFiles); - const files = diffResult.files.map((file) => file.filePath); - return files; + return diffResult.files.map((file) => ({ + filename: file.filePath, + status: file.diffType, + })); + } + + static async getChangedFileNames(fromRef: string, toRef?: string, shouldIncludeUntrackedFiles = false): Promise { + const files = await this.getChangedFilesWithStatus(fromRef, toRef, shouldIncludeUntrackedFiles); + return files.map((file) => file.filename); } /** @@ -582,5 +602,7 @@ class Git { } } +type ChangedFile = {filename: string; status: 'added' | 'modified' | 'removed' | 'renamed'}; + export default Git; -export type {DiffResult, FileDiff, DiffHunk, DiffLine}; +export type {DiffResult, FileDiff, DiffHunk, DiffLine, ChangedFile}; From 087844721cbfc3c76b544d2cd5d007b653716bfb Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 6 Apr 2026 15:54:05 -0700 Subject: [PATCH 03/23] Extract shared ReactCompilerConfig to eliminate duplication The compiler config was duplicated in babel.config.js and the eslint-plugin-react-compiler-compat. Now both import from a single source of truth at scripts/utils/reactCompilerConfig.js. Made-with: Cursor --- babel.config.js | 7 +++---- eslint-plugin-react-compiler-compat/index.mjs | 10 +--------- scripts/utils/reactCompilerConfig.js | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 scripts/utils/reactCompilerConfig.js diff --git a/babel.config.js b/babel.config.js index 66094c956fac..7c2edc3dd37e 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,10 +1,9 @@ require('dotenv').config(); +const BaseReactCompilerConfig = require('./scripts/utils/reactCompilerConfig'); + const ReactCompilerConfig = { - target: '19', - environment: { - enableTreatRefLikeIdentifiersAsRefs: true, - }, + ...BaseReactCompilerConfig, sources: (filename) => !filename.includes('tests/') && !filename.includes('node_modules/'), }; diff --git a/eslint-plugin-react-compiler-compat/index.mjs b/eslint-plugin-react-compiler-compat/index.mjs index aca2fc6d1eff..580bbc70272d 100644 --- a/eslint-plugin-react-compiler-compat/index.mjs +++ b/eslint-plugin-react-compiler-compat/index.mjs @@ -13,6 +13,7 @@ */ import {transformSync} from '@babel/core'; import _ from 'lodash'; +import ReactCompilerConfig from '../scripts/utils/reactCompilerConfig.js'; // Rules that are entirely unnecessary when React Compiler successfully compiles // all functions in a file. Add more rules here as needed. @@ -24,15 +25,6 @@ const RULES_SUPPRESSED_BY_REACT_COMPILER = new Set(['react/jsx-no-constructed-co // about genuinely missing dependencies. const EXHAUSTIVE_DEPS_USECALLBACK_USEMEMO_PATTERN = /\buseCallback\(\) Hook\b|\buseMemo\(\) Hook\b/; -// Mirror the compiler config from babel.config.js (excluding `sources`, -// which is only relevant for the Babel build pipeline, not for our analysis). -const ReactCompilerConfig = { - target: '19', - environment: { - enableTreatRefLikeIdentifiersAsRefs: true, - }, -}; - // Per-file compilation results, populated in preprocess, consumed in postprocess. const compilationResults = new Map(); diff --git a/scripts/utils/reactCompilerConfig.js b/scripts/utils/reactCompilerConfig.js new file mode 100644 index 000000000000..7f084769d768 --- /dev/null +++ b/scripts/utils/reactCompilerConfig.js @@ -0,0 +1,16 @@ +/** + * Shared React Compiler configuration used across: + * - babel.config.js (build pipeline, extends with `sources` filter) + * - eslint-plugin-react-compiler-compat (lint-time analysis) + * - react-compiler-compliance-check (CI and local checking) + * + * Intentionally omits `sources` since that's only relevant for the Babel build pipeline. + */ +const ReactCompilerConfig = { + target: '19', + environment: { + enableTreatRefLikeIdentifiersAsRefs: true, + }, +}; + +module.exports = ReactCompilerConfig; From 4510c18aebb909e0a9714340249178d426f7b78e Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 6 Apr 2026 15:56:38 -0700 Subject: [PATCH 04/23] Rewrite react-compiler compliance check to use babel transform directly Replace the 860-line script that shelled out to react-compiler-healthcheck and parsed stdout with regexes. The new implementation uses @babel/core's transformSync with babel-plugin-react-compiler directly (~200 lines). Two modes: - `check ` -- local CLI for checking specific files - `check-changed` -- CI mode with two rules: 1. New files with components/hooks must compile 2. Modified files must not regress vs main Uses a three-state return (compiled/failed/no-components) to correctly skip non-React files without false positives. Made-with: Cursor --- scripts/react-compiler-compliance-check.ts | 926 ++++-------------- .../unit/ReactCompilerComplianceCheckTest.ts | 63 ++ 2 files changed, 232 insertions(+), 757 deletions(-) create mode 100644 tests/unit/ReactCompilerComplianceCheckTest.ts diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 716b466404e2..4d06db9984f1 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -1,859 +1,271 @@ #!/usr/bin/env ts-node -/* eslint-disable max-classes-per-file */ /** - * React Compiler Compliance Checker + * React Compiler Compliance Check * - * This script tracks which components can be compiled by React Compiler and which cannot. - * It provides both CI and local development tools to enforce Rules of React compliance. + * Checks whether React components and hooks compile with React Compiler. + * Two modes: + * - `check ` -- check specific files, report per-file status + * - `check-changed` -- check files changed in a PR, enforce two rules: + * 1. New files with components/hooks must compile + * 2. Modified files must not regress (compiled on main -> must compile on PR) */ -import {execSync} from 'child_process'; -import fs, {readFileSync} from 'fs'; +import {transformSync} from '@babel/core'; +import fs from 'fs'; import path from 'path'; -import type {TupleToUnion} from 'type-fest'; import CLI from './utils/CLI'; -import EslintUtils from './utils/EslintUtils'; -import FileUtils from './utils/FileUtils'; import Git from './utils/Git'; -import type {DiffResult} from './utils/Git'; -import {log, bold as logBold, error as logError, info as logInfo, note as logNote, success as logSuccess, warn as logWarn} from './utils/Logger'; - -type CompilerResults = { - success: Set; - errors: Map; - errorsForAddedFiles: Map; - errorsForModifiedFiles: Map; - manualMemoErrors: Map; - suppressedErrors: Map; -}; - -type CompilerError = { - file: string; - line: number; - column: number; - reason?: string; -}; - -type ManualMemoizationError = { - keyword: string; - line: number; - column: number; -}; - -type CheckMode = 'static' | 'incremental'; - -type BaseCheckParameters = { - remote?: string; - verbose?: boolean; -}; - -type CheckParameters = BaseCheckParameters & { - mode?: CheckMode; - files?: string[]; -}; - -type CheckOptions = { - mode: CheckMode; - verbose: boolean; -}; +import {log, error as logError, info as logInfo, success as logSuccess, warn as logWarn} from './utils/Logger'; -/** - * Handles running react-compiler-healthcheck and parsing its output. - */ -class ReactCompilerHealthcheck { - private static readonly SUPPRESSED_ERRORS = [ - // This error is caused by an internal limitation of React Compiler - // https://github.com/facebook/react/issues/29583 - '(BuildHIR::lowerExpression) Expected Identifier, got MemberExpression key in ObjectExpression', - ] as const; - - private static readonly OUTPUT_REGEXES = { - SUCCESS: /Successfully compiled (?:hook|component) \[([^\]]+)\]\(([^)]+)\)/, - ERROR_WITH_REASON: /Failed to compile ([^:]+):(\d+):(\d+)\. Reason: (.+)/, - ERROR_WITHOUT_REASON: /Failed to compile ([^:]+):(\d+):(\d+)\./, - REASON: /Reason: (.+)/, - } as const; - - /** - * Run the react-compiler-healthcheck CLI tool and parse its output. - */ - static run(src?: string): CompilerResults { - const srcArg = src ? `--src "${src}"` : ''; - const output = execSync(`npx react-compiler-healthcheck ${srcArg} --verbose`, { - encoding: 'utf8', - cwd: process.cwd(), - }); - - return this.parseOutput(output); - } - - /** - * Parse the output of the react-compiler-healthcheck command. - */ - private static parseOutput(output: string): CompilerResults { - const lines = output.split('\n'); - - const results: CompilerResults = { - success: new Set(), - errors: new Map(), - errorsForAddedFiles: new Map(), - errorsForModifiedFiles: new Map(), - manualMemoErrors: new Map(), - suppressedErrors: new Map(), - }; - - let currentErrorWithoutReason: CompilerError | null = null; - - for (const line of lines) { - const successMatch = line.match(this.OUTPUT_REGEXES.SUCCESS); - if (successMatch) { - const filePath = successMatch[2]; - results.success.add(filePath); - continue; - } - - const errorWithReasonMatch = line.match(this.OUTPUT_REGEXES.ERROR_WITH_REASON); - if (errorWithReasonMatch) { - const newError: CompilerError = { - file: errorWithReasonMatch[1], - line: parseInt(errorWithReasonMatch[2], 10), - column: parseInt(errorWithReasonMatch[3], 10), - reason: errorWithReasonMatch[4], - }; - - currentErrorWithoutReason = null; - - if (this.shouldSuppressError(newError.reason)) { - this.addOrUpdateError(results.suppressedErrors, newError); - continue; - } - - this.addOrUpdateError(results.errors, newError); - } - - const errorWithoutReasonMatch = line.match(this.OUTPUT_REGEXES.ERROR_WITHOUT_REASON); - if (errorWithoutReasonMatch) { - const newError: CompilerError = { - file: errorWithoutReasonMatch[1], - line: parseInt(errorWithoutReasonMatch[2], 10), - column: parseInt(errorWithoutReasonMatch[3], 10), - }; - - currentErrorWithoutReason = newError; - this.addOrUpdateError(results.errors, newError); - continue; - } - - const reasonMatch = line.match(this.OUTPUT_REGEXES.REASON); - if (reasonMatch && currentErrorWithoutReason) { - const reason = reasonMatch[1]; - - const currentError: CompilerError = { - file: currentErrorWithoutReason.file, - line: currentErrorWithoutReason.line, - column: currentErrorWithoutReason.column, - reason, - }; - - currentErrorWithoutReason = null; - - if (this.shouldSuppressError(reason)) { - this.addOrUpdateError(results.suppressedErrors, currentError); - continue; - } - - this.addOrUpdateError(results.errors, currentError); - } - } +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment +const ReactCompilerConfig = require('./utils/reactCompilerConfig'); - results.success = new Set(Array.from(results.success).sort((a, b) => a.localeCompare(b))); - results.errors = this.sortErrors(results.errors); - results.suppressedErrors = this.sortErrors(results.suppressedErrors); - - return results; - } - - private static addOrUpdateError(errorMap: Map, newError: CompilerError): boolean { - const key = this.getErrorKey(newError); - const existingError = errorMap.get(key); - - if (existingError) { - const isReasonSet = !!existingError.reason; - const isNewReasonSet = !!newError.reason; - if (!isReasonSet && isNewReasonSet) { - errorMap.set(key, newError); - return true; - } +type CompilationResult = 'compiled' | 'failed' | 'no-components'; - return false; - } +const FILE_EXTENSIONS = ['.ts', '.tsx', '.jsx']; - errorMap.set(key, newError); - return true; - } +/** + * Check if a source string compiles with React Compiler. + * Returns a three-state result indicating compilation success, failure, or no React code found. + */ +function checkReactCompilerCompliance(source: string, filename: string): CompilationResult { + let hasError = false; + let hasSuccess = false; - private static sortErrors(errors: Map) { - const arr = Array.from(errors.entries()); - arr.sort(([, a], [, b]) => { - const keyA = this.getErrorKey(a); - const keyB = this.getErrorKey(b); - return keyA.localeCompare(keyB); + try { + transformSync(source, { + filename, + ast: false, + code: false, + configFile: false, + babelrc: false, + parserOpts: { + plugins: ['typescript', 'jsx'], + }, + plugins: [ + [ + 'babel-plugin-react-compiler', + { + ...ReactCompilerConfig, + panicThreshold: 'none', + noEmit: true, + logger: { + logEvent(_filename: string, event: {kind: string}) { + if (event.kind === 'CompileError') { + hasError = true; + } + if (event.kind === 'CompileSuccess') { + hasSuccess = true; + } + }, + }, + }, + ], + ], }); - return new Map(arr); + } catch { + hasError = true; } - private static shouldSuppressError(reason: string | undefined): boolean { - if (!reason) { - return false; - } - - return this.SUPPRESSED_ERRORS.some((suppressedError) => reason.includes(suppressedError)); + if (hasError) { + return 'failed'; } - - static getErrorKey({file, line, column}: CompilerError): string { - const isLineSet = line !== undefined; - const isLineAndColumnSet = isLineSet && column !== undefined; - - return file + (isLineSet ? `:${line}` : '') + (isLineAndColumnSet ? `:${column}` : ''); - } - - static getErrorsByFile(errors: Map) { - const errorsByFile = new Map>(); - for (const [key, error] of errors.entries()) { - if (!errorsByFile.has(error.file)) { - errorsByFile.set(error.file, new Map()); - } - errorsByFile.get(error.file)?.set(key, error); - } - - const filesWithErrors = new Set(errorsByFile.keys()); - - return { - errorsByFile, - filesWithErrors, - }; + if (hasSuccess) { + return 'compiled'; } + return 'no-components'; } -/** - * Analyzes git diffs to filter compiler results to only changed lines. - */ -class DiffAnalyzer { - private static readonly ESLINT_LINT_RULES = ['react-hooks'] as const; - - /** - * Filter compiler results to only include errors for lines that were changed in the git diff. - */ - static async filterResultsByDiff(results: CompilerResults, mainBaseCommitHash: string, diffResult: DiffResult, {verbose}: CheckOptions): Promise { - logInfo(`Filtering results by diff between ${mainBaseCommitHash} and the working tree...`); - - if (!diffResult.hasChanges) { - return { - success: new Set(), - errors: new Map(), - errorsForAddedFiles: new Map(), - errorsForModifiedFiles: new Map(), - manualMemoErrors: new Map(), - suppressedErrors: new Map(), - }; - } - - const changedLinesMap = this.buildChangedLinesMap(diffResult); - const {filesWithEslintDisable, linesWithEslintDisableNextLine} = this.detectEslintDisables(diffResult); - - const filterErrorsByChangedLines = (errors: Map) => { - const filteredErrors = new Map(); - - for (const [key, error] of errors) { - const changedLines = changedLinesMap.get(error.file); - - if (!changedLines) { - continue; - } - - if (filesWithEslintDisable.has(error.file)) { - filteredErrors.set(key, error); - continue; - } - - if (error.line !== undefined) { - const isLineChanged = changedLines.has(error.line); - const isLineEslintDisabled = linesWithEslintDisableNextLine.get(error.file)?.has(error.line); - - if (isLineChanged || isLineEslintDisabled) { - filteredErrors.set(key, error); - } - continue; - } - - filteredErrors.set(key, error); - } - - return filteredErrors; - }; - - const filteredErrors = filterErrorsByChangedLines(results.errors); - const filteredSuppressedErrors = filterErrorsByChangedLines(results.suppressedErrors); - - const changedFiles = new Set(diffResult.files.map((file) => file.filePath)); - const filteredSuccesses = new Set(); - for (const file of results.success) { - if (!changedFiles.has(file)) { - continue; - } - filteredSuccesses.add(file); - } - - if (filteredErrors.size === 0) { - logInfo('No errors remain after filtering by diff.'); - } else { - logInfo(`${filteredErrors.size} out of ${results.errors.size} errors remain after filtering by diff.`); - } - - if (verbose) { - if (filteredSuppressedErrors.size === 0) { - logInfo('No suppressed errors remain after filtering by diff.'); - } else { - logInfo(`${filteredSuppressedErrors.size} out of ${results.suppressedErrors.size} successes remain after filtering by diff.`); - } - - if (filteredSuccesses.size === 0) { - logInfo('No successes remain after filtering by diff.'); - } else { - logInfo(`${filteredSuccesses.size} out of ${results.success.size} successes remain after filtering by diff.`); - } - } - - return { - success: filteredSuccesses, - errors: filteredErrors, - errorsForAddedFiles: new Map(), - errorsForModifiedFiles: new Map(), - manualMemoErrors: new Map(), - suppressedErrors: filteredSuppressedErrors, - }; - } - - private static buildChangedLinesMap(diffResult: DiffResult): Map> { - const changedLinesMap = new Map>(); - - for (const file of diffResult.files) { - const changedLines = new Set([...file.addedLines, ...file.modifiedLines]); - changedLinesMap.set(file.filePath, changedLines); - } - - return changedLinesMap; - } - - private static detectEslintDisables(diffResult: DiffResult): { - filesWithEslintDisable: Set; - linesWithEslintDisableNextLine: Map>; - } { - const filesWithEslintDisable = new Set(); - const linesWithEslintDisableNextLine = new Map>(); - - for (const file of diffResult.files) { - for (const hunk of file.hunks) { - for (const line of hunk.lines) { - if (EslintUtils.hasEslintDisableComment(line.content, true, [...this.ESLINT_LINT_RULES])) { - filesWithEslintDisable.add(file.filePath); - } - - if (EslintUtils.hasEslintDisableComment(line.content, false, [...this.ESLINT_LINT_RULES])) { - if (!linesWithEslintDisableNextLine.has(file.filePath)) { - linesWithEslintDisableNextLine.set(file.filePath, new Set()); - } - - const disabledLines = linesWithEslintDisableNextLine.get(file.filePath); - if (!disabledLines) { - continue; - } - - const reactCompilerErrorLineNumber = line.type === 'removed' ? line.number + hunk.newCount : line.number + hunk.newCount + 1; - disabledLines.add(reactCompilerErrorLineNumber); - } - } - } - } - - return {filesWithEslintDisable, linesWithEslintDisableNextLine}; - } +function isReactFile(filePath: string): boolean { + return FILE_EXTENSIONS.some((ext) => filePath.endsWith(ext)); } /** - * Checks for manual memoization in files that should use automatic memoization. + * Check specific files and report per-file status. */ -class ManualMemoizationChecker { - static readonly PATTERNS = { - memo: /\b(?:React\.)?memo\s*\(/g, - useMemo: /\b(?:React\.)?useMemo\s*\(/g, - useCallback: /\b(?:React\.)?useCallback\s*\(/g, - } as const; - - private static readonly FILE_EXTENSIONS = ['.tsx', '.jsx'] as const; - - private static readonly NO_MEMO_DIRECTIVE_PATTERN = /["']use no memo["']\s*;?/; - - static getErrorMessage(keyword: keyof typeof this.PATTERNS): string { - return `Found a manual memoization usage of \`${keyword}\`. Newly added React component files must not contain any manual memoization and instead be auto-memoized by React Compiler. Remove \`${keyword}\` or disable automatic memoization by adding the \`"use no memo";\` directive at the beginning of the component and give a reason why automatic memoization is not applicable.`; - } - - /** - * Split errors by file diff type and check for manual memoization violations. - */ - static splitErrorsBasedOnFileDiffType({success, errors: reactCompilerErrors}: CompilerResults, diffResult: DiffResult) { - const {filesWithErrors, errorsByFile} = ReactCompilerHealthcheck.getErrorsByFile(reactCompilerErrors); - - const {addedFiles, enforcedAutoMemoFiles} = this.categorizeFiles(diffResult, success, filesWithErrors); - - const reactCompilerErrorsForModifiedFiles = reactCompilerErrors; - const reactCompilerErrorsForAddedFiles = new Map(); - - for (const file of filesWithErrors) { - if (enforcedAutoMemoFiles.has(file)) { - continue; - } - - const errors = errorsByFile.get(file); - - if (addedFiles.has(file)) { - for (const [errorKey, error] of errors?.entries() ?? []) { - reactCompilerErrorsForAddedFiles.set(errorKey, error); - reactCompilerErrors.delete(errorKey); - } - } - } - - const manualMemoErrors = this.findViolations(enforcedAutoMemoFiles); - - return { - manualMemoErrors, - reactCompilerErrorsForModifiedFiles, - reactCompilerErrorsForAddedFiles, - }; - } - - private static categorizeFiles( - diffResult: DiffResult, - successFiles: Set, - filesWithErrors: Set, - ): { - addedFiles: Set; - enforcedAutoMemoFiles: Set; - } { - const enforcedAutoMemoFiles = new Set(); - const addedFiles = new Set(); - - for (const file of diffResult.files) { - const filePath = file.filePath; - - filesWithErrors.add(filePath); - - const isAddedFile = file.diffType === 'added'; - if (isAddedFile) { - addedFiles.add(filePath); - } - - const isReactComponentSourceFile = this.FILE_EXTENSIONS.some((extension) => filePath.endsWith(extension)); - const isSuccessfullyCompiled = successFiles.has(filePath); +function checkFiles(files: string[], verbose: boolean): boolean { + let hasFailure = false; - if (isReactComponentSourceFile && isSuccessfullyCompiled && isAddedFile) { - enforcedAutoMemoFiles.add(filePath); + for (const file of files) { + if (!isReactFile(file)) { + if (verbose) { + logInfo(`SKIPPED ${file} (not a .ts/.tsx/.jsx file)`); } + continue; } - return {addedFiles, enforcedAutoMemoFiles}; - } - - private static findViolations(files: Set): Map { - const manualMemoErrors = new Map(); - - for (const file of files) { - let source: string | null = null; - try { - const absolutePath = path.join(process.cwd(), file); - source = readFileSync(absolutePath, 'utf8'); - } catch (error) { - logWarn(`Unable to read ${file} while enforcing new component rules.`, error); - } - - if (!source || this.NO_MEMO_DIRECTIVE_PATTERN.test(source)) { - continue; - } - - const manualMemoizationMatches = this.findMatches(source); - - if (manualMemoizationMatches.length === 0) { - continue; - } - - manualMemoErrors.set(file, manualMemoizationMatches); + const absolutePath = path.resolve(file); + if (!fs.existsSync(absolutePath)) { + logWarn(`SKIPPED ${file} (file not found)`); + continue; } - return manualMemoErrors; - } - - private static findMatches(source: string): ManualMemoizationError[] { - const matches: ManualMemoizationError[] = []; - - for (const keyword of Object.keys(this.PATTERNS) as Array) { - const regex = this.PATTERNS[keyword]; - const regexMatches = source.matchAll(regex); + const source = fs.readFileSync(absolutePath, 'utf8'); + const result = checkReactCompilerCompliance(source, absolutePath); - for (const regexMatch of regexMatches) { - const matchIndex = regexMatch.index; - if (matchIndex === undefined) { - continue; + switch (result) { + case 'compiled': + logSuccess(`COMPILED ${file}`); + break; + case 'failed': + logError(`FAILED ${file}`); + hasFailure = true; + break; + case 'no-components': + default: + if (verbose) { + logInfo(`SKIPPED ${file} (no components or hooks)`); } - const {line, column} = FileUtils.getLineAndColumnFromIndex(source, matchIndex); - matches.push({keyword, line, column}); - } + break; } - - matches.sort((a, b) => { - if (a.line !== b.line) { - return a.line - b.line; - } - return a.column - b.column; - }); - - return matches; } + + return !hasFailure; } /** - * Handles printing compiler results to the console. + * Check files changed in a PR for React Compiler compliance. + * Rule 1: New files with components/hooks must compile. + * Rule 2: Modified files must not regress (compiled on main -> must compile on PR). */ -class ResultsPrinter { - private static readonly TAB = ' '; - - /** - * Print all results and determine pass/fail status. - */ - static printResults({success, errorsForAddedFiles, errorsForModifiedFiles, suppressedErrors, manualMemoErrors}: CompilerResults, {verbose}: CheckOptions): boolean { - this.printSuccesses(success, verbose); - this.printSuppressedErrors(suppressedErrors, verbose); - - const {hasModifiedFilesErrors, hasAddedFilesErrors} = this.printCompilerErrors(errorsForModifiedFiles, errorsForAddedFiles); - const hasManualMemoErrors = this.printManualMemoErrors(manualMemoErrors); - - if ((hasModifiedFilesErrors || hasAddedFilesErrors) && !hasManualMemoErrors) { - log(); - } - - const didCheckForAddedFilesPass = errorsForAddedFiles.size === 0; - const isPassed = didCheckForAddedFilesPass && !hasManualMemoErrors; - - if (isPassed) { - if (hasModifiedFilesErrors) { - logWarn(`React Compiler compliance check passed with warnings! The warnings must NOT be fixed and can get ignored.`); - } +async function checkChangedFiles(remote: string, verbose: boolean): Promise { + const mainBaseCommitHash = await Git.getMainBranchCommitHash(remote); + const changedFiles = await Git.getChangedFilesWithStatus(mainBaseCommitHash); - logSuccess('React Compiler compliance check passed!'); - return true; - } - - log(); - logError( - `The files above failed the React Compiler compliance check. Do not remove any manual memoization patterns, unless a file is already able to compile with React Compiler. You can use the "React Compiler Marker" VS Code extension to check whether a file is being compiled with React Compiler.`, - ); + const reactFiles = changedFiles.filter((f) => isReactFile(f.filename) && f.status !== 'removed'); - return false; + if (reactFiles.length === 0) { + logSuccess('No React files changed, skipping check.'); + return true; } - private static printSuccesses(success: Set, verbose: boolean): void { - if (!verbose || success.size === 0) { - return; - } - - log(); - logSuccess(`Successfully compiled ${success.size} files with React Compiler:`); - log(); + logInfo(`Checking ${reactFiles.length} changed React files...`); - for (const successFile of success) { - logSuccess(`${successFile}`); - } + const failures: Array<{file: string; reason: string}> = []; - log(); - } - - private static printSuppressedErrors(suppressedErrors: Map, verbose: boolean): void { - if (!verbose || suppressedErrors.size === 0) { - return; + for (const {filename, status} of reactFiles) { + const absolutePath = path.resolve(filename); + if (!fs.existsSync(absolutePath)) { + continue; } - const suppressedErrorMap = new Map(); - for (const [, error] of suppressedErrors) { - if (!error.reason) { - continue; - } + const source = fs.readFileSync(absolutePath, 'utf8'); + const result = checkReactCompilerCompliance(source, absolutePath); - if (!suppressedErrorMap.has(error.reason)) { - suppressedErrorMap.set(error.reason, []); + if (status === 'added') { + if (result === 'failed') { + failures.push({file: filename, reason: 'New file contains components/hooks that fail to compile with React Compiler'}); + logError(`FAILED ${filename} (new file must compile)`); + } else if (verbose) { + const label = result === 'compiled' ? 'COMPILED' : 'SKIPPED '; + logSuccess(`${label} ${filename}`); } - - suppressedErrorMap.get(error.reason)?.push(error); + continue; } - log(); - logWarn(`Suppressed the following errors in these files:`); - log(); - - for (const [error, suppressedErrorFiles] of suppressedErrorMap) { - logBold(error); - const filesLine = suppressedErrorFiles.map((suppressedError) => ReactCompilerHealthcheck.getErrorKey(suppressedError)).join(', '); - logNote(`${this.TAB} - ${filesLine}`); - } - - log(); - } - - private static printCompilerErrors( - errorsForModifiedFiles: Map, - errorsForAddedFiles: Map, - ): {hasModifiedFilesErrors: boolean; hasAddedFilesErrors: boolean} { - const hasModifiedFilesErrors = errorsForModifiedFiles.size > 0; - const hasAddedFilesErrors = errorsForAddedFiles.size > 0; - - if (hasModifiedFilesErrors) { - const {filesWithErrors} = ReactCompilerHealthcheck.getErrorsByFile(errorsForModifiedFiles); - - if (filesWithErrors.size > 0) { - log(); - logWarn(`Failed to compile ${filesWithErrors.size} modified files with React Compiler:`); - log(); - - this.printErrors(errorsForModifiedFiles); + // Modified or renamed files: check for regression + if (result === 'failed') { + let mainResult: CompilationResult = 'no-components'; + try { + const mainSource = Git.show(`origin/main`, filename); + mainResult = checkReactCompilerCompliance(mainSource, filename); + } catch { + // File didn't exist on main (e.g. renamed from different path) -- treat as new + mainResult = 'no-components'; } - } - if (hasAddedFilesErrors) { - const {filesWithErrors} = ReactCompilerHealthcheck.getErrorsByFile(errorsForAddedFiles); - - if (filesWithErrors.size > 0) { - log(); - logError(`Failed to compile ${filesWithErrors.size} added files with React Compiler:`); - log(); - - this.printErrors(errorsForAddedFiles); + if (mainResult === 'compiled') { + failures.push({file: filename, reason: 'File compiled on main but fails to compile on this branch (regression)'}); + logError(`FAILED ${filename} (regression: compiled on main)`); + } else if (verbose) { + logWarn(`WARNING ${filename} (fails to compile, but also failed on main)`); } + } else if (verbose) { + const label = result === 'compiled' ? 'COMPILED' : 'SKIPPED '; + logSuccess(`${label} ${filename}`); } - - return {hasModifiedFilesErrors, hasAddedFilesErrors}; } - private static printManualMemoErrors(manualMemoErrors: Map): boolean { - const hasManualMemoErrors = manualMemoErrors.size > 0; - - if (!hasManualMemoErrors) { - return false; - } - + log(); + if (failures.length > 0) { + logError(`React Compiler compliance check failed with ${failures.length} error(s):`); log(); - logError(`The following newly added components should be auto memoized by the React Compiler (manual memoization is not allowed):`); - - for (const [filePath, manualMemoizationMatches] of manualMemoErrors) { - log(); - - for (const manualMemoizationMatch of manualMemoizationMatches) { - const location = manualMemoizationMatch.line && manualMemoizationMatch.column ? `:${manualMemoizationMatch.line}:${manualMemoizationMatch.column}` : ''; - logBold(`${filePath}${location}`); - logNote(`${this.TAB}${ManualMemoizationChecker.getErrorMessage(manualMemoizationMatch.keyword as keyof typeof ManualMemoizationChecker.PATTERNS)}`); - } - } - - return true; - } - - private static printErrors(errorsToPrint: Map, level = 0) { - for (const error of errorsToPrint.values()) { - const location = error.line && error.column ? `:${error.line}:${error.column}` : ''; - logBold(`${this.TAB.repeat(level)}${error.file}${location}`); - logNote(`${this.TAB.repeat(level + 1)}${error.reason ?? 'No reason provided'}`); + for (const {file, reason} of failures) { + logError(` ${file}`); + logInfo(` ${reason}`); } - } -} - -/** - * Generates JSON reports of compiler results. - */ -class ReportGenerator { - /** - * Generate a report and save it to /tmp. - */ - static generate({success, errors, suppressedErrors, manualMemoErrors, errorsForAddedFiles, errorsForModifiedFiles}: CompilerResults): void { - const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-'); - const reportFileName = `react-compiler-compliance-check-report-${timestamp}.json`; - const reportFile = path.join('/tmp', reportFileName); - - const resultsObject = { - success: Array.from(success), - errors: Object.fromEntries(errors.entries()), - errorsForAddedFiles: Object.fromEntries(errorsForAddedFiles.entries()), - errorsForModifiedFiles: Object.fromEntries(errorsForModifiedFiles.entries()), - manualMemoErrors: Object.fromEntries(manualMemoErrors.entries()), - suppressedErrors: Object.fromEntries(suppressedErrors.entries()), - } satisfies Record | string[]>; - - fs.writeFileSync( - reportFile, - JSON.stringify( - { - timestamp: new Date().toISOString(), - results: resultsObject, - }, - null, - 2, - ), - ); - log(); - logInfo(`Report saved to: ${reportFile}`); - } -} - -/** - * Main checker orchestrates the compliance check workflow. - */ -class Checker { - /** - * Check changed files for React Compiler compliance. - */ - static async checkChangedFiles({remote, ...restOptions}: BaseCheckParameters): Promise { - logInfo('Checking changed files for React Compiler compliance...'); - - const mainBaseCommitHash = await Git.getMainBranchCommitHash(remote); - const changedFiles = await Git.getChangedFileNames(mainBaseCommitHash, undefined, true); - - if (changedFiles.length === 0) { - logSuccess('No React files changed, skipping check.'); - return true; - } - - return this.check({mode: 'incremental', files: changedFiles, ...restOptions}); - } - - /** - * Check specific files or all files for React Compiler compliance. - */ - static async check({mode = 'static', files, remote, verbose = false}: CheckParameters): Promise { - const options: CheckOptions = {mode, verbose}; - - if (files) { - logInfo(`Running React Compiler check for ${files.length} files or glob patterns...`); - } else { - logInfo('Running React Compiler check for all files...'); - } - - let results = ReactCompilerHealthcheck.run(this.createFilesGlob(files)); - - const mainBaseCommitHash = await Git.getMainBranchCommitHash(remote); - const diffResult = Git.diff(mainBaseCommitHash, undefined, undefined, true); - - if (mode === 'incremental') { - results = await DiffAnalyzer.filterResultsByDiff(results, mainBaseCommitHash, diffResult, options); - } - - const {reactCompilerErrorsForModifiedFiles, reactCompilerErrorsForAddedFiles, manualMemoErrors} = ManualMemoizationChecker.splitErrorsBasedOnFileDiffType(results, diffResult); - - results.manualMemoErrors = manualMemoErrors; - results.errorsForAddedFiles = reactCompilerErrorsForAddedFiles; - results.errorsForModifiedFiles = reactCompilerErrorsForModifiedFiles; - - const isPassed = ResultsPrinter.printResults(results, options); - - ReportGenerator.generate(results); - - return isPassed; + logInfo('See contributingGuides/REACT_COMPILER.md for help fixing these errors.'); + return false; } - /** - * Create a glob pattern from an array of file paths. - */ - private static createFilesGlob(files?: string[]): string | undefined { - if (!files || files.length === 0) { - return undefined; - } - - if (files.length === 1) { - return files.at(0); - } - - return `**/+(${files.join('|')})`; - } + logSuccess('React Compiler compliance check passed!'); + return true; } const CLI_COMMANDS = ['check', 'check-changed'] as const; -type CliCommand = TupleToUnion; async function main() { const cli = new CLI({ positionalArgs: [ { name: 'command', - description: 'Command to run', - required: false, + description: 'Command to run (check or check-changed)', default: 'check', parse: (val) => { - if (!CLI_COMMANDS.includes(val as CliCommand)) { + if (!(CLI_COMMANDS as readonly string[]).includes(val)) { throw new Error(`Invalid command. Must be one of: ${CLI_COMMANDS.join(', ')}`); } return val; }, }, { - name: 'file', - description: 'File path or glob pattern to check', - required: false, - default: '', + name: 'files', + description: 'File paths to check (only for "check" command)', + variadic: true, + default: [], }, ], namedArgs: { remote: { - description: 'Git remote name to use for main branch (default: no remote locally and origin in CI)', + description: 'Git remote name (default: origin in CI, none locally)', required: false, - supersedes: ['check-changed'], }, }, flags: { verbose: { - description: 'Print logs of successes and suppressed errors', - required: false, - default: false, + description: 'Show detailed output including skipped files', }, }, }); - const {command, file} = cli.positionalArgs; + const {command, files} = cli.positionalArgs; const {remote} = cli.namedArgs; const {verbose} = cli.flags; - const commonOptions: BaseCheckParameters = { - verbose, - }; + let passed = false; - async function runCommand() { - switch (command) { - case 'check': - return Checker.check({files: file ? [file] : undefined, ...commonOptions}); - case 'check-changed': - return Checker.checkChangedFiles({remote, ...commonOptions}); - default: - logError(`Unknown command: ${String(command)}`); - return Promise.resolve(false); - } + switch (command) { + case 'check': + if (files.length === 0) { + logError('No files specified. Usage: npm run react-compiler-compliance-check check '); + process.exit(1); + } + passed = checkFiles(files, verbose); + break; + case 'check-changed': + passed = await checkChangedFiles(remote ?? 'origin', verbose); + break; + default: + logError(`Unknown command: ${String(command)}`); + process.exit(1); } - try { - const isPassed = await runCommand(); - process.exit(isPassed ? 0 : 1); - } catch (error) { - logError('Error running react-compiler-compliance-check:', error); - process.exit(1); - } + process.exit(passed ? 0 : 1); } if (require.main === module) { main(); } -export default Checker; +export {checkReactCompilerCompliance}; +export type {CompilationResult}; diff --git a/tests/unit/ReactCompilerComplianceCheckTest.ts b/tests/unit/ReactCompilerComplianceCheckTest.ts new file mode 100644 index 000000000000..43e4f6534616 --- /dev/null +++ b/tests/unit/ReactCompilerComplianceCheckTest.ts @@ -0,0 +1,63 @@ +import {checkReactCompilerCompliance} from '../../scripts/react-compiler-compliance-check'; + +describe('checkReactCompilerCompliance', () => { + it('returns compiled for a simple component', () => { + const source = ` + function MyComponent() { + return
Hello
; + } + `; + expect(checkReactCompilerCompliance(source, 'MyComponent.tsx')).toBe('compiled'); + }); + + it('returns compiled for a hook that compiles', () => { + const source = ` + import {useState} from 'react'; + function useMyHook() { + const [value, setValue] = useState(0); + return value; + } + `; + expect(checkReactCompilerCompliance(source, 'useMyHook.ts')).toBe('compiled'); + }); + + it('returns failed for a component with a Rules of React violation', () => { + const source = ` + import {useState} from 'react'; + function BadComponent({condition}) { + if (condition) { + const [value] = useState(0); + } + return
Bad
; + } + `; + expect(checkReactCompilerCompliance(source, 'BadComponent.tsx')).toBe('failed'); + }); + + it('returns no-components for a plain utility file', () => { + const source = ` + export function add(a: number, b: number): number { + return a + b; + } + export function subtract(a: number, b: number): number { + return a - b; + } + `; + expect(checkReactCompilerCompliance(source, 'mathUtils.ts')).toBe('no-components'); + }); + + it('returns no-components for a types-only file', () => { + const source = ` + export type User = { + id: string; + name: string; + email: string; + }; + export interface Settings { + theme: 'light' | 'dark'; + language: string; + } + `; + expect(checkReactCompilerCompliance(source, 'types.ts')).toBe('no-components'); + }); +}); From dc0d600a2c3313e34dbffdeadded438835d1385a Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 6 Apr 2026 15:56:54 -0700 Subject: [PATCH 05/23] Expand react-compiler workflow to cover .ts and .jsx files Hooks typically live in .ts files, not just .tsx. The path trigger now includes .ts, .tsx, and .jsx so the compliance check covers all files that could contain React components or hooks. Made-with: Cursor --- .github/workflows/react-compiler-compliance.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/react-compiler-compliance.yml b/.github/workflows/react-compiler-compliance.yml index 06cbf075e871..e35e45ec8c98 100644 --- a/.github/workflows/react-compiler-compliance.yml +++ b/.github/workflows/react-compiler-compliance.yml @@ -5,7 +5,7 @@ on: pull_request: types: [opened, synchronize] branches-ignore: [staging, production] - paths: ["**.tsx"] + paths: ["**.ts", "**.tsx", "**.jsx"] concurrency: group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-react-compiler-compliance From afbb4b7829959292c2b5574b60d1714be3df10ba Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 6 Apr 2026 15:57:26 -0700 Subject: [PATCH 06/23] Remove react-compiler-healthcheck dependency and patches The compliance check now uses babel-plugin-react-compiler directly, making the react-compiler-healthcheck package unnecessary. Made-with: Cursor --- package-lock.json | 86 ------------------ package.json | 1 - patches/react-compiler-healthcheck/details.md | 38 -------- ...001+add-verbose-error-logging-option.patch | 90 ------------------- ...-20241020+002+enable-ref-identifiers.patch | 28 ------ ...9.0.0-beta-8a03594-20241020+003+json.patch | 72 --------------- 6 files changed, 315 deletions(-) delete mode 100644 patches/react-compiler-healthcheck/details.md delete mode 100644 patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+001+add-verbose-error-logging-option.patch delete mode 100644 patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+002+enable-ref-identifiers.patch delete mode 100644 patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+003+json.patch diff --git a/package-lock.json b/package-lock.json index faa9bd849a04..ef9d908697b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -270,7 +270,6 @@ "peggy": "^4.0.3", "portfinder": "^1.0.34", "prettier": "3.7.4", - "react-compiler-healthcheck": "^19.0.0-beta-8a03594-20241020", "react-native-clean-project": "^4.0.0-alpha4.0", "react-refresh": "^0.14.2", "react-test-renderer": "19.2.0", @@ -34047,91 +34046,6 @@ "react": ">=16.3.0" } }, - "node_modules/react-compiler-healthcheck": { - "version": "19.0.0-beta-8a03594-20241020", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "chalk": "4", - "fast-glob": "^3.3.2", - "ora": "5.4.1", - "yargs": "^17.7.2", - "zod": "^3.22.4", - "zod-validation-error": "^3.0.3" - }, - "bin": { - "react-compiler-healthcheck": "dist/index.js" - }, - "engines": { - "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" - } - }, - "node_modules/react-compiler-healthcheck/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/react-compiler-healthcheck/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/react-compiler-healthcheck/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/react-compiler-healthcheck/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/react-compiler-healthcheck/node_modules/has-flag": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/react-compiler-healthcheck/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/react-content-loader": { "version": "7.0.0", "license": "MIT", diff --git a/package.json b/package.json index 44a6603c8598..2c0306d24147 100644 --- a/package.json +++ b/package.json @@ -333,7 +333,6 @@ "peggy": "^4.0.3", "portfinder": "^1.0.34", "prettier": "3.7.4", - "react-compiler-healthcheck": "^19.0.0-beta-8a03594-20241020", "react-native-clean-project": "^4.0.0-alpha4.0", "react-refresh": "^0.14.2", "react-test-renderer": "19.2.0", diff --git a/patches/react-compiler-healthcheck/details.md b/patches/react-compiler-healthcheck/details.md deleted file mode 100644 index aea119f68fe7..000000000000 --- a/patches/react-compiler-healthcheck/details.md +++ /dev/null @@ -1,38 +0,0 @@ -# `react-compiler-healthcheck` patches - -### [react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+001+add-verbose-error-logging-option.patch](react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+001+add-verbose-error-logging-option.patch) - -- Reason: - - ``` - This patch adds verbose error logging option. - ``` - -- Upstream PR/issue: https://github.com/facebook/react/pull/29080 and https://github.com/facebook/react/pull/29851 -- E/App issue: https://github.com/Expensify/App/issues/44384 -- PR introducing patch: https://github.com/Expensify/App/pull/44460 - -### [react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+002+enable-ref-identifiers.patch](react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+002+enable-ref-identifiers.patch) - -- Reason: - - ``` - This patch allows mutating refs in certain components. - ``` - -- Upstream PR/issue: https://github.com/facebook/react/pull/29916 -- E/App issue: Same as the PR. -- PR introducing patch: https://github.com/Expensify/App/pull/45464 - - -### [react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+003+json.patch](react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+003+json.patch) - -- Reason: - - ``` - This patch adds --json option to healthcheck CLI. - ``` - -- Upstream PR/issue: 🛑, commented in the App PR https://github.com/Expensify/App/pull/45915#issuecomment-3346345841 -- E/App issue: https://github.com/Expensify/App/pull/45464 -- PR introducing patch: https://github.com/Expensify/App/pull/45915 \ No newline at end of file diff --git a/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+001+add-verbose-error-logging-option.patch b/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+001+add-verbose-error-logging-option.patch deleted file mode 100644 index 03b386587338..000000000000 --- a/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+001+add-verbose-error-logging-option.patch +++ /dev/null @@ -1,90 +0,0 @@ -diff --git a/node_modules/react-compiler-healthcheck/dist/index.js b/node_modules/react-compiler-healthcheck/dist/index.js -index 5a4060d..460339b 100755 ---- a/node_modules/react-compiler-healthcheck/dist/index.js -+++ b/node_modules/react-compiler-healthcheck/dist/index.js -@@ -56969,7 +56969,7 @@ var reactCompilerCheck = { - compile(source, path); - } - }, -- report() { -+ report(verbose) { - const totalComponents = - SucessfulCompilation.length + - countUniqueLocInEvents(OtherFailures) + -@@ -56979,6 +56979,50 @@ var reactCompilerCheck = { - `Successfully compiled ${SucessfulCompilation.length} out of ${totalComponents} components.` - ) - ); -+ -+ if (verbose) { -+ for (const compilation of [...SucessfulCompilation, ...ActionableFailures, ...OtherFailures]) { -+ const filename = compilation.fnLoc?.filename; -+ -+ if (compilation.kind === "CompileSuccess") { -+ const name = compilation.fnName; -+ const isHook = name?.startsWith('use'); -+ -+ if (name) { -+ console.log( -+ chalk.green( -+ `Successfully compiled ${isHook ? "hook" : "component" } [${name}](${filename})` -+ ) -+ ); -+ } else { -+ console.log(chalk.green(`Successfully compiled ${compilation.fnLoc?.filename}`)); -+ } -+ } -+ -+ if (compilation.kind === "CompileError") { -+ const { reason, severity, loc } = compilation.detail; -+ -+ const lnNo = loc.start?.line; -+ const colNo = loc.start?.column; -+ -+ const isTodo = severity === ErrorSeverity.Todo; -+ -+ console.log( -+ chalk[isTodo ? 'yellow' : 'red']( -+ `Failed to compile ${ -+ filename -+ }${ -+ lnNo !== undefined ? `:${lnNo}${ -+ colNo !== undefined ? `:${colNo}` : "" -+ }.` : "" -+ }` -+ ), -+ chalk[isTodo ? 'yellow' : 'red'](reason? `Reason: ${reason}` : "") -+ ); -+ console.log("\n"); -+ } -+ } -+ } - }, - }; - const JsFileExtensionRE = /(js|ts|jsx|tsx)$/; -@@ -57015,9 +57059,16 @@ function main() { - type: 'string', - default: '**/+(*.{js,mjs,jsx,ts,tsx}|package.json)', - }) -+ .option('verbose', { -+ description: 'run with verbose logging', -+ type: 'boolean', -+ default: false, -+ alias: 'v', -+ }) - .parseSync(); - const spinner = ora('Checking').start(); - let src = argv.src; -+ let verbose = argv.verbose; - const globOptions = { - onlyFiles: true, - ignore: [ -@@ -57037,7 +57088,7 @@ function main() { - libraryCompatCheck.run(source, path); - } - spinner.stop(); -- reactCompilerCheck.report(); -+ reactCompilerCheck.report(verbose); - strictModeCheck.report(); - libraryCompatCheck.report(); - }); diff --git a/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+002+enable-ref-identifiers.patch b/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+002+enable-ref-identifiers.patch deleted file mode 100644 index 8ae46e379619..000000000000 --- a/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+002+enable-ref-identifiers.patch +++ /dev/null @@ -1,28 +0,0 @@ -diff --git a/node_modules/react-compiler-healthcheck/dist/index.js b/node_modules/react-compiler-healthcheck/dist/index.js -index 460339b..17b0f96 100755 ---- a/node_modules/react-compiler-healthcheck/dist/index.js -+++ b/node_modules/react-compiler-healthcheck/dist/index.js -@@ -56902,6 +56902,9 @@ const COMPILER_OPTIONS = { - noEmit: true, - compilationMode: 'infer', - panicThreshold: 'critical_errors', -+ environment: { -+ enableTreatRefLikeIdentifiersAsRefs: true, -+ }, - logger: logger, - }; - function isActionableDiagnostic(detail) { -diff --git a/node_modules/react-compiler-healthcheck/src/checks/reactCompiler.ts b/node_modules/react-compiler-healthcheck/src/checks/reactCompiler.ts -index 3094548..fd05b76 100644 ---- a/node_modules/react-compiler-healthcheck/src/checks/reactCompiler.ts -+++ b/node_modules/react-compiler-healthcheck/src/checks/reactCompiler.ts -@@ -50,6 +50,9 @@ const COMPILER_OPTIONS: Partial = { - noEmit: true, - compilationMode: 'infer', - panicThreshold: 'critical_errors', -+ environment: { -+ enableTreatRefLikeIdentifiersAsRefs: true, -+ }, - logger, - }; - diff --git a/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+003+json.patch b/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+003+json.patch deleted file mode 100644 index 246351351195..000000000000 --- a/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+003+json.patch +++ /dev/null @@ -1,72 +0,0 @@ -diff --git a/node_modules/react-compiler-healthcheck/dist/index.js b/node_modules/react-compiler-healthcheck/dist/index.js -index 17b0f96..e386e34 100755 ---- a/node_modules/react-compiler-healthcheck/dist/index.js -+++ b/node_modules/react-compiler-healthcheck/dist/index.js -@@ -56972,16 +56972,28 @@ var reactCompilerCheck = { - compile(source, path); - } - }, -- report(verbose) { -+ report(verbose, json) { - const totalComponents = - SucessfulCompilation.length + - countUniqueLocInEvents(OtherFailures) + - countUniqueLocInEvents(ActionableFailures); -- console.log( -- chalk.green( -- `Successfully compiled ${SucessfulCompilation.length} out of ${totalComponents} components.` -- ) -- ); -+ if (!json) { -+ console.log( -+ chalk.green( -+ `Successfully compiled ${SucessfulCompilation.length} out of ${totalComponents} components.` -+ ) -+ ); -+ } -+ -+ if (json) { -+ const extractFileName = (output) => output.fnLoc.filename; -+ const successfulFiles = SucessfulCompilation.map(extractFileName); -+ const unsuccessfulFiles = [...new Set([...OtherFailures, ...ActionableFailures].map(extractFileName))]; -+ console.log(JSON.stringify({ -+ success: successfulFiles, -+ failure: unsuccessfulFiles, -+ })); -+ } - - if (verbose) { - for (const compilation of [...SucessfulCompilation, ...ActionableFailures, ...OtherFailures]) { -@@ -57068,10 +57080,17 @@ function main() { - default: false, - alias: 'v', - }) -+ .option('json', { -+ description: 'print a list of compiled/not-compiled files as JSON', -+ type: 'boolean', -+ default: false, -+ alias: 'j', -+ }) - .parseSync(); - const spinner = ora('Checking').start(); - let src = argv.src; - let verbose = argv.verbose; -+ let json = argv.json; - const globOptions = { - onlyFiles: true, - ignore: [ -@@ -57091,9 +57110,11 @@ function main() { - libraryCompatCheck.run(source, path); - } - spinner.stop(); -- reactCompilerCheck.report(verbose); -- strictModeCheck.report(); -- libraryCompatCheck.report(); -+ reactCompilerCheck.report(verbose, json); -+ if (!json) { -+ strictModeCheck.report(); -+ libraryCompatCheck.report(); -+ } - }); - } - main(); From f20220c7b837c9a8448486730d2050957785c5c4 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 6 Apr 2026 15:58:00 -0700 Subject: [PATCH 07/23] Update React Compiler contributing guide for new compliance check Simplify docs to reflect the rewritten tool: direct babel transform, two CI rules (new files must compile, no regressions on modified files), and simpler CLI usage. Made-with: Cursor --- contributingGuides/REACT_COMPILER.md | 75 ++++++---------------------- 1 file changed, 16 insertions(+), 59 deletions(-) diff --git a/contributingGuides/REACT_COMPILER.md b/contributingGuides/REACT_COMPILER.md index 997b07d04dae..6f0b6ba581e2 100644 --- a/contributingGuides/REACT_COMPILER.md +++ b/contributingGuides/REACT_COMPILER.md @@ -8,82 +8,39 @@ At Expensify, we are early adopters of this tool and aim to fully leverage its c ## React Compiler compliance checker -We provide a script, `scripts/react-compiler-compliance-check.ts`, which checks for "Rules of React" compliance locally and enforces these in PRs adding or changing React code through a CI check. +We provide a script, `scripts/react-compiler-compliance-check.ts`, which checks whether React components and hooks compile with React Compiler. It runs in CI on every PR and can also be used locally for quick feedback. -### What it does +### How it works -Runs `react-compiler-healthcheck` in verbose mode, parses output, and summarizes which files compiled and which failed, including file, line, column, and reason. It can: +The script uses `@babel/core`'s `transformSync` with `babel-plugin-react-compiler` directly (no intermediate tools). For each file, the compiler reports whether components/hooks compiled successfully, failed, or weren't found. This produces a three-state result per file: `COMPILED`, `FAILED`, or `SKIPPED` (no components/hooks). -- Check all files or a specific file/glob -- Check only files changed relative to a base branch -- Optionally generate a machine-readable report `react-compiler-report.json` -- Exit with non-zero code when failures are found (useful for CI) +### CI enforcement (two rules) -### Usage - -> [!NOTE] -> This script uses `origin` as the base remote by default. If your GH remote is named differently, use the `--remote ` flag. - -#### Check entire codebase or a specific file/glob - -```bash -npm run react-compiler-compliance-check check # Check all files -npm run react-compiler-compliance-check check src/path/Component.tsx # Check specific file -npm run react-compiler-compliance-check check "src/**/*.tsx" # Check glob pattern -``` - -#### Check only changed files (against main) - -```bash -npm run react-compiler-compliance-check check-changed -``` - -#### Generate a detailed report (saved as `./react-compiler-report.json`) - -You can use the `--report` flag with both of the above commands: +The CI check (`check-changed`) enforces two rules on changed `.ts`, `.tsx`, and `.jsx` files: -```bash -npm run react-compiler-compliance-check check --report -npm run react-compiler-compliance-check check-changed --report -``` - -#### Additional flags - -**Filter by diff changes (`--filterByDiff`)** +1. **New files**: If a new file contains components or hooks that fail to compile, the check fails. +2. **Modified files**: If a file compiled successfully on `main` but fails on the PR branch, the check fails (regression). -Only check files that have been modified in the current diff. This is useful when you want to focus on files that have actual changes: - -```bash -npm run react-compiler-compliance-check check --filterByDiff -npm run react-compiler-compliance-check check-changed --filterByDiff -``` +Files with no React components or hooks are silently skipped. -**Print successful compilations (`--printSuccesses`)** +### Usage -By default, the script only shows compilation failures. Use this flag to also display files that compiled successfully: +#### Check specific files ```bash -npm run react-compiler-compliance-check check --printSuccesses -npm run react-compiler-compliance-check check-changed --printSuccesses +npm run react-compiler-compliance-check check src/components/Foo.tsx src/hooks/useBar.ts ``` -**Custom report filename (`--reportFileName`)** - -Specify a custom filename for the generated report instead of the default `react-compiler-report.json`: +#### Check changed files (CI mode, also works locally) ```bash -npm run react-compiler-compliance-check check --report --reportFileName my-custom-report.json -npm run react-compiler-compliance-check check-changed --report --reportFileName my-custom-report.json +npm run react-compiler-compliance-check check-changed ``` -**Custom remote name (`--remote`)** +#### Flags -By default, the script uses `origin` as the base remote. If your GitHub remote is named differently, specify it with this flag: - -```bash -npm run react-compiler-compliance-check check-changed --remote upstream -npm run react-compiler-compliance-check check --filterByDiff --remote my-remote -``` +- `--verbose` — Show detailed output including skipped files and files that compiled successfully. +- `--remote ` — Git remote name for the base branch (default: `origin`). ## How to fix a particular problem? From 79c28877e4ca7b90bf1a7bb46749d20dbe71fb9e Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 6 Apr 2026 16:04:18 -0700 Subject: [PATCH 08/23] Remove .jsx from file extension checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo has zero .jsx files — everything is TypeScript. Made-with: Cursor --- .github/workflows/react-compiler-compliance.yml | 2 +- contributingGuides/REACT_COMPILER.md | 2 +- scripts/react-compiler-compliance-check.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/react-compiler-compliance.yml b/.github/workflows/react-compiler-compliance.yml index e35e45ec8c98..13b38271e702 100644 --- a/.github/workflows/react-compiler-compliance.yml +++ b/.github/workflows/react-compiler-compliance.yml @@ -5,7 +5,7 @@ on: pull_request: types: [opened, synchronize] branches-ignore: [staging, production] - paths: ["**.ts", "**.tsx", "**.jsx"] + paths: ["**.ts", "**.tsx"] concurrency: group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-react-compiler-compliance diff --git a/contributingGuides/REACT_COMPILER.md b/contributingGuides/REACT_COMPILER.md index 6f0b6ba581e2..6e9bd259adae 100644 --- a/contributingGuides/REACT_COMPILER.md +++ b/contributingGuides/REACT_COMPILER.md @@ -16,7 +16,7 @@ The script uses `@babel/core`'s `transformSync` with `babel-plugin-react-compile ### CI enforcement (two rules) -The CI check (`check-changed`) enforces two rules on changed `.ts`, `.tsx`, and `.jsx` files: +The CI check (`check-changed`) enforces two rules on changed `.ts` and `.tsx` files: 1. **New files**: If a new file contains components or hooks that fail to compile, the check fails. 2. **Modified files**: If a file compiled successfully on `main` but fails on the PR branch, the check fails (regression). diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 4d06db9984f1..f55a111fe6e9 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -21,7 +21,7 @@ const ReactCompilerConfig = require('./utils/reactCompilerConfig'); type CompilationResult = 'compiled' | 'failed' | 'no-components'; -const FILE_EXTENSIONS = ['.ts', '.tsx', '.jsx']; +const FILE_EXTENSIONS = ['.ts', '.tsx']; /** * Check if a source string compiles with React Compiler. @@ -88,7 +88,7 @@ function checkFiles(files: string[], verbose: boolean): boolean { for (const file of files) { if (!isReactFile(file)) { if (verbose) { - logInfo(`SKIPPED ${file} (not a .ts/.tsx/.jsx file)`); + logInfo(`SKIPPED ${file} (not a .ts/.tsx file)`); } continue; } From d999ab0248c5af52917185cf47cd3681df047247 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 6 Apr 2026 16:08:53 -0700 Subject: [PATCH 09/23] Fix TypeScript error with variadic positional arg type inference Use `variadic?: true` literal type and explicit cast at the call site to work around TypeScript's inability to narrow conditional mapped types through tuple inference. Made-with: Cursor --- scripts/react-compiler-compliance-check.ts | 3 ++- scripts/utils/CLI.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index f55a111fe6e9..e59ee87efd4f 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -238,7 +238,8 @@ async function main() { }, }); - const {command, files} = cli.positionalArgs; + const {command} = cli.positionalArgs; + const files = cli.positionalArgs.files as string[]; const {remote} = cli.namedArgs; const {verbose} = cli.flags; diff --git a/scripts/utils/CLI.ts b/scripts/utils/CLI.ts index 0ee7c4687103..de8bfc0dfd22 100644 --- a/scripts/utils/CLI.ts +++ b/scripts/utils/CLI.ts @@ -38,7 +38,7 @@ type StringArg = CLIArg & { */ type PositionalArg = StringArg & { name: string; - variadic?: boolean; + variadic?: true; }; /** From d17c7bd6bd9427f40f61e2a2dd71a313cde2541f Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 6 Apr 2026 16:13:09 -0700 Subject: [PATCH 10/23] Move ReactCompilerConfig to config/babel/reactCompilerConfig.js Made-with: Cursor --- babel.config.js | 2 +- {scripts/utils => config/babel}/reactCompilerConfig.js | 0 eslint-plugin-react-compiler-compat/index.mjs | 5 ++++- scripts/react-compiler-compliance-check.ts | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) rename {scripts/utils => config/babel}/reactCompilerConfig.js (100%) diff --git a/babel.config.js b/babel.config.js index 7c2edc3dd37e..a95bcc0f9742 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,6 +1,6 @@ require('dotenv').config(); -const BaseReactCompilerConfig = require('./scripts/utils/reactCompilerConfig'); +const BaseReactCompilerConfig = require('./config/babel/reactCompilerConfig'); const ReactCompilerConfig = { ...BaseReactCompilerConfig, diff --git a/scripts/utils/reactCompilerConfig.js b/config/babel/reactCompilerConfig.js similarity index 100% rename from scripts/utils/reactCompilerConfig.js rename to config/babel/reactCompilerConfig.js diff --git a/eslint-plugin-react-compiler-compat/index.mjs b/eslint-plugin-react-compiler-compat/index.mjs index 580bbc70272d..2a1a60070583 100644 --- a/eslint-plugin-react-compiler-compat/index.mjs +++ b/eslint-plugin-react-compiler-compat/index.mjs @@ -13,7 +13,10 @@ */ import {transformSync} from '@babel/core'; import _ from 'lodash'; -import ReactCompilerConfig from '../scripts/utils/reactCompilerConfig.js'; +import {createRequire} from 'module'; + +const require = createRequire(import.meta.url); +const ReactCompilerConfig = require('../config/babel/reactCompilerConfig'); // Rules that are entirely unnecessary when React Compiler successfully compiles // all functions in a file. Add more rules here as needed. diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index e59ee87efd4f..00c57d58a0cc 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -17,7 +17,7 @@ import Git from './utils/Git'; import {log, error as logError, info as logInfo, success as logSuccess, warn as logWarn} from './utils/Logger'; // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment -const ReactCompilerConfig = require('./utils/reactCompilerConfig'); +const ReactCompilerConfig = require('../config/babel/reactCompilerConfig'); type CompilationResult = 'compiled' | 'failed' | 'no-components'; From 4b154370031bef79f1c2f1cbbae6ded22a7de5f2 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 6 Apr 2026 16:19:15 -0700 Subject: [PATCH 11/23] Inline FILE_EXTENSIONS check, remove isReactFile wrapper Made-with: Cursor --- scripts/react-compiler-compliance-check.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 00c57d58a0cc..da22d6e62c44 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -75,10 +75,6 @@ function checkReactCompilerCompliance(source: string, filename: string): Compila return 'no-components'; } -function isReactFile(filePath: string): boolean { - return FILE_EXTENSIONS.some((ext) => filePath.endsWith(ext)); -} - /** * Check specific files and report per-file status. */ @@ -86,7 +82,7 @@ function checkFiles(files: string[], verbose: boolean): boolean { let hasFailure = false; for (const file of files) { - if (!isReactFile(file)) { + if (!FILE_EXTENSIONS.some((ext) => file.endsWith(ext))) { if (verbose) { logInfo(`SKIPPED ${file} (not a .ts/.tsx file)`); } @@ -131,7 +127,7 @@ async function checkChangedFiles(remote: string, verbose: boolean): Promise isReactFile(f.filename) && f.status !== 'removed'); + const reactFiles = changedFiles.filter((f) => FILE_EXTENSIONS.some((ext) => f.filename.endsWith(ext)) && f.status !== 'removed'); if (reactFiles.length === 0) { logSuccess('No React files changed, skipping check.'); From 41b74f5b80dfbe71c06460f7e0d109b28cc17a66 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 6 Apr 2026 16:23:56 -0700 Subject: [PATCH 12/23] Support directories and glob patterns in check command The `check` command now accepts directories (recursively finds .ts/.tsx files) and glob patterns in addition to individual file paths. Inputs are deduplicated and non-.ts/.tsx files are filtered. Made-with: Cursor --- scripts/react-compiler-compliance-check.ts | 58 ++++++++++++---- tests/unit/ResolveFilePathsTest.ts | 80 ++++++++++++++++++++++ 2 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 tests/unit/ResolveFilePathsTest.ts diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index da22d6e62c44..f8be3ab6bbb4 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -11,6 +11,7 @@ */ import {transformSync} from '@babel/core'; import fs from 'fs'; +import {globSync} from 'glob'; import path from 'path'; import CLI from './utils/CLI'; import Git from './utils/Git'; @@ -76,27 +77,54 @@ function checkReactCompilerCompliance(source: string, filename: string): Compila } /** - * Check specific files and report per-file status. + * Resolve a list of inputs (file paths, directories, or glob patterns) + * to concrete .ts/.tsx file paths. */ -function checkFiles(files: string[], verbose: boolean): boolean { - let hasFailure = false; +function resolveFilePaths(inputs: string[]): string[] { + const resolved = new Set(); - for (const file of files) { - if (!FILE_EXTENSIONS.some((ext) => file.endsWith(ext))) { - if (verbose) { - logInfo(`SKIPPED ${file} (not a .ts/.tsx file)`); + for (const input of inputs) { + const absoluteInput = path.resolve(input); + + if (fs.existsSync(absoluteInput) && fs.statSync(absoluteInput).isDirectory()) { + const pattern = path.join(absoluteInput, '**', `*{${FILE_EXTENSIONS.join(',')}}`); + for (const file of globSync(pattern)) { + resolved.add(file); } continue; } - const absolutePath = path.resolve(file); - if (!fs.existsSync(absolutePath)) { - logWarn(`SKIPPED ${file} (file not found)`); + if (fs.existsSync(absoluteInput) && fs.statSync(absoluteInput).isFile()) { + resolved.add(absoluteInput); continue; } - const source = fs.readFileSync(absolutePath, 'utf8'); - const result = checkReactCompilerCompliance(source, absolutePath); + for (const file of globSync(input, {absolute: true})) { + if (FILE_EXTENSIONS.some((ext) => file.endsWith(ext))) { + resolved.add(file); + } + } + } + + return Array.from(resolved); +} + +/** + * Check specific files and report per-file status. + */ +function checkFiles(inputs: string[], verbose: boolean): boolean { + const files = resolveFilePaths(inputs); + + if (files.length === 0) { + logWarn('No .ts/.tsx files found matching the provided paths.'); + return true; + } + + let hasFailure = false; + + for (const file of files) { + const source = fs.readFileSync(file, 'utf8'); + const result = checkReactCompilerCompliance(source, file); switch (result) { case 'compiled': @@ -216,7 +244,7 @@ async function main() { }, { name: 'files', - description: 'File paths to check (only for "check" command)', + description: 'File paths, directories, or glob patterns to check (only for "check" command)', variadic: true, default: [], }, @@ -244,7 +272,7 @@ async function main() { switch (command) { case 'check': if (files.length === 0) { - logError('No files specified. Usage: npm run react-compiler-compliance-check check '); + logError('No paths specified. Usage: npm run react-compiler-compliance-check check '); process.exit(1); } passed = checkFiles(files, verbose); @@ -264,5 +292,5 @@ if (require.main === module) { main(); } -export {checkReactCompilerCompliance}; +export {checkReactCompilerCompliance, resolveFilePaths}; export type {CompilationResult}; diff --git a/tests/unit/ResolveFilePathsTest.ts b/tests/unit/ResolveFilePathsTest.ts new file mode 100644 index 000000000000..f5b955c54757 --- /dev/null +++ b/tests/unit/ResolveFilePathsTest.ts @@ -0,0 +1,80 @@ +import fs from 'fs'; +import path from 'path'; +import {resolveFilePaths} from '../../scripts/react-compiler-compliance-check'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join('/tmp', 'resolve-files-')); + + fs.mkdirSync(path.join(tmpDir, 'src', 'components'), {recursive: true}); + fs.mkdirSync(path.join(tmpDir, 'src', 'hooks'), {recursive: true}); + fs.mkdirSync(path.join(tmpDir, 'src', 'utils'), {recursive: true}); + + fs.writeFileSync(path.join(tmpDir, 'src', 'components', 'Button.tsx'), 'export default function Button() {}'); + fs.writeFileSync(path.join(tmpDir, 'src', 'components', 'Card.tsx'), 'export default function Card() {}'); + fs.writeFileSync(path.join(tmpDir, 'src', 'hooks', 'useToggle.ts'), 'export function useToggle() {}'); + fs.writeFileSync(path.join(tmpDir, 'src', 'utils', 'math.ts'), 'export const add = (a, b) => a + b;'); + fs.writeFileSync(path.join(tmpDir, 'src', 'utils', 'README.md'), '# Utils'); + fs.writeFileSync(path.join(tmpDir, 'src', 'utils', 'data.json'), '{}'); +}); + +afterEach(() => { + fs.rmSync(tmpDir, {recursive: true, force: true}); +}); + +describe('resolveFilePaths', () => { + it('passes through individual file paths unchanged', () => { + const file = path.join(tmpDir, 'src', 'components', 'Button.tsx'); + const result = resolveFilePaths([file]); + expect(result).toEqual([file]); + }); + + it('expands a directory to all .ts and .tsx files recursively', () => { + const dir = path.join(tmpDir, 'src'); + const result = resolveFilePaths([dir]); + expect(result.sort()).toEqual([ + path.join(tmpDir, 'src', 'components', 'Button.tsx'), + path.join(tmpDir, 'src', 'components', 'Card.tsx'), + path.join(tmpDir, 'src', 'hooks', 'useToggle.ts'), + path.join(tmpDir, 'src', 'utils', 'math.ts'), + ]); + }); + + it('expands glob patterns', () => { + const glob = path.join(tmpDir, 'src', 'components', '*.tsx'); + const result = resolveFilePaths([glob]); + expect(result.sort()).toEqual([path.join(tmpDir, 'src', 'components', 'Button.tsx'), path.join(tmpDir, 'src', 'components', 'Card.tsx')]); + }); + + it('expands recursive glob patterns', () => { + const glob = path.join(tmpDir, 'src', '**', '*.ts'); + const result = resolveFilePaths([glob]); + expect(result.sort()).toEqual([path.join(tmpDir, 'src', 'hooks', 'useToggle.ts'), path.join(tmpDir, 'src', 'utils', 'math.ts')]); + }); + + it('handles a mix of files, directories, and globs', () => { + const file = path.join(tmpDir, 'src', 'hooks', 'useToggle.ts'); + const dir = path.join(tmpDir, 'src', 'components'); + const result = resolveFilePaths([file, dir]); + expect(result.sort()).toEqual([ + path.join(tmpDir, 'src', 'components', 'Button.tsx'), + path.join(tmpDir, 'src', 'components', 'Card.tsx'), + path.join(tmpDir, 'src', 'hooks', 'useToggle.ts'), + ]); + }); + + it('deduplicates files that match multiple inputs', () => { + const file = path.join(tmpDir, 'src', 'components', 'Button.tsx'); + const dir = path.join(tmpDir, 'src', 'components'); + const result = resolveFilePaths([file, dir]); + const buttonCount = result.filter((f) => f.includes('Button.tsx')).length; + expect(buttonCount).toBe(1); + }); + + it('filters out non .ts/.tsx files from directories and globs', () => { + const dir = path.join(tmpDir, 'src', 'utils'); + const result = resolveFilePaths([dir]); + expect(result).toEqual([path.join(tmpDir, 'src', 'utils', 'math.ts')]); + }); +}); From 0d1fefef94fa23130bb1c7b1ae60e41666907493 Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 7 Apr 2026 16:18:59 -0700 Subject: [PATCH 13/23] Address review feedback: extract resolveFilePaths to FileUtils - Move resolveFilePaths to FileUtils.ts with configurable extensions - Cache fs.existsSync/statSync to avoid redundant calls - Use FILE_EXTENSIONS variable in log message Made-with: Cursor --- scripts/react-compiler-compliance-check.ts | 41 ++------------------ scripts/utils/FileUtils.ts | 44 ++++++++++++++++++++++ tests/unit/ResolveFilePathsTest.ts | 16 ++++---- 3 files changed, 56 insertions(+), 45 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index f8be3ab6bbb4..b528f4c69178 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -11,9 +11,9 @@ */ import {transformSync} from '@babel/core'; import fs from 'fs'; -import {globSync} from 'glob'; import path from 'path'; import CLI from './utils/CLI'; +import FileUtils from './utils/FileUtils'; import Git from './utils/Git'; import {log, error as logError, info as logInfo, success as logSuccess, warn as logWarn} from './utils/Logger'; @@ -76,47 +76,14 @@ function checkReactCompilerCompliance(source: string, filename: string): Compila return 'no-components'; } -/** - * Resolve a list of inputs (file paths, directories, or glob patterns) - * to concrete .ts/.tsx file paths. - */ -function resolveFilePaths(inputs: string[]): string[] { - const resolved = new Set(); - - for (const input of inputs) { - const absoluteInput = path.resolve(input); - - if (fs.existsSync(absoluteInput) && fs.statSync(absoluteInput).isDirectory()) { - const pattern = path.join(absoluteInput, '**', `*{${FILE_EXTENSIONS.join(',')}}`); - for (const file of globSync(pattern)) { - resolved.add(file); - } - continue; - } - - if (fs.existsSync(absoluteInput) && fs.statSync(absoluteInput).isFile()) { - resolved.add(absoluteInput); - continue; - } - - for (const file of globSync(input, {absolute: true})) { - if (FILE_EXTENSIONS.some((ext) => file.endsWith(ext))) { - resolved.add(file); - } - } - } - - return Array.from(resolved); -} - /** * Check specific files and report per-file status. */ function checkFiles(inputs: string[], verbose: boolean): boolean { - const files = resolveFilePaths(inputs); + const files = FileUtils.resolveFilePaths(inputs, FILE_EXTENSIONS); if (files.length === 0) { - logWarn('No .ts/.tsx files found matching the provided paths.'); + logWarn(`No ${FILE_EXTENSIONS.join('/')} files found matching the provided paths.`); return true; } @@ -292,5 +259,5 @@ if (require.main === module) { main(); } -export {checkReactCompilerCompliance, resolveFilePaths}; +export {checkReactCompilerCompliance}; export type {CompilationResult}; diff --git a/scripts/utils/FileUtils.ts b/scripts/utils/FileUtils.ts index 83e227c041da..a559a8aea817 100644 --- a/scripts/utils/FileUtils.ts +++ b/scripts/utils/FileUtils.ts @@ -1,3 +1,9 @@ +import fs from 'fs'; +import {globSync} from 'glob'; +import path from 'path'; + +const DEFAULT_EXTENSIONS = ['.ts', '.tsx']; + const ERROR_MESSAGES = { SOURCE_CANNOT_BE_EMPTY: 'Source cannot be empty', INDEX_CANNOT_BE_NEGATIVE: 'Index cannot be negative', @@ -31,6 +37,44 @@ const FileUtils = { const column = lastLineBreakIndex === -1 ? index + 1 : index - lastLineBreakIndex; return {line, column}; }, + + /** + * Resolve a list of inputs (file paths, directories, or glob patterns) to concrete file paths. + * Directories are expanded recursively. Results are deduplicated. + * + * @param inputs - File paths, directories, or glob patterns + * @param extensions - File extensions to include (default: .ts, .tsx) + */ + resolveFilePaths: (inputs: string[], extensions: string[] = DEFAULT_EXTENSIONS): string[] => { + const resolved = new Set(); + + for (const input of inputs) { + const absoluteInput = path.resolve(input); + const exists = fs.existsSync(absoluteInput); + const stat = exists ? fs.statSync(absoluteInput) : null; + + if (exists && stat?.isDirectory()) { + const pattern = path.join(absoluteInput, '**', `*{${extensions.join(',')}}`); + for (const file of globSync(pattern)) { + resolved.add(file); + } + continue; + } + + if (exists && stat?.isFile()) { + resolved.add(absoluteInput); + continue; + } + + for (const file of globSync(input, {absolute: true})) { + if (extensions.some((ext) => file.endsWith(ext))) { + resolved.add(file); + } + } + } + + return Array.from(resolved); + }, }; export default FileUtils; diff --git a/tests/unit/ResolveFilePathsTest.ts b/tests/unit/ResolveFilePathsTest.ts index f5b955c54757..9752a498e55d 100644 --- a/tests/unit/ResolveFilePathsTest.ts +++ b/tests/unit/ResolveFilePathsTest.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import {resolveFilePaths} from '../../scripts/react-compiler-compliance-check'; +import FileUtils from '../../scripts/utils/FileUtils'; let tmpDir: string; @@ -26,13 +26,13 @@ afterEach(() => { describe('resolveFilePaths', () => { it('passes through individual file paths unchanged', () => { const file = path.join(tmpDir, 'src', 'components', 'Button.tsx'); - const result = resolveFilePaths([file]); + const result = FileUtils.resolveFilePaths([file]); expect(result).toEqual([file]); }); it('expands a directory to all .ts and .tsx files recursively', () => { const dir = path.join(tmpDir, 'src'); - const result = resolveFilePaths([dir]); + const result = FileUtils.resolveFilePaths([dir]); expect(result.sort()).toEqual([ path.join(tmpDir, 'src', 'components', 'Button.tsx'), path.join(tmpDir, 'src', 'components', 'Card.tsx'), @@ -43,20 +43,20 @@ describe('resolveFilePaths', () => { it('expands glob patterns', () => { const glob = path.join(tmpDir, 'src', 'components', '*.tsx'); - const result = resolveFilePaths([glob]); + const result = FileUtils.resolveFilePaths([glob]); expect(result.sort()).toEqual([path.join(tmpDir, 'src', 'components', 'Button.tsx'), path.join(tmpDir, 'src', 'components', 'Card.tsx')]); }); it('expands recursive glob patterns', () => { const glob = path.join(tmpDir, 'src', '**', '*.ts'); - const result = resolveFilePaths([glob]); + const result = FileUtils.resolveFilePaths([glob]); expect(result.sort()).toEqual([path.join(tmpDir, 'src', 'hooks', 'useToggle.ts'), path.join(tmpDir, 'src', 'utils', 'math.ts')]); }); it('handles a mix of files, directories, and globs', () => { const file = path.join(tmpDir, 'src', 'hooks', 'useToggle.ts'); const dir = path.join(tmpDir, 'src', 'components'); - const result = resolveFilePaths([file, dir]); + const result = FileUtils.resolveFilePaths([file, dir]); expect(result.sort()).toEqual([ path.join(tmpDir, 'src', 'components', 'Button.tsx'), path.join(tmpDir, 'src', 'components', 'Card.tsx'), @@ -67,14 +67,14 @@ describe('resolveFilePaths', () => { it('deduplicates files that match multiple inputs', () => { const file = path.join(tmpDir, 'src', 'components', 'Button.tsx'); const dir = path.join(tmpDir, 'src', 'components'); - const result = resolveFilePaths([file, dir]); + const result = FileUtils.resolveFilePaths([file, dir]); const buttonCount = result.filter((f) => f.includes('Button.tsx')).length; expect(buttonCount).toBe(1); }); it('filters out non .ts/.tsx files from directories and globs', () => { const dir = path.join(tmpDir, 'src', 'utils'); - const result = resolveFilePaths([dir]); + const result = FileUtils.resolveFilePaths([dir]); expect(result).toEqual([path.join(tmpDir, 'src', 'utils', 'math.ts')]); }); }); From 3a541e5dbbca8eb4a260975c3f676194b4f1637e Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 7 Apr 2026 16:30:07 -0700 Subject: [PATCH 14/23] Fix CI: use .js for shared config, rebuild actions, exclude from new-JS check The typecheck workflow blocks new .js files; add an exclusion for config/babel/*.js since this config must be plain JS (consumed by babel.config.js before TS compilation). Also rebuild GitHub Action bundles to reflect getChangedFilesWithStatus() changes in Git.ts. Made-with: Cursor --- .../getPullRequestIncrementalChanges/index.js | 27 ++++++++++++++----- .github/workflows/typecheck.yml | 2 +- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js index 1a342b6645cc..7417f8b8cb05 100644 --- a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js +++ b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js @@ -12723,20 +12723,35 @@ class Git { return false; } } - static async getChangedFileNames(fromRef, toRef, shouldIncludeUntrackedFiles = false) { + /** + * Get changed files with their status (added, modified, removed, renamed). + * In CI, uses the GitHub API with pagination for accuracy. + * Locally, uses git diff against the provided ref. + */ + static async getChangedFilesWithStatus(fromRef, toRef, shouldIncludeUntrackedFiles = false) { if (IS_CI) { - const { data: changedFiles } = await GithubUtils_1.default.octokit.pulls.listFiles({ + const files = await GithubUtils_1.default.paginate(GithubUtils_1.default.octokit.pulls.listFiles, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, // eslint-disable-next-line @typescript-eslint/naming-convention pull_number: github_1.context.payload.pull_request?.number ?? 0, + // eslint-disable-next-line @typescript-eslint/naming-convention + per_page: 100, }); - return changedFiles.map((file) => file.filename); + return files.map((file) => ({ + filename: file.filename, + status: file.status, + })); } - // Get the diff output and check status const diffResult = this.diff(fromRef, toRef, undefined, shouldIncludeUntrackedFiles); - const files = diffResult.files.map((file) => file.filePath); - return files; + return diffResult.files.map((file) => ({ + filename: file.filePath, + status: file.diffType, + })); + } + static async getChangedFileNames(fromRef, toRef, shouldIncludeUntrackedFiles = false) { + const files = await this.getChangedFilesWithStatus(fromRef, toRef, shouldIncludeUntrackedFiles); + return files.map((file) => file.filename); } /** * Get list of untracked files from git. diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 9a079da783db..80fc2d0148bd 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -34,7 +34,7 @@ jobs: # - git diff is used to see the files that were added on this branch # - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main # - wc counts the words in the result of the intersection - count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' '.github/libs/*.js' '.github/scripts/*.js' ':!src/libs/SearchParser/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) + count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' '.github/libs/*.js' '.github/scripts/*.js' ':!src/libs/SearchParser/*.js' ':!config/babel/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) if [ "$count_new_js" -gt "0" ]; then echo "ERROR: Found new JavaScript files in the project; use TypeScript instead." exit 1 From 70bd0db4407e0a64bb0772cf5e44b423d6aed7f9 Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 7 Apr 2026 17:21:35 -0700 Subject: [PATCH 15/23] Handle renamed files in regression check using previousFilename For renamed files, the GitHub API provides previous_filename which we now surface through getChangedFilesWithStatus(). The regression check uses this to look up the file on main at its old path, correctly detecting regressions even when a file is renamed. Made-with: Cursor --- scripts/react-compiler-compliance-check.ts | 8 ++++---- scripts/utils/Git.ts | 15 ++++++++------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index b528f4c69178..d5c824e15d6e 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -133,7 +133,7 @@ async function checkChangedFiles(remote: string, verbose: boolean): Promise = []; - for (const {filename, status} of reactFiles) { + for (const {filename, status, previousFilename} of reactFiles) { const absolutePath = path.resolve(filename); if (!fs.existsSync(absolutePath)) { continue; @@ -156,11 +156,11 @@ async function checkChangedFiles(remote: string, verbose: boolean): Promise> { + static async getChangedFilesWithStatus(fromRef: string, toRef?: string, shouldIncludeUntrackedFiles = false): Promise { if (IS_CI) { const files = await GitHubUtils.paginate(GitHubUtils.octokit.pulls.listFiles, { owner: CONST.GITHUB_OWNER, @@ -481,6 +483,7 @@ class Git { return files.map((file) => ({ filename: file.filename, status: file.status as 'added' | 'modified' | 'removed' | 'renamed', + previousFilename: file.previous_filename, })); } @@ -602,7 +605,5 @@ class Git { } } -type ChangedFile = {filename: string; status: 'added' | 'modified' | 'removed' | 'renamed'}; - export default Git; export type {DiffResult, FileDiff, DiffHunk, DiffLine, ChangedFile}; From e2254e2a82c68e24847cb1a4d278d4aedab78cb2 Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 7 Apr 2026 17:23:52 -0700 Subject: [PATCH 16/23] Add rename detection to local git diff path Use -M flag in git diff to detect file renames locally. Without this, renames appeared as delete + add, incorrectly subjecting renamed files to the "new files must compile" rule. Now renames are properly detected with their previous path, matching the behavior of the GitHub API in CI. Made-with: Cursor --- scripts/utils/Git.ts | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/scripts/utils/Git.ts b/scripts/utils/Git.ts index 43b74800e919..145237b73553 100644 --- a/scripts/utils/Git.ts +++ b/scripts/utils/Git.ts @@ -64,7 +64,8 @@ type DiffHunk = { */ type FileDiff = { filePath: string; - diffType: 'added' | 'removed' | 'modified'; + diffType: 'added' | 'removed' | 'modified' | 'renamed'; + previousFilePath?: string; hunks: DiffHunk[]; addedLines: Set; removedLines: Set; @@ -116,8 +117,8 @@ class Git { * @throws Error when git command fails (invalid refs, not a git repo, file not found, etc.) */ static diff(fromRef: string, toRef?: string, filePaths?: string | string[], shouldIncludeUntrackedFiles = false): DiffResult { - // Build git diff command (with 0 context lines for easier parsing) - let command = `git diff -U0 ${fromRef}`; + // Build git diff command (with 0 context lines for easier parsing, -M for rename detection) + let command = `git diff -U0 -M ${fromRef}`; if (toRef) { command += ` ${toRef}`; } @@ -166,13 +167,13 @@ class Git { const files: FileDiff[] = []; let currentFile: FileDiff | null = null; let currentHunk: DiffHunk | null = null; - let oldFilePath: string | null = null; // Track old file path to determine fileDiffType + let oldFilePath: string | null = null; + let renameFromPath: string | null = null; for (const line of lines) { // File header: diff --git a/file b/file if (line.startsWith('diff --git')) { if (currentFile) { - // Push the current hunk to the current file before processing the new file if (currentHunk) { currentFile.hunks.push(currentHunk); } @@ -180,38 +181,48 @@ class Git { } currentFile = null; currentHunk = null; - oldFilePath = null; // Reset for next file + oldFilePath = null; + renameFromPath = null; + continue; + } + + // Rename detection: "rename from " appears before --- / +++ + if (line.startsWith('rename from ')) { + renameFromPath = line.slice('rename from '.length); + continue; + } + + if (line.startsWith('rename to ') || line.startsWith('similarity index ')) { continue; } // Old file path: --- a/file or --- /dev/null (for new files) - // This comes before +++ in git diff output if (line.startsWith('--- ')) { - oldFilePath = line.slice(4); // Store the old file path (remove '--- ') + oldFilePath = line.slice(4); continue; } // New file path: +++ b/file or +++ /dev/null (for removed files) if (line.startsWith('+++ ')) { - const newFilePath = line.slice(4); // Remove '+++ ' + const newFilePath = line.slice(4); - // Determine fileDiffType based on old and new file paths - // Note: oldFilePath should always be set by the time we see +++, but handle null for type safety - let fileDiffType: 'added' | 'removed' | 'modified' = 'modified'; + let fileDiffType: FileDiff['diffType'] = 'modified'; let diffFilePath: string; + let previousFilePath: string | undefined; const oldPath = oldFilePath ?? ''; if (oldPath === '/dev/null') { - // New file: use the new file path fileDiffType = 'added'; diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath; } else if (newFilePath === '/dev/null') { - // Removed file: use the old file path fileDiffType = 'removed'; diffFilePath = oldPath.startsWith('a/') ? oldPath.slice(2) : oldPath; + } else if (renameFromPath) { + fileDiffType = 'renamed'; + diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath; + previousFilePath = renameFromPath; } else { - // Modified file: use the new file path fileDiffType = 'modified'; diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath; } @@ -219,6 +230,7 @@ class Git { currentFile = { filePath: diffFilePath, diffType: fileDiffType, + previousFilePath, hunks: [], addedLines: new Set(), removedLines: new Set(), @@ -491,6 +503,7 @@ class Git { return diffResult.files.map((file) => ({ filename: file.filePath, status: file.diffType, + previousFilename: file.previousFilePath, })); } From 1edf47992a70033a41ec4a609585b0ef1e4749bf Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 8 Apr 2026 15:39:44 -0700 Subject: [PATCH 17/23] Rebuild gh actions --- .../getPullRequestIncrementalChanges/index.js | 38 ++++++++++++------- package-lock.json | 11 ------ 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js index 7417f8b8cb05..4406ba0d0093 100644 --- a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js +++ b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js @@ -12410,8 +12410,8 @@ class Git { * @throws Error when git command fails (invalid refs, not a git repo, file not found, etc.) */ static diff(fromRef, toRef, filePaths, shouldIncludeUntrackedFiles = false) { - // Build git diff command (with 0 context lines for easier parsing) - let command = `git diff -U0 ${fromRef}`; + // Build git diff command (with 0 context lines for easier parsing, -M for rename detection) + let command = `git diff -U0 -M ${fromRef}`; if (toRef) { command += ` ${toRef}`; } @@ -12453,12 +12453,12 @@ class Git { const files = []; let currentFile = null; let currentHunk = null; - let oldFilePath = null; // Track old file path to determine fileDiffType + let oldFilePath = null; + let renameFromPath = null; for (const line of lines) { // File header: diff --git a/file b/file if (line.startsWith('diff --git')) { if (currentFile) { - // Push the current hunk to the current file before processing the new file if (currentHunk) { currentFile.hunks.push(currentHunk); } @@ -12466,41 +12466,51 @@ class Git { } currentFile = null; currentHunk = null; - oldFilePath = null; // Reset for next file + oldFilePath = null; + renameFromPath = null; + continue; + } + // Rename detection: "rename from " appears before --- / +++ + if (line.startsWith('rename from ')) { + renameFromPath = line.slice('rename from '.length); + continue; + } + if (line.startsWith('rename to ') || line.startsWith('similarity index ')) { continue; } // Old file path: --- a/file or --- /dev/null (for new files) - // This comes before +++ in git diff output if (line.startsWith('--- ')) { - oldFilePath = line.slice(4); // Store the old file path (remove '--- ') + oldFilePath = line.slice(4); continue; } // New file path: +++ b/file or +++ /dev/null (for removed files) if (line.startsWith('+++ ')) { - const newFilePath = line.slice(4); // Remove '+++ ' - // Determine fileDiffType based on old and new file paths - // Note: oldFilePath should always be set by the time we see +++, but handle null for type safety + const newFilePath = line.slice(4); let fileDiffType = 'modified'; let diffFilePath; + let previousFilePath; const oldPath = oldFilePath ?? ''; if (oldPath === '/dev/null') { - // New file: use the new file path fileDiffType = 'added'; diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath; } else if (newFilePath === '/dev/null') { - // Removed file: use the old file path fileDiffType = 'removed'; diffFilePath = oldPath.startsWith('a/') ? oldPath.slice(2) : oldPath; } + else if (renameFromPath) { + fileDiffType = 'renamed'; + diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath; + previousFilePath = renameFromPath; + } else { - // Modified file: use the new file path fileDiffType = 'modified'; diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath; } currentFile = { filePath: diffFilePath, diffType: fileDiffType, + previousFilePath, hunks: [], addedLines: new Set(), removedLines: new Set(), @@ -12741,12 +12751,14 @@ class Git { return files.map((file) => ({ filename: file.filename, status: file.status, + previousFilename: file.previous_filename, })); } const diffResult = this.diff(fromRef, toRef, undefined, shouldIncludeUntrackedFiles); return diffResult.files.map((file) => ({ filename: file.filePath, status: file.diffType, + previousFilename: file.previousFilePath, })); } static async getChangedFileNames(fromRef, toRef, shouldIncludeUntrackedFiles = false) { diff --git a/package-lock.json b/package-lock.json index 84d23aa86034..3258f2c7eb57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34804,17 +34804,6 @@ "react-native-blob-util": ">=0.13.7" } }, - "node_modules/react-native-performance": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/react-native-performance/-/react-native-performance-6.0.0.tgz", - "integrity": "sha512-Sca75O8jhqXAnNbqvINnrw248Kv9cIwoGxToD8u2uX+BrkAxxXS+YhClEV5L3JdiOpdNCO1MJ5R9bgs2VkNpFg==", - "license": "MIT", - "optional": true, - "peer": true, - "peerDependencies": { - "react-native": "*" - } - }, "node_modules/react-native-permissions": { "version": "5.4.0", "license": "MIT", From 9b50ce699c096422a54a7fcfa5507139e58a6d78 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 10 Apr 2026 18:35:06 -0700 Subject: [PATCH 18/23] Fix GitTest assertions for -M flag in git diff command Made-with: Cursor --- tests/unit/GitTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/GitTest.ts b/tests/unit/GitTest.ts index bcb705ae3260..a5e1869387e8 100644 --- a/tests/unit/GitTest.ts +++ b/tests/unit/GitTest.ts @@ -87,7 +87,7 @@ describe('Git', () => { files: [], hasChanges: false, }); - expect(mockExecSync).toHaveBeenCalledWith('git diff -U0 main', { + expect(mockExecSync).toHaveBeenCalledWith('git diff -U0 -M main', { encoding: 'utf8', cwd: process.cwd(), }); @@ -264,7 +264,7 @@ describe('Git', () => { const result = Git.diff('main', undefined, 'src/languages/en.ts'); - expect(mockExecSync).toHaveBeenCalledWith('git diff -U0 main -- "src/languages/en.ts"', { + expect(mockExecSync).toHaveBeenCalledWith('git diff -U0 -M main -- "src/languages/en.ts"', { encoding: 'utf8', cwd: process.cwd(), }); From f43deb520c7ec6a8a47d335601d68a2ea7bfa725 Mon Sep 17 00:00:00 2001 From: rory Date: Sun, 12 Apr 2026 17:36:06 -0700 Subject: [PATCH 19/23] Show detailed compiler errors with file/line locations The compliance check now collects and displays error details from the React Compiler logger including reason, file:line:column, and function location. In GitHub Actions, errors for each file are wrapped in collapsible ::group:: sections. Made-with: Cursor --- scripts/react-compiler-compliance-check.ts | 112 ++++++++++++++---- .../unit/ReactCompilerComplianceCheckTest.ts | 59 ++++++--- 2 files changed, 131 insertions(+), 40 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index d5c824e15d6e..d0796d163a14 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -20,17 +20,46 @@ import {log, error as logError, info as logInfo, success as logSuccess, warn as // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment const ReactCompilerConfig = require('../config/babel/reactCompilerConfig'); -type CompilationResult = 'compiled' | 'failed' | 'no-components'; +type SourceLocation = { + start: {line: number; column: number}; + end: {line: number; column: number}; +}; + +type CompilerError = { + reason: string; + severity: string; + loc?: SourceLocation; + fnLoc?: SourceLocation; +}; + +type CompilationResult = { + status: 'compiled' | 'failed' | 'no-components'; + errors: CompilerError[]; +}; + +type CompilerLogEvent = { + kind: string; + fnLoc?: SourceLocation; + fnName?: string; + detail?: { + severity?: string; + reason?: string; + loc?: SourceLocation; + }; +}; const FILE_EXTENSIONS = ['.ts', '.tsx']; +const IS_CI = process.env.CI === 'true'; + /** * Check if a source string compiles with React Compiler. - * Returns a three-state result indicating compilation success, failure, or no React code found. + * Returns the compilation status and any errors with their details. */ function checkReactCompilerCompliance(source: string, filename: string): CompilationResult { let hasError = false; let hasSuccess = false; + const errors: CompilerError[] = []; try { transformSync(source, { @@ -50,9 +79,17 @@ function checkReactCompilerCompliance(source: string, filename: string): Compila panicThreshold: 'none', noEmit: true, logger: { - logEvent(_filename: string, event: {kind: string}) { + logEvent(_filename: string, event: CompilerLogEvent) { if (event.kind === 'CompileError') { hasError = true; + if (event.detail?.reason) { + errors.push({ + reason: event.detail.reason, + severity: event.detail.severity ?? 'Error', + loc: event.detail.loc, + fnLoc: event.fnLoc, + }); + } } if (event.kind === 'CompileSuccess') { hasSuccess = true; @@ -63,17 +100,42 @@ function checkReactCompilerCompliance(source: string, filename: string): Compila ], ], }); - } catch { + } catch (e) { hasError = true; + errors.push({ + reason: e instanceof Error ? e.message : String(e), + severity: 'Error', + }); } if (hasError) { - return 'failed'; + return {status: 'failed', errors}; } if (hasSuccess) { - return 'compiled'; + return {status: 'compiled', errors: []}; + } + return {status: 'no-components', errors: []}; +} + +function formatErrorLocation(filename: string, error: CompilerError): string { + const loc = error.loc ?? error.fnLoc; + if (loc) { + return `${filename}:${loc.start.line}:${loc.start.column}`; + } + return filename; +} + +function printErrors(filename: string, errors: CompilerError[]): void { + if (IS_CI) { + console.log(`::group::${filename} (${errors.length} error${errors.length === 1 ? '' : 's'})`); + } + for (const error of errors) { + const location = formatErrorLocation(filename, error); + logError(` ${location}: ${error.reason}`); + } + if (IS_CI) { + console.log('::endgroup::'); } - return 'no-components'; } /** @@ -93,12 +155,13 @@ function checkFiles(inputs: string[], verbose: boolean): boolean { const source = fs.readFileSync(file, 'utf8'); const result = checkReactCompilerCompliance(source, file); - switch (result) { + switch (result.status) { case 'compiled': logSuccess(`COMPILED ${file}`); break; case 'failed': logError(`FAILED ${file}`); + printErrors(file, result.errors); hasFailure = true; break; case 'no-components': @@ -131,7 +194,7 @@ async function checkChangedFiles(remote: string, verbose: boolean): Promise = []; + const failures: Array<{file: string; reason: string; errors: CompilerError[]}> = []; for (const {filename, status, previousFilename} of reactFiles) { const absolutePath = path.resolve(filename); @@ -143,47 +206,44 @@ async function checkChangedFiles(remote: string, verbose: boolean): Promise 0) { - logError(`React Compiler compliance check failed with ${failures.length} error(s):`); - log(); - for (const {file, reason} of failures) { - logError(` ${file}`); - logInfo(` ${reason}`); - } + logError(`React Compiler compliance check failed with ${failures.length} error(s).`); log(); logInfo('See contributingGuides/REACT_COMPILER.md for help fixing these errors.'); return false; @@ -260,4 +320,4 @@ if (require.main === module) { } export {checkReactCompilerCompliance}; -export type {CompilationResult}; +export type {CompilationResult, CompilerError}; diff --git a/tests/unit/ReactCompilerComplianceCheckTest.ts b/tests/unit/ReactCompilerComplianceCheckTest.ts index 43e4f6534616..a91b5cc0669e 100644 --- a/tests/unit/ReactCompilerComplianceCheckTest.ts +++ b/tests/unit/ReactCompilerComplianceCheckTest.ts @@ -7,7 +7,9 @@ describe('checkReactCompilerCompliance', () => { return
Hello
; } `; - expect(checkReactCompilerCompliance(source, 'MyComponent.tsx')).toBe('compiled'); + const result = checkReactCompilerCompliance(source, 'MyComponent.tsx'); + expect(result.status).toBe('compiled'); + expect(result.errors).toEqual([]); }); it('returns compiled for a hook that compiles', () => { @@ -18,20 +20,29 @@ describe('checkReactCompilerCompliance', () => { return value; } `; - expect(checkReactCompilerCompliance(source, 'useMyHook.ts')).toBe('compiled'); + const result = checkReactCompilerCompliance(source, 'useMyHook.ts'); + expect(result.status).toBe('compiled'); + expect(result.errors).toEqual([]); }); - it('returns failed for a component with a Rules of React violation', () => { + it('returns failed with error details for a component with a Rules of React violation', () => { const source = ` - import {useState} from 'react'; - function BadComponent({condition}) { - if (condition) { - const [value] = useState(0); - } - return
Bad
; - } - `; - expect(checkReactCompilerCompliance(source, 'BadComponent.tsx')).toBe('failed'); +import {useState} from 'react'; +function BadComponent({condition}) { + if (condition) { + const [value] = useState(0); + } + return
Bad
; +} + `.trim(); + const result = checkReactCompilerCompliance(source, 'BadComponent.tsx'); + expect(result.status).toBe('failed'); + expect(result.errors.length).toBeGreaterThan(0); + + const error = result.errors.at(0); + expect(error.reason).toContain('Hooks must always be called in a consistent order'); + expect(error.loc).toBeDefined(); + expect(error.loc?.start.line).toBeGreaterThan(0); }); it('returns no-components for a plain utility file', () => { @@ -43,7 +54,9 @@ describe('checkReactCompilerCompliance', () => { return a - b; } `; - expect(checkReactCompilerCompliance(source, 'mathUtils.ts')).toBe('no-components'); + const result = checkReactCompilerCompliance(source, 'mathUtils.ts'); + expect(result.status).toBe('no-components'); + expect(result.errors).toEqual([]); }); it('returns no-components for a types-only file', () => { @@ -58,6 +71,24 @@ describe('checkReactCompilerCompliance', () => { language: string; } `; - expect(checkReactCompilerCompliance(source, 'types.ts')).toBe('no-components'); + const result = checkReactCompilerCompliance(source, 'types.ts'); + expect(result.status).toBe('no-components'); + expect(result.errors).toEqual([]); + }); + + it('includes function location in error details', () => { + const source = ` +import {useState} from 'react'; +function BadComponent({condition}) { + if (condition) { + const [value] = useState(0); + } + return
Bad
; +} + `.trim(); + const result = checkReactCompilerCompliance(source, 'BadComponent.tsx'); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.at(0).fnLoc).toBeDefined(); + expect(result.errors.at(0).fnLoc?.start.line).toBe(2); }); }); From 67e6b2728c1944f7771b1cf549ce1eb86668c58b Mon Sep 17 00:00:00 2001 From: rory Date: Sun, 12 Apr 2026 17:41:57 -0700 Subject: [PATCH 20/23] Fix typecheck and spellcheck CI failures - Add non-null assertions for .at(0) in test assertions - Add 'endgroup' to cspell dictionary for GitHub Actions syntax Made-with: Cursor --- cspell.json | 1 + tests/unit/ReactCompilerComplianceCheckTest.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cspell.json b/cspell.json index ac25b59fc7cd..9cdf6d6f52e7 100644 --- a/cspell.json +++ b/cspell.json @@ -218,6 +218,7 @@ "endcapture", "enddate", "endfor", + "endgroup", "enroute", "entityid", "Entra", diff --git a/tests/unit/ReactCompilerComplianceCheckTest.ts b/tests/unit/ReactCompilerComplianceCheckTest.ts index a91b5cc0669e..0ca600fb4992 100644 --- a/tests/unit/ReactCompilerComplianceCheckTest.ts +++ b/tests/unit/ReactCompilerComplianceCheckTest.ts @@ -39,7 +39,7 @@ function BadComponent({condition}) { expect(result.status).toBe('failed'); expect(result.errors.length).toBeGreaterThan(0); - const error = result.errors.at(0); + const error = result.errors.at(0)!; expect(error.reason).toContain('Hooks must always be called in a consistent order'); expect(error.loc).toBeDefined(); expect(error.loc?.start.line).toBeGreaterThan(0); @@ -88,7 +88,7 @@ function BadComponent({condition}) { `.trim(); const result = checkReactCompilerCompliance(source, 'BadComponent.tsx'); expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors.at(0).fnLoc).toBeDefined(); - expect(result.errors.at(0).fnLoc?.start.line).toBe(2); + expect(result.errors.at(0)!.fnLoc).toBeDefined(); + expect(result.errors.at(0)!.fnLoc?.start.line).toBe(2); }); }); From 8432c5a101bb5b118a12d976ea7019e5ac80879e Mon Sep 17 00:00:00 2001 From: rory Date: Sun, 12 Apr 2026 17:47:05 -0700 Subject: [PATCH 21/23] Replace non-null assertions with optional chaining in tests Made-with: Cursor --- tests/unit/ReactCompilerComplianceCheckTest.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/unit/ReactCompilerComplianceCheckTest.ts b/tests/unit/ReactCompilerComplianceCheckTest.ts index 0ca600fb4992..bd1d5dc1a281 100644 --- a/tests/unit/ReactCompilerComplianceCheckTest.ts +++ b/tests/unit/ReactCompilerComplianceCheckTest.ts @@ -39,10 +39,11 @@ function BadComponent({condition}) { expect(result.status).toBe('failed'); expect(result.errors.length).toBeGreaterThan(0); - const error = result.errors.at(0)!; - expect(error.reason).toContain('Hooks must always be called in a consistent order'); - expect(error.loc).toBeDefined(); - expect(error.loc?.start.line).toBeGreaterThan(0); + const error = result.errors.at(0); + expect(error).toBeDefined(); + expect(error?.reason).toContain('Hooks must always be called in a consistent order'); + expect(error?.loc).toBeDefined(); + expect(error?.loc?.start.line).toBeGreaterThan(0); }); it('returns no-components for a plain utility file', () => { @@ -88,7 +89,9 @@ function BadComponent({condition}) { `.trim(); const result = checkReactCompilerCompliance(source, 'BadComponent.tsx'); expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors.at(0)!.fnLoc).toBeDefined(); - expect(result.errors.at(0)!.fnLoc?.start.line).toBe(2); + const error = result.errors.at(0); + expect(error).toBeDefined(); + expect(error?.fnLoc).toBeDefined(); + expect(error?.fnLoc?.start.line).toBe(2); }); }); From c820d57a0dd5712b5024632bd673ed61fea0eedd Mon Sep 17 00:00:00 2001 From: Rory Abraham <47436092+roryabraham@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:14:00 -0700 Subject: [PATCH 22/23] Update scripts/react-compiler-compliance-check.ts Co-authored-by: Vit Horacek <36083550+mountiny@users.noreply.github.com> --- scripts/react-compiler-compliance-check.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index d0796d163a14..d8f2acfb8f63 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -84,7 +84,7 @@ function checkReactCompilerCompliance(source: string, filename: string): Compila hasError = true; if (event.detail?.reason) { errors.push({ - reason: event.detail.reason, + reason: event.detail.reason ?? 'Unknown compiler error', severity: event.detail.severity ?? 'Error', loc: event.detail.loc, fnLoc: event.fnLoc, From 19b2fda7b90106237f37fa6b6034029932ac6748 Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 14 Apr 2026 14:16:34 -0700 Subject: [PATCH 23/23] Add top-level error handler to main() Made-with: Cursor --- scripts/react-compiler-compliance-check.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index d8f2acfb8f63..0acaf02b186b 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -316,7 +316,10 @@ async function main() { } if (require.main === module) { - main(); + main().catch((error: unknown) => { + logError('Unexpected error:', error); + process.exit(1); + }); } export {checkReactCompilerCompliance};