From 2b484eda5d78dd4d7d2f03da26e5d0ad82e9faef Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 27 Nov 2025 15:02:04 +0100 Subject: [PATCH 01/45] feat: add `isAddedDiffFile` and `isRemovedDiffFile` git util functions --- scripts/utils/Git.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/scripts/utils/Git.ts b/scripts/utils/Git.ts index 4b5ed7a2d9b9b..ce1a5ea94bf07 100644 --- a/scripts/utils/Git.ts +++ b/scripts/utils/Git.ts @@ -278,6 +278,44 @@ class Git { }; } + /** + * Check if a file from a Git diff is added. + * + * @param file - The file to check + * @returns true if the file is added, false otherwise + */ + static isAddedDiffFile(file: FileDiff): boolean { + const hasAddedLines = file.addedLines.size > 0; + const hasModifiedLines = file.modifiedLines.size > 0; + const hasRemovedLines = file.removedLines.size > 0; + + if (!hasAddedLines) { + return false; + } + + const hasOnlyAdditions = !hasModifiedLines && !hasRemovedLines; + return hasOnlyAdditions; + } + + /** + * Check if a file from a Git diff is removed. + * + * @param file - The file to check + * @returns true if the file is removed, false otherwise + */ + static isRemovedDiffFile(file: FileDiff): boolean { + const hasRemovedLines = file.removedLines.size > 0; + const hasModifiedLines = file.modifiedLines.size > 0; + const hasAddedLines = file.addedLines.size > 0; + + if (!hasRemovedLines) { + return false; + } + + const hasOnlyRemovals = !hasModifiedLines && !hasAddedLines; + return hasOnlyRemovals; + } + /** * Calculate the line number for a diff line based on the hunk and line type. */ From 13e2d8dcf78a11cc941535d0c40ec2632410f6a7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 27 Nov 2025 15:03:21 +0100 Subject: [PATCH 02/45] feat: check for added files and manual memoization --- scripts/react-compiler-compliance-check.ts | 209 ++++++++++++++++++++- 1 file changed, 200 insertions(+), 9 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 9412dc61a61b7..340b99ce17337 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -7,11 +7,12 @@ * It provides both CI and local development tools to enforce Rules of React compliance. */ import {execSync} from 'child_process'; -import {writeFileSync} from 'fs'; +import {readFileSync, writeFileSync} from 'fs'; import {join} from 'path'; import type {TupleToUnion} from 'type-fest'; import CLI from './utils/CLI'; 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'; const TAB = ' '; @@ -24,6 +25,28 @@ const SUPPRESSED_COMPILER_ERRORS = [ '(BuildHIR::lowerExpression) Expected Identifier, got MemberExpression key in ObjectExpression', ] as const satisfies string[]; +type ManualMemoizationPattern = { + keyword: string; + regex: RegExp; +}; + +const MANUAL_MEMOIZATION_PATTERNS: ManualMemoizationPattern[] = [ + { + keyword: 'memo', + regex: /\b(?:React\.)?memo\s*\(/g, + }, + { + keyword: 'useMemo', + regex: /\b(?:React\.)?useMemo\s*\(/g, + }, + { + keyword: 'useCallback', + regex: /\b(?:React\.)?useCallback\s*\(/g, + }, +]; + +const NO_MANUAL_MEMO_DIRECTIVE_PATTERN = /["']use no memo["']\s*;?/; + const ESLINT_DISABLE_PATTERNS = { FILE_KEYWORDS: ['// eslint-disable ', '/* eslint-disable '], LINE_KEYWORDS: ['// eslint-disable-next-line ', '/* eslint-disable-next-line '], @@ -52,6 +75,12 @@ type CompilerFailure = { reason?: string; }; +type ManualMemoizationMatch = { + keyword: string; + line: number; + column: number; +}; + type DiffFilteringCommits = { fromRef: string; toRef?: string; @@ -67,6 +96,7 @@ type BaseCheckOptions = PrintResultsOptions & { reportFileName?: string; shouldGenerateReport?: boolean; shouldFilterByDiff?: boolean; + shouldEnforceNewComponents?: boolean; }; type CheckOptions = BaseCheckOptions & { @@ -81,6 +111,7 @@ async function check({ remote, shouldPrintSuccesses = false, shouldPrintSuppressedErrors = false, + shouldEnforceNewComponents = false, }: CheckOptions): Promise { if (files) { logInfo(`Running React Compiler check for ${files.length} files or glob patterns...`); @@ -88,14 +119,25 @@ async function check({ logInfo('Running React Compiler check for all files...'); } + const shouldComputeDiff = shouldFilterByDiff || shouldEnforceNewComponents; const src = createFilesGlob(files); let results = runCompilerHealthcheck(src); - if (shouldFilterByDiff) { + if (shouldComputeDiff) { const mainBaseCommitHash = await Git.getMainBranchCommitHash(remote); const diffFilteringCommits: DiffFilteringCommits = {fromRef: mainBaseCommitHash}; + const diffResult = Git.diff(diffFilteringCommits.fromRef, diffFilteringCommits.toRef); - results = await filterResultsByDiff(results, diffFilteringCommits, {shouldPrintSuccesses, shouldPrintSuppressedErrors}); + if (shouldFilterByDiff) { + results = await filterResultsByDiff(results, diffFilteringCommits, diffResult, {shouldPrintSuccesses, shouldPrintSuppressedErrors}); + } + + if (shouldEnforceNewComponents) { + const newComponentFailures = enforceNewComponentGuard(results, diffResult, files); + for (const failure of newComponentFailures) { + addFailureIfDoesNotExist(results.failures, failure); + } + } } printResults(results, {shouldPrintSuccesses, shouldPrintSuppressedErrors}); @@ -272,6 +314,139 @@ function createFilesGlob(files?: string[]): string | undefined { return `**/+(${files.join('|')})`; } +function getNewComponentFiles(diffResult: DiffResult, filesToCheck?: string[]): Set { + const newFiles = new Set(); + const fileFilter = filesToCheck ? new Set(filesToCheck) : undefined; + + for (const file of diffResult.files) { + if (!Git.isAddedDiffFile(file)) { + continue; + } + + if (fileFilter && !fileFilter.has(file.filePath)) { + continue; + } + + newFiles.add(file.filePath); + } + return newFiles; +} + +function enforceNewComponentGuard(results: CompilerResults, diffResult: DiffResult | undefined, filesToCheck?: string[]): CompilerFailure[] { + if (!diffResult) { + return []; + } + + const newComponentFiles = getNewComponentFiles(diffResult, filesToCheck); + if (newComponentFiles.size === 0) { + return []; + } + + const failures: CompilerFailure[] = []; + + for (const filePath of newComponentFiles) { + const source = readSourceFile(filePath); + if (!source) { + continue; + } + + if (hasManualMemoOptOutDirective(source)) { + continue; + } + + const hasSuppressedFailure = hasFailureForFile(results.suppressedFailures, filePath); + const hasCompilerFailure = hasFailureForFile(results.failures, filePath); + const isCompilerSuccess = results.success.has(filePath); + + if (!isCompilerSuccess && !hasSuppressedFailure) { + if (hasCompilerFailure) { + continue; + } + + failures.push({ + file: filePath, + reason: 'New components must compile with React Compiler or opt out using "use no memo"; on the first line.', + }); + continue; + } + + if (hasSuppressedFailure) { + continue; + } + + const manualMemoMatches = findManualMemoizationMatches(source); + if (manualMemoMatches.length === 0) { + continue; + } + + const firstMatch = manualMemoMatches.at(0); + if (!firstMatch) { + continue; + } + + failures.push({ + file: filePath, + line: firstMatch.line, + column: firstMatch.column, + reason: `Manual memoization (${firstMatch.keyword}) is not allowed in new components.`, + }); + } + + return failures; +} + +function hasManualMemoOptOutDirective(source: string): boolean { + return NO_MANUAL_MEMO_DIRECTIVE_PATTERN.test(source); +} + +function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] { + const matches: ManualMemoizationMatch[] = []; + + for (const pattern of MANUAL_MEMOIZATION_PATTERNS) { + pattern.regex.lastIndex = 0; + let regexMatch: RegExpExecArray | null; + // eslint-disable-next-line no-cond-assign + while ((regexMatch = pattern.regex.exec(source)) !== null) { + const matchIndex = regexMatch.index; + const {line, column} = getLineAndColumnFromIndex(source, matchIndex); + matches.push({ + keyword: pattern.keyword, + line, + column, + }); + } + } + + return matches; +} + +function hasFailureForFile(failureMap: FailureMap, filePath: string): boolean { + for (const failure of failureMap.values()) { + if (failure.file === filePath) { + return true; + } + } + return false; +} + +function getLineAndColumnFromIndex(source: string, index: number): {line: number; column: number} { + const substring = source.slice(0, index); + const line = substring.split('\n').length; + const lastLineBreakIndex = substring.lastIndexOf('\n'); + const column = lastLineBreakIndex === -1 ? index + 1 : index - lastLineBreakIndex; + return {line, column}; +} + +function readSourceFile(filePath: string): string | null { + try { + const absolutePath = join(process.cwd(), filePath); + return readFileSync(absolutePath, 'utf8'); + } catch (error) { + logWarn(`Unable to read ${filePath} while enforcing new component rules.`, error); + return null; + } +} + /** * Filters compiler results to only include failures for lines that were changed in the git diff. * This helps focus on new issues introduced by the current changes rather than pre-existing issues. @@ -287,13 +462,11 @@ function createFilesGlob(files?: string[]): string | undefined { async function filterResultsByDiff( results: CompilerResults, diffFilteringCommits: DiffFilteringCommits, + diffResult: DiffResult, {shouldPrintSuccesses, shouldPrintSuppressedErrors}: PrintResultsOptions, ): Promise { logInfo(`Filtering results by diff between ${diffFilteringCommits.fromRef} and ${diffFilteringCommits.toRef ?? 'the working tree'}...`); - // Get the diff between the base ref and the working tree - const diffResult = Git.diff(diffFilteringCommits.fromRef, diffFilteringCommits.toRef); - // If there are no changes, return empty results if (!diffResult.hasChanges) { return { @@ -597,14 +770,32 @@ async function main() { required: false, default: false, }, + enforceNewComponents: { + description: 'Ensure new components compile with React Compiler and avoid manual memoization', + required: false, + default: false, + }, }, }); const {command, file} = cli.positionalArgs; const {remote, reportFileName} = cli.namedArgs; - const {report: shouldGenerateReport, filterByDiff: shouldFilterByDiff, printSuccesses: shouldPrintSuccesses, printSuppressedErrors: shouldPrintSuppressedErrors} = cli.flags; - - const commonOptions: BaseCheckOptions = {shouldGenerateReport, reportFileName, shouldFilterByDiff, shouldPrintSuccesses, shouldPrintSuppressedErrors}; + const { + report: shouldGenerateReport, + filterByDiff: shouldFilterByDiff, + printSuccesses: shouldPrintSuccesses, + printSuppressedErrors: shouldPrintSuppressedErrors, + enforceNewComponents: shouldEnforceNewComponents, + } = cli.flags; + + const commonOptions: BaseCheckOptions = { + shouldGenerateReport, + reportFileName, + shouldFilterByDiff, + shouldPrintSuccesses, + shouldPrintSuppressedErrors, + shouldEnforceNewComponents, + }; async function runCommand() { switch (command) { From 7319f32fae1d6afdb0e08c4690eca56c104076df Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 27 Nov 2025 15:19:57 +0100 Subject: [PATCH 03/45] only print failures from healthcheck --- scripts/react-compiler-compliance-check.ts | 109 +++++++++++++++------ 1 file changed, 79 insertions(+), 30 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 340b99ce17337..8ee8929af3308 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -86,9 +86,15 @@ type DiffFilteringCommits = { toRef?: string; }; +type NewComponentEnforcementSummary = { + compilerFailures: CompilerFailure[]; + manualMemoFailures: CompilerFailure[]; +}; + type PrintResultsOptions = { shouldPrintSuccesses: boolean; shouldPrintSuppressedErrors: boolean; + newComponentSummary?: NewComponentEnforcementSummary | null; }; type BaseCheckOptions = PrintResultsOptions & { @@ -113,6 +119,7 @@ async function check({ shouldPrintSuppressedErrors = false, shouldEnforceNewComponents = false, }: CheckOptions): Promise { + let newComponentSummary: NewComponentEnforcementSummary | undefined; if (files) { logInfo(`Running React Compiler check for ${files.length} files or glob patterns...`); } else { @@ -133,14 +140,11 @@ async function check({ } if (shouldEnforceNewComponents) { - const newComponentFailures = enforceNewComponentGuard(results, diffResult, files); - for (const failure of newComponentFailures) { - addFailureIfDoesNotExist(results.failures, failure); - } + newComponentSummary = enforceNewComponentGuard(results, diffResult, files); } } - printResults(results, {shouldPrintSuccesses, shouldPrintSuppressedErrors}); + printResults(results, {shouldPrintSuccesses, shouldPrintSuppressedErrors, newComponentSummary}); if (shouldGenerateReport) { generateReport(results, reportFileName); @@ -332,17 +336,25 @@ function getNewComponentFiles(diffResult: DiffResult, filesToCheck?: string[]): return newFiles; } -function enforceNewComponentGuard(results: CompilerResults, diffResult: DiffResult | undefined, filesToCheck?: string[]): CompilerFailure[] { +function enforceNewComponentGuard(results: CompilerResults, diffResult: DiffResult | undefined, filesToCheck?: string[]): NewComponentEnforcementSummary { if (!diffResult) { - return []; + return { + compilerFailures: [], + manualMemoFailures: [], + }; } const newComponentFiles = getNewComponentFiles(diffResult, filesToCheck); if (newComponentFiles.size === 0) { - return []; + return { + compilerFailures: [], + manualMemoFailures: [], + }; } - const failures: CompilerFailure[] = []; + const manualMemoFailures: CompilerFailure[] = []; + const compilerFailures: CompilerFailure[] = []; + collectCompilerFailuresForNewComponents(results.failures, newComponentFiles, compilerFailures); for (const filePath of newComponentFiles) { const source = readSourceFile(filePath); @@ -355,21 +367,6 @@ function enforceNewComponentGuard(results: CompilerResults, diffResult: DiffResu } const hasSuppressedFailure = hasFailureForFile(results.suppressedFailures, filePath); - const hasCompilerFailure = hasFailureForFile(results.failures, filePath); - const isCompilerSuccess = results.success.has(filePath); - - if (!isCompilerSuccess && !hasSuppressedFailure) { - if (hasCompilerFailure) { - continue; - } - - failures.push({ - file: filePath, - reason: 'New components must compile with React Compiler or opt out using "use no memo"; on the first line.', - }); - continue; - } - if (hasSuppressedFailure) { continue; } @@ -384,15 +381,20 @@ function enforceNewComponentGuard(results: CompilerResults, diffResult: DiffResu continue; } - failures.push({ + const failure: CompilerFailure = { file: filePath, line: firstMatch.line, column: firstMatch.column, reason: `Manual memoization (${firstMatch.keyword}) is not allowed in new components.`, - }); + }; + manualMemoFailures.push(failure); + addFailureIfDoesNotExist(results.failures, failure); } - return failures; + return { + compilerFailures, + manualMemoFailures, + }; } function hasManualMemoOptOutDirective(source: string): boolean { @@ -429,6 +431,47 @@ function hasFailureForFile(failureMap: FailureMap, filePath: string): boolean { return false; } +function collectCompilerFailuresForNewComponents(failureMap: FailureMap, newComponentFiles: Set, bucket: CompilerFailure[]): void { + for (const failure of failureMap.values()) { + if (!newComponentFiles.has(failure.file)) { + continue; + } + bucket.push(failure); + } +} + +function printNewComponentFailuresSection({compilerFailures, manualMemoFailures}: NewComponentEnforcementSummary): void { + const hasCompilerIssues = compilerFailures.length > 0; + const hasManualMemoIssues = manualMemoFailures.length > 0; + if (!hasCompilerIssues && !hasManualMemoIssues) { + return; + } + + log(); + logWarn('Added files with enforced automatic memoization:'); + log(); + + if (hasCompilerIssues) { + logBold('Compiler errors:'); + for (const failure of compilerFailures) { + const location = failure.line && failure.column ? `:${failure.line}:${failure.column}` : ''; + logError(`${TAB}${failure.file}${location}`); + logNote(`${TAB}${TAB}${failure.reason ?? 'No reason provided'}`); + } + log(); + } + + if (hasManualMemoIssues) { + logBold('Manual memoization detected:'); + for (const failure of manualMemoFailures) { + const location = failure.line && failure.column ? `:${failure.line}:${failure.column}` : ''; + logError(`${TAB}${failure.file}${location}`); + logNote(`${TAB}${TAB}${failure.reason ?? 'No reason provided'}`); + } + log(); + } +} + function getLineAndColumnFromIndex(source: string, index: number): {line: number; column: number} { const substring = source.slice(0, index); const line = substring.split('\n').length; @@ -609,7 +652,12 @@ async function filterResultsByDiff( }; } -function printResults({success, failures, suppressedFailures}: CompilerResults, {shouldPrintSuccesses, shouldPrintSuppressedErrors}: PrintResultsOptions): void { +function printResults({success, failures, suppressedFailures}: CompilerResults, {shouldPrintSuccesses, shouldPrintSuppressedErrors, newComponentSummary}: PrintResultsOptions): void { + if (newComponentSummary) { + printNewComponentFailuresSection(newComponentSummary); + } + const newComponentFailureKeys = createNewComponentFailureKeySet(newComponentSummary); + if (shouldPrintSuccesses && success.size > 0) { log(); logSuccess(`Successfully compiled ${success.size} files with React Compiler:`); @@ -652,7 +700,8 @@ function printResults({success, failures, suppressedFailures}: CompilerResults, log(); } - const isPassed = failures.size === 0; + const filteredFailures = filterOutNewComponentFailures(failures, newComponentFailureKeys); + const isPassed = filteredFailures.size === 0; if (isPassed) { logSuccess('All files pass React Compiler compliance check!'); return; @@ -669,7 +718,7 @@ function printResults({success, failures, suppressedFailures}: CompilerResults, log(); // eslint-disable-next-line unicorn/no-array-for-each - failures.forEach((failure) => { + filteredFailures.forEach((failure) => { const location = failure.line && failure.column ? `:${failure.line}:${failure.column}` : ''; logBold(`${failure.file}${location}`); logNote(`${TAB}${failure.reason ?? 'No reason provided'}`); From fcc3e7792ad76d252bae0f22f84e9e1c65f5e8d9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 14:09:43 +0100 Subject: [PATCH 04/45] fix: improve output and group together compiler errors with auto memo message --- scripts/react-compiler-compliance-check.ts | 219 +++++++++------------ 1 file changed, 90 insertions(+), 129 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 8ee8929af3308..13cc1816c6e2a 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -45,6 +45,8 @@ const MANUAL_MEMOIZATION_PATTERNS: ManualMemoizationPattern[] = [ }, ]; +const MANUAL_MEMOIZATION_FAILURE_MESSAGE = `Manual memoization is not allowed in new React component files. Please remove any manual memoization functions (\`useMemo\`, \`useCallback\`, \`memo\`) or use the \`"use no memo"\` directive at the beginning of the component.`; + const NO_MANUAL_MEMO_DIRECTIVE_PATTERN = /["']use no memo["']\s*;?/; const ESLINT_DISABLE_PATTERNS = { @@ -60,14 +62,15 @@ const VERBOSE_OUTPUT_LINE_REGEXES = { REASON: /Reason: (.+)/, } as const satisfies Record; -type FailureMap = Map; - type CompilerResults = { success: Set; failures: FailureMap; suppressedFailures: FailureMap; + enforcedAddedComponentFailures?: EnforcedAddedComponentFailureMap; }; +type FailureMap = Map; + type CompilerFailure = { file: string; line?: number; @@ -75,6 +78,13 @@ type CompilerFailure = { reason?: string; }; +type EnforcedAddedComponentFailureMap = Map; + +type ManualMemoFailure = { + message: string; + compilerFailures: FailureMap | undefined; +}; + type ManualMemoizationMatch = { keyword: string; line: number; @@ -86,15 +96,9 @@ type DiffFilteringCommits = { toRef?: string; }; -type NewComponentEnforcementSummary = { - compilerFailures: CompilerFailure[]; - manualMemoFailures: CompilerFailure[]; -}; - type PrintResultsOptions = { shouldPrintSuccesses: boolean; shouldPrintSuppressedErrors: boolean; - newComponentSummary?: NewComponentEnforcementSummary | null; }; type BaseCheckOptions = PrintResultsOptions & { @@ -119,7 +123,6 @@ async function check({ shouldPrintSuppressedErrors = false, shouldEnforceNewComponents = false, }: CheckOptions): Promise { - let newComponentSummary: NewComponentEnforcementSummary | undefined; if (files) { logInfo(`Running React Compiler check for ${files.length} files or glob patterns...`); } else { @@ -140,11 +143,14 @@ async function check({ } if (shouldEnforceNewComponents) { - newComponentSummary = enforceNewComponentGuard(results, diffResult, files); + const {nonAutoMemoEnforcedFailures, addedComponentFailures} = enforceNewComponentGuard(results, diffResult); + + results.enforcedAddedComponentFailures = addedComponentFailures; + results.failures = nonAutoMemoEnforcedFailures; } } - printResults(results, {shouldPrintSuccesses, shouldPrintSuppressedErrors, newComponentSummary}); + printResults(results, {shouldPrintSuccesses, shouldPrintSuppressedErrors}); if (shouldGenerateReport) { generateReport(results, reportFileName); @@ -318,82 +324,69 @@ function createFilesGlob(files?: string[]): string | undefined { return `**/+(${files.join('|')})`; } -function getNewComponentFiles(diffResult: DiffResult, filesToCheck?: string[]): Set { - const newFiles = new Set(); - const fileFilter = filesToCheck ? new Set(filesToCheck) : undefined; - +function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffResult) { + const addedDiffFiles = new Set(); for (const file of diffResult.files) { - if (!Git.isAddedDiffFile(file)) { - continue; + if (Git.isAddedDiffFile(file)) { + addedDiffFiles.add(file.filePath); } + } + + const nonAutoMemoEnforcedFailures: FailureMap = new Map(); + const addedFileFailures = new Map(); + for (const [failureKey, failure] of failures) { + const addedFilePath = failure.file; - if (fileFilter && !fileFilter.has(file.filePath)) { + if (!addedDiffFiles.has(addedFilePath)) { + nonAutoMemoEnforcedFailures.set(failureKey, failure); continue; } - newFiles.add(file.filePath); - } - return newFiles; -} - -function enforceNewComponentGuard(results: CompilerResults, diffResult: DiffResult | undefined, filesToCheck?: string[]): NewComponentEnforcementSummary { - if (!diffResult) { - return { - compilerFailures: [], - manualMemoFailures: [], - }; - } + if (addedFileFailures.has(addedFilePath)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const existingAddedFileFailuresMap = addedFileFailures.get(addedFilePath)!; + existingAddedFileFailuresMap.set(failureKey, failure); + } - const newComponentFiles = getNewComponentFiles(diffResult, filesToCheck); - if (newComponentFiles.size === 0) { - return { - compilerFailures: [], - manualMemoFailures: [], - }; + addedFileFailures.set(addedFilePath, new Map([[failureKey, failure]])); } - const manualMemoFailures: CompilerFailure[] = []; - const compilerFailures: CompilerFailure[] = []; - collectCompilerFailuresForNewComponents(results.failures, newComponentFiles, compilerFailures); + function addNonAutoMemoEnforcedFailures(addedFilePath: string): void { + const addedFileFailuresMap = addedFileFailures.get(addedFilePath); - for (const filePath of newComponentFiles) { - const source = readSourceFile(filePath); - if (!source) { - continue; + if (!addedFileFailuresMap) { + return; } - if (hasManualMemoOptOutDirective(source)) { - continue; + for (const [failureKey, failure] of failures) { + nonAutoMemoEnforcedFailures.set(failureKey, failure); } + } - const hasSuppressedFailure = hasFailureForFile(results.suppressedFailures, filePath); - if (hasSuppressedFailure) { + const addedComponentFailures: EnforcedAddedComponentFailureMap = new Map(); + for (const addedFilePath of addedDiffFiles) { + const source = readSourceFile(addedFilePath); + if (!source || hasManualMemoOptOutDirective(source)) { + addNonAutoMemoEnforcedFailures(addedFilePath); continue; } const manualMemoMatches = findManualMemoizationMatches(source); if (manualMemoMatches.length === 0) { + addNonAutoMemoEnforcedFailures(addedFilePath); continue; } - const firstMatch = manualMemoMatches.at(0); - if (!firstMatch) { - continue; - } - - const failure: CompilerFailure = { - file: filePath, - line: firstMatch.line, - column: firstMatch.column, - reason: `Manual memoization (${firstMatch.keyword}) is not allowed in new components.`, + const manualMemoFailure: ManualMemoFailure = { + message: MANUAL_MEMOIZATION_FAILURE_MESSAGE, + compilerFailures: addedFileFailures.get(addedFilePath), }; - manualMemoFailures.push(failure); - addFailureIfDoesNotExist(results.failures, failure); + addedComponentFailures.set(addedFilePath, manualMemoFailure); } return { - compilerFailures, - manualMemoFailures, + nonAutoMemoEnforcedFailures, + addedComponentFailures, }; } @@ -422,56 +415,6 @@ function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] return matches; } -function hasFailureForFile(failureMap: FailureMap, filePath: string): boolean { - for (const failure of failureMap.values()) { - if (failure.file === filePath) { - return true; - } - } - return false; -} - -function collectCompilerFailuresForNewComponents(failureMap: FailureMap, newComponentFiles: Set, bucket: CompilerFailure[]): void { - for (const failure of failureMap.values()) { - if (!newComponentFiles.has(failure.file)) { - continue; - } - bucket.push(failure); - } -} - -function printNewComponentFailuresSection({compilerFailures, manualMemoFailures}: NewComponentEnforcementSummary): void { - const hasCompilerIssues = compilerFailures.length > 0; - const hasManualMemoIssues = manualMemoFailures.length > 0; - if (!hasCompilerIssues && !hasManualMemoIssues) { - return; - } - - log(); - logWarn('Added files with enforced automatic memoization:'); - log(); - - if (hasCompilerIssues) { - logBold('Compiler errors:'); - for (const failure of compilerFailures) { - const location = failure.line && failure.column ? `:${failure.line}:${failure.column}` : ''; - logError(`${TAB}${failure.file}${location}`); - logNote(`${TAB}${TAB}${failure.reason ?? 'No reason provided'}`); - } - log(); - } - - if (hasManualMemoIssues) { - logBold('Manual memoization detected:'); - for (const failure of manualMemoFailures) { - const location = failure.line && failure.column ? `:${failure.line}:${failure.column}` : ''; - logError(`${TAB}${failure.file}${location}`); - logNote(`${TAB}${TAB}${failure.reason ?? 'No reason provided'}`); - } - log(); - } -} - function getLineAndColumnFromIndex(source: string, index: number): {line: number; column: number} { const substring = source.slice(0, index); const line = substring.split('\n').length; @@ -652,21 +595,19 @@ async function filterResultsByDiff( }; } -function printResults({success, failures, suppressedFailures}: CompilerResults, {shouldPrintSuccesses, shouldPrintSuppressedErrors, newComponentSummary}: PrintResultsOptions): void { - if (newComponentSummary) { - printNewComponentFailuresSection(newComponentSummary); - } - const newComponentFailureKeys = createNewComponentFailureKeySet(newComponentSummary); - +function printResults( + {success, failures, suppressedFailures, enforcedAddedComponentFailures}: CompilerResults, + {shouldPrintSuccesses, shouldPrintSuppressedErrors}: PrintResultsOptions, +): void { if (shouldPrintSuccesses && success.size > 0) { log(); logSuccess(`Successfully compiled ${success.size} files with React Compiler:`); log(); // eslint-disable-next-line unicorn/no-array-for-each - success.forEach((successFile) => { + for (const successFile of success) { logSuccess(`${successFile}`); - }); + } log(); } @@ -700,8 +641,9 @@ function printResults({success, failures, suppressedFailures}: CompilerResults, log(); } - const filteredFailures = filterOutNewComponentFailures(failures, newComponentFailureKeys); - const isPassed = filteredFailures.size === 0; + const hasEnforcedAddedComponentFailures = enforcedAddedComponentFailures && enforcedAddedComponentFailures.size > 0; + + const isPassed = failures.size === 0 && !hasEnforcedAddedComponentFailures; if (isPassed) { logSuccess('All files pass React Compiler compliance check!'); return; @@ -709,20 +651,39 @@ function printResults({success, failures, suppressedFailures}: CompilerResults, const distinctFileNames = new Set(); // eslint-disable-next-line unicorn/no-array-for-each - failures.forEach((failure) => { + for (const failure of failures.values()) { distinctFileNames.add(failure.file); - }); + } log(); logError(`Failed to compile ${distinctFileNames.size} files with React Compiler:`); log(); - // eslint-disable-next-line unicorn/no-array-for-each - filteredFailures.forEach((failure) => { - const location = failure.line && failure.column ? `:${failure.line}:${failure.column}` : ''; - logBold(`${failure.file}${location}`); - logNote(`${TAB}${failure.reason ?? 'No reason provided'}`); - }); + function printFailures(failuresToPrint: FailureMap, level = 0) { + for (const failure of failuresToPrint.values()) { + const location = failure.line && failure.column ? `:${failure.line}:${failure.column}` : ''; + logBold(`${TAB.repeat(level)}${failure.file}${location}`); + logNote(`${TAB.repeat(level + 1)}${failure.reason ?? 'No reason provided'}`); + } + } + + printFailures(failures); + + log(); + logError(`These newly added component files were enforced to be automatically memoized with React Compiler:`); + log(); + + if (hasEnforcedAddedComponentFailures) { + for (const [filePath, {message, compilerFailures}] of enforcedAddedComponentFailures.entries()) { + logBold(`${filePath}:`); + logNote(`${TAB}${message}`); + + if (compilerFailures) { + logNote(`${TAB}Additional failures:`); + printFailures(compilerFailures, 1); + } + } + } log(); logError('The files above failed to compile with React Compiler, probably because of Rules of React violations. Please fix the issues and run the check again.'); From 0380a131a375e2163b4bfcb782700761e29f70dc Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 14:22:06 +0100 Subject: [PATCH 05/45] refactor: move around functions --- scripts/react-compiler-compliance-check.ts | 226 +++++++++++---------- 1 file changed, 116 insertions(+), 110 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 13cc1816c6e2a..5f59329426d36 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -45,7 +45,8 @@ const MANUAL_MEMOIZATION_PATTERNS: ManualMemoizationPattern[] = [ }, ]; -const MANUAL_MEMOIZATION_FAILURE_MESSAGE = `Manual memoization is not allowed in new React component files. Please remove any manual memoization functions (\`useMemo\`, \`useCallback\`, \`memo\`) or use the \`"use no memo"\` directive at the beginning of the component.`; +const MANUAL_MEMOIZATION_FAILURE_MESSAGE = + 'Manual memoization is not allowed in new React component files. Please remove any manual memoization functions (`useMemo`, `useCallback`, `memo`) or use the `"use no memo"` directive at the beginning of the component.'; const NO_MANUAL_MEMO_DIRECTIVE_PATTERN = /["']use no memo["']\s*;?/; @@ -324,115 +325,6 @@ function createFilesGlob(files?: string[]): string | undefined { return `**/+(${files.join('|')})`; } -function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffResult) { - const addedDiffFiles = new Set(); - for (const file of diffResult.files) { - if (Git.isAddedDiffFile(file)) { - addedDiffFiles.add(file.filePath); - } - } - - const nonAutoMemoEnforcedFailures: FailureMap = new Map(); - const addedFileFailures = new Map(); - for (const [failureKey, failure] of failures) { - const addedFilePath = failure.file; - - if (!addedDiffFiles.has(addedFilePath)) { - nonAutoMemoEnforcedFailures.set(failureKey, failure); - continue; - } - - if (addedFileFailures.has(addedFilePath)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const existingAddedFileFailuresMap = addedFileFailures.get(addedFilePath)!; - existingAddedFileFailuresMap.set(failureKey, failure); - } - - addedFileFailures.set(addedFilePath, new Map([[failureKey, failure]])); - } - - function addNonAutoMemoEnforcedFailures(addedFilePath: string): void { - const addedFileFailuresMap = addedFileFailures.get(addedFilePath); - - if (!addedFileFailuresMap) { - return; - } - - for (const [failureKey, failure] of failures) { - nonAutoMemoEnforcedFailures.set(failureKey, failure); - } - } - - const addedComponentFailures: EnforcedAddedComponentFailureMap = new Map(); - for (const addedFilePath of addedDiffFiles) { - const source = readSourceFile(addedFilePath); - if (!source || hasManualMemoOptOutDirective(source)) { - addNonAutoMemoEnforcedFailures(addedFilePath); - continue; - } - - const manualMemoMatches = findManualMemoizationMatches(source); - if (manualMemoMatches.length === 0) { - addNonAutoMemoEnforcedFailures(addedFilePath); - continue; - } - - const manualMemoFailure: ManualMemoFailure = { - message: MANUAL_MEMOIZATION_FAILURE_MESSAGE, - compilerFailures: addedFileFailures.get(addedFilePath), - }; - addedComponentFailures.set(addedFilePath, manualMemoFailure); - } - - return { - nonAutoMemoEnforcedFailures, - addedComponentFailures, - }; -} - -function hasManualMemoOptOutDirective(source: string): boolean { - return NO_MANUAL_MEMO_DIRECTIVE_PATTERN.test(source); -} - -function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] { - const matches: ManualMemoizationMatch[] = []; - - for (const pattern of MANUAL_MEMOIZATION_PATTERNS) { - pattern.regex.lastIndex = 0; - let regexMatch: RegExpExecArray | null; - // eslint-disable-next-line no-cond-assign - while ((regexMatch = pattern.regex.exec(source)) !== null) { - const matchIndex = regexMatch.index; - const {line, column} = getLineAndColumnFromIndex(source, matchIndex); - matches.push({ - keyword: pattern.keyword, - line, - column, - }); - } - } - - return matches; -} - -function getLineAndColumnFromIndex(source: string, index: number): {line: number; column: number} { - const substring = source.slice(0, index); - const line = substring.split('\n').length; - const lastLineBreakIndex = substring.lastIndexOf('\n'); - const column = lastLineBreakIndex === -1 ? index + 1 : index - lastLineBreakIndex; - return {line, column}; -} - -function readSourceFile(filePath: string): string | null { - try { - const absolutePath = join(process.cwd(), filePath); - return readFileSync(absolutePath, 'utf8'); - } catch (error) { - logWarn(`Unable to read ${filePath} while enforcing new component rules.`, error); - return null; - } -} - /** * Filters compiler results to only include failures for lines that were changed in the git diff. * This helps focus on new issues introduced by the current changes rather than pre-existing issues. @@ -595,6 +487,120 @@ async function filterResultsByDiff( }; } +function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffResult) { + const addedDiffFiles = new Set(); + for (const file of diffResult.files) { + if (Git.isAddedDiffFile(file)) { + addedDiffFiles.add(file.filePath); + } + } + + // Partition failures into non-auto memo enforced failures and added file failures + const nonAutoMemoEnforcedFailures: FailureMap = new Map(); + const addedFileFailures = new Map(); + for (const [failureKey, failure] of failures) { + const addedFilePath = failure.file; + + if (!addedDiffFiles.has(addedFilePath)) { + nonAutoMemoEnforcedFailures.set(failureKey, failure); + continue; + } + + if (addedFileFailures.has(addedFilePath)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const existingAddedFileFailuresMap = addedFileFailures.get(addedFilePath)!; + existingAddedFileFailuresMap.set(failureKey, failure); + } + + addedFileFailures.set(addedFilePath, new Map([[failureKey, failure]])); + } + + // Used as fallback to add back the failures from added files that didn't have manual memoization + function addNonAutoMemoEnforcedFailures(addedFilePath: string): void { + const addedFileFailuresMap = addedFileFailures.get(addedFilePath); + + if (!addedFileFailuresMap) { + return; + } + + for (const [failureKey, failure] of failures) { + nonAutoMemoEnforcedFailures.set(failureKey, failure); + } + } + + const addedComponentFailures: EnforcedAddedComponentFailureMap = new Map(); + for (const addedFilePath of addedDiffFiles) { + const source = readSourceFile(addedFilePath); + if (!source || hasManualMemoOptOutDirective(source)) { + addNonAutoMemoEnforcedFailures(addedFilePath); + continue; + } + + const manualMemoMatches = findManualMemoizationMatches(source); + + console.log('manualMemoMatches', manualMemoMatches); + + if (manualMemoMatches.length === 0) { + addNonAutoMemoEnforcedFailures(addedFilePath); + continue; + } + + const manualMemoFailure: ManualMemoFailure = { + message: MANUAL_MEMOIZATION_FAILURE_MESSAGE, + compilerFailures: addedFileFailures.get(addedFilePath), + }; + addedComponentFailures.set(addedFilePath, manualMemoFailure); + } + + return { + nonAutoMemoEnforcedFailures, + addedComponentFailures, + }; +} + +function hasManualMemoOptOutDirective(source: string): boolean { + return NO_MANUAL_MEMO_DIRECTIVE_PATTERN.test(source); +} + +function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] { + const matches: ManualMemoizationMatch[] = []; + + for (const pattern of MANUAL_MEMOIZATION_PATTERNS) { + pattern.regex.lastIndex = 0; + let regexMatch: RegExpExecArray | null; + // eslint-disable-next-line no-cond-assign + while ((regexMatch = pattern.regex.exec(source)) !== null) { + const matchIndex = regexMatch.index; + const {line, column} = getLineAndColumnFromIndex(source, matchIndex); + matches.push({ + keyword: pattern.keyword, + line, + column, + }); + } + } + + return matches; +} + +function getLineAndColumnFromIndex(source: string, index: number): {line: number; column: number} { + const substring = source.slice(0, index); + const line = substring.split('\n').length; + const lastLineBreakIndex = substring.lastIndexOf('\n'); + const column = lastLineBreakIndex === -1 ? index + 1 : index - lastLineBreakIndex; + return {line, column}; +} + +function readSourceFile(filePath: string): string | null { + try { + const absolutePath = join(process.cwd(), filePath); + return readFileSync(absolutePath, 'utf8'); + } catch (error) { + logWarn(`Unable to read ${filePath} while enforcing new component rules.`, error); + return null; + } +} + function printResults( {success, failures, suppressedFailures, enforcedAddedComponentFailures}: CompilerResults, {shouldPrintSuccesses, shouldPrintSuppressedErrors}: PrintResultsOptions, From e503e0ecf4639bed0b9d5d54ee627cfbba89b1e0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 14:32:54 +0100 Subject: [PATCH 06/45] only print failures if there are any --- scripts/react-compiler-compliance-check.ts | 30 ++++++++++++---------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 5f59329426d36..39d9123a19863 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -655,16 +655,6 @@ function printResults( return; } - const distinctFileNames = new Set(); - // eslint-disable-next-line unicorn/no-array-for-each - for (const failure of failures.values()) { - distinctFileNames.add(failure.file); - } - - log(); - logError(`Failed to compile ${distinctFileNames.size} files with React Compiler:`); - log(); - function printFailures(failuresToPrint: FailureMap, level = 0) { for (const failure of failuresToPrint.values()) { const location = failure.line && failure.column ? `:${failure.line}:${failure.column}` : ''; @@ -673,13 +663,25 @@ function printResults( } } - printFailures(failures); + const distinctFileNames = new Set(); + // eslint-disable-next-line unicorn/no-array-for-each + for (const failure of failures.values()) { + distinctFileNames.add(failure.file); + } - log(); - logError(`These newly added component files were enforced to be automatically memoized with React Compiler:`); - log(); + if (distinctFileNames.size > 0) { + log(); + logError(`Failed to compile ${distinctFileNames.size} files with React Compiler:`); + log(); + + printFailures(failures); + } if (hasEnforcedAddedComponentFailures) { + log(); + logError(`These newly added component files were enforced to be automatically memoized with React Compiler:`); + log(); + for (const [filePath, {message, compilerFailures}] of enforcedAddedComponentFailures.entries()) { logBold(`${filePath}:`); logNote(`${TAB}${message}`); From 47d2d5a00e3c4b481b8ba559405012cf81c8bd3d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 14:33:07 +0100 Subject: [PATCH 07/45] refactor: no condition assignment --- scripts/react-compiler-compliance-check.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 39d9123a19863..cf8e372b81389 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -565,18 +565,14 @@ function hasManualMemoOptOutDirective(source: string): boolean { function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] { const matches: ManualMemoizationMatch[] = []; + let regexMatch: RegExpExecArray | null; for (const pattern of MANUAL_MEMOIZATION_PATTERNS) { pattern.regex.lastIndex = 0; - let regexMatch: RegExpExecArray | null; - // eslint-disable-next-line no-cond-assign - while ((regexMatch = pattern.regex.exec(source)) !== null) { + regexMatch = pattern.regex.exec(source); + if (regexMatch) { const matchIndex = regexMatch.index; const {line, column} = getLineAndColumnFromIndex(source, matchIndex); - matches.push({ - keyword: pattern.keyword, - line, - column, - }); + matches.push({keyword: pattern.keyword, line, column}); } } From 3bb4e04332d34d4a969e4beca9ed5ff21b93576e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 14:33:24 +0100 Subject: [PATCH 08/45] store manual memo matches --- scripts/react-compiler-compliance-check.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index cf8e372b81389..653cdc5e1356d 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -83,6 +83,7 @@ type EnforcedAddedComponentFailureMap = Map; type ManualMemoFailure = { message: string; + manualMemoizationMatches: ManualMemoizationMatch[]; compilerFailures: FailureMap | undefined; }; @@ -536,17 +537,18 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR continue; } - const manualMemoMatches = findManualMemoizationMatches(source); + const manualMemoizationMatches = findManualMemoizationMatches(source); console.log('manualMemoMatches', manualMemoMatches); - if (manualMemoMatches.length === 0) { + if (manualMemoizationMatches.length === 0) { addNonAutoMemoEnforcedFailures(addedFilePath); continue; } const manualMemoFailure: ManualMemoFailure = { message: MANUAL_MEMOIZATION_FAILURE_MESSAGE, + manualMemoizationMatches, compilerFailures: addedFileFailures.get(addedFilePath), }; addedComponentFailures.set(addedFilePath, manualMemoFailure); From a98620e1d60121739a4c656f298d5f84060e1e01 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 15:19:57 +0100 Subject: [PATCH 09/45] fix: `isAddedFile` util not working correctly --- scripts/utils/Git.ts | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/scripts/utils/Git.ts b/scripts/utils/Git.ts index ce1a5ea94bf07..3c21241deba03 100644 --- a/scripts/utils/Git.ts +++ b/scripts/utils/Git.ts @@ -289,31 +289,15 @@ class Git { const hasModifiedLines = file.modifiedLines.size > 0; const hasRemovedLines = file.removedLines.size > 0; - if (!hasAddedLines) { + if (!hasAddedLines || hasModifiedLines || hasRemovedLines) { return false; } - const hasOnlyAdditions = !hasModifiedLines && !hasRemovedLines; - return hasOnlyAdditions; - } - - /** - * Check if a file from a Git diff is removed. - * - * @param file - The file to check - * @returns true if the file is removed, false otherwise - */ - static isRemovedDiffFile(file: FileDiff): boolean { - const hasRemovedLines = file.removedLines.size > 0; - const hasModifiedLines = file.modifiedLines.size > 0; - const hasAddedLines = file.addedLines.size > 0; - - if (!hasRemovedLines) { - return false; + if (file.hunks.length === 1 && file.hunks.at(0)!.oldStart === 0) { + return true; } - const hasOnlyRemovals = !hasModifiedLines && !hasAddedLines; - return hasOnlyRemovals; + return false; } /** From 738e50bcd960ecd073820127f799011b7b864776 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 15:26:47 +0100 Subject: [PATCH 10/45] Create RCAutoMemoComponent.tsx --- src/components/RCAutoMemoComponent.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/components/RCAutoMemoComponent.tsx diff --git a/src/components/RCAutoMemoComponent.tsx b/src/components/RCAutoMemoComponent.tsx new file mode 100644 index 0000000000000..070f8441093e2 --- /dev/null +++ b/src/components/RCAutoMemoComponent.tsx @@ -0,0 +1,11 @@ +import {useMemo} from 'react'; + +function RCAutoMemoComponent() { + const someValue = useMemo(() => { + return 'someValue'; + }, []); + + return
{someValue}
; +} + +export default RCAutoMemoComponent; From 8795d767562fabceb9047136a88657d64fd70994 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 15:29:51 +0100 Subject: [PATCH 11/45] fix: print manual memo usages individually --- scripts/react-compiler-compliance-check.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 653cdc5e1356d..b0171c354b91e 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -82,7 +82,6 @@ type CompilerFailure = { type EnforcedAddedComponentFailureMap = Map; type ManualMemoFailure = { - message: string; manualMemoizationMatches: ManualMemoizationMatch[]; compilerFailures: FailureMap | undefined; }; @@ -532,22 +531,19 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR const addedComponentFailures: EnforcedAddedComponentFailureMap = new Map(); for (const addedFilePath of addedDiffFiles) { const source = readSourceFile(addedFilePath); - if (!source || hasManualMemoOptOutDirective(source)) { + if (!source || NO_MANUAL_MEMO_DIRECTIVE_PATTERN.test(source)) { addNonAutoMemoEnforcedFailures(addedFilePath); continue; } const manualMemoizationMatches = findManualMemoizationMatches(source); - console.log('manualMemoMatches', manualMemoMatches); - if (manualMemoizationMatches.length === 0) { addNonAutoMemoEnforcedFailures(addedFilePath); continue; } const manualMemoFailure: ManualMemoFailure = { - message: MANUAL_MEMOIZATION_FAILURE_MESSAGE, manualMemoizationMatches, compilerFailures: addedFileFailures.get(addedFilePath), }; @@ -560,10 +556,6 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR }; } -function hasManualMemoOptOutDirective(source: string): boolean { - return NO_MANUAL_MEMO_DIRECTIVE_PATTERN.test(source); -} - function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] { const matches: ManualMemoizationMatch[] = []; @@ -680,12 +672,16 @@ function printResults( logError(`These newly added component files were enforced to be automatically memoized with React Compiler:`); log(); - for (const [filePath, {message, compilerFailures}] of enforcedAddedComponentFailures.entries()) { - logBold(`${filePath}:`); - logNote(`${TAB}${message}`); + for (const [filePath, {manualMemoizationMatches, compilerFailures}] of enforcedAddedComponentFailures.entries()) { + for (const manualMemoizationMatch of manualMemoizationMatches) { + const location = manualMemoizationMatch.line && manualMemoizationMatch.column ? `:${manualMemoizationMatch.line}:${manualMemoizationMatch.column}` : ''; + logBold(`${filePath}${location}`); + logNote(`${TAB}${MANUAL_MEMOIZATION_FAILURE_MESSAGE}`); + } if (compilerFailures) { - logNote(`${TAB}Additional failures:`); + log(); + logBold(`${TAB}Additional React Compiler errors:`); printFailures(compilerFailures, 1); } } From 52714ec5960f29ca5f9b4ed6691e20054e505575 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 15:30:30 +0100 Subject: [PATCH 12/45] fix: no memo message --- 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 b0171c354b91e..b633bb339ad59 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -46,7 +46,7 @@ const MANUAL_MEMOIZATION_PATTERNS: ManualMemoizationPattern[] = [ ]; const MANUAL_MEMOIZATION_FAILURE_MESSAGE = - 'Manual memoization is not allowed in new React component files. Please remove any manual memoization functions (`useMemo`, `useCallback`, `memo`) or use the `"use no memo"` directive at the beginning of the component.'; + 'Manual memoization is not allowed in new React component files. Please remove any manual memoization functions (`useMemo`, `useCallback`, `memo`) or use the `"use no memo";` directive at the beginning of the component.'; const NO_MANUAL_MEMO_DIRECTIVE_PATTERN = /["']use no memo["']\s*;?/; From 3604b752d49a62ccbbdf6e232b8a51dce9ba2a60 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 17:55:50 +0100 Subject: [PATCH 13/45] fix: update manual memo message and log specific manual memo keyword --- scripts/react-compiler-compliance-check.ts | 59 +++++++++------------- 1 file changed, 24 insertions(+), 35 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index b633bb339ad59..71bc9706cdf97 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -25,28 +25,16 @@ const SUPPRESSED_COMPILER_ERRORS = [ '(BuildHIR::lowerExpression) Expected Identifier, got MemberExpression key in ObjectExpression', ] as const satisfies string[]; -type ManualMemoizationPattern = { - keyword: string; - regex: RegExp; -}; +const MANUAL_MEMOIZATION_PATTERNS = { + memo: /\b(?:React\.)?memo\s*\(/g, + useMemo: /\b(?:React\.)?useMemo\s*\(/g, + useCallback: /\b(?:React\.)?useCallback\s*\(/g, +} as const satisfies Record; + +type ManualMemoizationKeyword = keyof typeof MANUAL_MEMOIZATION_PATTERNS; -const MANUAL_MEMOIZATION_PATTERNS: ManualMemoizationPattern[] = [ - { - keyword: 'memo', - regex: /\b(?:React\.)?memo\s*\(/g, - }, - { - keyword: 'useMemo', - regex: /\b(?:React\.)?useMemo\s*\(/g, - }, - { - keyword: 'useCallback', - regex: /\b(?:React\.)?useCallback\s*\(/g, - }, -]; - -const MANUAL_MEMOIZATION_FAILURE_MESSAGE = - 'Manual memoization is not allowed in new React component files. Please remove any manual memoization functions (`useMemo`, `useCallback`, `memo`) or use the `"use no memo";` directive at the beginning of the component.'; +const MANUAL_MEMOIZATION_FAILURE_MESSAGE = (manualMemoizationKeyword: ManualMemoizationKeyword) => + `Found a manual memoization usage of ${manualMemoizationKeyword}. Newly added React component files must not contain any manual memoization and instead be auto-memoized by React Compiler. Remove ${manualMemoizationKeyword} 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.`; const NO_MANUAL_MEMO_DIRECTIVE_PATTERN = /["']use no memo["']\s*;?/; @@ -87,7 +75,7 @@ type ManualMemoFailure = { }; type ManualMemoizationMatch = { - keyword: string; + keyword: ManualMemoizationKeyword; line: number; column: number; }; @@ -560,13 +548,14 @@ function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] const matches: ManualMemoizationMatch[] = []; let regexMatch: RegExpExecArray | null; - for (const pattern of MANUAL_MEMOIZATION_PATTERNS) { - pattern.regex.lastIndex = 0; - regexMatch = pattern.regex.exec(source); + for (const keyword of Object.keys(MANUAL_MEMOIZATION_PATTERNS) as ManualMemoizationKeyword[]) { + const regex = MANUAL_MEMOIZATION_PATTERNS[keyword]; + regex.lastIndex = 0; + regexMatch = regex.exec(source); if (regexMatch) { const matchIndex = regexMatch.index; const {line, column} = getLineAndColumnFromIndex(source, matchIndex); - matches.push({keyword: pattern.keyword, line, column}); + matches.push({keyword, line, column}); } } @@ -645,14 +634,6 @@ function printResults( return; } - function printFailures(failuresToPrint: FailureMap, level = 0) { - for (const failure of failuresToPrint.values()) { - const location = failure.line && failure.column ? `:${failure.line}:${failure.column}` : ''; - logBold(`${TAB.repeat(level)}${failure.file}${location}`); - logNote(`${TAB.repeat(level + 1)}${failure.reason ?? 'No reason provided'}`); - } - } - const distinctFileNames = new Set(); // eslint-disable-next-line unicorn/no-array-for-each for (const failure of failures.values()) { @@ -676,7 +657,7 @@ function printResults( for (const manualMemoizationMatch of manualMemoizationMatches) { const location = manualMemoizationMatch.line && manualMemoizationMatch.column ? `:${manualMemoizationMatch.line}:${manualMemoizationMatch.column}` : ''; logBold(`${filePath}${location}`); - logNote(`${TAB}${MANUAL_MEMOIZATION_FAILURE_MESSAGE}`); + logNote(`${TAB}${MANUAL_MEMOIZATION_FAILURE_MESSAGE(manualMemoizationMatch.keyword)}`); } if (compilerFailures) { @@ -691,6 +672,14 @@ function printResults( logError('The files above failed to compile with React Compiler, probably because of Rules of React violations. Please fix the issues and run the check again.'); } +function printFailures(failuresToPrint: FailureMap, level = 0) { + for (const failure of failuresToPrint.values()) { + const location = failure.line && failure.column ? `:${failure.line}:${failure.column}` : ''; + logBold(`${TAB.repeat(level)}${failure.file}${location}`); + logNote(`${TAB.repeat(level + 1)}${failure.reason ?? 'No reason provided'}`); + } +} + function generateReport(results: CompilerResults, outputFileName = DEFAULT_REPORT_FILENAME): void { log('\n'); logInfo('Creating React Compiler Compliance Check report:'); From 1842f671f258b135dc1d328eceb94f615badd852 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 17:58:23 +0100 Subject: [PATCH 14/45] feat: enable auto-memo enforcement in workflow --- .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 e8900a6ed6344..8df910f0b2597 100644 --- a/.github/workflows/react-compiler-compliance.yml +++ b/.github/workflows/react-compiler-compliance.yml @@ -28,7 +28,7 @@ jobs: # In phase 0 of the React Compiler compliance check rollout, # we want to report failures but don't fail the check. # See https://github.com/Expensify/App/issues/68765#issuecomment-3487317881 - run: npm run react-compiler-compliance-check check-changed || true + run: npm run react-compiler-compliance-check check-changed --enforce-new-components || true env: CI: true GITHUB_TOKEN: ${{ github.token }} From 644d4d98ff02acbd9756a355ef1f7228793bc880 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 17:58:27 +0100 Subject: [PATCH 15/45] Update RCAutoMemoComponent.tsx --- src/components/RCAutoMemoComponent.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/RCAutoMemoComponent.tsx b/src/components/RCAutoMemoComponent.tsx index 070f8441093e2..f6a7af9ad8135 100644 --- a/src/components/RCAutoMemoComponent.tsx +++ b/src/components/RCAutoMemoComponent.tsx @@ -1,11 +1,16 @@ -import {useMemo} from 'react'; +import {useCallback, useMemo} from 'react'; +import {Pressable} from 'react-native'; function RCAutoMemoComponent() { const someValue = useMemo(() => { return 'someValue'; }, []); - return
{someValue}
; + const someCallback = useCallback(() => { + return 'someValue'; + }, []); + + return {someValue}; } export default RCAutoMemoComponent; From f49d6f6e34402540e12a0c65ab617b205fc14e7a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 18:00:17 +0100 Subject: [PATCH 16/45] fix: invalid check flag --- .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 8df910f0b2597..6e419beec172e 100644 --- a/.github/workflows/react-compiler-compliance.yml +++ b/.github/workflows/react-compiler-compliance.yml @@ -28,7 +28,7 @@ jobs: # In phase 0 of the React Compiler compliance check rollout, # we want to report failures but don't fail the check. # See https://github.com/Expensify/App/issues/68765#issuecomment-3487317881 - run: npm run react-compiler-compliance-check check-changed --enforce-new-components || true + run: npm run react-compiler-compliance-check check-changed --enforceNewComponents || true env: CI: true GITHUB_TOKEN: ${{ github.token }} From 6e5496b95a362a08ba16a13feffe368f486f3002 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 18:04:14 +0100 Subject: [PATCH 17/45] Update RCAutoMemoComponent.tsx --- src/components/RCAutoMemoComponent.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/RCAutoMemoComponent.tsx b/src/components/RCAutoMemoComponent.tsx index f6a7af9ad8135..abaebfcca7e01 100644 --- a/src/components/RCAutoMemoComponent.tsx +++ b/src/components/RCAutoMemoComponent.tsx @@ -10,6 +10,7 @@ function RCAutoMemoComponent() { return 'someValue'; }, []); + // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors return {someValue}; } From 01d84d6dcfb24825ba9e8c2679a9578ffe699f75 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 18:04:19 +0100 Subject: [PATCH 18/45] fix: TS errors --- scripts/utils/Git.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/utils/Git.ts b/scripts/utils/Git.ts index 3c21241deba03..8c7f15ee106d7 100644 --- a/scripts/utils/Git.ts +++ b/scripts/utils/Git.ts @@ -293,7 +293,7 @@ class Git { return false; } - if (file.hunks.length === 1 && file.hunks.at(0)!.oldStart === 0) { + if (file.hunks.length === 1 && file.hunks.at(0)?.oldStart === 0) { return true; } From 951b21b67b49517ab027cd77337ae28b13c0c497 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 18:06:27 +0100 Subject: [PATCH 19/45] add logs --- scripts/react-compiler-compliance-check.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 71bc9706cdf97..58805f6f12347 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -483,6 +483,9 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR } } + console.log(addedDiffFiles); + console.log(diffResult); + // Partition failures into non-auto memo enforced failures and added file failures const nonAutoMemoEnforcedFailures: FailureMap = new Map(); const addedFileFailures = new Map(); @@ -526,6 +529,8 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR const manualMemoizationMatches = findManualMemoizationMatches(source); + console.log(manualMemoizationMatches); + if (manualMemoizationMatches.length === 0) { addNonAutoMemoEnforcedFailures(addedFilePath); continue; From 5ad1b132c8d5be2ea679f026e24d7b30ede764f3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 18:11:18 +0100 Subject: [PATCH 20/45] add log --- scripts/react-compiler-compliance-check.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 58805f6f12347..119850822778e 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -118,11 +118,12 @@ async function check({ logInfo('Running React Compiler check for all files...'); } - const shouldComputeDiff = shouldFilterByDiff || shouldEnforceNewComponents; + console.log(shouldEnforceNewComponents); + const src = createFilesGlob(files); let results = runCompilerHealthcheck(src); - if (shouldComputeDiff) { + if (shouldFilterByDiff || shouldEnforceNewComponents) { const mainBaseCommitHash = await Git.getMainBranchCommitHash(remote); const diffFilteringCommits: DiffFilteringCommits = {fromRef: mainBaseCommitHash}; const diffResult = Git.diff(diffFilteringCommits.fromRef, diffFilteringCommits.toRef); From 83313ac759057d6f49f1cf4d17bf00077562b4a9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 18:19:22 +0100 Subject: [PATCH 21/45] fix: NPM script params need to be properly forwarded --- .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 6e419beec172e..9c067268dee42 100644 --- a/.github/workflows/react-compiler-compliance.yml +++ b/.github/workflows/react-compiler-compliance.yml @@ -28,7 +28,7 @@ jobs: # In phase 0 of the React Compiler compliance check rollout, # we want to report failures but don't fail the check. # See https://github.com/Expensify/App/issues/68765#issuecomment-3487317881 - run: npm run react-compiler-compliance-check check-changed --enforceNewComponents || true + run: npm run react-compiler-compliance-check check-changed -- --enforceNewComponents || true env: CI: true GITHUB_TOKEN: ${{ github.token }} From 891fd33f72c1c94955f8a855534828da1b7cb067 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 18:22:06 +0100 Subject: [PATCH 22/45] chore: change workflow so that it can fail --- .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 9c067268dee42..e67c2a694de37 100644 --- a/.github/workflows/react-compiler-compliance.yml +++ b/.github/workflows/react-compiler-compliance.yml @@ -28,7 +28,7 @@ jobs: # In phase 0 of the React Compiler compliance check rollout, # we want to report failures but don't fail the check. # See https://github.com/Expensify/App/issues/68765#issuecomment-3487317881 - run: npm run react-compiler-compliance-check check-changed -- --enforceNewComponents || true + run: npm run react-compiler-compliance-check check-changed -- --enforceNewComponents env: CI: true GITHUB_TOKEN: ${{ github.token }} From c8cbd30e41eab3ceac5a366e515f9a81a7e2dd7f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 18:23:31 +0100 Subject: [PATCH 23/45] remove logs --- scripts/react-compiler-compliance-check.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 119850822778e..8b8592694cb6a 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -118,8 +118,6 @@ async function check({ logInfo('Running React Compiler check for all files...'); } - console.log(shouldEnforceNewComponents); - const src = createFilesGlob(files); let results = runCompilerHealthcheck(src); @@ -484,9 +482,6 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR } } - console.log(addedDiffFiles); - console.log(diffResult); - // Partition failures into non-auto memo enforced failures and added file failures const nonAutoMemoEnforcedFailures: FailureMap = new Map(); const addedFileFailures = new Map(); From 3443b120c0e7410268002abc9e3da03a2803ad4e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 18:23:49 +0100 Subject: [PATCH 24/45] remove log --- scripts/react-compiler-compliance-check.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 8b8592694cb6a..ac78915a93064 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -525,8 +525,6 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR const manualMemoizationMatches = findManualMemoizationMatches(source); - console.log(manualMemoizationMatches); - if (manualMemoizationMatches.length === 0) { addNonAutoMemoEnforcedFailures(addedFilePath); continue; From a7157e5955863099ea9f2a30334aa4124529b0bd Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 18:28:07 +0100 Subject: [PATCH 25/45] docs: add JSDoc comments --- scripts/react-compiler-compliance-check.ts | 49 +++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index ac78915a93064..1d9e7b60f8e6e 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -198,6 +198,11 @@ function addFailureIfDoesNotExist(failureMap: FailureMap, newFailure: CompilerFa return true; } +/** + * Parses the output of the react-compiler-healthcheck command and returns the compiler results. + * @param output - The output of the react-compiler-healthcheck command + * @returns The compiler results + */ function parseHealthcheckOutput(output: string): CompilerResults { const lines = output.split('\n'); @@ -284,6 +289,11 @@ function parseHealthcheckOutput(output: string): CompilerResults { return results; } +/** + * Checks if a compiler error should be suppressed based on the error reason. + * @param reason - The reason for the compiler error + * @returns True if the error should be suppressed, false otherwise + */ function shouldSuppressCompilerError(reason: string | undefined): boolean { if (!reason) { return false; @@ -293,6 +303,11 @@ function shouldSuppressCompilerError(reason: string | undefined): boolean { return SUPPRESSED_COMPILER_ERRORS.some((suppressedError) => reason.includes(suppressedError)); } +/** + * Creates a unique key for a compiler failure by combining the file path, line number, and column number. + * @param failure - The compiler failure to create a unique key for + * @returns A unique key for the compiler failure + */ function getUniqueFileKey({file, line, column}: CompilerFailure): string { const isLineSet = line !== undefined; const isLineAndColumnSet = isLineSet && column !== undefined; @@ -300,6 +315,11 @@ function getUniqueFileKey({file, line, column}: CompilerFailure): string { return file + (isLineSet ? `:${line}` : '') + (isLineAndColumnSet ? `:${column}` : ''); } +/** + * Creates a glob pattern from an array of file paths. + * @param files - The file paths to create a glob pattern from + * @returns A glob pattern string + */ function createFilesGlob(files?: string[]): string | undefined { if (!files || files.length === 0) { return undefined; @@ -392,7 +412,11 @@ async function filterResultsByDiff( } } - // Filter failures to only include those on changed lines and files/chunks for which an eslint-disable comment is was removed + /** + * Filter failures to only include those on changed lines and files/chunks for which an eslint-disable comment is was removed + * @param failures - The unfiltered compiler failures + * @returns The filtered compiler failures + */ function filterFailuresByChangedLines(failures: Map) { // Filter failures to only include those on changed lines const filteredFailures = new Map(); @@ -474,6 +498,12 @@ async function filterResultsByDiff( }; } +/** + * Enforces the new component guard by checking for manual memoization keywords in added files and attaching React compiler failures. + * @param failures - The compiler results to enforce the new component guard on + * @param diffResult - The diff result to check for added files + * @returns The enforced compiler results + */ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffResult) { const addedDiffFiles = new Set(); for (const file of diffResult.files) { @@ -515,6 +545,8 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR } } + // Check all added files for manual memoization keywords and attach React compiler failures. + // If no manual memoization keywords are found add the failures back to the regular failures. const addedComponentFailures: EnforcedAddedComponentFailureMap = new Map(); for (const addedFilePath of addedDiffFiles) { const source = readSourceFile(addedFilePath); @@ -543,6 +575,11 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR }; } +/** + * Finds all manual memoization keywords matches in source file and returns their line and column numbers. + * @param source - The source code to search for manual memoization matches + * @returns An array of manual memoization matches + */ function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] { const matches: ManualMemoizationMatch[] = []; @@ -579,6 +616,11 @@ function readSourceFile(filePath: string): string | null { } } +/** + * Prints the results of the React Compiler compliance check. + * @param results - The compiler results to print + * @param options - The options for printing the results + */ function printResults( {success, failures, suppressedFailures, enforcedAddedComponentFailures}: CompilerResults, {shouldPrintSuccesses, shouldPrintSuppressedErrors}: PrintResultsOptions, @@ -679,6 +721,11 @@ function printFailures(failuresToPrint: FailureMap, level = 0) { } } +/** + * Generates a report of the React Compiler compliance check. + * @param results - The compiler results to generate a report for + * @param outputFileName - The file name to save the report to + */ function generateReport(results: CompilerResults, outputFileName = DEFAULT_REPORT_FILENAME): void { log('\n'); logInfo('Creating React Compiler Compliance Check report:'); From 3f60d642686780c47a707b61f9d81a1be9e7d750 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 28 Nov 2025 18:30:37 +0100 Subject: [PATCH 26/45] fix: fail test if new files are not RC compatible --- 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 1d9e7b60f8e6e..f3a1bfd985f67 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -144,7 +144,7 @@ async function check({ generateReport(results, reportFileName); } - const isPassed = results.failures.size === 0; + const isPassed = results.failures.size === 0 && results.enforcedAddedComponentFailures?.size === 0; return isPassed; } From 999b482e0dfedee899b41f0a5b60b00fe00347a8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 2 Dec 2025 08:51:19 +0100 Subject: [PATCH 27/45] fix: pass test if no enforced added components --- 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 f3a1bfd985f67..37ee4c39fe3cb 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -144,7 +144,7 @@ async function check({ generateReport(results, reportFileName); } - const isPassed = results.failures.size === 0 && results.enforcedAddedComponentFailures?.size === 0; + const isPassed = results.failures.size === 0 && (results.enforcedAddedComponentFailures?.size ?? 0) === 0; return isPassed; } From 778bebe78e09ab3518b19e675894986c6fa117ce Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 2 Dec 2025 08:51:27 +0100 Subject: [PATCH 28/45] fix: update final error message --- 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 37ee4c39fe3cb..bf00fdb7348b9 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -710,7 +710,7 @@ function printResults( } log(); - logError('The files above failed to compile with React Compiler, probably because of Rules of React violations. Please fix the issues and run the check again.'); + logError('The files above failed the React Compiler compliance check. Please fix the issues and run the check again...'); } function printFailures(failuresToPrint: FailureMap, level = 0) { From 292eeef7325c6499a3b02590ccc1490487330abb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 2 Dec 2025 09:04:03 +0100 Subject: [PATCH 29/45] fix: missing continue --- scripts/react-compiler-compliance-check.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index bf00fdb7348b9..7748b854a0e52 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -523,10 +523,10 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR continue; } - if (addedFileFailures.has(addedFilePath)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const existingAddedFileFailuresMap = addedFileFailures.get(addedFilePath)!; + const existingAddedFileFailuresMap = addedFileFailures.get(addedFilePath); + if (existingAddedFileFailuresMap) { existingAddedFileFailuresMap.set(failureKey, failure); + continue; } addedFileFailures.set(addedFilePath, new Map([[failureKey, failure]])); From 40de5ffe30ef604dfb05fcb2540ca826da1c112a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 2 Dec 2025 09:05:14 +0100 Subject: [PATCH 30/45] fix: find multiple regex matches --- scripts/react-compiler-compliance-check.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 7748b854a0e52..ab1f92d1c2a0d 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -583,12 +583,12 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] { const matches: ManualMemoizationMatch[] = []; - let regexMatch: RegExpExecArray | null; for (const keyword of Object.keys(MANUAL_MEMOIZATION_PATTERNS) as ManualMemoizationKeyword[]) { const regex = MANUAL_MEMOIZATION_PATTERNS[keyword]; regex.lastIndex = 0; - regexMatch = regex.exec(source); - if (regexMatch) { + + let regexMatch: RegExpExecArray | null; + while ((regexMatch = regex.exec(source)) !== null) { const matchIndex = regexMatch.index; const {line, column} = getLineAndColumnFromIndex(source, matchIndex); matches.push({keyword, line, column}); From b1f2952f469062b450bb318405df55619a112ddb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 2 Dec 2025 09:07:29 +0100 Subject: [PATCH 31/45] fix: logic error --- 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 ab1f92d1c2a0d..3df68ded08f39 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -540,7 +540,7 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR return; } - for (const [failureKey, failure] of failures) { + for (const [failureKey, failure] of addedFileFailuresMap) { nonAutoMemoEnforcedFailures.set(failureKey, failure); } } From 3af2da6969a78a6688ef2be8129e28e1751934b5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 2 Dec 2025 09:11:34 +0100 Subject: [PATCH 32/45] fix: also check for Git diff hunk `oldCount` --- scripts/utils/Git.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/utils/Git.ts b/scripts/utils/Git.ts index 8c7f15ee106d7..bdbe22918c464 100644 --- a/scripts/utils/Git.ts +++ b/scripts/utils/Git.ts @@ -293,7 +293,7 @@ class Git { return false; } - if (file.hunks.length === 1 && file.hunks.at(0)?.oldStart === 0) { + if (file.hunks.length === 1 && file.hunks.at(0)?.oldStart === 0 && file.hunks.at(0)?.oldCount === 0) { return true; } From b7b11794ad61f3a38adfbc59804472b0dbe48cc0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 2 Dec 2025 09:17:44 +0100 Subject: [PATCH 33/45] test: add unit tests for `Git.isAddedFile` --- tests/unit/GitTest.ts | 264 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) diff --git a/tests/unit/GitTest.ts b/tests/unit/GitTest.ts index 88610a12d1350..14e198d103f6f 100644 --- a/tests/unit/GitTest.ts +++ b/tests/unit/GitTest.ts @@ -509,4 +509,268 @@ describe('Git', () => { expect(file.removedLines.size).toBe(0); }); }); + + describe('isAddedDiffFile', () => { + it('returns true for newly added files with single hunk starting at line 0', () => { + const mockDiffOutput = dedent(` + diff --git a/new-file.ts b/new-file.ts + new file mode 100644 + index 0000000..1234567 + --- /dev/null + +++ b/new-file.ts + @@ -0,0 +1,3 @@ + +const hello = 'world'; + +const foo = 'bar'; + +const baz = 'qux'; + `); + + mockExecSync.mockReturnValue(mockDiffOutput); + + const result = Git.diff('main'); + const file = result.files.at(0); + expect(file).toBeDefined(); + if (!file) { + return; + } + + expect(Git.isAddedDiffFile(file)).toBe(true); + }); + + it('returns true for newly added files with single line', () => { + const mockDiffOutput = dedent(` + diff --git a/single-line.ts b/single-line.ts + new file mode 100644 + index 0000000..1234567 + --- /dev/null + +++ b/single-line.ts + @@ -0,0 +1,1 @@ + +export const test = 'value'; + `); + + mockExecSync.mockReturnValue(mockDiffOutput); + + const result = Git.diff('main'); + const file = result.files.at(0); + expect(file).toBeDefined(); + if (!file) { + return; + } + + expect(Git.isAddedDiffFile(file)).toBe(true); + }); + + it('returns false for files with modified lines', () => { + const mockDiffOutput = dedent(` + diff --git a/modified.ts b/modified.ts + index 1234567..abcdefg 100644 + --- a/modified.ts + +++ b/modified.ts + @@ -1,1 +1,1 @@ + -const old = 'value'; + +const new = 'value'; + `); + + mockExecSync.mockReturnValue(mockDiffOutput); + + const result = Git.diff('main'); + const file = result.files.at(0); + expect(file).toBeDefined(); + if (!file) { + return; + } + + expect(Git.isAddedDiffFile(file)).toBe(false); + }); + + it('returns false for files with removed lines', () => { + const mockDiffOutput = dedent(` + diff --git a/removed.ts b/removed.ts + index 1234567..abcdefg 100644 + --- a/removed.ts + +++ b/removed.ts + @@ -2,2 +2,0 @@ + -const removed1 = 'value1'; + -const removed2 = 'value2'; + `); + + mockExecSync.mockReturnValue(mockDiffOutput); + + const result = Git.diff('main'); + const file = result.files.at(0); + expect(file).toBeDefined(); + if (!file) { + return; + } + + expect(Git.isAddedDiffFile(file)).toBe(false); + }); + + it('returns false for files with both added and modified lines', () => { + const mockDiffOutput = dedent(` + diff --git a/mixed.ts b/mixed.ts + index 1234567..abcdefg 100644 + --- a/mixed.ts + +++ b/mixed.ts + @@ -1,1 +1,2 @@ + -const old = 'value'; + +const new = 'value'; + +const added = 'new'; + `); + + mockExecSync.mockReturnValue(mockDiffOutput); + + const result = Git.diff('main'); + const file = result.files.at(0); + expect(file).toBeDefined(); + if (!file) { + return; + } + + expect(Git.isAddedDiffFile(file)).toBe(false); + }); + + it('returns false for files with both added and removed lines', () => { + const mockDiffOutput = dedent(` + diff --git a/mixed.ts b/mixed.ts + index 1234567..abcdefg 100644 + --- a/mixed.ts + +++ b/mixed.ts + @@ -1,1 +1,1 @@ + -const removed = 'value'; + +const added = 'value'; + @@ -3,0 +4,1 @@ + +const newLine = 'new'; + `); + + mockExecSync.mockReturnValue(mockDiffOutput); + + const result = Git.diff('main'); + const file = result.files.at(0); + expect(file).toBeDefined(); + if (!file) { + return; + } + + expect(Git.isAddedDiffFile(file)).toBe(false); + }); + + it('returns false for files with multiple hunks', () => { + const mockDiffOutput = dedent(` + diff --git a/multi-hunk.ts b/multi-hunk.ts + new file mode 100644 + index 0000000..1234567 + --- /dev/null + +++ b/multi-hunk.ts + @@ -0,0 +1,2 @@ + +const first = 'value'; + +const second = 'value'; + @@ -0,0 +3,2 @@ + +const third = 'value'; + +const fourth = 'value'; + `); + + mockExecSync.mockReturnValue(mockDiffOutput); + + const result = Git.diff('main'); + const file = result.files.at(0); + expect(file).toBeDefined(); + if (!file) { + return; + } + + expect(Git.isAddedDiffFile(file)).toBe(false); + }); + + it('returns false for files with added lines but oldStart !== 0', () => { + const mockDiffOutput = dedent(` + diff --git a/inserted.ts b/inserted.ts + index 1234567..abcdefg 100644 + --- a/inserted.ts + +++ b/inserted.ts + @@ -5,0 +6,2 @@ + +const inserted1 = 'value1'; + +const inserted2 = 'value2'; + `); + + mockExecSync.mockReturnValue(mockDiffOutput); + + const result = Git.diff('main'); + const file = result.files.at(0); + expect(file).toBeDefined(); + if (!file) { + return; + } + + expect(Git.isAddedDiffFile(file)).toBe(false); + }); + + it('returns false for files with added lines but oldCount !== 0', () => { + const mockDiffOutput = dedent(` + diff --git a/partial.ts b/partial.ts + index 1234567..abcdefg 100644 + --- a/partial.ts + +++ b/partial.ts + @@ -1,1 +1,2 @@ + const existing = 'value'; + +const added = 'new'; + `); + + mockExecSync.mockReturnValue(mockDiffOutput); + + const result = Git.diff('main'); + const file = result.files.at(0); + expect(file).toBeDefined(); + if (!file) { + return; + } + + expect(Git.isAddedDiffFile(file)).toBe(false); + }); + + it('returns false for files with no added lines', () => { + const mockDiffOutput = dedent(` + diff --git a/no-additions.ts b/no-additions.ts + index 1234567..abcdefg 100644 + --- a/no-additions.ts + +++ b/no-additions.ts + @@ -1,2 +1,0 @@ + -const removed1 = 'value1'; + -const removed2 = 'value2'; + `); + + mockExecSync.mockReturnValue(mockDiffOutput); + + const result = Git.diff('main'); + const file = result.files.at(0); + expect(file).toBeDefined(); + if (!file) { + return; + } + + expect(Git.isAddedDiffFile(file)).toBe(false); + }); + + it('returns false for files with only modified lines and no additions', () => { + const mockDiffOutput = dedent(` + diff --git a/modified-only.ts b/modified-only.ts + index 1234567..abcdefg 100644 + --- a/modified-only.ts + +++ b/modified-only.ts + @@ -1,1 +1,1 @@ + -const old = 'old'; + +const new = 'new'; + `); + + mockExecSync.mockReturnValue(mockDiffOutput); + + const result = Git.diff('main'); + const file = result.files.at(0); + expect(file).toBeDefined(); + if (!file) { + return; + } + + expect(Git.isAddedDiffFile(file)).toBe(false); + }); + }); }); From 3f16496060f37806a655d8f047cb353650b25108 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 2 Dec 2025 09:18:56 +0100 Subject: [PATCH 34/45] Update RCAutoMemoComponent.tsx --- src/components/RCAutoMemoComponent.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/RCAutoMemoComponent.tsx b/src/components/RCAutoMemoComponent.tsx index abaebfcca7e01..2a6c89eaf8cb9 100644 --- a/src/components/RCAutoMemoComponent.tsx +++ b/src/components/RCAutoMemoComponent.tsx @@ -1,17 +1,25 @@ -import {useCallback, useMemo} from 'react'; -import {Pressable} from 'react-native'; +import {memo, useCallback, useMemo} from 'react'; +import {View} from 'react-native'; function RCAutoMemoComponent() { const someValue = useMemo(() => { return 'someValue'; }, []); + const someOtherValue = useMemo(() => { + return 'someValue'; + }, []); + const someCallback = useCallback(() => { return 'someValue'; }, []); // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors - return {someValue}; + return ( + + {someValue} {someOtherValue} + + ); } -export default RCAutoMemoComponent; +export default memo(RCAutoMemoComponent); From e7a97ef84aad003200e0ce03a17555374c75ac6f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 2 Dec 2025 09:21:33 +0100 Subject: [PATCH 35/45] chore: run `npm run gh-actions-build` --- .../getPullRequestIncrementalChanges/index.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js index 5cce6f0b14860..5ec39fec1476a 100644 --- a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js +++ b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js @@ -12746,6 +12746,24 @@ class Git { hasChanges: files.length > 0, }; } + /** + * Check if a file from a Git diff is added. + * + * @param file - The file to check + * @returns true if the file is added, false otherwise + */ + static isAddedDiffFile(file) { + const hasAddedLines = file.addedLines.size > 0; + const hasModifiedLines = file.modifiedLines.size > 0; + const hasRemovedLines = file.removedLines.size > 0; + if (!hasAddedLines || hasModifiedLines || hasRemovedLines) { + return false; + } + if (file.hunks.length === 1 && file.hunks.at(0)?.oldStart === 0 && file.hunks.at(0)?.oldCount === 0) { + return true; + } + return false; + } /** * Calculate the line number for a diff line based on the hunk and line type. */ From 95f4cddda6b62f97917f6daa9f45edeac2594b77 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 2 Dec 2025 09:23:08 +0100 Subject: [PATCH 36/45] fix: sort manual memoization matches --- scripts/react-compiler-compliance-check.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 3df68ded08f39..5c8bd6bf33c25 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -595,6 +595,14 @@ function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] } } + // Sort matches by line number first, then by column number + matches.sort((a, b) => { + if (a.line !== b.line) { + return a.line - b.line; + } + return a.column - b.column; + }); + return matches; } From e734c163b131ecebafe9517458526004a6e6f5b7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 2 Dec 2025 09:25:17 +0100 Subject: [PATCH 37/45] fix: update error message to include backquotes --- 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 5c8bd6bf33c25..db4ab30213eb7 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -34,7 +34,7 @@ const MANUAL_MEMOIZATION_PATTERNS = { type ManualMemoizationKeyword = keyof typeof MANUAL_MEMOIZATION_PATTERNS; const MANUAL_MEMOIZATION_FAILURE_MESSAGE = (manualMemoizationKeyword: ManualMemoizationKeyword) => - `Found a manual memoization usage of ${manualMemoizationKeyword}. Newly added React component files must not contain any manual memoization and instead be auto-memoized by React Compiler. Remove ${manualMemoizationKeyword} 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.`; + `Found a manual memoization usage of \`${manualMemoizationKeyword}\`. Newly added React component files must not contain any manual memoization and instead be auto-memoized by React Compiler. Remove \`${manualMemoizationKeyword}\` 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.`; const NO_MANUAL_MEMO_DIRECTIVE_PATTERN = /["']use no memo["']\s*;?/; From 4ba3f14a8021ec4cba57ff29cabfaece5c2e3c43 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Dec 2025 20:08:23 +0000 Subject: [PATCH 38/45] fix: optimize regex matching for manual memoization patterns --- scripts/react-compiler-compliance-check.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index db4ab30213eb7..454e083b25eeb 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -585,11 +585,13 @@ function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] for (const keyword of Object.keys(MANUAL_MEMOIZATION_PATTERNS) as ManualMemoizationKeyword[]) { const regex = MANUAL_MEMOIZATION_PATTERNS[keyword]; - regex.lastIndex = 0; + const regexMatches = source.matchAll(regex); - let regexMatch: RegExpExecArray | null; - while ((regexMatch = regex.exec(source)) !== null) { + for (const regexMatch of regexMatches) { const matchIndex = regexMatch.index; + if (matchIndex === undefined) { + continue; + } const {line, column} = getLineAndColumnFromIndex(source, matchIndex); matches.push({keyword, line, column}); } From 1519deecf0192e5ed004e1484571468a093f727a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 9 Dec 2025 23:00:27 +0000 Subject: [PATCH 39/45] Update scripts/react-compiler-compliance-check.ts Co-authored-by: Rory Abraham <47436092+roryabraham@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 691d0ffbe67e9..9aee95fd0aff4 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -696,7 +696,7 @@ function printResults( if (hasEnforcedAddedComponentFailures) { log(); - logError(`These newly added component files were enforced to be automatically memoized with React Compiler:`); + logError(`The following newly added components should rely on React Compiler’s automatic memoization (manual memoization is not allowed):`); log(); for (const [filePath, {manualMemoizationMatches, compilerFailures}] of enforcedAddedComponentFailures) { From 25c720e6eff2f214ee277eeaf290d3cc6f2f5eae Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 9 Dec 2025 23:07:40 +0000 Subject: [PATCH 40/45] fix: extract `getLineAndColumnFromIndex` to FileUtils and refactor --- scripts/react-compiler-compliance-check.ts | 36 ++++++++-------------- scripts/utils/FileUtils.ts | 16 ++++++++++ 2 files changed, 29 insertions(+), 23 deletions(-) create mode 100644 scripts/utils/FileUtils.ts diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 9aee95fd0aff4..0db33b071b7fc 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -7,10 +7,11 @@ * It provides both CI and local development tools to enforce Rules of React compliance. */ import {execSync} from 'child_process'; -import {readFileSync, writeFileSync} from 'fs'; -import {join} from 'path'; +import fs, {readFileSync} from 'fs'; +import path from 'path'; import type {TupleToUnion} from 'type-fest'; import CLI from './utils/CLI'; +import {getLineAndColumnFromIndex} 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'; @@ -547,7 +548,14 @@ function enforceNewComponentGuard({failures}: CompilerResults, diffResult: DiffR // If no manual memoization keywords are found add the failures back to the regular failures. const addedComponentFailures: EnforcedAddedComponentFailureMap = new Map(); for (const addedFilePath of addedDiffFiles) { - const source = readSourceFile(addedFilePath); + let source: string | null = null; + try { + const absolutePath = path.join(process.cwd(), addedFilePath); + source = readFileSync(absolutePath, 'utf8'); + } catch (error) { + logWarn(`Unable to read ${addedFilePath} while enforcing new component rules.`, error); + } + if (!source || NO_MANUAL_MEMO_DIRECTIVE_PATTERN.test(source)) { addNonAutoMemoEnforcedFailures(addedFilePath); continue; @@ -606,24 +614,6 @@ function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] return matches; } -function getLineAndColumnFromIndex(source: string, index: number): {line: number; column: number} { - const substring = source.slice(0, index); - const line = substring.split('\n').length; - const lastLineBreakIndex = substring.lastIndexOf('\n'); - const column = lastLineBreakIndex === -1 ? index + 1 : index - lastLineBreakIndex; - return {line, column}; -} - -function readSourceFile(filePath: string): string | null { - try { - const absolutePath = join(process.cwd(), filePath); - return readFileSync(absolutePath, 'utf8'); - } catch (error) { - logWarn(`Unable to read ${filePath} while enforcing new component rules.`, error); - return null; - } -} - /** * Prints the results of the React Compiler compliance check. * @param results - The compiler results to print @@ -736,8 +726,8 @@ function generateReport(results: CompilerResults, outputFileName = DEFAULT_REPOR logInfo('Creating React Compiler Compliance Check report:'); // Save detailed report - const reportFile = join(process.cwd(), outputFileName); - writeFileSync( + const reportFile = path.join(process.cwd(), outputFileName); + fs.writeFileSync( reportFile, JSON.stringify( { diff --git a/scripts/utils/FileUtils.ts b/scripts/utils/FileUtils.ts new file mode 100644 index 0000000000000..6b3520037ed4a --- /dev/null +++ b/scripts/utils/FileUtils.ts @@ -0,0 +1,16 @@ +/** + * Get the line and column from an index in a source string. + * @param source - The source string. + * @param index - The index in the source string. + * @returns The line and column. + */ +function getLineAndColumnFromIndex(source: string, index: number): {line: number; column: number} { + const substring = source.slice(0, index); + const line = substring.split('\n').length; + const lastLineBreakIndex = substring.lastIndexOf('\n'); + const column = lastLineBreakIndex === -1 ? index + 1 : index - lastLineBreakIndex; + return {line, column}; +} + +// eslint-disable-next-line import/prefer-default-export +export {getLineAndColumnFromIndex}; From f60a205352860ae5b3e17a8999d7725672c29ab7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 9 Dec 2025 23:31:33 +0000 Subject: [PATCH 41/45] feat: add error handling to `getLineAndColumnFromIndex` --- scripts/utils/FileUtils.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/utils/FileUtils.ts b/scripts/utils/FileUtils.ts index 6b3520037ed4a..a39f191e300b9 100644 --- a/scripts/utils/FileUtils.ts +++ b/scripts/utils/FileUtils.ts @@ -1,3 +1,10 @@ +const ERROR_MESSAGES = { + SOURCE_CANNOT_BE_EMPTY: 'Source cannot be empty', + INDEX_CANNOT_BE_NEGATIVE: 'Index cannot be negative', + // eslint-disable-next-line @typescript-eslint/naming-convention + INDEX_OUT_OF_BOUNDS: (sourceLength: number, index: number) => `Index ${index} is out of bounds for source length ${sourceLength}`, +} as const; + /** * Get the line and column from an index in a source string. * @param source - The source string. @@ -5,6 +12,18 @@ * @returns The line and column. */ function getLineAndColumnFromIndex(source: string, index: number): {line: number; column: number} { + if (source.length === 0) { + throw new Error(ERROR_MESSAGES.SOURCE_CANNOT_BE_EMPTY); + } + + if (index < 0) { + throw new Error(ERROR_MESSAGES.INDEX_CANNOT_BE_NEGATIVE); + } + + if (index > source.length) { + throw new Error(ERROR_MESSAGES.INDEX_OUT_OF_BOUNDS(source.length, index)); + } + const substring = source.slice(0, index); const line = substring.split('\n').length; const lastLineBreakIndex = substring.lastIndexOf('\n'); @@ -13,4 +32,4 @@ function getLineAndColumnFromIndex(source: string, index: number): {line: number } // eslint-disable-next-line import/prefer-default-export -export {getLineAndColumnFromIndex}; +export {getLineAndColumnFromIndex, ERROR_MESSAGES}; From 16c5e0fa5e340545b947f7cf10f08ed3009eb8c6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 9 Dec 2025 23:31:37 +0000 Subject: [PATCH 42/45] Create ScriptFileUtilsTest.ts --- tests/unit/ScriptFileUtilsTest.ts | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/unit/ScriptFileUtilsTest.ts diff --git a/tests/unit/ScriptFileUtilsTest.ts b/tests/unit/ScriptFileUtilsTest.ts new file mode 100644 index 0000000000000..b88af743faa31 --- /dev/null +++ b/tests/unit/ScriptFileUtilsTest.ts @@ -0,0 +1,35 @@ +import {ERROR_MESSAGES, getLineAndColumnFromIndex} from '../../scripts/utils/FileUtils'; + +const multiLineSource = `abc +hello +world +test +multi +line +content`; + +describe('FileUtils (Scripts)', () => { + describe('getLineAndColumnFromIndex', () => { + it('should return correct line and column for multi-line source', () => { + expect(getLineAndColumnFromIndex(multiLineSource, 0)).toStrictEqual({line: 1, column: 1}); + expect(getLineAndColumnFromIndex(multiLineSource, 3)).toStrictEqual({line: 1, column: 4}); + expect(getLineAndColumnFromIndex(multiLineSource, 4)).toStrictEqual({line: 2, column: 1}); + expect(getLineAndColumnFromIndex(multiLineSource, 10)).toStrictEqual({line: 3, column: 1}); + expect(getLineAndColumnFromIndex(multiLineSource, multiLineSource.length)).toStrictEqual({line: 7, column: 8}); + }); + + it('should throw an error if source is empty', () => { + expect(() => getLineAndColumnFromIndex('', 0)).toThrow(ERROR_MESSAGES.SOURCE_CANNOT_BE_EMPTY); + }); + + it('should throw an error if index is negative', () => { + expect(() => getLineAndColumnFromIndex(multiLineSource, -1)).toThrow(ERROR_MESSAGES.INDEX_CANNOT_BE_NEGATIVE); + }); + + it('should throw an error if index is out of bounds', () => { + expect(() => getLineAndColumnFromIndex(multiLineSource, multiLineSource.length + 10)).toThrow( + ERROR_MESSAGES.INDEX_OUT_OF_BOUNDS(multiLineSource.length, multiLineSource.length + 10), + ); + }); + }); +}); From 3170ca5825e59bcbc9e90b4d47d7f159433236e3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 9 Dec 2025 23:33:48 +0000 Subject: [PATCH 43/45] refactor: FileUtils imports/exports --- scripts/react-compiler-compliance-check.ts | 4 ++-- scripts/utils/FileUtils.ts | 6 ++++-- tests/unit/ScriptFileUtilsTest.ts | 18 +++++++++--------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 0db33b071b7fc..3e206c9190bfb 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -11,7 +11,7 @@ import fs, {readFileSync} from 'fs'; import path from 'path'; import type {TupleToUnion} from 'type-fest'; import CLI from './utils/CLI'; -import {getLineAndColumnFromIndex} from './utils/FileUtils'; +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'; @@ -598,7 +598,7 @@ function findManualMemoizationMatches(source: string): ManualMemoizationMatch[] if (matchIndex === undefined) { continue; } - const {line, column} = getLineAndColumnFromIndex(source, matchIndex); + const {line, column} = FileUtils.getLineAndColumnFromIndex(source, matchIndex); matches.push({keyword, line, column}); } } diff --git a/scripts/utils/FileUtils.ts b/scripts/utils/FileUtils.ts index a39f191e300b9..e73405859fb64 100644 --- a/scripts/utils/FileUtils.ts +++ b/scripts/utils/FileUtils.ts @@ -31,5 +31,7 @@ function getLineAndColumnFromIndex(source: string, index: number): {line: number return {line, column}; } -// eslint-disable-next-line import/prefer-default-export -export {getLineAndColumnFromIndex, ERROR_MESSAGES}; +const FileUtils = {getLineAndColumnFromIndex}; + +export default FileUtils; +export {ERROR_MESSAGES}; diff --git a/tests/unit/ScriptFileUtilsTest.ts b/tests/unit/ScriptFileUtilsTest.ts index b88af743faa31..847a3d65626ca 100644 --- a/tests/unit/ScriptFileUtilsTest.ts +++ b/tests/unit/ScriptFileUtilsTest.ts @@ -1,4 +1,4 @@ -import {ERROR_MESSAGES, getLineAndColumnFromIndex} from '../../scripts/utils/FileUtils'; +import FileUtils, {ERROR_MESSAGES} from '@scripts/utils/FileUtils'; const multiLineSource = `abc hello @@ -11,23 +11,23 @@ content`; describe('FileUtils (Scripts)', () => { describe('getLineAndColumnFromIndex', () => { it('should return correct line and column for multi-line source', () => { - expect(getLineAndColumnFromIndex(multiLineSource, 0)).toStrictEqual({line: 1, column: 1}); - expect(getLineAndColumnFromIndex(multiLineSource, 3)).toStrictEqual({line: 1, column: 4}); - expect(getLineAndColumnFromIndex(multiLineSource, 4)).toStrictEqual({line: 2, column: 1}); - expect(getLineAndColumnFromIndex(multiLineSource, 10)).toStrictEqual({line: 3, column: 1}); - expect(getLineAndColumnFromIndex(multiLineSource, multiLineSource.length)).toStrictEqual({line: 7, column: 8}); + expect(FileUtils.getLineAndColumnFromIndex(multiLineSource, 0)).toStrictEqual({line: 1, column: 1}); + expect(FileUtils.getLineAndColumnFromIndex(multiLineSource, 3)).toStrictEqual({line: 1, column: 4}); + expect(FileUtils.getLineAndColumnFromIndex(multiLineSource, 4)).toStrictEqual({line: 2, column: 1}); + expect(FileUtils.getLineAndColumnFromIndex(multiLineSource, 10)).toStrictEqual({line: 3, column: 1}); + expect(FileUtils.getLineAndColumnFromIndex(multiLineSource, multiLineSource.length)).toStrictEqual({line: 7, column: 8}); }); it('should throw an error if source is empty', () => { - expect(() => getLineAndColumnFromIndex('', 0)).toThrow(ERROR_MESSAGES.SOURCE_CANNOT_BE_EMPTY); + expect(() => FileUtils.getLineAndColumnFromIndex('', 0)).toThrow(ERROR_MESSAGES.SOURCE_CANNOT_BE_EMPTY); }); it('should throw an error if index is negative', () => { - expect(() => getLineAndColumnFromIndex(multiLineSource, -1)).toThrow(ERROR_MESSAGES.INDEX_CANNOT_BE_NEGATIVE); + expect(() => FileUtils.getLineAndColumnFromIndex(multiLineSource, -1)).toThrow(ERROR_MESSAGES.INDEX_CANNOT_BE_NEGATIVE); }); it('should throw an error if index is out of bounds', () => { - expect(() => getLineAndColumnFromIndex(multiLineSource, multiLineSource.length + 10)).toThrow( + expect(() => FileUtils.getLineAndColumnFromIndex(multiLineSource, multiLineSource.length + 10)).toThrow( ERROR_MESSAGES.INDEX_OUT_OF_BOUNDS(multiLineSource.length, multiLineSource.length + 10), ); }); From 6b284c59b15ddcac2061bee4e9fd49bf6d96c701 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 9 Dec 2025 23:33:49 +0000 Subject: [PATCH 44/45] Delete RCAutoMemoComponent.tsx --- src/components/RCAutoMemoComponent.tsx | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 src/components/RCAutoMemoComponent.tsx diff --git a/src/components/RCAutoMemoComponent.tsx b/src/components/RCAutoMemoComponent.tsx deleted file mode 100644 index 2a6c89eaf8cb9..0000000000000 --- a/src/components/RCAutoMemoComponent.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import {memo, useCallback, useMemo} from 'react'; -import {View} from 'react-native'; - -function RCAutoMemoComponent() { - const someValue = useMemo(() => { - return 'someValue'; - }, []); - - const someOtherValue = useMemo(() => { - return 'someValue'; - }, []); - - const someCallback = useCallback(() => { - return 'someValue'; - }, []); - - // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors - return ( - - {someValue} {someOtherValue} - - ); -} - -export default memo(RCAutoMemoComponent); From 4bd4f6420aab14e0b05b93a3ad6a365f96543ac3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 9 Dec 2025 23:42:33 +0000 Subject: [PATCH 45/45] refactor: re-structure `FileUtils` as class-like export --- scripts/utils/FileUtils.ts | 48 +++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/scripts/utils/FileUtils.ts b/scripts/utils/FileUtils.ts index e73405859fb64..83e227c041da2 100644 --- a/scripts/utils/FileUtils.ts +++ b/scripts/utils/FileUtils.ts @@ -5,33 +5,33 @@ const ERROR_MESSAGES = { INDEX_OUT_OF_BOUNDS: (sourceLength: number, index: number) => `Index ${index} is out of bounds for source length ${sourceLength}`, } as const; -/** - * Get the line and column from an index in a source string. - * @param source - The source string. - * @param index - The index in the source string. - * @returns The line and column. - */ -function getLineAndColumnFromIndex(source: string, index: number): {line: number; column: number} { - if (source.length === 0) { - throw new Error(ERROR_MESSAGES.SOURCE_CANNOT_BE_EMPTY); - } +const FileUtils = { + /** + * Get the line and column from an index in a source string. + * @param source - The source string. + * @param index - The index in the source string. + * @returns The line and column. + */ + getLineAndColumnFromIndex: (source: string, index: number): {line: number; column: number} => { + if (source.length === 0) { + throw new Error(ERROR_MESSAGES.SOURCE_CANNOT_BE_EMPTY); + } - if (index < 0) { - throw new Error(ERROR_MESSAGES.INDEX_CANNOT_BE_NEGATIVE); - } + if (index < 0) { + throw new Error(ERROR_MESSAGES.INDEX_CANNOT_BE_NEGATIVE); + } - if (index > source.length) { - throw new Error(ERROR_MESSAGES.INDEX_OUT_OF_BOUNDS(source.length, index)); - } + if (index > source.length) { + throw new Error(ERROR_MESSAGES.INDEX_OUT_OF_BOUNDS(source.length, index)); + } - const substring = source.slice(0, index); - const line = substring.split('\n').length; - const lastLineBreakIndex = substring.lastIndexOf('\n'); - const column = lastLineBreakIndex === -1 ? index + 1 : index - lastLineBreakIndex; - return {line, column}; -} - -const FileUtils = {getLineAndColumnFromIndex}; + const substring = source.slice(0, index); + const line = substring.split('\n').length; + const lastLineBreakIndex = substring.lastIndexOf('\n'); + const column = lastLineBreakIndex === -1 ? index + 1 : index - lastLineBreakIndex; + return {line, column}; + }, +}; export default FileUtils; export {ERROR_MESSAGES};