diff --git a/packages/studio/package.json b/packages/studio/package.json index 504e4b1ff6..68a1adbbbc 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -49,6 +49,7 @@ "build": "vite build && tsup", "typecheck": "tsc --noEmit", "test": "vitest run", + "compiler:bailouts": "node scripts/compiler-bailouts.mjs", "test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs", "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs", "test:watch": "vitest", diff --git a/packages/studio/scripts/compiler-bailouts.mjs b/packages/studio/scripts/compiler-bailouts.mjs new file mode 100644 index 0000000000..12497cf0e4 --- /dev/null +++ b/packages/studio/scripts/compiler-bailouts.mjs @@ -0,0 +1,369 @@ +/** + * The React Compiler bail-out scan. + * + * `react({ compiler: true })` compiles what it can and silently emits the rest + * as written, so a component that opts itself out costs nothing visible. This + * turns that silence into a number per file, which `compilerBailouts.test.ts` + * ratchets. + * + * Three facts about `oxc-transform-react` 0.149.0 that shape the code below, + * each measured against this version rather than read from a doc: + * + * 1. There is no non-fatal diagnostic channel. `outputMode: "lint"` with the + * default `panicThreshold` returns zero errors even for a component written + * to violate the rules of React. Escalating `panicThreshold` to + * `"all_errors"` is the only way a skip surfaces. + * 2. That escalation reports every diagnostic inside the FIRST function it + * declines and then aborts the module, so the second declined function in a + * file is invisible until the first is fixed. See the ceiling note on + * `analyzeSource`. + * 3. A `"use no memo"` / `"use no forget"` directive is honoured before any + * diagnostic is produced, so the compiler never reports it at all. It is + * counted from the source text instead. A `react-hooks` suppression is the + * opposite: the compiler already reports it as "React rule suppression + * prevents optimization", so counting those separately would double count. + */ + +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { transformSync } from "oxc-transform-react"; + +/** @typedef {{ readonly count: number, readonly causes: readonly string[] }} FileBailouts */ + +export const STUDIO_ROOT = path.resolve(fileURLToPath(import.meta.url), "../.."); + +const SRC = "src"; + +/** Directive prologue that opts a function out. Its own statement, so anchored. */ +const OPT_OUT_DIRECTIVE = /^[ \t]*(["'])(use no (?:memo|forget))\1[ \t]*;?[ \t]*$/gm; + +const SKIPPED_DIRS = new Set(["node_modules", "dist", "__snapshots__"]); + +/** + * @param {string} relative Studio-relative, POSIX separators. + * @returns {boolean} + */ +export function isScanned(relative) { + if (!relative.startsWith(`${SRC}/`)) return false; + if (!/\.tsx?$/.test(relative)) return false; + if (/\.d\.ts$/.test(relative)) return false; + if (/\.(test|stories)\.tsx?$/.test(relative)) return false; + return relative !== "src/test-setup.ts"; +} + +/** + * Every scanned file under `src`, Studio-relative and sorted. + * + * @param {string} [root] + * @returns {string[]} + */ +export function listSourceFiles(root = STUDIO_ROOT) { + /** @type {string[]} */ + const files = []; + /** @param {string} relativeDir */ + const walk = (relativeDir) => { + for (const entry of readdirSync(path.join(root, relativeDir), { withFileTypes: true })) { + const relative = `${relativeDir}/${entry.name}`; + if (entry.isDirectory()) { + if (!SKIPPED_DIRS.has(entry.name)) walk(relative); + } else if (isScanned(relative)) { + files.push(relative); + } + } + }; + walk(SRC); + return files.sort(); +} + +/** + * @param {string} relative + * @returns {{ lang: "tsx" | "ts", sourceType: "module" }} + */ +function parseOptions(relative) { + return { lang: relative.endsWith(".tsx") ? "tsx" : "ts", sourceType: "module" }; +} + +/** + * The diagnostics oxc-transform-react reports for one module, escalated so a + * skip is actually visible (see the module docstring, fact 1). Only the FIRST + * declined function's diagnostics come back; the transform aborts the module + * there. A parse failure is not a bail-out and throws instead of returning. + * + * @param {string} relative + * @param {string} source + * @param {{ lang: "tsx" | "ts", sourceType: "module" }} options + * @returns {import("oxc-transform-react").OxcError[]} + */ +function escalatedErrors(relative, source, options) { + const escalated = transformSync(path.basename(relative), source, { + ...options, + reactCompiler: { panicThreshold: "all_errors" }, + }); + if (escalated.errors.length === 0) return []; + // A file that does not parse also lands here, and is not a bail-out. Only + // the files that already errored pay for this second pass. + const parsed = transformSync(path.basename(relative), source, { + ...options, + reactCompiler: false, + }); + if (parsed.errors.length > 0) { + throw new Error(`${relative} does not parse: ${parsed.errors[0]?.message}`); + } + return escalated.errors; +} + +/** + * What the compiler declines to compile in one module. + * + * `count` is one per opt-out the scan can see: one per directive, plus one if + * the compiler declines the file at all. It is deliberately NOT the number of + * reported diagnostics. + * + * ponytail: the diagnostic count is not monotone and a ratchet needs monotone. + * Escalating `panicThreshold` yields every diagnostic inside the first declined + * function and then aborts the module, so `App.tsx` reports 21 ref reads from + * one function while a second declined function further down reports nothing. + * Fixing that first function would UNCOVER the second and the count could go up + * while the code got better, which is a gate that cries wolf. One per file is + * monotone: it catches every file going 0 -> 1, and once a file is at 0 it is + * exact. The hole it accepts is a second bail-out added to a file that already + * bails. Upgrade path: a true per-function count needs oxc to expose non-fatal + * diagnostics, not a cleverer caller. + * + * @param {string} relative + * @param {string} source + * @returns {FileBailouts} + */ +export function analyzeSource(relative, source) { + /** @type {string[]} */ + const causes = []; + for (const match of source.matchAll(OPT_OUT_DIRECTIVE)) causes.push(`"${match[2]}" directive`); + let count = causes.length; + + const errors = escalatedErrors(relative, source, parseOptions(relative)); + if (errors.length > 0) { + count += 1; + for (const error of errors) { + if (!causes.includes(error.message)) causes.push(error.message); + } + } + + return { count, causes }; +} + +/** @typedef {{ readonly file: string, readonly cause: string, readonly codeframe: string }} Frame */ + +/** + * Every diagnostic oxc-transform-react reports for the given files, one entry + * per diagnostic and NOT deduped by cause (unlike `causes` above), so two ref + * reads in the same function both get their own line and caret. + * + * Only the first declined function per module has anything to show here; see + * the ceiling note on `analyzeSource`. A `"use no memo"` / `"use no forget"` + * directive opts out before any diagnostic is produced, so it never appears + * in this list either, only in `analyzeSource`'s count. + * + * @param {readonly string[]} files Studio-relative, POSIX separators. + * @param {string} [root] + * @returns {Frame[]} + */ +export function collectFrames(files, root = STUDIO_ROOT) { + /** @type {Frame[]} */ + const frames = []; + for (const relative of files) { + const source = readFileSync(path.join(root, relative), "utf8"); + for (const error of escalatedErrors(relative, source, parseOptions(relative))) { + if (error.codeframe) + frames.push({ file: relative, cause: error.message, codeframe: error.codeframe }); + } + } + return frames; +} + +/** + * Turn `--frames` arguments (files or directories, relative to cwd or + * absolute) into scanned Studio-relative file paths. No arguments scans + * everything `listSourceFiles` would. + * + * @param {readonly string[]} args + * @param {string} [root] + * @returns {string[]} + */ +export function resolveScanTargets(args, root = STUDIO_ROOT) { + const all = listSourceFiles(root); + if (args.length === 0) return all; + const targets = args.map((arg) => + path.relative(root, path.resolve(arg)).split(path.sep).join("/"), + ); + return all.filter((file) => + targets.some((target) => file === target || file.startsWith(`${target}/`)), + ); +} + +/** + * @param {readonly Frame[]} frames + * @returns {string} + */ +export function formatFrames(frames) { + if (frames.length === 0) + return "No React Compiler diagnostics with a codeframe in the given files.\n"; + return `${frames.map(({ file, cause, codeframe }) => `${file}: ${cause}${codeframe}`).join("\n")}\n`; +} + +/** + * @param {string} [root] + * @returns {Map} + */ +export function scanTree(root = STUDIO_ROOT) { + /** @type {Map} */ + const found = new Map(); + for (const relative of listSourceFiles(root)) { + const bailouts = analyzeSource(relative, readFileSync(path.join(root, relative), "utf8")); + if (bailouts.count > 0) found.set(relative, bailouts); + } + return found; +} + +/** + * @param {ReadonlyMap} found + * @returns {Map} + */ +export function toCounts(found) { + return new Map([...found].map(([file, { count }]) => [file, count])); +} + +/** @typedef {{ readonly total: number, readonly files: Readonly> }} Baseline */ + +/** + * @param {ReadonlyMap} counts + * @returns {Baseline} + */ +export function toBaseline(counts) { + /** @type {Record} */ + const files = {}; + let total = 0; + for (const file of [...counts.keys()].sort()) { + files[file] = counts.get(file) ?? 0; + total += files[file]; + } + return { total, files }; +} + +/** + * Every file whose count moved, split by direction. + * + * @param {ReadonlyMap} counts + * @param {Baseline} baseline + */ +function compare(counts, baseline) { + /** @type {string[]} */ + const risen = []; + /** @type {string[]} */ + const fallen = []; + // A file the baseline has never seen has a baseline of zero, so a rename + // cannot smuggle a bail-out past the ratchet. + for (const [file, count] of counts) { + const allowed = baseline.files[file] ?? 0; + if (count > allowed) risen.push(`${file}: ${count} bail-outs, baseline ${allowed}`); + if (count < allowed) fallen.push(`${file}: ${count}, baseline ${allowed}`); + } + for (const [file, allowed] of Object.entries(baseline.files)) { + if (!counts.has(file)) fallen.push(`${file}: now 0, baseline ${allowed}`); + } + return { risen, fallen }; +} + +export const WRITE_FLAG = "COMPILER_BAILOUTS_WRITE"; +export const LOWER_COMMAND = `${WRITE_FLAG}=1 bunx vitest run src/styles/compilerBailouts.test.ts`; +export const BASELINE_RELATIVE = "src/styles/compiler-bailouts.json"; + +/** + * What the author has to hear. A rise is a failure. A fall is fine, and is + * reported with the command that banks it, because a baseline left high is a + * budget the next change can spend. + * + * @param {ReadonlyMap} counts + * @param {Baseline | undefined} baseline + * @returns {string[]} + */ +export function verdict(counts, baseline) { + if (baseline === undefined) { + return [`${BASELINE_RELATIVE} is missing. Write it with: ${LOWER_COMMAND}`]; + } + const { risen, fallen } = compare(counts, baseline); + if (risen.length > 0) { + return [ + "These files gained a React Compiler bail-out, so their components are no longer memoized.", + ...risen, + `Fix the bail-out; a directive or a react-hooks suppression counts too. If it is truly unavoidable: ${LOWER_COMMAND}`, + ]; + } + if (fallen.length > 0) return [`Bail-outs went down. Bank it: ${LOWER_COMMAND}`, ...fallen]; + return []; +} + +/** + * The grouped, human-facing report: causes largest group first, files sorted. + * + * @param {ReadonlyMap} found + * @returns {string} + */ +export function formatReport(found) { + /** @type {Map} */ + const byCause = new Map(); + let total = 0; + for (const [file, { count, causes }] of found) { + total += count; + for (const cause of causes) { + const files = byCause.get(cause) ?? []; + files.push(file); + byCause.set(cause, files); + } + } + const groups = [...byCause].sort( + ([causeA, a], [causeB, b]) => b.length - a.length || causeA.localeCompare(causeB), + ); + const lines = [ + `${found.size} of ${listSourceFiles().length} scanned files bail out of the React Compiler (${total} bail-outs).`, + ]; + for (const [cause, files] of groups) { + lines.push("", `${cause} (${files.length})`); + for (const file of files.sort()) lines.push(` ${file}`); + } + return `${lines.join("\n")}\n`; +} + +const HELP = `Usage: compiler-bailouts.mjs [--json] [--frames [...]] + + (no flags) Print the grouped, human-facing bail-out report. + --json Print the same scan as { total, files: { file: causes[] } } JSON. + --frames Print, for every React Compiler diagnostic, the file, the + cause, and oxc-transform-react's own codeframe (line, column, + caret), so an engineer lands on the exact statement. + Restrict the scan to the given files/dirs; scans everything + under src/ with no arguments. Only the FIRST declined + function per module is reported: escalating the compiler's + panic threshold aborts the module after its first bail-out, + so a second declined function further down stays invisible + until the first is fixed. A "use no memo" / "use no forget" + directive has no diagnostic to frame; it never appears here. +`; + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const argv = process.argv.slice(2); + if (argv.includes("--help") || argv.includes("-h")) { + process.stdout.write(HELP); + } else if (argv.includes("--frames")) { + const targets = resolveScanTargets(argv.slice(argv.indexOf("--frames") + 1)); + process.stdout.write(formatFrames(collectFrames(targets))); + } else { + const found = scanTree(); + const asJson = Object.fromEntries([...found].map(([file, { causes }]) => [file, causes])); + process.stdout.write( + argv.includes("--json") + ? `${JSON.stringify({ total: toBaseline(toCounts(found)).total, files: asJson }, null, 2)}\n` + : formatReport(found), + ); + } +} diff --git a/packages/studio/src/styles/compiler-bailouts.json b/packages/studio/src/styles/compiler-bailouts.json new file mode 100644 index 0000000000..012d362c8f --- /dev/null +++ b/packages/studio/src/styles/compiler-bailouts.json @@ -0,0 +1,131 @@ +{ + "total": 126, + "files": { + "src/App.tsx": 1, + "src/captions/components/CaptionOverlay.tsx": 1, + "src/captions/hooks/useCaptionSync.ts": 1, + "src/components/ExternalFileConflictBanner.tsx": 1, + "src/components/TimelineToolbar.tsx": 1, + "src/components/editor/BlockParamsPanel.tsx": 1, + "src/components/editor/DomEditOverlay.tsx": 1, + "src/components/editor/EaseCurveSection.tsx": 1, + "src/components/editor/FileTreeNodes.tsx": 1, + "src/components/editor/GestureTrailOverlay.tsx": 1, + "src/components/editor/MotionPathOverlay.tsx": 1, + "src/components/editor/OffCanvasIndicators.tsx": 1, + "src/components/editor/PropertyPanel.tsx": 1, + "src/components/editor/PropertyPanelFlat.tsx": 1, + "src/components/editor/SnapGuideOverlay.tsx": 1, + "src/components/editor/SourceEditor.tsx": 1, + "src/components/editor/TopologyLens.tsx": 1, + "src/components/editor/Transform3DCube.tsx": 1, + "src/components/editor/marqueeCommit.ts": 1, + "src/components/editor/propertyPanelAudioFxGroup.tsx": 1, + "src/components/editor/propertyPanelColor.tsx": 1, + "src/components/editor/propertyPanelColorGradingSlider.tsx": 1, + "src/components/editor/propertyPanelColorSecondary.tsx": 1, + "src/components/editor/propertyPanelCommitField.tsx": 1, + "src/components/editor/propertyPanelFill.tsx": 1, + "src/components/editor/propertyPanelFlatMediaSection.tsx": 1, + "src/components/editor/propertyPanelFlatPrimitives.tsx": 1, + "src/components/editor/propertyPanelFont.tsx": 1, + "src/components/editor/propertyPanelFxSection.tsx": 1, + "src/components/editor/propertyPanelMediaSection.tsx": 1, + "src/components/editor/propertyPanelPrimitives.tsx": 1, + "src/components/editor/propertyPanelSections.tsx": 1, + "src/components/editor/useColorGradingController.ts": 1, + "src/components/editor/useColorGradingPreviews.ts": 1, + "src/components/editor/useColorGradingScopes.ts": 1, + "src/components/editor/useDomEditNudge.ts": 1, + "src/components/editor/useFxAudition.ts": 1, + "src/components/editor/useFxCarve.ts": 1, + "src/components/editor/useFxLevelling.ts": 1, + "src/components/editor/useInspectorGestureTransaction.ts": 1, + "src/components/editor/useLayerRevealOverride.ts": 1, + "src/components/editor/useMotionPathData.ts": 1, + "src/components/feedback/StudioFeedbackCard.tsx": 1, + "src/components/nle/NLEContext.tsx": 1, + "src/components/nle/NLEPreview.tsx": 1, + "src/components/nle/PreviewPane.tsx": 1, + "src/components/nle/TimelineResizeDivider.tsx": 1, + "src/components/nle/useCanvasZOrderTimelineMirror.ts": 1, + "src/components/nle/useCompositionStack.ts": 1, + "src/components/nle/useTimelineEditCallbacks.ts": 1, + "src/components/panels/SlideshowPanel.tsx": 1, + "src/components/panels/VariablesPanel.tsx": 1, + "src/components/renders/renderQueueTestHarness.tsx": 1, + "src/components/sidebar/AssetsTab.tsx": 1, + "src/components/sidebar/LeftSidebar.tsx": 1, + "src/components/sidebar/PromptPreviewModal.tsx": 1, + "src/components/storyboard/StoryboardFrameFocus.tsx": 1, + "src/components/storyboard/StoryboardLoaded.tsx": 1, + "src/components/storyboard/StoryboardSourceEditor.tsx": 1, + "src/components/storyboard/useFrameComments.ts": 1, + "src/components/ui/useDialogBehavior.ts": 1, + "src/contexts/DomEditContext.tsx": 1, + "src/contexts/TimelineEditContext.tsx": 1, + "src/contexts/VariablePromoteContext.tsx": 1, + "src/hooks/timelineAudioGroupCreate.ts": 1, + "src/hooks/useAnimatedPropertyCommit.ts": 1, + "src/hooks/useAppHotkeys.ts": 1, + "src/hooks/useBlockCatalog.ts": 1, + "src/hooks/useBlockHandlers.ts": 1, + "src/hooks/useClipboard.ts": 1, + "src/hooks/useConsoleErrorCapture.ts": 1, + "src/hooks/useDomEditPreviewSync.ts": 1, + "src/hooks/useDomEditSession.ts": 1, + "src/hooks/useDomSelection.ts": 1, + "src/hooks/useElementLifecycleOps.ts": 1, + "src/hooks/useElementPicker.ts": 1, + "src/hooks/useExternalFileChangeCoordinator.ts": 1, + "src/hooks/useFileManager.ts": 1, + "src/hooks/useFrameCapture.ts": 1, + "src/hooks/useGestureCommit.ts": 1, + "src/hooks/useGestureRecording.ts": 1, + "src/hooks/useGsapAwareEditing.ts": 1, + "src/hooks/useGsapPropertyDebounce.ts": 1, + "src/hooks/useGsapScriptCommits.ts": 1, + "src/hooks/useGsapSelectionHandlers.ts": 1, + "src/hooks/useGsapTweenCache.ts": 1, + "src/hooks/useLintModal.ts": 1, + "src/hooks/useLivePlayheadTime.ts": 1, + "src/hooks/useMountEffect.ts": 1, + "src/hooks/useMusicBeatAnalysis.ts": 1, + "src/hooks/usePanelLayout.ts": 1, + "src/hooks/usePersistentEditHistory.ts": 1, + "src/hooks/usePreviewPersistence.ts": 1, + "src/hooks/useProjectCompositionVariables.ts": 1, + "src/hooks/useProjectSignaturePoll.ts": 1, + "src/hooks/useRazorSplit.ts": 1, + "src/hooks/useRemoveBackground.ts": 1, + "src/hooks/useSdkSession.ts": 1, + "src/hooks/useSlideshowTabState.ts": 1, + "src/hooks/useThumbnailLease.ts": 1, + "src/hooks/useTimelineDeleteOps.ts": 1, + "src/hooks/useTimelineEditing.ts": 1, + "src/hooks/useTimelineGroupEditing.ts": 1, + "src/hooks/useTimelineSelectionPreviewSync.ts": 1, + "src/hooks/useToast.ts": 1, + "src/player/components/Player.tsx": 1, + "src/player/components/PlayerControls.tsx": 1, + "src/player/components/Timeline.tsx": 1, + "src/player/components/TimelineCanvas.tsx": 1, + "src/player/components/TimelineClipDiamonds.tsx": 1, + "src/player/components/TimelineGestureOverlay.tsx": 1, + "src/player/components/TimelineLanes.tsx": 1, + "src/player/components/useAutomationLaneGestures.ts": 1, + "src/player/components/useTimelineClipDrag.ts": 1, + "src/player/components/useTimelineFocusCoordinator.ts": 1, + "src/player/components/useTimelineGeometry.ts": 1, + "src/player/components/useTimelinePlayhead.ts": 1, + "src/player/components/useTimelineRangeSelection.ts": 1, + "src/player/components/useTimelineScrollViewport.ts": 1, + "src/player/components/useTimelineSelectionLifecycle.ts": 1, + "src/player/components/useTimelineStackingSync.ts": 1, + "src/player/components/useTimelineTrackLayout.ts": 1, + "src/player/components/useTimelineVirtualRows.ts": 1, + "src/player/hooks/usePlaybackKeyboard.ts": 1, + "src/player/hooks/useTimelinePlayer.ts": 1, + "src/webmcp/useStudioAgentTools.ts": 1 + } +} diff --git a/packages/studio/src/styles/compilerBailouts.test.ts b/packages/studio/src/styles/compilerBailouts.test.ts new file mode 100644 index 0000000000..eb6050fc56 --- /dev/null +++ b/packages/studio/src/styles/compilerBailouts.test.ts @@ -0,0 +1,203 @@ +/** + * The React Compiler bail-out ratchet (C1). + * + * `react({ compiler: true })` is silent about what it declines: a component the + * compiler skips is emitted exactly as written, so losing memoization costs + * nothing you can see in a diff, a build log or a test run. This is the alarm. + * + * Per file, not per repository, for the same reason the hex ratchet is: two + * sweeps that touch the same file conflict on that file's line, which is when a + * recount is worth doing. A single total would merge cleanly and be wrong. + * + * The baseline is never regenerated as a side effect of a normal run. A missing + * one fails loudly, and rewriting it takes the named flag, so the number in git + * is always one a human chose to accept. The scan itself lives in + * `scripts/compiler-bailouts.mjs`, which `bun run compiler:bailouts` prints. + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + analyzeSource, + type Baseline, + BASELINE_RELATIVE, + collectFrames, + formatFrames, + isScanned, + listSourceFiles, + LOWER_COMMAND, + resolveScanTargets, + scanTree, + STUDIO_ROOT, + toBaseline, + toCounts, + verdict, + WRITE_FLAG, +} from "../../scripts/compiler-bailouts.mjs"; + +const BASELINE_PATH = path.join(STUDIO_ROOT, BASELINE_RELATIVE); + +const REF_DURING_RENDER = ` +import { useRef } from "react"; +export function RefDuringRender({ n }: { n: number }) { + const ref = useRef(0); + return
{ref.current + n}
; +} +`; + +const CLEAN = ` +export function Clean({ n }: { n: number }) { + return
{n * 2}
; +} +`; + +const OPTED_OUT = ` +export function OptedOut({ n }: { n: number }) { + "use no memo"; + return
{n}
; +} +`; + +function readBaseline(): Baseline | undefined { + if (!existsSync(BASELINE_PATH)) return undefined; + return JSON.parse(readFileSync(BASELINE_PATH, "utf8")) as Baseline; +} + +describe("bail-out scan", () => { + it("reports a ref read during render, with the file and the cause", () => { + const found = analyzeSource("src/RefDuringRender.tsx", REF_DURING_RENDER); + + expect(found.count).toBe(1); + expect(found.causes).toEqual(["Cannot access refs during render"]); + }); + + it("reports nothing for a component the compiler can compile", () => { + expect(analyzeSource("src/Clean.tsx", CLEAN)).toEqual({ count: 0, causes: [] }); + }); + + it('counts a "use no memo" directive, which the compiler itself never reports', () => { + // Measured, not assumed: with `panicThreshold: "all_errors"` the transform + // returns zero errors for this source, because the directive is honoured + // before any diagnostic is produced. Counting it from the text is the only + // way it is visible, and without it the directive is a way past the gate. + expect(analyzeSource("src/OptedOut.tsx", OPTED_OUT)).toEqual({ + count: 1, + causes: ['"use no memo" directive'], + }); + }); + + it("leaves tests, stories, declarations and the setup file out of the scan", () => { + expect(isScanned("src/App.tsx")).toBe(true); + expect(isScanned("src/utils/timeline.ts")).toBe(true); + expect(isScanned("src/App.test.tsx")).toBe(false); + expect(isScanned("src/components/Button.stories.tsx")).toBe(false); + expect(isScanned("src/vite-env.d.ts")).toBe(false); + expect(isScanned("src/test-setup.ts")).toBe(false); + expect(isScanned("scripts/compiler-bailouts.mjs")).toBe(false); + }); +}); + +describe("--frames", () => { + // resolveScanTargets walks `/src`, mirroring Studio's real layout. + function fixtureRoot(files: Record): string { + const dir = mkdtempSync(path.join(tmpdir(), "bailout-frames-")); + mkdirSync(path.join(dir, "src")); + for (const [name, source] of Object.entries(files)) + writeFileSync(path.join(dir, "src", name), source); + return dir; + } + + it("prints the file, the cause, and a codeframe pinpointing the ref read's line", () => { + const dir = fixtureRoot({ "RefDuringRender.tsx": REF_DURING_RENDER }); + + const frames = collectFrames(["src/RefDuringRender.tsx"], dir); + + expect(frames).toEqual([ + { + file: "src/RefDuringRender.tsx", + cause: "Cannot access refs during render", + codeframe: expect.stringContaining("RefDuringRender.tsx:5:16"), + }, + ]); + const output = formatFrames(frames); + expect(output).toContain("src/RefDuringRender.tsx: Cannot access refs during render"); + // The codeframe carries the line number and a caret under the ref read. + expect(output).toContain("RefDuringRender.tsx:5:16"); + expect(output).toContain("^"); + }); + + it("reports nothing for a component the compiler can compile", () => { + const dir = fixtureRoot({ "Clean.tsx": CLEAN }); + + expect(collectFrames(["src/Clean.tsx"], dir)).toEqual([]); + }); + + it("restricts the scan to the given files, leaving other files out", () => { + const dir = fixtureRoot({ "RefDuringRender.tsx": REF_DURING_RENDER, "Clean.tsx": CLEAN }); + + const targets = resolveScanTargets([path.join(dir, "src/RefDuringRender.tsx")], dir); + + expect(targets).toEqual(["src/RefDuringRender.tsx"]); + }); + + it("falls back to every scanned file when no target is given", () => { + expect(resolveScanTargets([])).toEqual(listSourceFiles()); + }); +}); + +describe("bail-out ratchet", () => { + const baseline: Baseline = { total: 2, files: { "src/a.tsx": 2 } }; + + it("fails when a file's count rises, naming the file and both numbers", () => { + const message = verdict(new Map([["src/a.tsx", 3]]), baseline).join("\n"); + + expect(message).toContain("src/a.tsx: 3 bail-outs, baseline 2"); + expect(message).toContain(LOWER_COMMAND); + }); + + it("passes when a count falls and says how to bank it", () => { + const message = verdict(new Map([["src/a.tsx", 1]]), baseline).join("\n"); + + expect(message).toContain("Bail-outs went down"); + expect(message).toContain(LOWER_COMMAND); + }); + + it("treats a file the baseline has never seen as a baseline of zero", () => { + const empty: Baseline = { total: 0, files: {} }; + + expect(verdict(new Map([["src/new.tsx", 0]]), empty)).toEqual([]); + expect(verdict(new Map([["src/new.tsx", 1]]), empty).join("\n")).toContain( + "src/new.tsx: 1 bail-outs, baseline 0", + ); + }); + + it("fails with the flag named when there is no baseline at all", () => { + expect(verdict(new Map([["src/a.tsx", 0]]), undefined).join("\n")).toContain(`${WRITE_FLAG}=1`); + }); + + it("round-trips: a baseline written to disk accepts the scan that produced it", () => { + const counts = new Map([ + ["src/b.tsx", 4], + ["src/a.tsx", 2], + ]); + const scratch = path.join(mkdtempSync(path.join(tmpdir(), "bailouts-")), "baseline.json"); + + writeFileSync(scratch, `${JSON.stringify(toBaseline(counts), null, 2)}\n`); + const written = JSON.parse(readFileSync(scratch, "utf8")) as Baseline; + + expect(Object.keys(written.files)).toEqual(["src/a.tsx", "src/b.tsx"]); + expect(written.total).toBe(6); + expect(verdict(counts, written)).toEqual([]); + }); + + it("holds Studio at or below its committed React Compiler bail-out baseline", () => { + const counts = toCounts(scanTree()); + if (process.env[WRITE_FLAG] === "1") { + writeFileSync(BASELINE_PATH, `${JSON.stringify(toBaseline(counts), null, 2)}\n`); + } + + expect(verdict(counts, readBaseline())).toEqual([]); + }); +});