Repository navigation
🤖 ci: fail PRs that grow first-load JS over the recorded budget #6084
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
57a8990
c265159
4974e5e
3fe3d4b
20c6ed5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "comment": [ | ||
| "First-load budget for `make check-first-load-js` (scripts/perf/firstLoadJs.ts --budget), run in CI after `make build`. The totals are index.html plus its statically reachable module JS.", | ||
| "The check fails when first-load brotli bytes grow more than 2% over brBytes, or raw bytes more than 100 KiB over rawBytes.", | ||
| "A smaller first load never fails, so a PR that shrinks it needs no update here.", | ||
| "If a PR grows the first load on purpose, the perf owner updates the recorded value in that same PR.", | ||
| "Recorded from a clean `make build` of main ea19a44101 with Bun 1.3.12." | ||
| ], | ||
| "rawBytes": 5134092, | ||
| "brBytes": 1219205 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,14 @@ | |
| * `xum server` sends those bytes. It skips files under 1 KiB, which are then sent raw. | ||
| * | ||
| * Usage: bun scripts/perf/firstLoadJs.ts [distDir] [--json] [--forbid <substring>]... | ||
| * Exit codes: 0 ok, 1 a forbidden source is on the first load, 2 unusable input. | ||
| * [--budget <file>] | ||
| * The totals also count index.html itself (T3 PR8), so growth of its inline boot script or | ||
| * markup is measured. Before PR8 the totals were module JS only (9.1 KiB less on main). | ||
| * An external classic `<script src>` exits 2: the graph does not measure it (index.html has none). | ||
| * `--budget` (T3 PR8, `make check-first-load-js` in CI) also fails when the first load grows | ||
| * over the recorded values in that file: brotli by more than 2%, raw by more than 100 KiB. | ||
| * Exit codes: 0 ok, 1 a forbidden source is on the first load or a budget is exceeded, | ||
| * 2 unusable input. | ||
| */ | ||
| import * as fs from "fs"; | ||
| import * as path from "path"; | ||
|
|
@@ -22,9 +29,28 @@ export const FIRST_LOAD_FORBIDDEN_SOURCES: readonly string[] = [ | |
| "features/Settings/Sections/ProvidersSection", | ||
| "node_modules/@shikijs/langs/", | ||
| "node_modules/recharts/", | ||
| // Right-sidebar panels made lazy by PR7. Timeline and Artifacts stay eager: TimelineDialog | ||
| // and ArtifactsDialog import them statically. | ||
| "features/desktop/DesktopPanel", | ||
| "components/InstructionsTab/InstructionsTab", | ||
| "components/OutputTab/OutputTab", | ||
| "features/RightSidebar/BrowserTab/BrowserTab", | ||
| "features/RightSidebar/DevToolsTab/DevToolsTab", | ||
| "features/RightSidebar/GoalTab", | ||
| "features/RightSidebar/Memory/MemoryTab", | ||
| "features/RightSidebar/Workflows/WorkflowsTab", | ||
| ]; | ||
|
|
||
| // Growth allowed over the recorded budget before `--budget` fails. A smaller first load never | ||
| // fails, so a PR that shrinks it needs no budget update. | ||
| const BUDGET_BR_GROWTH = 0.02; | ||
| const BUDGET_RAW_GROWTH_BYTES = 100 * 1024; | ||
|
|
||
| interface FirstLoadBudget { | ||
| rawBytes: number; | ||
| brBytes: number; | ||
| } | ||
|
|
||
| function fail(message: string): never { | ||
| console.error(`firstLoadJs: ${message}`); | ||
| process.exit(2); | ||
|
|
@@ -57,6 +83,9 @@ function htmlRoots(distDir: string): string[] { | |
| ? attrs.get("type") === "module" | ||
| : attrs.get("rel")?.toLowerCase().split(/\s+/).includes("modulepreload") === true; | ||
| const href = attrs.get(isScript ? "src" : "href"); | ||
| if (isScript && !isRoot && href != null) { | ||
| fail(`index.html loads classic script ${href}, which first-load totals cannot measure`); | ||
| } | ||
| if (!isRoot || href == null) continue; // inline boot scripts, stylesheets, icons | ||
|
Comment on lines
+86
to
89
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| // vite `base: "./"` writes `./main-<hash>.js`; a leading `/` means the dist root. | ||
| const target = href.startsWith("/") ? path.join(distDir, href) : path.resolve(distDir, href); | ||
|
|
@@ -101,6 +130,50 @@ function servedSize(file: string, extension: string, rawBytes: number): [number, | |
| return sibling?.isFile() ? [sibling.size, true] : [rawBytes, false]; | ||
| } | ||
|
|
||
| function readBudget(file: string): FirstLoadBudget { | ||
| let budget: unknown; | ||
| try { | ||
| budget = JSON.parse(fs.readFileSync(file, "utf-8")); | ||
| } catch (error) { | ||
| fail(`cannot read budget ${file}: ${error instanceof Error ? error.message : String(error)}`); | ||
| } | ||
| const isBytes = (value: unknown) => Number.isSafeInteger(value) && (value as number) > 0; | ||
| if ( | ||
| typeof budget !== "object" || | ||
| budget === null || | ||
| !isBytes((budget as Partial<FirstLoadBudget>).rawBytes) || | ||
| !isBytes((budget as Partial<FirstLoadBudget>).brBytes) | ||
| ) { | ||
| fail(`${file} must be a JSON object with positive integer rawBytes and brBytes`); | ||
| } | ||
| const { rawBytes, brBytes } = budget as FirstLoadBudget; | ||
| return { rawBytes, brBytes }; | ||
| } | ||
|
|
||
| /** Compares totals with the budget file; over-budget messages say how to update the budget. */ | ||
| function checkBudget( | ||
| totals: FirstLoadBudget, | ||
| file: string | ||
| ): Array<{ over: boolean; text: string }> { | ||
| const budget = readBudget(file); | ||
| const checks = [ | ||
| ["brBytes", "brotli", Math.floor(budget.brBytes * (1 + BUDGET_BR_GROWTH)), "+2%"], | ||
| ["rawBytes", "raw", budget.rawBytes + BUDGET_RAW_GROWTH_BYTES, "+100 KiB"], | ||
| ] as const; | ||
| return checks.map(([key, name, limit, growth]) => { | ||
| const over = totals[key] > limit; | ||
| const limitText = `budget of ${limit} bytes (${key} ${budget[key]} in ${file}, ${growth})`; | ||
| return over | ||
| ? { | ||
| over, | ||
| text: | ||
| `first-load ${name} is ${totals[key]} bytes, over the ${limitText}. If this growth ` + | ||
| `is intended, the perf owner updates ${key} in ${file} in the PR that causes it.`, | ||
| } | ||
| : { over, text: `first-load ${name} ${totals[key]} bytes is within the ${limitText}` }; | ||
| }); | ||
| } | ||
|
|
||
| const KIB = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); | ||
|
|
||
| /** A right-aligned table cell; `*` marks a file served raw for lack of a precompressed sibling. */ | ||
|
|
@@ -111,19 +184,22 @@ function kib(bytes: number, precompressed = true): string { | |
| function main(argv: string[]): void { | ||
| let distArg: string | undefined; | ||
| let json = false; | ||
| let budgetFile: string | undefined; | ||
| const patterns = [...FIRST_LOAD_FORBIDDEN_SOURCES]; | ||
| for (let i = 0; i < argv.length; i++) { | ||
| const arg = argv[i]; | ||
| if (arg === "--json") json = true; | ||
| else if (arg === "--forbid" && /^[^-]/.test(argv[i + 1] ?? "")) patterns.push(argv[++i]); | ||
| else if (arg === "--budget" && /^[^-]/.test(argv[i + 1] ?? "")) budgetFile = argv[++i]; | ||
| else if (arg.startsWith("-") || distArg != null) fail(`bad or incomplete argument ${arg}`); | ||
| else distArg = arg; | ||
| } | ||
| const distDir = path.resolve(distArg ?? "dist"); | ||
| const relative = (file: string) => path.relative(distDir, file).replaceAll("\\", "/"); | ||
|
|
||
| const code = firstLoadFiles(distDir); | ||
| const files = [...code.keys()].map((file) => { | ||
| // index.html is measured but not scanned for forbidden sources: it has no source map. | ||
| const files = [path.join(distDir, "index.html"), ...code.keys()].map((file) => { | ||
| const rawBytes = fs.statSync(file).size; | ||
| const [brBytes, br] = servedSize(file, ".br", rawBytes); | ||
| const [gzipBytes, gzip] = servedSize(file, ".gz", rawBytes); | ||
|
|
@@ -162,10 +238,15 @@ function main(argv: string[]): void { | |
| } | ||
| } | ||
|
|
||
| const budgetChecks = budgetFile == null ? [] : checkBudget(totals, budgetFile); | ||
|
ThomasK33 marked this conversation as resolved.
|
||
| for (const v of violations) { | ||
| console.error(`forbidden on first load: ${v.file} has ${v.source} (matches "${v.pattern}")`); | ||
| } | ||
| if (violations.length > 0) process.exit(1); | ||
| for (const check of budgetChecks) { | ||
| if (check.over) console.error(check.text); | ||
| else if (!json) console.log(check.text); | ||
| } | ||
| if (violations.length > 0 || budgetChecks.some((c) => c.over)) process.exit(1); | ||
| } | ||
|
|
||
| if (import.meta.main) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.