Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ Designed to run as a part of Git hooks and CI, this tool helps prevent this and

## Prerequisites

This tool requires the React Compiler to be configured in your project using `babel.config.js`.
This tool requires `babel-plugin-react-compiler` to be installed in your project:

See the [React Compiler installation guide](https://react.dev/learn/react-compiler/installation#babel) for setup instructions.
```bash
npm install --save-dev babel-plugin-react-compiler
```

## Installation

Expand Down
283 changes: 113 additions & 170 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,6 @@
"*.{js,ts,mjs,mts,json,md}": "biome check --write --no-errors-on-unmatched"
},
"devDependencies": {
"@babel/core": "7.28.5",
"@babel/preset-react": "7.28.5",
"@babel/preset-typescript": "7.28.5",
"@biomejs/biome": "2.3.11",
"@types/babel__core": "7.20.5",
"@types/node": "25.0.6",
Expand All @@ -61,10 +58,12 @@
"vitest": "4.0.16"
},
"peerDependencies": {
"@babel/core": "^7.0.0",
"babel-plugin-react-compiler": "^1.0.0"
},
"dependencies": {
"@babel/core": "7.28.6",
"@babel/preset-react": "7.28.5",
"@babel/preset-typescript": "7.28.5",
"glob": "13.0.0"
}
}
11 changes: 11 additions & 0 deletions src/__fixtures__/sample-project/src/bad-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useRef } from 'react'

/**
* A custom hook with a React Compiler violation (mutating ref during render)
*/
export function useBadCounter() {
const countRef = useRef(0)
// Mutating ref during render - React Compiler violation
countRef.current += 1
return countRef.current
}
11 changes: 11 additions & 0 deletions src/__fixtures__/sample-project/src/good-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useCallback, useState } from 'react'

/**
* A clean custom hook that compiles without issues
*/
export function useCounter(initialValue: number = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount((c) => c + 1), [])
const decrement = useCallback(() => setCount((c) => c - 1), [])
return { count, increment, decrement }
}
54 changes: 7 additions & 47 deletions src/babel.mts
Original file line number Diff line number Diff line change
@@ -1,41 +1,11 @@
import { createRequire } from 'node:module'
import { join } from 'node:path'
import {
type ConfigAPI,
type ConfigFunction,
type TransformOptions,
transformFileAsync,
} from '@babel/core'
import { type TransformOptions, transformFileAsync } from '@babel/core'
import type { Logger } from 'babel-plugin-react-compiler'

function loadConfig(configPath: string) {
const require = createRequire(import.meta.url)
const babelConfigPath = join(process.cwd(), configPath)
const babelConfigFn: ConfigFunction = require(babelConfigPath)
const babelConfig = babelConfigFn({
cache: {
using: (callback) => callback(),
},
} as ConfigAPI)

return babelConfig
}

