Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/eager-closure-budget-5324.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
---

CI and build tooling only — this publishes nothing, declared explicitly with an empty
frontmatter rather than left undeclared. No package `src/` is touched.

The console "performance budget" now weighs the **eager closure** instead of one chunk.

`.github/workflows/performance-budget.yml` gzipped `apps/console/dist/assets/index-*.js`
and compared it against a 350 KB line. Measured on `77f846a8b`, that chunk is 25,910 bytes
gzipped, while the closure it statically pulls in — every chunk the browser must fetch and
parse before the app renders — is 3,881,609 bytes across 58 of 507 chunks. The gate passed
on 0.67% of the payload it claimed to govern, and `advancedChunks` routes vendor and
workspace code into named chunks on purpose, so most regressions land outside the entry
chunk. objectui#5266 is the worked example: 89 KiB gzipped added to every page load, landing
in `vendor-objectstack-*.js`, structurally invisible here (objectui#5324).

`emitEagerClosureReport` in `apps/console/vite.config.ts` walks rolldown's own
`chunk.imports` from the entry chunks — static edges only, because the dynamic edge is the
lazy boundary — gzips the bytes actually written to disk, and writes
`dist/eager-closure.json`. `scripts/check-eager-closure-budget.mjs` applies the ceiling.
The split is deliberate: a size ceiling enforced inside `vite build` would fail every
Vercel preview and every local build, which is how a budget gets switched off rather than
fixed. Exit codes are distinct — `1` over budget (a verdict about the bundle), `2` no
trustworthy measurement (a verdict about the gauge) — so a broken gauge is never reported
as a clean bundle, and vice versa.

Every check in that path is a counter-probe, because this gate's failure mode is silent: a
walk that finds too little, a stale report, an absent field read as zero all produce a
SMALL number, and a budget reads a small number as good news. So the build refuses to
publish a figure unless `react-dom` is inside the closure and at least one chunk is outside
it, and the checker refuses a report whose totals disagree with its own chunk list, whose
version it does not recognise, or that has collapsed to its entry chunk — that last one
being precisely the gauge this replaces.

The ceiling is 3,960,000 gzipped bytes: today's measurement plus 78,391 bytes of headroom.
It passes on current `main`, and the headroom is deliberately narrower than the 89 KiB
regression the gate exists to catch, so a repeat of objectui#5266 fails it (verified: the
baseline plus 89 KiB comes out 12.4 KB over). Both constraints are asserted in
`scripts/__tests__/check-eager-closure-budget.test.ts`, not merely argued in a comment.

This is a truthful current-state ceiling, not a target. 3.79 MB gzipped before first render
is a bad payload and the honest long-term line is far below it; lowering it is a separate
decision with its own work behind it. The entry-chunk budget and its 350 KB line are
unchanged — replacing a blind gauge is not licence to drop the check that was already there.
77 changes: 66 additions & 11 deletions .github/workflows/performance-budget.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,9 +65,36 @@ jobs:
- name: Check console performance budget
id: budget
run: |
# Performance budget: main entry must be < 350 KB gzip
# This is a realistic threshold for a full-featured enterprise app
# with React, routing, UI components, and core business logic.
# TWO measurements, one verdict.
#
# 1. The `index-*.js` entry chunk against a 350 KB line. This is the
# original budget and it stays: a fat entry chunk is still worth
# its own signal, and replacing a gauge is not licence to weaken
# the one already here.
# 2. The EAGER CLOSURE — every chunk the entry reaches through STATIC
# imports, i.e. everything the browser must fetch and parse before
# the app renders.
#
# Measurement 1 alone was the whole budget until objectui#5324. On
# `77f846a8b` the entry chunk gzips to 25.9 KB against the 350 KB line
# while the closure it pulls in is 3,881,609 bytes across 58 of 507
# chunks — so the gate passed on 0.67% of the payload it claimed to
# govern, and the 89 KiB regression of objectui#5266 landed in
# `vendor-objectstack-*.js` where nothing here could see it.
# `advancedChunks` routes vendor and workspace code into named chunks
# on purpose, so MOST regressions land outside `index-*.js`.
#
# The closure ceiling is NOT set here. It lives in
# `scripts/check-eager-closure-budget.mjs` next to the reasoning that
# produced it, so a re-baseline is one edit against a documented
# argument rather than a number in YAML: today's measured payload plus
# ~2% of headroom, chosen to pass on today's `main` while staying
# NARROWER than the 89 KiB regression the gate exists to catch. It is a
# truthful current-state ceiling, not a statement that 3.79 MB is fine.
#
# Both measurements run before either may fail the step: a run that
# reports one number and hides the other teaches readers to distrust
# the comment, which is how objectui#3152 nearly took this gate down.
MAX_ENTRY_GZIP_KB=350

