diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e63de19c73..b28106857b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,6 +29,7 @@ jobs: 'typecheck:ios', 'typecheck:android', 'typecheck:web', + 'react-compiler:report', ] steps: - name: ⬇️ Check out Git repository diff --git a/.oxlintrc.json b/.oxlintrc.json index 319b110386..2ee67c3109 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -387,7 +387,8 @@ "files": [ "bskylink/**/*.{js,jsx,ts,tsx}", "bskyogcard/**/*.{js,jsx,ts,tsx}", - "dev-env/**/*.{js,jsx,ts,tsx}" + "dev-env/**/*.{js,jsx,ts,tsx}", + "scripts/**/*.{js,jsx,ts,tsx}" ], "rules": { "import/no-nodejs-modules": "off" diff --git a/package.json b/package.json index 3ae22dc51a..f12c991e45 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "lint": "oxlint --quiet src modules", "lint-native": "swiftlint ./modules && ktlint ./modules", "lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules", + "react-compiler:report": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/react-compiler-report.ts", "lexicons:generate": "lex build --clear --index-file --import-ext ''", "lexicons:install": "lex install", "lexicons:update": "lex install --update", diff --git a/scripts/README.md b/scripts/README.md index 99d6236f90..a9e84db653 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,11 @@ # Tool Scripts +## react-compiler-report.ts + +Reports which components and hooks React Compiler skipped optimizing, grouped by +the compiler's own diagnostic category. Run with `pnpm react-compiler:report`; +also runs in the Lint workflow, where it writes the report to the job summary. + ## updateExtensions.sh Updates the extensions in `/modules` with the current iOS/Android project changes. diff --git a/scripts/react-compiler-report.ts b/scripts/react-compiler-report.ts new file mode 100755 index 0000000000..f497a0675c --- /dev/null +++ b/scripts/react-compiler-report.ts @@ -0,0 +1,218 @@ +#!/usr/bin/env node +/** + * Reports which components and hooks React Compiler skipped optimizing. + * + * The Babel plugin exposes a `logger` option that emits one event per function + * `compilationMode: 'infer'` selected: CompileSuccess when it compiled the + * function, CompileError when it skipped it, and CompileSkip when the function + * opted out of compilation with a `'use no memo'` directive. Running the plugin + * standalone over src and tallying those events is the whole report. + * + * Diagnostics are grouped by the compiler's own ErrorCategory. Severity says who + * owns the fix: `Error` is code the compiler cannot safely optimize, `Hint` is a + * `Todo`, i.e. syntax React Compiler does not support yet. + * + * `react-compiler-healthcheck` does roughly this, but its severity classifier + * still expects the pre-1.0 ErrorSeverity members and throws on every real + * severity, into an empty catch. That silently drops the rest of each file, so + * it reports 100% compiled on any codebase. Hence our own. + * + * Usage: node scripts/react-compiler-report.ts + * Runs under Node's type stripping, so keep the syntax erasable - no enums, no + * namespaces, and type-only imports must say `import type`. + * + * Prints the same report to stdout and, under GitHub Actions, to the job + * summary. Always exits 0 - this is a metric, not a gate. + */ + +import {appendFileSync, readdirSync, readFileSync, statSync} from 'node:fs' +import {dirname, join, relative, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' + +import {transformSync} from '@babel/core' +import { + type ErrorCategory, + type ErrorSeverity, + type Logger, +} from 'babel-plugin-react-compiler' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const OPT_OUT_DIRECTIVE = "'use no memo'" + +/** A component or hook, as `path/to/File.tsx:12`. */ +type FunctionKey = string + +type Diagnostic = { + category: ErrorCategory + severity: ErrorSeverity + reason: string +} + +const files: string[] = [] +;(function walk(dir: string) { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry) + if (statSync(path).isDirectory()) { + if (entry !== '__tests__') walk(path) + } else if (/\.[jt]sx?$/.test(entry) && !/\.d\.ts$|\.test\./.test(entry)) { + files.push(path) + } + } +})(join(ROOT, 'src')) +files.sort() + +const compiled = new Set() +const skipped = new Map() +const optedOut = new Set() +/** Files the plugin could not process at all, e.g. syntax it cannot parse. */ +const unreadable: [file: string, message: string][] = [] +let currentFile: string | null = null + +const logger: Logger = { + logEvent(_filename, event) { + if (!('fnLoc' in event) || event.fnLoc == null) return + const key = `${relative(ROOT, currentFile!)}:${event.fnLoc.start.line}` + if (event.kind === 'CompileSuccess') { + compiled.add(key) + } else if (event.kind === 'CompileSkip') { + optedOut.add(key) + } else if (event.kind === 'CompileError') { + /* + * One function emits one event per diagnostic, so collect them per + * location - counting raw events overstates the number of skipped + * components and hooks by roughly 40%. + */ + if (!skipped.has(key)) skipped.set(key, []) + skipped.get(key)!.push({ + category: event.detail.category, + severity: event.detail.severity, + reason: event.detail.reason, + }) + } + }, +} + +for (const file of files) { + currentFile = file + try { + transformSync(readFileSync(file, 'utf8'), { + filename: file, + babelrc: false, + configFile: false, + sourceType: 'module', + parserOpts: { + plugins: [ + /\.tsx?$/.test(file) && 'typescript', + /x$/.test(file) && 'jsx', + ].filter(Boolean) as ('typescript' | 'jsx')[], + }, + plugins: [ + /* + * Must run before the compiler, mirroring babel.config.js. Without it + * every l`...` macro reads as unsupported tagged template syntax and the + * number of skipped components and hooks roughly doubles. + */ + '@lingui/babel-plugin-lingui-macro', + ['babel-plugin-react-compiler', {target: '19', logger}], + ], + generatorOpts: {compact: true}, + }) + } catch (err) { + /* + * A skipped function does not throw - panicThreshold defaults to 'none' - so + * this is the plugin itself failing. Report it rather than exiting, so a + * metric can never block the check it runs alongside. + */ + unreadable.push([ + relative(ROOT, file), + (err as Error).message.split('\n')[0], + ]) + } +} + +/* A function often reports the same diagnostic several times; show it once. */ +for (const [key, diagnostics] of skipped) { + const unique = new Map(diagnostics.map(d => [`${d.category} ${d.reason}`, d])) + skipped.set(key, [...unique.values()]) +} + +const rows = [...skipped].sort(([a], [b]) => a.localeCompare(b)) +const selected = compiled.size + rows.length + optedOut.size +const affectedFiles = new Set( + [...rows.map(([key]) => key), ...optedOut].map(key => key.split(':')[0]), +) + +/** Categories ranked by how many components and hooks each one skipped. */ +const byCategory = new Map< + ErrorCategory, + {severity: ErrorSeverity; count: number} +>() +for (const [, diagnostics] of rows) { + for (const d of new Map(diagnostics.map(d => [d.category, d])).values()) { + const entry = byCategory.get(d.category) ?? {severity: d.severity, count: 0} + entry.count++ + byCategory.set(d.category, entry) + } +} +const ranked = [...byCategory].sort((a, b) => b[1].count - a[1].count) + +const headline = `Successfully compiled ${compiled.size} out of ${selected} components and hooks.` +const subhead = + `${rows.length} skipped, ${optedOut.size} opted out of compilation ` + + `(${OPT_OUT_DIRECTIVE}), across ${affectedFiles.size} files.` + +for (const [key, diagnostics] of rows) { + console.log(key) + for (const d of diagnostics) console.log(` ${d.category}: ${d.reason}`) +} +for (const key of [...optedOut].sort()) { + console.log(`${key}\n CompileSkip: opted out via ${OPT_OUT_DIRECTIVE}`) +} +console.log() +for (const [category, {severity, count}] of ranked) { + console.log(`${String(count).padStart(4)} ${category} (${severity})`) +} +console.log(`\n${headline}\n${subhead}`) +for (const [file, message] of unreadable) { + console.log( + `::warning file=${file}::React Compiler could not run: ${message}`, + ) +} + +const summaryPath = process.env.GITHUB_STEP_SUMMARY +if (summaryPath) { + appendFileSync( + summaryPath, + [ + `## React Compiler`, + ``, + `**${headline}** ${subhead}`, + ``, + `| category | severity | components and hooks |`, + `| --- | --- | --- |`, + ...ranked.map( + ([category, {severity, count}]) => + `| ${category} | ${severity} | ${count} |`, + ), + ``, + `\`Hint\` is a \`Todo\`: syntax React Compiler does not support yet.`, + `\`Error\` is code the compiler cannot safely optimize.`, + ``, + `
All ${rows.length + optedOut.size} skipped components and hooks`, + ``, + ...rows.map( + ([key, diagnostics]) => + `- \`${key}\` - ${diagnostics.map(d => `${d.category}: ${d.reason}`).join('; ')}`, + ), + ...[...optedOut] + .sort() + .map( + key => + `- \`${key}\` - CompileSkip: opted out via ${OPT_OUT_DIRECTIVE}`, + ), + ``, + `
`, + ``, + ].join('\n'), + ) +} diff --git a/tsconfig.check.json b/tsconfig.check.json index 6a3a0ed03d..a5bae39b96 100644 --- a/tsconfig.check.json +++ b/tsconfig.check.json @@ -1,6 +1,6 @@ { "extends": "./tsconfig.json", - "include": ["src", "modules", "app.config.js"], + "include": ["src", "modules", "app.config.js", "scripts/*.ts"], /* * Ambient check shims apply only to the platform pass that includes them * back (tsconfig.check.android.json, tsconfig.check.web.json).