function setCustomReactCompilerLogger(
babelConfig: TransformOptions,
customReactCompilerLogger: Logger,
) {
if (!babelConfig?.plugins) {
throw new Error('Failed to load Babel config')
}

const reactCompilerPlugin = babelConfig.plugins.find(
(plugin) => Array.isArray(plugin) && plugin[0] === 'babel-plugin-react-compiler',
)
if (!reactCompilerPlugin || !Array.isArray(reactCompilerPlugin)) {
throw new Error('Failed to find React Compiler plugin in Babel config')
function createConfig(logger: Logger): TransformOptions {
return {
presets: [['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript'],
plugins: [['babel-plugin-react-compiler', { logger }]],
}
reactCompilerPlugin[1] = { ...reactCompilerPlugin[1], logger: customReactCompilerLogger }
}

async function compileFileWithBabel(filePath: string, config: TransformOptions) {
Expand All @@ -49,23 +19,13 @@ async function compileFileWithBabel(filePath: string, config: TransformOptions)

async function compileFiles({
filePaths,
configPath,
customReactCompilerLogger,
}: {
filePaths: string[]
configPath: string
customReactCompilerLogger: Logger
}) {
const config = loadConfig(configPath)
setCustomReactCompilerLogger(config, customReactCompilerLogger)

const processJobs: Promise<void>[] = []

for (const filePath of filePaths) {
processJobs.push(compileFileWithBabel(filePath, config))
}

await Promise.all(processJobs)
const config = createConfig(customReactCompilerLogger)
await Promise.all(filePaths.map((filePath) => compileFileWithBabel(filePath, config)))
}

export { compileFiles }
23 changes: 14 additions & 9 deletions src/index.integration.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -32,39 +32,44 @@ describe('CLI', () => {
it('runs check on all files when no flag provided', () => {
const output = runCLI()

expect(output).toContain('🔍 Checking all 2 source files for React Compiler errors…')
expect(output).toContain('⚠️ Found 1 React Compiler issues across 1 files')
expect(output).toContain('🔍 Checking all 4 source files for React Compiler errors…')
expect(output).toContain('⚠️ Found 4 React Compiler issues across 2 files')
expect(() => JSON.parse(readFileSync(recordsPath, 'utf8'))).toThrow()
})

it('accepts --check-files flag with file arguments', () => {
const output = runCLI(['--check-files', 'src/with-violation.tsx'])
const output = runCLI(['--check-files', 'src/bad-component.tsx', 'src/bad-hook.ts'])

expect(output).toContain('🔍 Checking 1 files for React Compiler errors…')
expect(output).toContain('🔍 Checking 2 files for React Compiler errors…')
expect(output).toContain('React Compiler errors have increased in:')
expect(output).toContain('• src/with-violation.tsx: +1')
expect(output).toContain('• src/bad-component.tsx: +1')
expect(output).toContain('• src/bad-hook.ts: +3')
expect(output).toContain('Please fix the errors and run the command again.')
expect(output).not.toContain('src/clean.tsx')
expect(output).not.toContain('src/good-component.tsx')
expect(output).not.toContain('src/good-hook.ts')
expect(() => JSON.parse(readFileSync(recordsPath, 'utf8'))).toThrow()
})

it('accepts --overwrite flag', () => {
const output = runCLI(['--overwrite'])

expect(output).toContain(
'🔍 Checking all 2 source files for React Compiler errors and recreating records…',
'🔍 Checking all 4 source files for React Compiler errors and recreating records…',
)
expect(output).toContain(
'✅ Records file completed. Found 1 total React Compiler issues across 1 files',
'✅ Records file completed. Found 4 total React Compiler issues across 2 files',
)

const records = JSON.parse(readFileSync(recordsPath, 'utf8'))
expect(records.recordVersion).toBe(1)
expect(records['react-compiler-version']).toBe('1.0.0')
expect(records.files).toEqual({
'src/with-violation.tsx': {
'src/bad-component.tsx': {
CompileError: 1,
},
'src/bad-hook.ts': {
CompileError: 3,
},
})
})
})
5 changes: 0 additions & 5 deletions src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import * as sourceFiles from './source-files.mjs'
const RECORDS_PATH = '.react-compiler.rec.json'
const SUPPORTED_FILE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']
const SOURCE_FILES = 'src/**/*.{js,jsx,ts,tsx}'
const BABEL_CONFIG_PATH = 'babel.config.js'

const OVERWRITE_FLAG = '--overwrite'
const STAGE_RECORD_FILE_FLAG = '--stage-record-file'
Expand Down Expand Up @@ -91,7 +90,6 @@ async function runOverwriteRecords() {

await babel.compileFiles({
filePaths,
configPath: BABEL_CONFIG_PATH,
customReactCompilerLogger: customReactCompilerLogger,
})

Expand Down Expand Up @@ -147,7 +145,6 @@ async function runStageRecords() {

await babel.compileFiles({
filePaths,
configPath: BABEL_CONFIG_PATH,
customReactCompilerLogger: customReactCompilerLogger,
})

Expand Down Expand Up @@ -197,7 +194,6 @@ async function runCheckFiles(filePathArgs: string[]) {

await babel.compileFiles({
filePaths: filePaths,
configPath: BABEL_CONFIG_PATH,
customReactCompilerLogger: customReactCompilerLogger,
})

Expand Down Expand Up @@ -229,7 +225,6 @@ async function runCheckAllFiles() {

await babel.compileFiles({
filePaths,
configPath: BABEL_CONFIG_PATH,
customReactCompilerLogger: customReactCompilerLogger,
})

Expand Down
4 changes: 4 additions & 0 deletions src/records-file.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,12 @@ describe('load', () => {
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(readFileSync).mockReturnValue('not valid json')

vi.stubGlobal('console', { warn: vi.fn(), log: console.log })

const result = load('/path/to/records.json')

vi.unstubAllGlobals()

expect(result).toBeNull()
})
})
Expand Down