DIST_DIR="apps/console/dist/assets"
Expand DownExpand Up@@ -110,19 +137,43 @@ jobs:
echo "budget_kb=$MAX_ENTRY_GZIP_KB" >> "$GITHUB_OUTPUT"
echo "entry_file=$(basename $ENTRY_FILE)" >> "$GITHUB_OUTPUT"

# Check budget
OVER=$(awk "BEGIN {print ($GZIP_KB > $MAX_ENTRY_GZIP_KB) ? 1 : 0}")
if [ "$OVER" -eq 1 ]; then
ENTRY_OVER=$(awk "BEGIN {print ($GZIP_KB > $MAX_ENTRY_GZIP_KB) ? 1 : 0}")
if [ "$ENTRY_OVER" -eq 1 ]; then
echo "❌ ENTRY BUDGET EXCEEDED: Main entry is ${GZIP_KB} KB gzip (limit: ${MAX_ENTRY_GZIP_KB} KB)"
else
echo "✅ Entry budget OK: Main entry is ${GZIP_KB} KB gzip (limit: ${MAX_ENTRY_GZIP_KB} KB)"
fi

echo ""
echo "📦 Eager closure (what a page load actually pays for):"
# Writes closure_status / closure_gzip_kb / closure_budget_kb /
# closure_chunks to $GITHUB_OUTPUT itself. Exit codes are distinct on
# purpose: 1 = over budget (a verdict about the bundle), 2 = no
# trustworthy measurement (a verdict about the gauge). Reporting one as
# the other is how a broken gauge gets read as a size regression, and a
# size regression as a broken gauge.
set +e
node scripts/check-eager-closure-budget.mjs
CLOSURE_CODE=$?
set -e

if [ "$CLOSURE_CODE" -eq 2 ]; then
echo "budget_status=error" >> "$GITHUB_OUTPUT"
echo "budget_message=The entry chunk measured ${GZIP_KB} KB, but the eager-closure gauge produced no trustworthy measurement — see the step log. This is a broken gauge, not a passing budget." >> "$GITHUB_OUTPUT"
exit 1
fi

if [ "$ENTRY_OVER" -eq 1 ] || [ "$CLOSURE_CODE" -ne 0 ]; then
echo ""
echo "❌ BUDGET EXCEEDED: Main entry is ${GZIP_KB} KB gzip (limit: ${MAX_ENTRY_GZIP_KB} KB)"
echo "❌ BUDGET EXCEEDED"
echo "budget_status=fail" >> "$GITHUB_OUTPUT"
exit 1
else
echo ""
echo "✅ Budget OK: Main entry is ${GZIP_KB} KB gzip (limit: ${MAX_ENTRY_GZIP_KB} KB)"
echo "budget_status=pass" >> "$GITHUB_OUTPUT"
fi

echo ""
echo "✅ Budget OK: entry chunk and eager closure are both within budget"
echo "budget_status=pass" >> "$GITHUB_OUTPUT"

- name: Generate package size report
id: size-report
# NOT `always()`. `always()` also fires on a cancelled run, where
Expand DownExpand Up@@ -183,6 +234,10 @@ jobs:
BUDGET_GZIP_KB: ${{ steps.budget.outputs.gzip_kb }}
BUDGET_LIMIT_KB: ${{ steps.budget.outputs.budget_kb }}
BUDGET_ENTRY_FILE: ${{ steps.budget.outputs.entry_file }}
BUDGET_CLOSURE_STATUS: ${{ steps.budget.outputs.closure_status }}
BUDGET_CLOSURE_GZIP_KB: ${{ steps.budget.outputs.closure_gzip_kb }}
BUDGET_CLOSURE_BUDGET_KB: ${{ steps.budget.outputs.closure_budget_kb }}
BUDGET_CLOSURE_CHUNKS: ${{ steps.budget.outputs.closure_chunks }}
BUDGET_STEP_OUTCOME: ${{ steps.budget.outcome }}
BUILD_PACKAGES_OUTCOME: ${{ steps.build_packages.outcome }}
run: node scripts/render-budget-comment.mjs > budget-comment.md
Expand Down
139 changes: 139 additions & 0 deletions apps/console/vite.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import type { Plugin, Rollup } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import fs from 'fs';
import zlib from 'node:zlib';
// Relative specifiers carry their real file extension, and `__dirname` is
// spelled `import.meta.dirname` throughout this file, so the config stays
// loadable by Vite's `configLoader: 'native'` — which imports this file with
Expand DownExpand Up@@ -187,6 +188,138 @@ function assertLazyLinterStaysLazy(specTest: RegExp): Plugin {
};
}

