Skip to content

🤖 ci: fail PRs that grow first-load JS over the recorded budget - #6084

Open
ThomasK33 wants to merge 5 commits into
mainfrom
perf/t3-pr8-first-load-budget
Open

ThomasK33 wants to merge 5 commits into
mainfrom
perf/t3-pr8-first-load-budget

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Summary

CI now fails a PR that grows first-load JS over a recorded budget, or that puts a must-stay-lazy module back on the first load. make check-first-load-js runs in Smoke / Server right after that job's existing make build, so CI does no extra build. Refs #5971 (T3 PR8).

Background

T3 PR1–PR7 cut first-load JS for browser mode, and each added a forbid entry for the module it made lazy. Nothing in CI ran that check or watched the total bytes. This PR does both.

Implementation

  • scripts/perf/firstLoadJs.ts --budget <file> compares the first-load totals with scripts/perf/firstLoadBudget.json. It exits 1 when 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. The failure message names the new value, the limit, the budget key and the budget file. It also says how to fix: the perf owner updates the recorded value in the PR that causes the growth. The budget file has the same rule in its comment field.
  • An unusable budget file (bad JSON, missing value, or a value that is not a positive integer) exits 2, like any other unusable input.
  • The must-stay-lazy list (FIRST_LOAD_FORBIDDEN_SOURCES) now also covers the seven lazy right-sidebar panels from 🤖 perf: lazy-load the right-sidebar panels #6081, next to DesktopPanel. TimelinePanel and ArtifactsPanel are not on the list: they stay eager because TimelineDialog and ArtifactsDialog import them statically.
  • What is measured: index.html plus the module JS it loads (its module scripts, modulepreloads and their static imports). The check refuses any other script form: an external <script src> that is not type="module" exits 2 with a message naming it (Codex round 3). index.html has none today. index.html counts as one entry (Codex round 2), so growth of its inline boot script or markup is measured. It is not scanned for forbidden sources.
  • Change of measurement: the T3 byte numbers before this PR (PR0–PR7, the plan, and the Lighthouse comparisons) were module JS only. The budget now also includes index.html, which adds 9,341 bytes on main to both totals.
  • Recorded budget (clean make build of main ea19a44101, Bun 1.3.12, under the host measurement lock): rawBytes 5,134,092 and brBytes 1,219,205. The limits are 5,236,492 raw and 1,243,589 br.
  • index.html is added to the changes job's config paths filter (Codex round 1). It defines the first-load roots, so an index.html-only PR must run Smoke / Server and this check. Such PRs now also run the other test jobs.
  • The .PHONY change only appends check-first-load-js to the end of its existing line.

