Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement β†’
Sign In

@doist/react-compiler-tracker

Package Overview
Dependencies
Maintainers
9
Versions
12
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@doist/react-compiler-tracker - npm Package Compare versions

Comparing version
1.1.0
to
2.0.1
+42
CHANGELOG.md
# 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');
});
});
+75
-1
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({

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 };
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');
});
});
{
"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": {

# 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.
[![npm version](https://img.shields.io/npm/v/@doist/react-compiler-tracker)](https://www.npmjs.com/package/@doist/react-compiler-tracker)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/node/v/@doist/react-compiler-tracker)](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).