/**
* Emits the eager-closure size report the console performance budget weighs.
*
* The budget in `.github/workflows/performance-budget.yml` used to gzip one
* file — the `index-*.js` entry chunk — and call the result "the console
* performance budget". Measured on `77f846a8b` that chunk is 25.3 KB gzipped
* against a 350 KB line, while the closure the browser must fetch and parse
* before the app renders is 3,791 KB across 58 chunks. The gate therefore
* passed on 0.67% of the payload it claimed to govern, and the 89 KiB
* regression of objectui#5266 landed in `vendor-objectstack-*.js` where it was
* structurally invisible (objectui#5324).
*
* What the page pays for is the EAGER CLOSURE: every chunk reachable from an
* entry chunk through STATIC imports only. `dynamicImports` is deliberately not
* followed — that edge is the lazy boundary, and following it would turn this
* number into "the whole bundle", which no page load pays.
*
* This plugin only MEASURES. The ceiling and the verdict live in
* `scripts/check-eager-closure-budget.mjs`, which the workflow runs against the
* report written here. The split is deliberate: a size ceiling that fails
* `vite build` would also fail every Vercel preview deploy and every local
* build, which is how a budget gets switched off. The graph knowledge stays
* here, where rolldown's own `chunk.imports` is authoritative; the policy stays
* in a file with unit tests.
*
* The report is written INTO `dist` on purpose. A report that outlives its
* build is worse than no report — `rm -rf dist` followed by a stale read is a
* verdict about a bundle that no longer exists. Living in the output directory
* makes the report and the bundle the same artifact. It carries only chunk file
* names and byte counts, all of which are already observable in the deployed
* bundle itself.
*
* Both counter-probes below refuse a verdict rather than report a number that
* happens to be small, in the spirit of `assertLazyLinterStaysLazy` above:
* an under-counting walk is the dangerous direction, because the budget's
* failure mode is silent — a closure walk that finds nothing reads exactly like
* a bundle that got smaller.
*/
function emitEagerClosureReport(reportFileName = 'eager-closure.json'): Plugin {
// React is reached synchronously from the entry — nothing in the console
// renders without it — and `advancedChunks` routes it to its own
// `vendor-react` chunk, so it is a KNOWN member of the eager closure that is
// NOT the entry chunk. If the walk cannot see it, the walk is wrong.
const EAGER_COUNTER_PROBE = /[\\/]node_modules[\\/]react-dom[\\/]/;

return {
name: 'emit-eager-closure-report',
writeBundle(options, bundle) {
const outDir = options.dir ?? path.resolve(import.meta.dirname, 'dist');

const chunks = new Map<string, Rollup.OutputChunk>();
for (const [fileName, output] of Object.entries(bundle)) {
if (output.type === 'chunk') chunks.set(fileName, output);
}

const entries = [...chunks.values()].filter((c) => c.isEntry).map((c) => c.fileName);
const eager = new Set<string>();
const queue = [...entries];
while (queue.length > 0) {
const fileName = queue.pop() as string;
if (eager.has(fileName)) continue;
eager.add(fileName);
for (const imported of chunks.get(fileName)?.imports ?? []) {
if (!eager.has(imported)) queue.push(imported);
}
}

// Counter-probe 1 — the walk must SEE something known to be eager.
// Asserting only "the total is under the ceiling" would also pass on a
// walk that returned the entry chunk alone, which is the very gauge this
// report replaces.
const probeChunks = [...chunks.values()]
.filter((chunk) => Object.keys(chunk.modules).some((id) => EAGER_COUNTER_PROBE.test(id)))
.map((chunk) => chunk.fileName);
const eagerProbe = probeChunks.filter((fileName) => eager.has(fileName));
if (eagerProbe.length === 0) {
this.error(
`[emit-eager-closure-report] counter-probe failed: no eagerly loaded chunk contains ` +
`a \`react-dom\` module. React is reached synchronously from the app entry, so it ` +
`must be in the eager closure — its absence means this walk is reading the graph ` +
`wrongly, not that the bundle improved. Fix the walk before trusting the size ` +
`below; a walk that finds too little produces a SMALL number, and a budget check ` +
`reads a small number as good news. ` +
`(probe: ${EAGER_COUNTER_PROBE}; chunks holding it: ${probeChunks.join(', ') || 'NONE'}; ` +
`entry chunks: ${entries.join(', ') || 'NONE'}; eager: ${eager.size}/${chunks.size})`,
);
}

// Counter-probe 2 — the other direction. If EVERY chunk is eager then
// either this bundle has no lazy boundary at all or the walk is following
// dynamic edges; in both cases the number is not "what a page load costs"
// and must not be published under that name.
if (eager.size === chunks.size) {
this.error(
`[emit-eager-closure-report] counter-probe failed: every one of the ${chunks.size} ` +
`chunks is in the eager closure, so this walk is not distinguishing static from ` +
`dynamic imports. Either \`imports\` has stopped excluding \`dynamicImports\`, or ` +
`the console genuinely has no lazy boundary left — check which before reading the ` +
`size as a page-load cost. (entry chunks: ${entries.join(', ') || 'NONE'})`,
);
}

const files = [...eager].sort().map((fileName) => {
const raw = fs.readFileSync(path.join(outDir, fileName));
// Level 6 — zlib's default, and the level `gzip -c` uses in the
// workflow's entry-chunk check, so the two numbers in one PR comment
// are measured the same way.
return { fileName, bytes: raw.length, gzipBytes: zlib.gzipSync(raw).length };
});

const report = {
// Bumped when the shape below changes; the checker refuses a report it
// does not understand rather than reading absent fields as zero.
reportVersion: 1,
entryChunks: entries.sort(),
eagerChunkCount: files.length,
totalChunkCount: chunks.size,
eagerGzipBytes: files.reduce((n, f) => n + f.gzipBytes, 0),
eagerRawBytes: files.reduce((n, f) => n + f.bytes, 0),
files,
};

fs.writeFileSync(path.join(outDir, reportFileName), `${JSON.stringify(report, null, 2)}\n`);
const kb = (report.eagerGzipBytes / 1024).toFixed(1);
this.info(
`eager closure: ${report.eagerChunkCount}/${report.totalChunkCount} chunks, ` +
`${report.eagerGzipBytes} bytes gzipped (${kb} KB) → ${reportFileName}`,
);
},
};
}

