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
69 changes: 62 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

The [React Compiler](https://react.dev/learn/react-compiler) automatically memoizes your components, eliminating the need for `useCallback` and `useMemo`. However, certain code patterns cause the compiler to bail out. When this happens, your components lose automatic optimization, potentially causing performance regressions.

Inspired by [esplint](https://github.com/hjylewis/esplint) and [react-compiler-marker](https://github.com/blazejkustra/react-compiler-marker), this tool tracks compiler errors in a `.react-compiler-tracker.json` file and integrates with Git hooks and CI to prevent new violations from being introduced.
Inspired by [esplint](https://github.com/hjylewis/esplint) and [react-compiler-marker](https://github.com/blazejkustra/react-compiler-marker), this tool tracks compiler errors in a `.react-compiler.rec.json` file and integrates with Git hooks and CI to prevent new violations from being introduced.

## Prerequisites

Expand All @@ -22,24 +22,66 @@ npm install --save-dev babel-plugin-react-compiler
npm install --save-dev @doist/react-compiler-tracker
```

## Configuration

Create a `.react-compiler-tracker.config.json` file in your project root to customize the tool's behavior:

```json
{
"recordsFile": ".react-compiler.rec.json",
"sourceGlob": "src/**/*.{js,jsx,ts,tsx}"
}
```

All fields are optional. Default values are shown above.

| Option | Description | Default |
|--------|-------------|---------|
| `recordsFile` | Path to the records file | `.react-compiler.rec.json` |
| `sourceGlob` | Glob pattern for source files | `src/**/*.{js,jsx,ts,tsx}` |

### Monorepo Usage

In a monorepo, run the tool from your package directory. The config file and all paths are relative to where you run the command. The defaults typically work out of the box:

```
my-monorepo/
├── packages/
│ └── frontend/
│ ├── .react-compiler-tracker.config.json # Optional config
│ ├── .react-compiler.rec.json # Records file
│ └── src/
│ └── ...
```

If your source files are in a different location (e.g., `app/` instead of `src/`), customize the config:

```json
{
"sourceGlob": "app/**/*.{ts,tsx}"
}
```

## Usage

### `--overwrite`

Regenerates the entire `.react-compiler-tracker.json` by scanning all supported source files (`src/**/*.{js,jsx,ts,tsx}`). Useful for initialization or picking up changes from skipped Git hooks.
Regenerates the entire records file by scanning all source files matching `sourceGlob`. Useful for initialization or picking up changes from skipped Git hooks.

```bash
npx @doist/react-compiler-tracker --overwrite
```

### `--stage-record-file`
### `--stage-record-file <file1> <file2> ...`

Checks Git staged files and updates the records. Exits with code 1 if errors increase (preventing the commit), otherwise updates `.react-compiler-tracker.json` for staged files.
Checks the provided files and updates the records. Exits with code 1 if errors increase (preventing the commit), otherwise updates the records file for the checked files.

```bash
npx @doist/react-compiler-tracker --stage-record-file
npx @doist/react-compiler-tracker --stage-record-file src/components/Button.tsx src/hooks/useData.ts
```

If no files are provided, exits cleanly with a success message.

### `--check-files <file1> <file2> ...`

Checks specific files without updating records. Exits with code 1 if checked files show increased error counts (or new errors). Primarily for CI to ensure PRs don't introduce new compiler errors.
Expand All @@ -50,7 +92,7 @@ npx @doist/react-compiler-tracker --check-files src/components/Button.tsx src/ho

### No flags

Checks all supported source files and reports the total error count. The records file is **not** updated in this mode.
Checks all source files matching `sourceGlob` and reports the total error count. The records file is **not** updated in this mode.

```bash
npx @doist/react-compiler-tracker
Expand All @@ -65,11 +107,13 @@ In `package.json`:
```json
{
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx}": "npx @doist/react-compiler-tracker --stage-record-file"
"*.{js,jsx,ts,tsx}": "npx @doist/react-compiler-tracker --stage-record-file"
}
}
```

With lint-staged, the matched files are automatically passed as arguments to the command.

### GitHub Actions CI

```yaml
Expand All @@ -82,6 +126,17 @@ In `package.json`:
fi
```

### Pre-commit hook (manual)

```bash
#!/bin/sh
# Get staged files and pass them to the tracker
FILES=$(git diff --diff-filter=ACMR --cached --name-only -- '*.tsx' '*.ts' '*.jsx' '*.js' | tr '\n' ' ')
if [ -n "$FILES" ]; then
npx @doist/react-compiler-tracker --stage-record-file $FILES
fi
```

## License

Released under the [MIT License](https://opensource.org/licenses/MIT).
53 changes: 53 additions & 0 deletions src/config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'

type Config = {
recordsFile: string
sourceGlob: string
}

const DEFAULT_CONFIG: Config = {
recordsFile: '.react-compiler.rec.json',
sourceGlob: 'src/**/*.{js,jsx,ts,tsx}',
}

const CONFIG_FILE_NAME = '.react-compiler-tracker.config.json'

function isValidConfig(config: unknown): config is Partial<Config> {
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
return false
}
const obj = config as Record<string, unknown>
if (obj.recordsFile !== undefined && typeof obj.recordsFile !== 'string') {
return false
}
if (obj.sourceGlob !== undefined && typeof obj.sourceGlob !== 'string') {
return false
}
return true
}
Comment on lines +16 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 Perhaps we should just use zod here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe if we have more config options to add in the future?


function loadConfig(): Config {
const configPath = join(process.cwd(), CONFIG_FILE_NAME)

if (!existsSync(configPath)) {
return { ...DEFAULT_CONFIG }
}

try {
const configContent = readFileSync(configPath, 'utf8')
const parsed: unknown = JSON.parse(configContent)
if (!isValidConfig(parsed)) {
throw new Error(`Invalid config file at ${configPath}`)
}

return {
...DEFAULT_CONFIG,
...parsed,
}
} catch (error) {
throw new Error(`Failed to parse config at ${configPath}: ${error}`)
}
}

export { type Config, DEFAULT_CONFIG, loadConfig }
83 changes: 83 additions & 0 deletions src/config.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { existsSync, readFileSync } from 'node:fs'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_CONFIG, loadConfig } from './config.mjs'

vi.mock('node:fs', () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
}))

afterEach(() => {
vi.clearAllMocks()
})

describe('loadConfig', () => {
it('returns defaults when no config file exists', () => {
vi.mocked(existsSync).mockReturnValue(false)

const result = loadConfig()

expect(result).toEqual(DEFAULT_CONFIG)
})

it('loads and merges user config correctly', () => {
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({
recordsFile: 'custom-records.json',
sourceGlob: 'app/**/*.{ts,tsx}',
}),
)

const result = loadConfig()

expect(result).toEqual({
recordsFile: 'custom-records.json',
sourceGlob: 'app/**/*.{ts,tsx}',
})
})

it('handles partial config (only some fields specified)', () => {
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({
sourceGlob: 'packages/frontend/**/*.tsx',
}),
)

const result = loadConfig()

expect(result).toEqual({
recordsFile: DEFAULT_CONFIG.recordsFile,
sourceGlob: 'packages/frontend/**/*.tsx',
})
})

it('throws when config file is invalid JSON', () => {
vi.mocked(existsSync).mockReturnValue(true)
// Trailing comma - a common mistake when editing JSON by hand
vi.mocked(readFileSync).mockReturnValue(`{
"recordsFile": ".react-compiler.rec.json",
}`)

expect(() => loadConfig()).toThrow('Failed to parse config')
})

it('throws when config has invalid field types', () => {
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({
recordsFile: { path: '.react-compiler.rec.json' },
}),
)

expect(() => loadConfig()).toThrow('Invalid config file')
})

it('throws when config file contains an array instead of object', () => {
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ recordsFile: 'records.json' }]))

expect(() => loadConfig()).toThrow('Invalid config file')
})
})
101 changes: 100 additions & 1 deletion src/index.integration.test.mts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { execSync } from 'node:child_process'
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'

const __dirname = dirname(fileURLToPath(import.meta.url))
const fixtureDir = join(__dirname, '__fixtures__/sample-project')
const recordsPath = join(fixtureDir, '.react-compiler.rec.json')
const configPath = join(fixtureDir, '.react-compiler-tracker.config.json')

function runCLI(args: string[] = [], cwd = fixtureDir): string {
const cliPath = join(__dirname, 'index.mts')
Expand All @@ -27,6 +28,9 @@ describe('CLI', () => {
if (existsSync(recordsPath)) {
rmSync(recordsPath)
}
if (existsSync(configPath)) {
rmSync(configPath)
}
})

it('runs check on all files when no flag provided', () => {
Expand All @@ -50,6 +54,12 @@ describe('CLI', () => {
expect(() => JSON.parse(readFileSync(recordsPath, 'utf8'))).toThrow()
})

it('errors when file does not exist', () => {
const output = runCLI(['--check-files', 'src/nonexistent-file.tsx'])

expect(output).toContain('File not found: src/nonexistent-file.tsx')
})

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

Expand All @@ -72,4 +82,93 @@ describe('CLI', () => {
},
})
})

describe('--stage-record-file flag', () => {
it('exits cleanly when no files provided', () => {
const output = runCLI(['--stage-record-file'])

expect(output).toContain('✅ No files to check')
})

it('checks provided files and reports increased errors', () => {
const output = runCLI([
'--stage-record-file',
'src/bad-component.tsx',
'src/bad-hook.ts',
])

expect(output).toContain(
'🔍 Checking 2 files for React Compiler errors and updating records…',
)
expect(output).toContain('React Compiler errors have increased in:')
expect(output).toContain('• src/bad-component.tsx: +1')
expect(output).toContain('• src/bad-hook.ts: +3')
})

it('checks provided files with existing records', () => {
// First create records
runCLI(['--overwrite'])

// Then check staged files - should pass since errors match records
const output = runCLI([
'--stage-record-file',
'src/bad-component.tsx',
'src/good-component.tsx',
])

expect(output).toContain(
'🔍 Checking 2 files for React Compiler errors and updating records…',
)
// The staging step may fail in test environment due to gitignore,
// but the important thing is that error checking passed
expect(output).not.toContain('React Compiler errors have increased')
})
})

describe('config file', () => {
it('uses custom sourceGlob from config', () => {
writeFileSync(
configPath,
JSON.stringify({
sourceGlob: 'src/**/*.tsx',
}),
)

const output = runCLI()

// Should only find .tsx files (2 instead of 4)
expect(output).toContain('🔍 Checking all 2 source files for React Compiler errors…')
})

it('uses custom recordsFile from config', () => {
const customRecordsPath = join(fixtureDir, 'custom-records.json')
writeFileSync(
configPath,
JSON.stringify({
recordsFile: 'custom-records.json',
}),
)

try {
runCLI(['--overwrite'])

expect(existsSync(customRecordsPath)).toBe(true)
expect(existsSync(recordsPath)).toBe(false)

const records = JSON.parse(readFileSync(customRecordsPath, 'utf8'))
expect(records.files).toEqual({
'src/bad-component.tsx': {
CompileError: 1,
},
'src/bad-hook.ts': {
CompileError: 3,
},
})
} finally {
if (existsSync(customRecordsPath)) {
rmSync(customRecordsPath)
}
}
})
})
})
Loading