mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
328 lines
11 KiB
JavaScript
328 lines
11 KiB
JavaScript
/**
|
|
* React Compiler diagnostic checker.
|
|
*
|
|
* Runs babel-plugin-react-compiler over every .ts/.tsx file in the
|
|
* target directories and reports functions that failed to compile or
|
|
* were skipped. Exits with code 1 when any diagnostics are present
|
|
* or a target directory is missing.
|
|
*
|
|
* Usage: node scripts/check-compiler.mjs
|
|
*/
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { join, relative } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { transformSync } from "@babel/core";
|
|
|
|
// Resolve the site/ directory (ESM equivalent of __dirname + "..").
|
|
const siteDir = new URL("..", import.meta.url).pathname;
|
|
|
|
// Only AgentsPage is currently opted in to React Compiler. Add new
|
|
// directories here as more pages are migrated.
|
|
const targetDirs = [
|
|
"src/pages/AgentsPage",
|
|
];
|
|
|
|
const skipPatterns = [".test.", ".stories.", ".jest."];
|
|
|
|
// Maximum length for truncated error messages in the report.
|
|
const MAX_ERROR_LENGTH = 120;
|
|
|
|
// Patterns that identify a function/closure value on the RHS of an
|
|
// assignment. Primitives (strings, numbers, booleans) are fine without
|
|
// memoization because `!==` compares them by value. Only reference types
|
|
// (closures, objects, arrays) cause problems.
|
|
const CLOSURE_RHS = /^\s*(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)\s*=>|\w+\s*=>|function\s*\()/;
|
|
|
|
// Matches a `$[N] !== name` fragment inside an `if (...)` guard.
|
|
const DEP_CHECK = /\$\[\d+\]\s*!==\s*(\w+)/g;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File collection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Recursively collect .ts/.tsx files under `dir`, skipping test and
|
|
* story files. Returns paths relative to `siteDir`. Sets
|
|
* `hadCollectionErrors` and returns an empty array on ENOENT so the
|
|
* caller and recursive calls both stay safe.
|
|
*/
|
|
function collectFiles(dir) {
|
|
let entries;
|
|
try {
|
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
} catch (e) {
|
|
if (e.code === "ENOENT") {
|
|
console.error(`Target directory not found: ${relative(siteDir, dir)}`);
|
|
hadCollectionErrors = true;
|
|
return [];
|
|
}
|
|
throw e;
|
|
}
|
|
const results = [];
|
|
for (const entry of entries) {
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
results.push(...collectFiles(full));
|
|
} else if (
|
|
(entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) &&
|
|
!skipPatterns.some((p) => entry.name.includes(p))
|
|
) {
|
|
results.push(relative(siteDir, full));
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compilation & diagnostics
|
|
//
|
|
// We use transformSync deliberately. The React Compiler plugin is
|
|
// CPU-bound (parse-only takes ~2s vs ~19s with the compiler over all
|
|
// of site/src), so transformAsync + Promise.all gives no speedup
|
|
// because Node still runs all transforms on a single thread. Benchmarked
|
|
// sync, async-sequential, and async-parallel: all land within noise
|
|
// of each other. The sync API keeps the code simple.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Shorten a compiler diagnostic message to its first sentence, stripping
|
|
* the leading "Error: " prefix and any trailing URL references so the
|
|
* one-line report stays readable.
|
|
*
|
|
* Example:
|
|
* "Error: Ref values are not allowed. Use ref types instead (https://…)."
|
|
* → "Ref values are not allowed"
|
|
*/
|
|
export function shortenMessage(msg) {
|
|
const str = typeof msg === "string" ? msg : String(msg);
|
|
return str
|
|
.replace(/^Error: /, "")
|
|
.split(/\.\s/)[0]
|
|
.split("(http")[0]
|
|
.replace(/\.\s*$/, "")
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Remove diagnostics that share the same line + message. The compiler
|
|
* can emit duplicate events for the same function when it retries
|
|
* compilation, so we deduplicate before reporting.
|
|
*/
|
|
export function deduplicateDiagnostics(diagnostics) {
|
|
const seen = new Set();
|
|
return diagnostics.filter((d) => {
|
|
const key = `${d.line}:${d.short}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Run the React Compiler over a single file and return the number of
|
|
* successfully compiled functions plus any diagnostics. Transform
|
|
* errors are caught and returned as a diagnostic with line 0 rather
|
|
* than thrown, so the caller always gets a result.
|
|
*/
|
|
function compileFile(file) {
|
|
const isTSX = file.endsWith(".tsx");
|
|
const diagnostics = [];
|
|
|
|
try {
|
|
const code = readFileSync(join(siteDir, file), "utf-8");
|
|
const result = transformSync(code, {
|
|
plugins: [
|
|
["@babel/plugin-syntax-typescript", { isTSX }],
|
|
["babel-plugin-react-compiler", {
|
|
logger: {
|
|
logEvent(_filename, event) {
|
|
if (event.kind === "CompileError" || event.kind === "CompileSkip") {
|
|
const msg = event.detail || event.reason || "(unknown)";
|
|
diagnostics.push({
|
|
line: event.fnLoc?.start?.line ?? 0,
|
|
short: shortenMessage(msg),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
}],
|
|
],
|
|
filename: file,
|
|
// Skip config-file resolution. No babel.config.js exists in the
|
|
// repo, so the search is wasted I/O on every file.
|
|
configFile: false,
|
|
babelrc: false,
|
|
});
|
|
|
|
// The compiler inserts `const $ = _c(N)` at the top of every
|
|
// function it successfully compiles, where N is the number of
|
|
// memoization slots. Counting these tells us how many functions
|
|
// were compiled in this file.
|
|
const compiledCount = result?.code?.match(/const \$ = _c\(\d+\)/g)?.length ?? 0;
|
|
|
|
return {
|
|
compiled: compiledCount,
|
|
code: result?.code ?? "",
|
|
diagnostics: deduplicateDiagnostics(diagnostics),
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
compiled: 0,
|
|
code: "",
|
|
diagnostics: [{
|
|
line: 0,
|
|
// Truncate to keep the one-line report readable.
|
|
short: `Transform error: ${(e instanceof Error ? e.message : String(e)).substring(0, MAX_ERROR_LENGTH)}`,
|
|
}],
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scope-pruning detection
|
|
//
|
|
// The compiler's flattenScopesWithHooksOrUse pass silently drops
|
|
// memoization scopes that span across hook calls. A closure whose
|
|
// scope is pruned appears as a bare `const name = (...) =>` with
|
|
// no `$[N]` guard, yet it may still be listed as a dependency in a
|
|
// downstream JSX memoization block (`$[N] !== name`). That means
|
|
// the JSX cache check fails every render because `name` is a new
|
|
// function reference each time.
|
|
//
|
|
// findUnmemoizedClosureDeps detects this pattern in compiled output:
|
|
// 1. Collect every name that appears in a `$[N] !== name` dep check.
|
|
// 2. For each, check if the name is assigned a function value
|
|
// (arrow or function expression) outside any `$[N]` guard.
|
|
// 3. If so, the closure is unmemoized but used as a reactive dep,
|
|
// which defeats the downstream memoization.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Scan compiled output for closures that appear as dependencies in
|
|
* memoization guards but are not themselves memoized. Returns an
|
|
* array of `{ name, line }` objects for each finding.
|
|
*/
|
|
export function findUnmemoizedClosureDeps(code) {
|
|
if (!code) return [];
|
|
|
|
const lines = code.split("\n");
|
|
|
|
// Pass 1: collect every name used in a $[N] !== name dep check.
|
|
const depNames = new Set();
|
|
for (const line of lines) {
|
|
for (const m of line.matchAll(DEP_CHECK)) {
|
|
depNames.add(m[1]);
|
|
}
|
|
}
|
|
if (depNames.size === 0) return [];
|
|
|
|
// Pass 2: find closure definitions that are directly assigned a
|
|
// function value (not assigned from a temp like `const x = t1`).
|
|
// A memoized closure uses the temp pattern:
|
|
// if ($[N] !== dep) { t1 = () => {...}; } else { t1 = $[N]; }
|
|
// const name = t1;
|
|
// An unmemoized closure is assigned the function directly:
|
|
// const name = () => {...};
|
|
const findings = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const match = lines[i].match(CLOSURE_RHS);
|
|
if (!match) continue;
|
|
|
|
const name = match[1];
|
|
if (!depNames.has(name)) continue;
|
|
|
|
// Compiler temporaries are named t0, t1, ... tN. If the
|
|
// variable name matches that pattern it's an intermediate,
|
|
// not a user-visible declaration.
|
|
if (/^t\d+$/.test(name)) continue;
|
|
|
|
findings.push({ name, line: i + 1 });
|
|
}
|
|
|
|
return findings;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Report
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Derive a short display path by stripping the first matching target
|
|
* dir prefix so the output stays compact.
|
|
*/
|
|
export function shortPath(file, dirs = targetDirs) {
|
|
for (const dir of dirs) {
|
|
const prefix = `${dir}/`;
|
|
if (file.startsWith(prefix)) {
|
|
return file.slice(prefix.length);
|
|
}
|
|
}
|
|
return file;
|
|
}
|
|
|
|
/** Print a summary of compilation results and per-file diagnostics. */
|
|
function printReport(failures, totalCompiled, fileCount, hadErrors) {
|
|
console.log(`\nTotal: ${totalCompiled} functions compiled across ${fileCount} files`);
|
|
console.log(`Files with diagnostics: ${failures.length}\n`);
|
|
|
|
for (const f of failures) {
|
|
console.log(`✗ ${shortPath(f.file)} (${f.compiled} compiled)`);
|
|
for (const d of f.diagnostics) {
|
|
console.log(` line ${d.line}: ${d.short}`);
|
|
}
|
|
}
|
|
|
|
if (failures.length === 0 && !hadErrors) {
|
|
console.log("✓ All files compile cleanly.");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Tracks whether collectFiles encountered a missing directory.
|
|
// Module-scoped so the function can set it and the main block can
|
|
// read it after collection finishes.
|
|
let hadCollectionErrors = false;
|
|
|
|
// Only run the main block when executed directly, not when imported
|
|
// by tests for the exported pure functions.
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
|
|
const files = targetDirs.flatMap((d) => collectFiles(join(siteDir, d)));
|
|
|
|
let totalCompiled = 0;
|
|
const failures = [];
|
|
|
|
const scopePruned = [];
|
|
|
|
for (const file of files) {
|
|
const { compiled, code, diagnostics } = compileFile(file);
|
|
totalCompiled += compiled;
|
|
if (diagnostics.length > 0) {
|
|
failures.push({ file, compiled, diagnostics });
|
|
}
|
|
const pruned = findUnmemoizedClosureDeps(code);
|
|
if (pruned.length > 0) {
|
|
scopePruned.push({ file, closures: pruned });
|
|
}
|
|
}
|
|
|
|
printReport(failures, totalCompiled, files.length, hadCollectionErrors);
|
|
|
|
if (scopePruned.length > 0) {
|
|
console.log("\nUnmemoized closures used as reactive dependencies:");
|
|
console.log("(Move these after all hook calls to restore memoization)\n");
|
|
for (const { file, closures } of scopePruned) {
|
|
for (const c of closures) {
|
|
console.log(` ✗ ${shortPath(file)}: ${c.name}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (failures.length > 0 || hadCollectionErrors || scopePruned.length > 0) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|