@doist/react-compiler-tracker
Advanced tools
+42
| # Changelog | ||
| This file documents all notable changes, following the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. | ||
| ## [2.0.1](https://github.com/Doist/react-compiler-tracker/compare/react-compiler-tracker-v2.0.0...react-compiler-tracker-v2.0.1) (2026-01-18) | ||
| ### Bug Fixes | ||
| * **ci:** add scope to setup-node for npm registry publishing ([#23](https://github.com/Doist/react-compiler-tracker/issues/23)) ([8352f69](https://github.com/Doist/react-compiler-tracker/commit/8352f69a9646ef2e44662f432078b647396ca435)) | ||
| ## [2.0.0](https://github.com/Doist/react-compiler-tracker/compare/react-compiler-tracker-v1.1.0...react-compiler-tracker-v2.0.0) (2026-01-18) | ||
| ### β BREAKING CHANGES | ||
| * `--stage-record-file` now requires files list; add config file support ([#21](https://github.com/Doist/react-compiler-tracker/issues/21)) | ||
| ### Features | ||
| * `--stage-record-file` now requires files list; add config file support ([#21](https://github.com/Doist/react-compiler-tracker/issues/21)) ([ccb45dc](https://github.com/Doist/react-compiler-tracker/commit/ccb45dcdca6e9fcb84652b29ca8e83004203ded6)) | ||
| ## [1.1.0](https://github.com/Doist/react-compiler-tracker/compare/react-compiler-tracker-v1.0.1...react-compiler-tracker-v1.1.0) (2026-01-16) | ||
| ### Features | ||
| * Drop requirement to have a user-provided babel config ([#15](https://github.com/Doist/react-compiler-tracker/issues/15)) ([82ecfd4](https://github.com/Doist/react-compiler-tracker/commit/82ecfd46aefe459aec2b8d5701ef9b35205737bd)) | ||
| ## [1.0.1](https://github.com/Doist/react-compiler-tracker/compare/react-compiler-tracker-v1.0.0...react-compiler-tracker-v1.0.1) (2026-01-12) | ||
| ### Bug Fixes | ||
| * **deps:** pin dependencies ([#10](https://github.com/Doist/react-compiler-tracker/issues/10)) ([c5d7fc1](https://github.com/Doist/react-compiler-tracker/commit/c5d7fc1081562a1a18f411fe1775608efbefed66)) | ||
| ## 1.0.0 (2026-01-10) | ||
| ### Features | ||
| * Port React Compiler tracker from todoist-web ([#6](https://github.com/Doist/react-compiler-tracker/issues/6)) ([8e8ec3b](https://github.com/Doist/react-compiler-tracker/commit/8e8ec3be05a9738aca7215d98f9d2e83ef59378c)) |
| import { existsSync, readFileSync } from 'node:fs'; | ||
| import { join } from 'node:path'; | ||
| const DEFAULT_CONFIG = { | ||
| recordsFile: '.react-compiler.rec.json', | ||
| sourceGlob: 'src/**/*.{js,jsx,ts,tsx}', | ||
| }; | ||
| const CONFIG_FILE_NAME = '.react-compiler-tracker.config.json'; | ||
| function isValidConfig(config) { | ||
| if (typeof config !== 'object' || config === null || Array.isArray(config)) { | ||
| return false; | ||
| } | ||
| const obj = config; | ||
| if (obj.recordsFile !== undefined && typeof obj.recordsFile !== 'string') { | ||
| return false; | ||
| } | ||
| if (obj.sourceGlob !== undefined && typeof obj.sourceGlob !== 'string') { | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| function loadConfig() { | ||
| const configPath = join(process.cwd(), CONFIG_FILE_NAME); | ||
| if (!existsSync(configPath)) { | ||
| return { ...DEFAULT_CONFIG }; | ||
| } | ||
| try { | ||
| const configContent = readFileSync(configPath, 'utf8'); | ||
| const parsed = 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 { DEFAULT_CONFIG, loadConfig }; |
| 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'); | ||
| }); | ||
| }); |
| 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'; | ||
@@ -9,2 +9,3 @@ import { fileURLToPath } from 'node:url'; | ||
| const recordsPath = join(fixtureDir, '.react-compiler.rec.json'); | ||
| const configPath = join(fixtureDir, '.react-compiler-tracker.config.json'); | ||
| function runCLI(args = [], cwd = fixtureDir) { | ||
@@ -29,2 +30,5 @@ const cliPath = join(__dirname, 'index.mts'); | ||
| } | ||
| if (existsSync(configPath)) { | ||
| rmSync(configPath); | ||
| } | ||
| }); | ||
@@ -48,2 +52,6 @@ it('runs check on all files when no flag provided', () => { | ||
| }); | ||
| 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', () => { | ||
@@ -65,2 +73,68 @@ const output = runCLI(['--overwrite']); | ||
| }); | ||
| 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); | ||
| } | ||
| } | ||
| }); | ||
| }); | ||
| }); |
+48
-43
| #!/usr/bin/env node | ||
| import { join, relative } from 'node:path'; | ||
| import * as babel from './babel.mjs'; | ||
| import { loadConfig } from './config.mjs'; | ||
| import * as recordsFile from './records-file.mjs'; | ||
| 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 OVERWRITE_FLAG = '--overwrite'; | ||
@@ -13,3 +11,2 @@ const STAGE_RECORD_FILE_FLAG = '--stage-record-file'; | ||
| const compilerErrors = new Map(); | ||
| const recordsFilePath = join(process.cwd(), RECORDS_PATH); | ||
| const customReactCompilerLogger = { | ||
@@ -31,19 +28,36 @@ logEvent: (filename, event) => { | ||
| async function main() { | ||
| const config = loadConfig(); | ||
| try { | ||
| const flag = process.argv[2]; | ||
| switch (flag) { | ||
| case STAGE_RECORD_FILE_FLAG: | ||
| await runStageRecords(); | ||
| break; | ||
| case OVERWRITE_FLAG: | ||
| await runOverwriteRecords(); | ||
| break; | ||
| case CHECK_FILES_FLAG: | ||
| { | ||
| const filePaths = process.argv.slice(3); | ||
| await runCheckFiles(filePaths); | ||
| } | ||
| break; | ||
| default: | ||
| await runCheckAllFiles(); | ||
| case STAGE_RECORD_FILE_FLAG: { | ||
| const filePathParams = process.argv.slice(3); | ||
| const filePaths = sourceFiles.filterByGlob({ | ||
| filePaths: sourceFiles.normalizeFilePaths(filePathParams), | ||
| globPattern: config.sourceGlob, | ||
| }); | ||
| sourceFiles.validateFilesExist(filePaths); | ||
| return await runStageRecords({ | ||
| filePaths, | ||
| recordsFilePath: config.recordsFile, | ||
| }); | ||
| } | ||
| case OVERWRITE_FLAG: { | ||
| return await runOverwriteRecords({ | ||
| sourceGlob: config.sourceGlob, | ||
| recordsFilePath: config.recordsFile, | ||
| }); | ||
| } | ||
| case CHECK_FILES_FLAG: { | ||
| const filePathParams = process.argv.slice(3); | ||
| const filePaths = sourceFiles.filterByGlob({ | ||
| filePaths: sourceFiles.normalizeFilePaths(filePathParams), | ||
| globPattern: config.sourceGlob, | ||
| }); | ||
| sourceFiles.validateFilesExist(filePaths); | ||
| return await runCheckFiles({ filePaths, recordsFilePath: config.recordsFile }); | ||
| } | ||
| default: { | ||
| return await runCheckAllFiles({ sourceGlob: config.sourceGlob }); | ||
| } | ||
| } | ||
@@ -63,6 +77,5 @@ } | ||
| */ | ||
| async function runOverwriteRecords() { | ||
| async function runOverwriteRecords({ sourceGlob, recordsFilePath, }) { | ||
| const filePaths = sourceFiles.getAll({ | ||
| globPattern: SOURCE_FILES, | ||
| supportedFileExtensions: SUPPORTED_FILE_EXTENSIONS, | ||
| globPattern: sourceGlob, | ||
| }); | ||
@@ -101,16 +114,12 @@ if (!filePaths.length) { | ||
| /** | ||
| * Handles the `--stage-record-file` flag by checking git staged files and updating the records file. | ||
| * Handles the `--stage-record-file` flag by checking provided files and updating the records file. | ||
| * | ||
| * If errors have increased, the process will exit with code 1 and the records file will not be updated. | ||
| */ | ||
| async function runStageRecords() { | ||
| const filePaths = sourceFiles.getStagedFromGit({ | ||
| globPattern: SOURCE_FILES, | ||
| supportedFileExtensions: SUPPORTED_FILE_EXTENSIONS, | ||
| }); | ||
| async function runStageRecords({ filePaths, recordsFilePath, }) { | ||
| if (!filePaths.length) { | ||
| console.log('β No staged files to check'); | ||
| console.log('β No files to check'); | ||
| return; | ||
| } | ||
| console.log(`π Checking ${filePaths.length} staged files for React Compiler errors and updating recordsβ¦`); | ||
| console.log(`π Checking ${filePaths.length} files for React Compiler errors and updating recordsβ¦`); | ||
| // | ||
@@ -123,3 +132,3 @@ // Compile files and update `compilerErrors` with `customReactCompilerLogger` | ||
| }); | ||
| const records = exitIfErrorsIncreased({ filePaths }); | ||
| const records = exitIfErrorsIncreased({ filePaths, recordsFilePath }); | ||
| // | ||
@@ -134,9 +143,10 @@ // Update and stage records file | ||
| }); | ||
| const recordsFileRelativePath = join(process.cwd(), recordsFilePath); | ||
| try { | ||
| recordsFile.stage(RECORDS_PATH); | ||
| recordsFile.stage(recordsFileRelativePath); | ||
| } | ||
| catch { | ||
| exitWithWarning(`Failed to stage records file at ${RECORDS_PATH}`); | ||
| exitWithWarning(`Failed to stage records file at ${recordsFileRelativePath}`); | ||
| } | ||
| console.log('β No new React Compiler errors in staged files'); | ||
| console.log('β No new React Compiler errors'); | ||
| } | ||
@@ -148,7 +158,3 @@ /** | ||
| */ | ||
| async function runCheckFiles(filePathArgs) { | ||
| const filePaths = sourceFiles.filterSupportedFiles({ | ||
| filePaths: filePathArgs, | ||
| supportedFileExtensions: SUPPORTED_FILE_EXTENSIONS, | ||
| }); | ||
| async function runCheckFiles({ filePaths, recordsFilePath, }) { | ||
| if (!filePaths.length) { | ||
@@ -166,3 +172,3 @@ console.log('β No files to check'); | ||
| }); | ||
| exitIfErrorsIncreased({ filePaths }); | ||
| exitIfErrorsIncreased({ filePaths, recordsFilePath }); | ||
| console.log('β No new React Compiler errors in checked files'); | ||
@@ -175,6 +181,5 @@ } | ||
| */ | ||
| async function runCheckAllFiles() { | ||
| async function runCheckAllFiles({ sourceGlob }) { | ||
| const filePaths = sourceFiles.getAll({ | ||
| globPattern: SOURCE_FILES, | ||
| supportedFileExtensions: SUPPORTED_FILE_EXTENSIONS, | ||
| globPattern: sourceGlob, | ||
| }); | ||
@@ -208,3 +213,3 @@ if (!filePaths.length) { | ||
| */ | ||
| function exitIfErrorsIncreased({ filePaths }) { | ||
| function exitIfErrorsIncreased({ filePaths, recordsFilePath, }) { | ||
| const records = recordsFile.load(recordsFilePath); | ||
@@ -211,0 +216,0 @@ const errorIncreases = recordsFile.getErrorIncreases({ |
+69
-19
| import { execSync } from 'node:child_process'; | ||
| import { existsSync } from 'node:fs'; | ||
| import { relative } from 'node:path'; | ||
| import { glob } from 'glob'; | ||
| function getStagedFromGit({ globPattern, supportedFileExtensions, }) { | ||
| const allFilePaths = getAll({ globPattern, supportedFileExtensions }); | ||
| import { minimatch } from 'minimatch'; | ||
| function getAll({ globPattern }) { | ||
| return glob.sync(globPattern, { | ||
| cwd: process.cwd(), | ||
| absolute: false, | ||
| }); | ||
| } | ||
| /** | ||
| * Gets the current working directory relative to the git repository root. | ||
| * | ||
| * @returns The cwd relative to git root (e.g., "apps/frontend/"), empty string if at git root, or null if not in a git repo | ||
| */ | ||
| function getGitPrefix() { | ||
| try { | ||
| const output = execSync('git diff --cached --name-only', { encoding: 'utf8' }); | ||
| const stagedFiles = output.trim().split('\n').filter(Boolean); | ||
| const allFilePathsSet = new Set(allFilePaths); | ||
| const filePaths = stagedFiles.filter((path) => allFilePathsSet.has(path)); | ||
| return filterSupportedFiles({ filePaths, supportedFileExtensions }); | ||
| const result = execSync('git rev-parse --show-prefix', { | ||
| encoding: 'utf8', | ||
| stdio: ['pipe', 'pipe', 'pipe'], | ||
| }); | ||
| return result.trim(); | ||
| } | ||
| catch { | ||
| return []; | ||
| return null; | ||
| } | ||
| } | ||
| function getAll({ globPattern, supportedFileExtensions, }) { | ||
| const srcFiles = glob.sync(globPattern, { | ||
| cwd: process.cwd(), | ||
| absolute: false, | ||
| /** | ||
| * Normalizes file paths by converting absolute paths to cwd-relative | ||
| * and stripping the git prefix when present. | ||
| * | ||
| * When running from a package subdirectory in a monorepo, file paths from git | ||
| * (e.g., lint-staged) are relative to the repo root. This function converts | ||
| * those paths to cwd-relative paths. | ||
| * | ||
| * @example | ||
| * // When cwd is apps/frontend/ (prefix is "apps/frontend/") | ||
| * normalizeFilePaths(["apps/frontend/src/file.tsx"]) // => ["src/file.tsx"] | ||
| * | ||
| * // Absolute paths are converted to cwd-relative | ||
| * normalizeFilePaths(["/Users/frankie/project/src/file.tsx"]) // => ["src/file.tsx"] | ||
| * | ||
| * // Paths that don't start with prefix are unchanged | ||
| * normalizeFilePaths(["src/file.tsx"]) // => ["src/file.tsx"] | ||
| */ | ||
| function normalizeFilePaths(filePaths) { | ||
| const prefix = getGitPrefix(); | ||
| const cwd = process.cwd(); | ||
| return filePaths.map((filePath) => { | ||
| // Handle absolute paths by converting to cwd-relative | ||
| if (filePath.startsWith('/')) { | ||
| return relative(cwd, filePath); | ||
| } | ||
| // Handle monorepo prefix stripping | ||
| if (prefix && filePath.startsWith(prefix)) { | ||
| return filePath.slice(prefix.length); | ||
| } | ||
| return filePath; | ||
| }); | ||
| return filterSupportedFiles({ filePaths: srcFiles, supportedFileExtensions }); | ||
| } | ||
| function filterSupportedFiles({ filePaths, supportedFileExtensions, }) { | ||
| return filePaths.filter((filePath) => { | ||
| const ext = filePath.split('.').pop()?.toLowerCase(); | ||
| return supportedFileExtensions.includes(ext || ''); | ||
| }); | ||
| /** | ||
| * Filters file paths to only include those matching the glob pattern. | ||
| */ | ||
| function filterByGlob({ filePaths, globPattern }) { | ||
| return filePaths.filter((filePath) => minimatch(filePath, globPattern)); | ||
| } | ||
| export { filterSupportedFiles, getAll, getStagedFromGit }; | ||
| /** | ||
| * Validates that all file paths exist on the filesystem. | ||
| * Throws an error if any file is not found. | ||
| */ | ||
| function validateFilesExist(filePaths) { | ||
| for (const filePath of filePaths) { | ||
| if (!existsSync(filePath)) { | ||
| throw new Error(`File not found: ${filePath}`); | ||
| } | ||
| } | ||
| } | ||
| export { getAll, normalizeFilePaths, filterByGlob, validateFilesExist }; |
+104
-60
| import { execSync } from 'node:child_process'; | ||
| import { existsSync } from 'node:fs'; | ||
| import { glob } from 'glob'; | ||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
| import { filterSupportedFiles, getAll, getStagedFromGit } from './source-files.mjs'; | ||
| import { filterByGlob, getAll, normalizeFilePaths, validateFilesExist } from './source-files.mjs'; | ||
| vi.mock('node:child_process', () => ({ | ||
| execSync: vi.fn(), | ||
| })); | ||
| vi.mock('node:fs', () => ({ | ||
| existsSync: vi.fn(), | ||
| })); | ||
| vi.mock('glob', () => ({ | ||
@@ -10,43 +17,11 @@ glob: { | ||
| })); | ||
| vi.mock('node:child_process', () => ({ | ||
| execSync: vi.fn(), | ||
| })); | ||
| afterEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
| describe('filterSupportedFiles', () => { | ||
| const supportedFileExtensions = ['js', 'jsx', 'ts', 'tsx']; | ||
| it('filters files by supported extensions', () => { | ||
| const filePaths = [ | ||
| 'src/index.ts', | ||
| 'src/app.tsx', | ||
| 'src/utils.js', | ||
| 'src/styles.module.css', | ||
| 'README.md', | ||
| 'src/fixture.json', | ||
| 'Makefile', | ||
| ]; | ||
| const result = filterSupportedFiles({ filePaths, supportedFileExtensions }); | ||
| expect(result).toEqual(['src/index.ts', 'src/app.tsx', 'src/utils.js']); | ||
| }); | ||
| it('handles empty file list', () => { | ||
| const result = filterSupportedFiles({ filePaths: [], supportedFileExtensions }); | ||
| expect(result).toEqual([]); | ||
| }); | ||
| }); | ||
| describe('getAll', () => { | ||
| const supportedFileExtensions = ['js', 'jsx', 'ts', 'tsx']; | ||
| it('returns results that match glob pattern and supported extensions', () => { | ||
| vi.mocked(glob.sync).mockReturnValue([ | ||
| 'src/index.ts', | ||
| 'src/app.tsx', | ||
| 'src/utils.js', | ||
| 'src/styles.module.css', | ||
| 'README.md', | ||
| 'src/fixture.json', | ||
| 'Makefile', | ||
| ]); | ||
| const result = getAll({ globPattern: 'src/**/*', supportedFileExtensions }); | ||
| it('returns files matching the glob pattern', () => { | ||
| vi.mocked(glob.sync).mockReturnValue(['src/index.ts', 'src/app.tsx', 'src/utils.js']); | ||
| const result = getAll({ globPattern: 'src/**/*.{js,jsx,ts,tsx}' }); | ||
| expect(result).toEqual(['src/index.ts', 'src/app.tsx', 'src/utils.js']); | ||
| expect(glob.sync).toHaveBeenCalledWith('src/**/*', { | ||
| expect(glob.sync).toHaveBeenCalledWith('src/**/*.{js,jsx,ts,tsx}', { | ||
| cwd: process.cwd(), | ||
@@ -56,33 +31,102 @@ absolute: false, | ||
| }); | ||
| it('returns empty array when no files match', () => { | ||
| vi.mocked(glob.sync).mockReturnValue([]); | ||
| const result = getAll({ globPattern: 'nonexistent/**/*.ts' }); | ||
| expect(result).toEqual([]); | ||
| }); | ||
| }); | ||
| describe('getStagedFromGit', () => { | ||
| const supportedFileExtensions = ['js', 'jsx', 'ts', 'tsx']; | ||
| it('returns staged files that match glob pattern and supported extensions', () => { | ||
| vi.mocked(glob.sync).mockReturnValue([ | ||
| 'src/index.ts', | ||
| 'src/app.tsx', | ||
| 'src/utils.js', | ||
| 'src/styles.module.css', | ||
| 'README.md', | ||
| 'src/fixture.json', | ||
| 'Makefile', | ||
| describe('normalizeFilePaths', () => { | ||
| it('strips prefix from matching paths', () => { | ||
| vi.mocked(execSync).mockReturnValue('apps/frontend/\n'); | ||
| const result = normalizeFilePaths([ | ||
| 'apps/frontend/src/App.tsx', | ||
| 'apps/frontend/src/utils/helper.ts', | ||
| ]); | ||
| vi.mocked(execSync).mockReturnValue('eslint.config.ts\nsrc/index.ts\nsrc/app.tsx\nsrc/styles.module.css\nsrc/fixture.json\nMakefile'); | ||
| const result = getStagedFromGit({ globPattern: 'src/**/*', supportedFileExtensions }); | ||
| expect(result).toEqual(['src/index.ts', 'src/app.tsx']); | ||
| expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']); | ||
| }); | ||
| it('returns empty array when no files are staged', () => { | ||
| vi.mocked(glob.sync).mockReturnValue(['src/index.ts']); | ||
| vi.mocked(execSync).mockReturnValue(''); | ||
| const result = getStagedFromGit({ globPattern: 'src/**/*', supportedFileExtensions }); | ||
| expect(result).toEqual([]); | ||
| it('leaves non-matching paths unchanged', () => { | ||
| vi.mocked(execSync).mockReturnValue('apps/frontend/\n'); | ||
| const result = normalizeFilePaths(['src/App.tsx', 'src/utils/helper.ts']); | ||
| expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']); | ||
| }); | ||
| it('returns empty array when git command fails', () => { | ||
| vi.mocked(glob.sync).mockReturnValue(['src/index.ts']); | ||
| it('handles mixed paths (some matching, some not)', () => { | ||
| vi.mocked(execSync).mockReturnValue('apps/frontend/\n'); | ||
| const result = normalizeFilePaths([ | ||
| 'apps/frontend/src/App.tsx', | ||
| 'src/local.ts', | ||
| 'apps/frontend/index.ts', | ||
| ]); | ||
| expect(result).toEqual(['src/App.tsx', 'src/local.ts', 'index.ts']); | ||
| }); | ||
| it('returns paths unchanged when at git root', () => { | ||
| vi.mocked(execSync).mockReturnValue('\n'); | ||
| const result = normalizeFilePaths(['src/App.tsx', 'src/utils/helper.ts']); | ||
| expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']); | ||
| }); | ||
| it('returns paths unchanged when not in a git repo', () => { | ||
| vi.mocked(execSync).mockImplementation(() => { | ||
| throw new Error('git error'); | ||
| throw new Error('fatal: not a git repository'); | ||
| }); | ||
| const result = getStagedFromGit({ globPattern: 'src/**/*', supportedFileExtensions }); | ||
| const result = normalizeFilePaths(['src/App.tsx', 'src/utils/helper.ts']); | ||
| expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']); | ||
| }); | ||
| it('handles empty file list', () => { | ||
| vi.mocked(execSync).mockReturnValue('apps/frontend/\n'); | ||
| const result = normalizeFilePaths([]); | ||
| expect(result).toEqual([]); | ||
| }); | ||
| it('converts absolute paths to cwd-relative paths', () => { | ||
| vi.mocked(execSync).mockReturnValue('\n'); | ||
| const cwd = process.cwd(); | ||
| const result = normalizeFilePaths([`${cwd}/src/App.tsx`, `${cwd}/src/utils/helper.ts`]); | ||
| expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']); | ||
| }); | ||
| it('handles absolute paths outside cwd with ../', () => { | ||
| vi.mocked(execSync).mockReturnValue('\n'); | ||
| const result = normalizeFilePaths(['/other/project/file.tsx']); | ||
| expect(result[0]).toMatch(/^\.\.\//); | ||
| }); | ||
| it('handles mixed absolute and relative paths', () => { | ||
| vi.mocked(execSync).mockReturnValue('\n'); | ||
| const cwd = process.cwd(); | ||
| const result = normalizeFilePaths([`${cwd}/src/App.tsx`, 'src/local.ts']); | ||
| expect(result).toEqual(['src/App.tsx', 'src/local.ts']); | ||
| }); | ||
| }); | ||
| describe('filterByGlob', () => { | ||
| it('filters paths matching the glob pattern', () => { | ||
| const result = filterByGlob({ | ||
| filePaths: ['src/App.tsx', 'src/utils.ts', 'package.json', 'README.md'], | ||
| globPattern: 'src/**/*.{ts,tsx}', | ||
| }); | ||
| expect(result).toEqual(['src/App.tsx', 'src/utils.ts']); | ||
| }); | ||
| it('returns empty array when no paths match', () => { | ||
| const result = filterByGlob({ | ||
| filePaths: ['package.json', 'README.md'], | ||
| globPattern: 'src/**/*.{ts,tsx}', | ||
| }); | ||
| expect(result).toEqual([]); | ||
| }); | ||
| it('returns all paths when all match', () => { | ||
| const result = filterByGlob({ | ||
| filePaths: ['src/a.ts', 'src/b.tsx'], | ||
| globPattern: 'src/**/*.{ts,tsx}', | ||
| }); | ||
| expect(result).toEqual(['src/a.ts', 'src/b.tsx']); | ||
| }); | ||
| }); | ||
| describe('validateFilesExist', () => { | ||
| it('throws error when file does not exist', () => { | ||
| vi.mocked(existsSync).mockReturnValue(false); | ||
| expect(() => validateFilesExist(['nonexistent.tsx'])).toThrow('File not found: nonexistent.tsx'); | ||
| }); | ||
| it('does not throw when all files exist', () => { | ||
| vi.mocked(existsSync).mockReturnValue(true); | ||
| expect(() => validateFilesExist(['exists.tsx'])).not.toThrow(); | ||
| }); | ||
| it('throws on first missing file', () => { | ||
| vi.mocked(existsSync).mockImplementation((path) => path === 'exists.tsx'); | ||
| expect(() => validateFilesExist(['exists.tsx', 'missing.tsx'])).toThrow('File not found: missing.tsx'); | ||
| }); | ||
| }); |
+4
-3
| { | ||
| "name": "@doist/react-compiler-tracker", | ||
| "version": "1.1.0", | ||
| "version": "2.0.1", | ||
| "description": "A React Compiler violation tracker to help migrations and prevent regressions", | ||
@@ -38,3 +38,3 @@ "type": "module", | ||
| "engines": { | ||
| "node": ">=22" | ||
| "node": "^22.22.0 || >=24.13.0" | ||
| }, | ||
@@ -44,3 +44,4 @@ "files": [ | ||
| "README.md", | ||
| "LICENSE" | ||
| "LICENSE", | ||
| "CHANGELOG.md" | ||
| ], | ||
@@ -47,0 +48,0 @@ "lint-staged": { |
+67
-8
| # React Compiler Tracker | ||
| While it's safe to opt out of using memoization hooks such as `useCallback` and `useMemo` in code optimized by the [React Compiler](https://react.dev/learn/react-compiler), it is easy to introduce violations that cause the compiler to bail out, leading to performance issues when values are not memoized. | ||
| [](https://www.npmjs.com/package/@doist/react-compiler-tracker) | ||
| [](https://opensource.org/licenses/MIT) | ||
| [](https://nodejs.org/) | ||
| Designed to run as a part of Git hooks and CI, this tool helps prevent this and progressively adopt the React Compiler by tracking and warning against newly introduced compilation errors. It maintains a `.react-compiler-tracker.json` record file to track known compiler errors per file. | ||
| 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.rec.json` file and integrates with Git hooks and CI to prevent new violations from being introduced. | ||
| ## Prerequisites | ||
@@ -21,2 +25,42 @@ | ||
| ## 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 | ||
@@ -26,3 +70,3 @@ | ||
| 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. | ||
@@ -33,10 +77,12 @@ ```bash | ||
| ### `--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> ...` | ||
@@ -52,3 +98,3 @@ | ||
| 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. | ||
@@ -68,3 +114,3 @@ ```bash | ||
| "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" | ||
| } | ||
@@ -74,2 +120,4 @@ } | ||
| With lint-staged, the matched files are automatically passed as arguments to the command. | ||
| ### GitHub Actions CI | ||
@@ -87,4 +135,15 @@ | ||
| ### 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). |
45754
51.06%14
27.27%939
41.2%143
70.24%