Files
HiveGrid-Wireframes/hivegrid-cloud/docs/architecture/bundle-budgets.md
T

6.7 KiB

Bundle Budgets

Status: active Owner: web platform Related: scripts/bundle-budgets.json, scripts/verify-bundle-size.mjs, docs/improvement-plan/10-compliance-polish/bundle-budget.md

Why

Web bundle growth is silent unless we measure it. The boilerplate tracks two complementary signals:

  1. Group budgets (BUDGETS constant in verify-bundle-size.mjs): coarse caps for root / initial_payload_js / vendor / route / other / css / total_js / manifest_js. Hard CI gate — failure exits 1.
  2. Per-route + per-vendor-chunk budgets (scripts/bundle-budgets.json): fine-grained gzip limits per route and per vendor chunk. Non-blocking — surfaces as a PR-comment with a Δ-from-main column. Reviewers decide whether growth is justified.

The per-route layer was added in task #71 (Phase 10) to make day-to-day creep visible without blocking legitimate feature work.

initial_payload_js is a dedicated hard signal for the boot asset aggregate the current parser can identify without reading Rollup/Vite dependency graphs: entry.client-*, root-*, and manifest-*. It intentionally excludes route, vendor, and component chunks so lazy feature payloads cannot hide a shell regression in total_js.

Config schema

scripts/bundle-budgets.json validated by scripts/bundle-budgets.schema.json:

{
  "default": { "limitKb": 200, "warningKb": 180 },
  "routes": {
    "app/$tenant/dashboard": { "limitKb": 250, "warningKb": 225 },
    "auth/magic-link": { "limitKb": 80, "warningKb": 75 }
  },
  "vendorChunks": {
    "vendor-react": { "limitKb": 150, "warningKb": 135 }
  }
}
  • All sizes are gzip kilobytes (the transport-relevant number).
  • limitKb is the hard cap; assets above this report [EXCEED] in CI output and the PR comment.
  • warningKb is the soft threshold; between warningKb and limitKb assets report [WARN].
  • Routes use React Router v7 route ids in either dot- or slash-form (auth/magic-link and auth.magic-link both match).
  • Unmatched routes fall back to default.
  • Unmatched vendor chunks (anything starting with vendor- or chunk-) are reported under the chunk basename and also fall back to default.

Local workflow

# Build apps/web normally and print the per-route table:
pnpm --filter @toolctrl/web build
node scripts/verify-bundle-size.mjs

# Only the per-route table (skips group checks):
node scripts/verify-bundle-size.mjs --per-route-only

# Machine-readable JSON (used by the PR-comment workflow):
node scripts/verify-bundle-size.mjs --json > pr-bundle-report.json

--json emits only the non-blocking per-route/per-vendor report and exits with code 0 when the report was produced. Keep the hard group-budget gate on the plain node scripts/verify-bundle-size.mjs path so redirected JSON never mixes machine-readable output with human diagnostics.

Visualizer (treemap)

To inspect why a chunk is big:

pnpm --filter @toolctrl/web run bundle:visualize
open apps/web/build/stats.html

The visualizer is gated on ANALYZE_BUNDLE=true so the regular production build keeps its current footprint. It uses rollup-plugin-visualizer (treemap, gzip + brotli sizes per chunk).

Security note: stats.html contains source-map references and should never be deployed publicly. Treat it as a developer-only artifact.

PR-comment workflow

.github/workflows/bundle-budget.yml (and .gitea/workflows/bundle-budget.yml) run on every PR that touches apps/web/**, packages/**, or the budget config / verifier. They:

  1. Build @toolctrl/web.
  2. Run verify-bundle-size.mjs --json.
  3. Download the latest bundle-baseline-main artifact (captured by bundle-budget-baseline.yml on main pushes).
  4. Render a sticky markdown comment via scripts/render-bundle-budget-comment.mjs.
  5. Upsert the comment using its <!-- bundle-budget-report --> marker (GitHub: marocchino/sticky-pull-request-comment; Gitea: scripts/post-bundle-budget-gitea-comment.mjs).

The comment renders rows in severity order ([EXCEED] → [WARN] → unbudgeted → ok), with columns: status / kind / route or chunk key / current gzip size / configured limit / Δ from main. Δ is — if no baseline is available.

When the baseline is missing

The first PR after a fresh checkout (or after a baseline-artifact expiry) sees Δ from main = — for every row. This is expected — the next push to main re-captures the baseline.

When to raise a limit

  1. Confirm the growth is intentional (a new feature shipped, a dependency landed) — not a regression from accidental imports.
  2. Open the PR; the bundle-budget comment shows the Δ. Reviewers and the PR author should agree the new floor is acceptable.
  3. Edit scripts/bundle-budgets.json in the same PR that ships the growth. Bump warningKb to ≈ (current gzip + 5 KB) and limitKb to ≈ (current gzip + 20 KB).
  4. Call the change out in the PR description so the new floor is reviewable.

Anti-patterns

  • Don't disable a budget by raising it to 9999 KB. The point is to surface drift. If a route is legitimately huge, raise the limit to a tight new floor, not infinity.
  • Don't lazy-load critical-path code. Bundle splits help non-critical routes, but the initial payload (entry.client-* / root-* / manifest-*) still has its own initial_payload_js hard group budget that must stay tight.
  • Don't bundle-split shared deps. Splitting react into many chunks just shifts cost into chunk boot-up. Keep vendor-react, vendor-core-utils etc. as single chunks; the manualChunks mapping in apps/web/app/utils/build/manualChunks.ts is the source of truth.

Troubleshooting

  • PR comment never shows up: check the Bundle Budget workflow run — the Build web step may have failed (e.g. server-only import in a client export). The comment job only runs if all upstream steps succeed.
  • vendor-monaco-... reports as vendor-monaco-...- with default budget: rollup occasionally emits chunk hashes ending in -; the baseline normalizer skips that suffix and the asset falls back to default. Either rename via manualChunks.ts or add an explicit budget entry.
  • CI run is much slower than pnpm typecheck: the workflow does a full pnpm --filter @toolctrl/web build. That's the expected cost. If it ever exceeds ~30 minutes, profile with ANALYZE_BUNDLE=true locally first.

Rollback

  • If the per-route budget surfaces too many false alarms, lower the warning thresholds in scripts/bundle-budgets.json and ship a follow-up.
  • If the entire PR-comment flow is broken, set if: false on the measure job in bundle-budget.yml — no other CI stage depends on it.