Validation

  • Each must-stay-lazy entry fails where its module is eager:
    • A scratch build of main with the product src changes of PR1–PR6 reverted (🤖 perf: lazy-load the Lottie loading animation #6013 only WorkspaceShell.tsx, because later PRs reuse its LazyFeature) exits 1. It has hits for all six earlier entries: lottie-web 1, ghostty-web 1, mermaid 15, ProvidersSection 1, @shikijs/langs 14, recharts 72.
    • A build of the PR7 base 08390cd9b6 exits 1. It has exactly one hit for each of the 8 panel entries, and each hit is that panel's own .tsx file.
    • The same check on main exits 0.
  • Tests first: on the script before this PR, 3 of the 4 new tests fail. The malformed-file test passes there only because the old script rejects --budget itself. Mutation M5 shows it still catches lax validation. All 6 mutations fail at least one test: never over, >= instead of >, 200 KiB raw slack, 5% br slack, lax budget validation, and a budget result that hides a forbidden module.
  • Dogfood (output in the comment below): make check-first-load-js on main passes. A one-line scratch change that imports OutputTab statically in tabRegistry.tsx fails with exit 1 on the forbidden module, while its bytes stay within budget. The two scratch builds above fail on both budgets as well.
  • CI step time (first head): in Smoke / Server on c265159054, make check-first-load-js ran from 20:02:32.552 to 20:02:32.901 (about 0.35 s) and passed on CI's own build: 1,209,802 br and 5,124,749 raw bytes. Those differ from the recorded values by 62 and 2 bytes.
  • make static-check passes (23/24 hot components compile (1 known skipped)).

Follow-ups

Risks

Low, and CI-only. The script runs only in CI and from make. Bundle bytes change slightly between Bun or Vite versions, so a toolchain bump can move the totals. The 2% / 100 KiB slack covers small moves. A larger move fails with a message that says which value to update.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $181.42

Refs #5971. Adds --budget tests to scripts/perf/firstLoadJs.test.ts:
growth at or under the recorded values passes, brotli growth over 2%
fails, raw growth over 100 KiB fails, a forbidden module fails even
under budget, and an unusable budget file exits 2. They fail on the
script before the budget mode exists.
Refs #5971. firstLoadJs.ts --budget fails when first-load brotli grows
more than 2% or raw more than 100 KiB over scripts/perf/firstLoadBudget.json
(recorded from a clean build of main ea19a44). The must-stay-lazy list
gains the seven lazy right-sidebar panels. make check-first-load-js runs it
in Smoke / Server right after that job's make build, so CI adds no build.
@ThomasK33

Copy link
Copy Markdown
Member Author

Dogfood output for head c265159054 (Bun 1.3.12, builds under the host measurement lock). I removed the per-file table rows and kept the totals.

  1. Main ea19a44101, make check-first-load-js: exit 0.
$ make check-first-load-js
file                                 raw KiB      br KiB      gz KiB 
total (5 files)                      5,004.6     1,181.5     1,416.7 
* no precompressed sibling: xum server sends the raw file
first-load brotli 1209864 bytes is within the budget of 1234061 bytes (brBytes 1209864 in scripts/perf/firstLoadBudget.json, +2%)
first-load raw 5124751 bytes is within the budget of 5227151 bytes (rawBytes 5124751 in scripts/perf/firstLoadBudget.json, +100 KiB)
exit code: 0
  1. Scratch change: main plus one static OutputTab import in tabRegistry.tsx. The bytes stay within budget, and the must-stay-lazy check fails.
@@ -30,6 +30,7 @@ import {
 } from "lucide-react";
 import { ErrorBoundary } from "@/browser/components/ErrorBoundary/ErrorBoundary";
 import { LazyFeature } from "@/browser/components/LazyFeature/LazyFeature";
+import { OutputTab } from "@/browser/components/OutputTab/OutputTab";
 import { StatsContainer } from "@/browser/features/RightSidebar/StatsContainer";
 import { ReviewPanel } from "@/browser/features/RightSidebar/CodeReview/ReviewPanel";
 import type { GoalCreateIntent } from "@/browser/features/RightSidebar/GoalTab";
@@ -74,9 +75,7 @@ function lazyPanel<P extends object>(name: string, load: () => Promise<React.Com
 const InstructionsTab = lazyPanel("Instructions", () =>
   import("@/browser/components/InstructionsTab/InstructionsTab").then((m) => m.InstructionsTab)
 );
-const OutputTab = lazyPanel("Output", () =>
-  import("@/browser/components/OutputTab/OutputTab").then((m) => m.OutputTab)
-);
+
 const DesktopPanel = lazyPanel("Desktop", () =>
   import("@/browser/features/desktop/DesktopPanel").then((m) => m.DesktopPanel)
 );
$ bun scripts/perf/firstLoadJs.ts <scratch: main + eager OutputTab import>/dist --budget scripts/perf/firstLoadBudget.json
file                                 raw KiB      br KiB      gz KiB 
total (5 files)                      5,008.4     1,182.6     1,418.5 
* no precompressed sibling: xum server sends the raw file
forbidden on first load: main-BA-KBnPU.js has ../src/browser/components/OutputTab/OutputTab.tsx (matches "components/OutputTab/OutputTab")
first-load brotli 1210981 bytes is within the budget of 1234061 bytes (brBytes 1209864 in scripts/perf/firstLoadBudget.json, +2%)
first-load raw 5128608 bytes is within the budget of 5227151 bytes (rawBytes 5124751 in scripts/perf/firstLoadBudget.json, +100 KiB)
exit code: 1
3. PR7 base 08390cd (all panels eager): 8 forbidden hits, and both budgets fail
$ bun scripts/perf/firstLoadJs.ts <pr7base-wt>/dist --budget scripts/perf/firstLoadBudget.json
file                                 raw KiB      br KiB      gz KiB 
total (5 files)                      5,195.4     1,222.6     1,468.7 
* no precompressed sibling: xum server sends the raw file
forbidden on first load: main-BOCGPtQT.js has ../src/browser/components/InstructionsTab/InstructionsTab.tsx (matches "components/InstructionsTab/InstructionsTab")
forbidden on first load: main-BOCGPtQT.js has ../src/browser/components/OutputTab/OutputTab.tsx (matches "components/OutputTab/OutputTab")
forbidden on first load: main-BOCGPtQT.js has ../src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx (matches "features/RightSidebar/BrowserTab/BrowserTab")
forbidden on first load: main-BOCGPtQT.js has ../src/browser/features/RightSidebar/DevToolsTab/DevToolsTab.tsx (matches "features/RightSidebar/DevToolsTab/DevToolsTab")
forbidden on first load: main-BOCGPtQT.js has ../src/browser/features/RightSidebar/GoalTab.tsx (matches "features/RightSidebar/GoalTab")
forbidden on first load: main-BOCGPtQT.js has ../src/browser/features/RightSidebar/Memory/MemoryTab.tsx (matches "features/RightSidebar/Memory/MemoryTab")
forbidden on first load: main-BOCGPtQT.js has ../src/browser/features/RightSidebar/Workflows/WorkflowsTab.tsx (matches "features/RightSidebar/Workflows/WorkflowsTab")
forbidden on first load: DesktopPanel-C3tX6Wa5.js has ../src/browser/features/desktop/DesktopPanel.tsx (matches "features/desktop/DesktopPanel")
first-load brotli is 1251900 bytes, over the budget of 1234061 bytes (brBytes 1209864 in scripts/perf/firstLoadBudget.json, +2%). If this growth is intended, the perf owner updates brBytes in scripts/perf/firstLoadBudget.json in the PR that causes it.
first-load raw is 5320101 bytes, over the budget of 5227151 bytes (rawBytes 5124751 in scripts/perf/firstLoadBudget.json, +100 KiB). If this growth is intended, the perf owner updates rawBytes in scripts/perf/firstLoadBudget.json in the PR that causes it.
exit code: 1
4. Main with the PR1–PR6 product changes reverted: 104 forbidden hits (6 entries), and both budgets fail
$ bun scripts/perf/firstLoadJs.ts <revert-wt>/dist --budget scripts/perf/firstLoadBudget.json
file                                            raw KiB      br KiB      gz KiB 
total (5 files)                                 9,583.5     1,900.6     2,331.8 
* no precompressed sibling: xum server sends the raw file
first-load brotli is 1946221 bytes, over the budget of 1234061 bytes (brBytes 1209864 in scripts/perf/firstLoadBudget.json, +2%). If this growth is intended, the perf owner updates brBytes in scripts/perf/firstLoadBudget.json in the PR that causes it.
first-load raw is 9813491 bytes, over the budget of 5227151 bytes (rawBytes 5124751 in scripts/perf/firstLoadBudget.json, +100 KiB). If this growth is intended, the perf owner updates rawBytes in scripts/perf/firstLoadBudget.json in the PR that causes it.
exit code: 1

      1 matches "features/Settings/Sections/ProvidersSection"
     14 matches "node_modules/@shikijs/langs/"
      1 matches "node_modules/ghostty-web/"
      1 matches "node_modules/lottie-web/"
     15 matches "node_modules/mermaid/"
     72 matches "node_modules/recharts/"

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-10T20:35:58.255713Z 20c6ed5 Manual request
🔒 Security Review ✅ Completed 2026-10-10T20:35:06.928018Z 20c6ed5 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c265159054

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/pr.yml
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: c265159054

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

index.html defines the first-load roots, so an index.html-only PR must run Smoke / Server and its first-load budget check.
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4974e5e8ee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/perf/firstLoadJs.ts
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 4974e5e8ee

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Codex round 2: the totals skipped index.html, so its inline boot script could grow without failing the budget. index.html now counts as one measured entry (not scanned for forbidden sources). Budget re-recorded from the same clean build of main ea19a44: +9,341 bytes.
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 3fe3d4b06e

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3fe3d4b06e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/perf/firstLoadJs.ts Outdated
Codex round 3: htmlRoots() follows only module scripts, so a classic <script src> added to index.html was not measured. It now exits 2. index.html has none today, so the budget is unchanged.
@ThomasK33

Copy link
Copy Markdown
Member Author

Pausing at the three-round Codex cap. Codex round 3 on 3fe3d4b06e found one remaining gap: a classic external <script src> in index.html is not measured. Index.html has no such script today.

I have a minimal fix ready locally (20c6ed5b4f, not pushed). Such a script now exits 2 with a message naming it, and one new test covers it. The budget values do not change, and make static-check passes. I am leaving this thread open and will not push, request round 4 or merge until the perf owner approves one extra review round.

@ThomasK33

Copy link
Copy Markdown
Member Author

Perf owner: I approve one fourth Codex round on this PR as an explicit exception, and it is the last one. The user-level cap is 6, and the 3-round cap was my own lane limit. Rounds 1-3 each found one new case in what the check counts. The round-3 fix rejects other script forms (exit 2) instead of counting them, so the check does not grow further. If round 4 leaves any finding, this PR stops unmerged, and I decide between a reasoned won't-fix and parking. No round 5 will happen.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 20c6ed5b4f

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 20c6ed5b4f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +86 to 89
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Traverse imports from inline module scripts

When index.html contains an inline module such as <script type="module">import "./large.js"</script>, isRoot is true but href is null, so this validation does not reject it and the next line skips it. Counting index.html adds only the small import statement; the imported file's bytes and forbidden sources never enter the graph, allowing substantial first-load JavaScript growth to pass CI. Parse inline modules for static imports or reject inline module scripts.

Useful? React with 👍 / 👎.

@ThomasK33

Copy link
Copy Markdown
Member Author

Stopping here: Codex round 4 (the final approved round) on 20c6ed5b4f found one more case. An inline <script type="module"> that imports a file is not followed, so the imported file's bytes and forbidden sources are not measured. index.html has no inline module script today. I have made no fix. This PR stays unmerged, and the round-4 thread stays open, until the perf owner chooses between a reasoned won't-fix and parking the PR.

@ThomasK33

Copy link
Copy Markdown
Member Author

Perf owner: parked. Do not merge. Head 20c6ed5b4f stays as is, and the round-4 thread stays open.

Why: Codex rounds 1-4 each found one new case of what the first-load check counts (the index.html path filter, index.html bytes, classic external scripts, and now imports from an inline module script). That pattern does not converge. A won't-fix for round 4 is not justified, because a budget check must catch future entry changes, and "main has no inline module today" does not show the gap is harmless. A fifth round is not allowed under the final-round rule I set.

A restart needs:

  1. A reproduction of the inline-module bypass through the real Vite build (a hand-edited dist/index.html does not count).
  2. If the build keeps the bypass, a narrow contract first: count the HTML, follow the supported external module entries, keep the classic boot script, and refuse every other executable script form. No JavaScript parser.
  3. Tests for every bypass found so far, a fresh GO from me, and the existing four-round history (a rewrite or replacement PR does not reset the six-round ceiling).

The recorded budget values and the CI step time (about 0.35 s) stay valid as evidence for the restart.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant