Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0a0c055
Add variadic positional arg support to CLI utility
roryabraham Apr 6, 2026
3561670
Add getChangedFilesWithStatus() with paginated GitHub API support
roryabraham Apr 6, 2026
0878447
Extract shared ReactCompilerConfig to eliminate duplication
roryabraham Apr 6, 2026
4510c18
Rewrite react-compiler compliance check to use babel transform directly
roryabraham Apr 6, 2026
dc0d600
Expand react-compiler workflow to cover .ts and .jsx files
roryabraham Apr 6, 2026
afbb4b7
Remove react-compiler-healthcheck dependency and patches
roryabraham Apr 6, 2026
f20220c
Update React Compiler contributing guide for new compliance check
roryabraham Apr 6, 2026
79c2887
Remove .jsx from file extension checks
roryabraham Apr 6, 2026
d999ab0
Fix TypeScript error with variadic positional arg type inference
roryabraham Apr 6, 2026
d17c7bd
Move ReactCompilerConfig to config/babel/reactCompilerConfig.js
roryabraham Apr 6, 2026
4b15437
Inline FILE_EXTENSIONS check, remove isReactFile wrapper
roryabraham Apr 6, 2026
41b74f5
Support directories and glob patterns in check command
roryabraham Apr 6, 2026
0d1fefe
Address review feedback: extract resolveFilePaths to FileUtils
roryabraham Apr 7, 2026
3a541e5
Fix CI: use .js for shared config, rebuild actions, exclude from new-…
roryabraham Apr 7, 2026
270c39d
Merge remote-tracking branch 'origin/main' into rory/react-compiler-c…
roryabraham Apr 8, 2026
70bd0db
Handle renamed files in regression check using previousFilename
roryabraham Apr 8, 2026
e2254e2
Add rename detection to local git diff path
roryabraham Apr 8, 2026
1edf479
Rebuild gh actions
roryabraham Apr 8, 2026
0f3e134
Merge remote-tracking branch 'origin/main' into rory/react-compiler-c…
roryabraham Apr 11, 2026
9b50ce6
Fix GitTest assertions for -M flag in git diff command
roryabraham Apr 11, 2026
f43deb5
Show detailed compiler errors with file/line locations
roryabraham Apr 13, 2026
67e6b27
Fix typecheck and spellcheck CI failures
roryabraham Apr 13, 2026
8432c5a
Replace non-null assertions with optional chaining in tests
roryabraham Apr 13, 2026
c820d57
Update scripts/react-compiler-compliance-check.ts
roryabraham Apr 14, 2026
19b2fda
Add top-level error handler to main()
roryabraham Apr 14, 2026
209bb3d
Merge remote-tracking branch 'origin/main' into rory/react-compiler-c…
roryabraham Apr 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12410,8 +12410,8 @@ class Git {
* @throws Error when git command fails (invalid refs, not a git repo, file not found, etc.)
*/
static diff(fromRef, toRef, filePaths, shouldIncludeUntrackedFiles = false) {
// Build git diff command (with 0 context lines for easier parsing)
let command = `git diff -U0 ${fromRef}`;
// Build git diff command (with 0 context lines for easier parsing, -M for rename detection)
let command = `git diff -U0 -M ${fromRef}`;
if (toRef) {
command += ` ${toRef}`;
}
Expand Down Expand Up @@ -12453,54 +12453,64 @@ class Git {
const files = [];
let currentFile = null;
let currentHunk = null;
let oldFilePath = null; // Track old file path to determine fileDiffType
let oldFilePath = null;
let renameFromPath = null;
for (const line of lines) {
// File header: diff --git a/file b/file
if (line.startsWith('diff --git')) {
if (currentFile) {
// Push the current hunk to the current file before processing the new file
if (currentHunk) {
currentFile.hunks.push(currentHunk);
}
files.push(currentFile);
}
currentFile = null;
currentHunk = null;
oldFilePath = null; // Reset for next file
oldFilePath = null;
renameFromPath = null;
continue;
}
// Rename detection: "rename from <path>" appears before --- / +++
if (line.startsWith('rename from ')) {
renameFromPath = line.slice('rename from '.length);
continue;
}
if (line.startsWith('rename to ') || line.startsWith('similarity index ')) {
continue;
}
// Old file path: --- a/file or --- /dev/null (for new files)
// This comes before +++ in git diff output
if (line.startsWith('--- ')) {
oldFilePath = line.slice(4); // Store the old file path (remove '--- ')
oldFilePath = line.slice(4);
continue;
}
// New file path: +++ b/file or +++ /dev/null (for removed files)
if (line.startsWith('+++ ')) {
const newFilePath = line.slice(4); // Remove '+++ '
// Determine fileDiffType based on old and new file paths
// Note: oldFilePath should always be set by the time we see +++, but handle null for type safety
const newFilePath = line.slice(4);
let fileDiffType = 'modified';
let diffFilePath;
let previousFilePath;
const oldPath = oldFilePath ?? '';
if (oldPath === '/dev/null') {
// New file: use the new file path
fileDiffType = 'added';
diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath;
}
else if (newFilePath === '/dev/null') {
// Removed file: use the old file path
fileDiffType = 'removed';
diffFilePath = oldPath.startsWith('a/') ? oldPath.slice(2) : oldPath;
}
else if (renameFromPath) {
fileDiffType = 'renamed';
diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath;
previousFilePath = renameFromPath;
}
else {
// Modified file: use the new file path
fileDiffType = 'modified';
diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath;
}
currentFile = {
filePath: diffFilePath,
diffType: fileDiffType,
previousFilePath,
hunks: [],
addedLines: new Set(),
removedLines: new Set(),
Expand Down Expand Up @@ -12723,20 +12733,37 @@ class Git {
return false;
}
}
static async getChangedFileNames(fromRef, toRef, shouldIncludeUntrackedFiles = false) {
/**
* Get changed files with their status (added, modified, removed, renamed).
* In CI, uses the GitHub API with pagination for accuracy.
* Locally, uses git diff against the provided ref.
*/
static async getChangedFilesWithStatus(fromRef, toRef, shouldIncludeUntrackedFiles = false) {
if (IS_CI) {
const { data: changedFiles } = await GithubUtils_1.default.octokit.pulls.listFiles({
const files = await GithubUtils_1.default.paginate(GithubUtils_1.default.octokit.pulls.listFiles, {
owner: CONST_1.default.GITHUB_OWNER,
repo: CONST_1.default.APP_REPO,
// eslint-disable-next-line @typescript-eslint/naming-convention
pull_number: github_1.context.payload.pull_request?.number ?? 0,
// eslint-disable-next-line @typescript-eslint/naming-convention
per_page: 100,
});
return changedFiles.map((file) => file.filename);
return files.map((file) => ({
filename: file.filename,
status: file.status,
previousFilename: file.previous_filename,
}));
}
// Get the diff output and check status
const diffResult = this.diff(fromRef, toRef, undefined, shouldIncludeUntrackedFiles);
const files = diffResult.files.map((file) => file.filePath);
return files;
return diffResult.files.map((file) => ({
filename: file.filePath,
status: file.diffType,
previousFilename: file.previousFilePath,
}));
}
static async getChangedFileNames(fromRef, toRef, shouldIncludeUntrackedFiles = false) {
const files = await this.getChangedFilesWithStatus(fromRef, toRef, shouldIncludeUntrackedFiles);
return files.map((file) => file.filename);
}
/**
* Get list of untracked files from git.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/react-compiler-compliance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
pull_request:
types: [opened, synchronize]
branches-ignore: [staging, production]
paths: ["**.tsx"]
paths: ["**.ts", "**.tsx"]

concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-react-compiler-compliance
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
# - git diff is used to see the files that were added on this branch
# - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main
# - wc counts the words in the result of the intersection
count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' '.github/libs/*.js' '.github/scripts/*.js' ':!src/libs/SearchParser/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l)
count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' '.github/libs/*.js' '.github/scripts/*.js' ':!src/libs/SearchParser/*.js' ':!config/babel/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l)
if [ "$count_new_js" -gt "0" ]; then
echo "ERROR: Found new JavaScript files in the project; use TypeScript instead."
exit 1
Expand Down
7 changes: 3 additions & 4 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
require('dotenv').config();

const BaseReactCompilerConfig = require('./config/babel/reactCompilerConfig');

const ReactCompilerConfig = {
target: '19',
environment: {
enableTreatRefLikeIdentifiersAsRefs: true,
},
...BaseReactCompilerConfig,
sources: (filename) => !filename.includes('tests/') && !filename.includes('node_modules/'),
};

Expand Down
16 changes: 16 additions & 0 deletions config/babel/reactCompilerConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Shared React Compiler configuration used across:
* - babel.config.js (build pipeline, extends with `sources` filter)
* - eslint-plugin-react-compiler-compat (lint-time analysis)
* - react-compiler-compliance-check (CI and local checking)
*
* Intentionally omits `sources` since that's only relevant for the Babel build pipeline.
*/
const ReactCompilerConfig = {
target: '19',
environment: {
enableTreatRefLikeIdentifiersAsRefs: true,
},
};

module.exports = ReactCompilerConfig;
75 changes: 16 additions & 59 deletions contributingGuides/REACT_COMPILER.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,82 +8,39 @@ At Expensify, we are early adopters of this tool and aim to fully leverage its c

## React Compiler compliance checker

We provide a script, `scripts/react-compiler-compliance-check.ts`, which checks for "Rules of React" compliance locally and enforces these in PRs adding or changing React code through a CI check.
We provide a script, `scripts/react-compiler-compliance-check.ts`, which checks whether React components and hooks compile with React Compiler. It runs in CI on every PR and can also be used locally for quick feedback.

### What it does
### How it works

Runs `react-compiler-healthcheck` in verbose mode, parses output, and summarizes which files compiled and which failed, including file, line, column, and reason. It can:
The script uses `@babel/core`'s `transformSync` with `babel-plugin-react-compiler` directly (no intermediate tools). For each file, the compiler reports whether components/hooks compiled successfully, failed, or weren't found. This produces a three-state result per file: `COMPILED`, `FAILED`, or `SKIPPED` (no components/hooks).

- Check all files or a specific file/glob
- Check only files changed relative to a base branch
- Optionally generate a machine-readable report `react-compiler-report.json`
- Exit with non-zero code when failures are found (useful for CI)
### CI enforcement (two rules)

### Usage

> [!NOTE]
> This script uses `origin` as the base remote by default. If your GH remote is named differently, use the `--remote <name>` flag.

#### Check entire codebase or a specific file/glob

```bash
npm run react-compiler-compliance-check check # Check all files
npm run react-compiler-compliance-check check src/path/Component.tsx # Check specific file
npm run react-compiler-compliance-check check "src/**/*.tsx" # Check glob pattern
```

#### Check only changed files (against main)

```bash
npm run react-compiler-compliance-check check-changed
```

#### Generate a detailed report (saved as `./react-compiler-report.json`)

You can use the `--report` flag with both of the above commands:
The CI check (`check-changed`) enforces two rules on changed `.ts` and `.tsx` files:

```bash
npm run react-compiler-compliance-check check --report
npm run react-compiler-compliance-check check-changed --report
```

#### Additional flags

**Filter by diff changes (`--filterByDiff`)**
1. **New files**: If a new file contains components or hooks that fail to compile, the check fails.
2. **Modified files**: If a file compiled successfully on `main` but fails on the PR branch, the check fails (regression).

Only check files that have been modified in the current diff. This is useful when you want to focus on files that have actual changes:

```bash
npm run react-compiler-compliance-check check --filterByDiff
npm run react-compiler-compliance-check check-changed --filterByDiff
```
Files with no React components or hooks are silently skipped.

**Print successful compilations (`--printSuccesses`)**
### Usage

By default, the script only shows compilation failures. Use this flag to also display files that compiled successfully:
#### Check specific files

```bash
npm run react-compiler-compliance-check check --printSuccesses
npm run react-compiler-compliance-check check-changed --printSuccesses
npm run react-compiler-compliance-check check src/components/Foo.tsx src/hooks/useBar.ts
```

**Custom report filename (`--reportFileName`)**

Specify a custom filename for the generated report instead of the default `react-compiler-report.json`:
#### Check changed files (CI mode, also works locally)

```bash
npm run react-compiler-compliance-check check --report --reportFileName my-custom-report.json
npm run react-compiler-compliance-check check-changed --report --reportFileName my-custom-report.json
npm run react-compiler-compliance-check check-changed
```

**Custom remote name (`--remote`)**
#### Flags

By default, the script uses `origin` as the base remote. If your GitHub remote is named differently, specify it with this flag:

```bash
npm run react-compiler-compliance-check check-changed --remote upstream
npm run react-compiler-compliance-check check --filterByDiff --remote my-remote
```
- `--verbose` — Show detailed output including skipped files and files that compiled successfully.
- `--remote <name>` — Git remote name for the base branch (default: `origin`).

## How to fix a particular problem?

Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@
"endcapture",
"enddate",
"endfor",
"endgroup",
"enroute",
"entityid",
"Entra",
Expand Down
13 changes: 4 additions & 9 deletions eslint-plugin-react-compiler-compat/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
*/
import {transformSync} from '@babel/core';
import _ from 'lodash';
import {createRequire} from 'module';

const require = createRequire(import.meta.url);
const ReactCompilerConfig = require('../config/babel/reactCompilerConfig');

// Rules that are entirely unnecessary when React Compiler successfully compiles
// all functions in a file. Add more rules here as needed.
Expand All @@ -24,15 +28,6 @@ const RULES_SUPPRESSED_BY_REACT_COMPILER = new Set(['react/jsx-no-constructed-co
// about genuinely missing dependencies.
const EXHAUSTIVE_DEPS_USECALLBACK_USEMEMO_PATTERN = /\buseCallback\(\) Hook\b|\buseMemo\(\) Hook\b/;

// Mirror the compiler config from babel.config.js (excluding `sources`,
// which is only relevant for the Babel build pipeline, not for our analysis).
const ReactCompilerConfig = {
target: '19',
environment: {
enableTreatRefLikeIdentifiersAsRefs: true,
},
};

// Per-file compilation results, populated in preprocess, consumed in postprocess.
const compilationResults = new Map();

Expand Down
Loading
Loading