Report the percentage compiled and a lost/regained diff against the PR base

The sticky comment now leads with the share of components compiled and a
comparison with the PR's base commit: components that lost optimization
(with the compiler's reasons) and ones that regained it. Functions are
diffed by file + name rather than line so unrelated edits that shift a
component down a file do not read as changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Tomek Zawadzki
2026-08-31 14:19:29 +02:00
parent e42d7f9859
commit 90409ead1e
3 changed files with 173 additions and 5 deletions
@@ -32,9 +32,24 @@ jobs:
cache: pnpm cache: pnpm
- name: 📦 pnpm install - name: 📦 pnpm install
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: ⬇️ Check out base commit for comparison
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
git fetch --depth=1 origin "$BASE_SHA"
git worktree add --detach "$RUNNER_TEMP/base" "$BASE_SHA"
- name: ⚛️ Snapshot React Compiler results on base
env:
REACT_COMPILER_SRC_ROOT: ${{ runner.temp }}/base
REACT_COMPILER_SNAPSHOT_PATH: ${{ runner.temp }}/base-snapshot.json
# The base run is only for the snapshot - keep its report out of the
# job summary, which the PR run below writes.
GITHUB_STEP_SUMMARY: ''
run: pnpm react-compiler:report > /dev/null
- name: ⚛️ Generate React Compiler report - name: ⚛️ Generate React Compiler report
env: env:
REACT_COMPILER_REPORT_PATH: ${{ runner.temp }}/react-compiler-report.md REACT_COMPILER_REPORT_PATH: ${{ runner.temp }}/react-compiler-report.md
REACT_COMPILER_BASE_SNAPSHOT_PATH: ${{ runner.temp }}/base-snapshot.json
run: pnpm react-compiler:report run: pnpm react-compiler:report
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
+2 -1
View File
@@ -5,7 +5,8 @@
Reports which components and hooks React Compiler skipped optimizing, grouped by Reports which components and hooks React Compiler skipped optimizing, grouped by
the compiler's own diagnostic category. Run with `pnpm react-compiler:report`; the compiler's own diagnostic category. Run with `pnpm react-compiler:report`;
the React Compiler report workflow also runs it on every pull request and posts the React Compiler report workflow also runs it on every pull request and posts
the report as a sticky PR comment. the report as a sticky PR comment, including a diff against the base commit of
components that lost or regained optimization.
## updateExtensions.sh ## updateExtensions.sh
+156 -4
View File
@@ -25,10 +25,17 @@
* summary under GitHub Actions and to REACT_COMPILER_REPORT_PATH when set * summary under GitHub Actions and to REACT_COMPILER_REPORT_PATH when set
* (the React Compiler report workflow posts that file as a sticky PR * (the React Compiler report workflow posts that file as a sticky PR
* comment). Always exits 0 - this is a metric, not a gate. * comment). Always exits 0 - this is a metric, not a gate.
*
* The workflow also diffs each PR against its base commit:
* REACT_COMPILER_SRC_ROOT points the script at another checkout,
* REACT_COMPILER_SNAPSHOT_PATH writes a JSON snapshot of every function's
* status, and REACT_COMPILER_BASE_SNAPSHOT_PATH reads such a snapshot back to
* report which components lost or regained optimization.
*/ */
import { import {
appendFileSync, appendFileSync,
existsSync,
readdirSync, readdirSync,
readFileSync, readFileSync,
statSync, statSync,
@@ -44,12 +51,29 @@ import {
type Logger, type Logger,
} from 'babel-plugin-react-compiler' } from 'babel-plugin-react-compiler'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') /**
* Root of the tree to analyze. Defaults to this repo; the report workflow sets
* REACT_COMPILER_SRC_ROOT to a checkout of the PR's base commit so this
* script (and this repo's node_modules) can snapshot the base too.
*/
const ROOT = process.env.REACT_COMPILER_SRC_ROOT
? resolve(process.env.REACT_COMPILER_SRC_ROOT)
: resolve(dirname(fileURLToPath(import.meta.url)), '..')
const OPT_OUT_DIRECTIVE = "'use no memo'" const OPT_OUT_DIRECTIVE = "'use no memo'"
/** A component or hook, as `path/to/File.tsx:12`. */ /** A component or hook, as `path/to/File.tsx:12`. */
type FunctionKey = string type FunctionKey = string
/**
* A component or hook as `path/to/File.tsx::Name` (`#n` disambiguates
* same-named functions within one file). Snapshots are diffed on these keys
* so a component merely pushed down by unrelated edits does not read as
* having lost and regained optimization.
*/
type StableKey = string
type FunctionStatus = 'compiled' | 'skipped' | 'optedOut'
type Diagnostic = { type Diagnostic = {
category: ErrorCategory category: ErrorCategory
severity: ErrorSeverity severity: ErrorSeverity
@@ -75,16 +99,52 @@ const optedOut = new Set<FunctionKey>()
/** Files the plugin could not process at all, e.g. syntax it cannot parse. */ /** Files the plugin could not process at all, e.g. syntax it cannot parse. */
const unreadable: [file: string, message: string][] = [] const unreadable: [file: string, message: string][] = []
let currentFile: string | null = null let currentFile: string | null = null
let currentSource: string | null = null
const status = new Map<StableKey, FunctionStatus>()
const locByStable = new Map<StableKey, FunctionKey>()
const stableByLoc = new Map<FunctionKey, StableKey>()
const stableNameCounts = new Map<string, number>()
/**
* Best-effort function name from the declaration line - enough to tell
* `function Foo`, `const useBar = ...`, and `name: () => ...` apart.
*/
function functionName(source: string, line: number): string {
const text = source.split('\n')[line - 1] ?? ''
const match =
text.match(/function\s+([A-Za-z0-9_$]+)/) ??
text.match(/(?:const|let|var)\s+([A-Za-z0-9_$]+)/) ??
text.match(/^\s*(?:export\s+)?([A-Za-z0-9_$]+)\s*[:=(]/)
return match?.[1] ?? 'anonymous'
}
const logger: Logger = { const logger: Logger = {
logEvent(_filename, event) { logEvent(_filename, event) {
if (!('fnLoc' in event) || event.fnLoc == null) return if (!('fnLoc' in event) || event.fnLoc == null) return
const key = `${relative(ROOT, currentFile!)}:${event.fnLoc.start.line}` const file = relative(ROOT, currentFile!)
const key = `${file}:${event.fnLoc.start.line}`
/*
* One function can emit several events (one per diagnostic), so mint its
* stable key once per location.
*/
let stable = stableByLoc.get(key)
if (!stable) {
const name = `${file}::${functionName(currentSource!, event.fnLoc.start.line)}`
const n = (stableNameCounts.get(name) ?? 0) + 1
stableNameCounts.set(name, n)
stable = n > 1 ? `${name}#${n}` : name
stableByLoc.set(key, stable)
locByStable.set(stable, key)
}
if (event.kind === 'CompileSuccess') { if (event.kind === 'CompileSuccess') {
compiled.add(key) compiled.add(key)
status.set(stable, 'compiled')
} else if (event.kind === 'CompileSkip') { } else if (event.kind === 'CompileSkip') {
optedOut.add(key) optedOut.add(key)
status.set(stable, 'optedOut')
} else if (event.kind === 'CompileError') { } else if (event.kind === 'CompileError') {
status.set(stable, 'skipped')
/* /*
* One function emits one event per diagnostic, so collect them per * One function emits one event per diagnostic, so collect them per
* location - counting raw events overstates the number of skipped * location - counting raw events overstates the number of skipped
@@ -102,8 +162,9 @@ const logger: Logger = {
for (const file of files) { for (const file of files) {
currentFile = file currentFile = file
currentSource = readFileSync(file, 'utf8')
try { try {
transformSync(readFileSync(file, 'utf8'), { transformSync(currentSource, {
filename: file, filename: file,
babelrc: false, babelrc: false,
configFile: false, configFile: false,
@@ -164,11 +225,96 @@ for (const [, diagnostics] of rows) {
} }
const ranked = [...byCategory].sort((a, b) => b[1].count - a[1].count) const ranked = [...byCategory].sort((a, b) => b[1].count - a[1].count)
const headline = `Successfully compiled ${compiled.size} out of ${selected} components and hooks.` /** Share of selected components and hooks compiled, as e.g. `95.3%`. */
function percent(n: number, of: number): string {
return `${of ? ((n / of) * 100).toFixed(1) : '0.0'}%`
}
const headline = `Successfully compiled ${compiled.size} out of ${selected} components and hooks (${percent(compiled.size, selected)}).`
const subhead = const subhead =
`${rows.length} skipped, ${optedOut.size} opted out of compilation ` + `${rows.length} skipped, ${optedOut.size} opted out of compilation ` +
`(${OPT_OUT_DIRECTIVE}), across ${affectedFiles.size} files.` `(${OPT_OUT_DIRECTIVE}), across ${affectedFiles.size} files.`
type Snapshot = {
compiled: number
selected: number
functions: Record<StableKey, FunctionStatus>
}
/*
* Snapshot/diff wiring for the React Compiler report workflow: the run over
* the PR's base commit writes a snapshot, then the PR run diffs itself
* against it so the sticky comment can call out components that lost or
* regained optimization.
*/
const snapshotPath = process.env.REACT_COMPILER_SNAPSHOT_PATH
if (snapshotPath) {
const snapshot: Snapshot = {
compiled: compiled.size,
selected,
functions: Object.fromEntries(
[...status].sort(([a], [b]) => a.localeCompare(b)),
),
}
writeFileSync(snapshotPath, JSON.stringify(snapshot))
}
const baseSnapshotPath = process.env.REACT_COMPILER_BASE_SNAPSHOT_PATH
const base: Snapshot | null =
baseSnapshotPath && existsSync(baseSnapshotPath)
? JSON.parse(readFileSync(baseSnapshotPath, 'utf8'))
: null
const lost: StableKey[] = []
const regained: StableKey[] = []
if (base) {
for (const [stable, s] of status) {
const was = base.functions[stable]
if (s === 'compiled' && was && was !== 'compiled') regained.push(stable)
if (s !== 'compiled' && was === 'compiled') lost.push(stable)
}
lost.sort()
regained.sort()
}
/** A diff list entry: location, name, and why it is not compiled. */
function describeLost(stable: StableKey): string {
const loc = locByStable.get(stable)!
const diagnostics = skipped.get(loc)
const reasons = diagnostics
? diagnostics.map(d => `${d.category}: ${d.reason}`).join('; ')
: `CompileSkip: opted out via ${OPT_OUT_DIRECTIVE}`
return `- \`${loc}\` \`${stable.split('::')[1]}\` - ${reasons}`
}
const diffSection = base
? [
`**Compared to base** (${base.compiled} out of ${base.selected}, ${percent(base.compiled, base.selected)}):` +
(lost.length || regained.length
? ''
: ' no components changed optimization status.'),
``,
...(lost.length
? [
`⚠️ **${lost.length} lost optimization:**`,
``,
...lost.map(describeLost),
``,
]
: []),
...(regained.length
? [
`✅ **${regained.length} regained optimization:**`,
``,
...regained.map(
key => `- \`${locByStable.get(key)!}\` \`${key.split('::')[1]}\``,
),
``,
]
: []),
]
: []
for (const [key, diagnostics] of rows) { for (const [key, diagnostics] of rows) {
console.log(key) console.log(key)
for (const d of diagnostics) console.log(` ${d.category}: ${d.reason}`) for (const d of diagnostics) console.log(` ${d.category}: ${d.reason}`)
@@ -181,6 +327,11 @@ for (const [category, {severity, count}] of ranked) {
console.log(`${String(count).padStart(4)} ${category} (${severity})`) console.log(`${String(count).padStart(4)} ${category} (${severity})`)
} }
console.log(`\n${headline}\n${subhead}`) console.log(`\n${headline}\n${subhead}`)
if (base) {
console.log(
`\nCompared to base: ${lost.length} lost optimization, ${regained.length} regained.`,
)
}
for (const [file, message] of unreadable) { for (const [file, message] of unreadable) {
console.log( console.log(
`::warning file=${file}::React Compiler could not run: ${message}`, `::warning file=${file}::React Compiler could not run: ${message}`,
@@ -200,6 +351,7 @@ if (summaryPath || reportPath) {
``, ``,
`**${headline}** ${subhead}`, `**${headline}** ${subhead}`,
``, ``,
...diffSection,
`| category | severity | components and hooks |`, `| category | severity | components and hooks |`,
`| --- | --- | --- |`, `| --- | --- | --- |`,
...ranked.map( ...ranked.map(