/**
* Dev-only Vite plugin: serves runtime branding assets at /runtime/assets/*.
*
Expand DownExpand Up@@ -433,6 +566,12 @@ export default defineConfig({
// eagerly-loaded chunk. Runs on CI/Vercel too — it costs microseconds and
// the regression it catches is invisible in every other signal.
assertLazyLinterStaysLazy(specModuleTest),
// Writes `dist/eager-closure.json` — the size of everything a page load
// pays for before the app renders. `.github/workflows/performance-budget.yml`
// weighs THAT against the budget; weighing the entry chunk alone measured
// 0.67% of it (objectui#5324). Measurement only: the verdict is the
// workflow's, so a size regression never blocks a preview deploy.
emitEagerClosureReport(),
// maplibre-gl loads its worker as a sibling of its own chunk URL — an
// edge no bundler can see — so the worker (and the shared module it
// imports) must be copied into assets/ or every map page 404s
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,6 +51,7 @@
"check:skills-paths": "node scripts/check-skills-paths.mjs",
"check:doc-types": "node scripts/check-doc-component-types.mjs",
"check:doc-snippets": "node scripts/check-doc-snippet-types.mjs",
"check:eager-closure": "node scripts/check-eager-closure-budget.mjs",
"cli": "node packages/cli/dist/cli.js",
"objectui": "node packages/cli/dist/cli.js",
"create-plugin": "node packages/create-plugin/dist/index.js",
Expand Down
Loading
Loading