The panel has one base client budget:
Client budgets: base panel JS ≤ 250 KB gzip (excluding React), rich-text/code/image editors and bklit chart modules lazy chunks.
Both halves are enforced in CI by the Bundle size (size-limit) job, which is a
required check. Run the same
gate locally with pnpm size.
What is measured
scripts/panel_bundle.js bundles the entry a scaffolded panel actually boots —
adonia({}) from inertia/adonia.ts plus the six packaged pages the installer
re-exports under inertia/pages/adonia/ — and measures gzip of the chunks the
browser downloads before the first paint.
- Excluded, per §18’s carve-out:
react,react-dom,react/jsx-runtime,@inertiajs/*and@adonisjs/*. They are peer dependencies supplied by the host app and shared with its non-panel pages, so they are not Adonia’s bytes to budget. - Included: everything
adonia({})pulls (the registry, and therefore every built-in field, display entry, cell, filter and layout), the six pages, the panel chrome, the theme layer,useFormEngineanduseTableState. - Source, not
dist:packages/uibuilds withtsc -b, sodist/index.jsis a barrel of re-exports whose file size means nothing. Bundlingsrcdirectly keeps the check hermetic — it needs no priorpnpm build, and the same esbuild pass feeds the lazy-chunk assertion.
Measuring the un-bundled dist/index.js against a flat file-size limit — what
.size-limit.json did before D2-7 — measured neither the right bytes nor the
right graph. That entry is gone; the @adonia/core and @adonia/devtools
entries that remain are plain published-artifact tripwires, not §18 budgets.
Measured figures
Snapshot after retuning the lazy renderer ABI, 2026-08-02, with pnpm size:bundle:
| Metric | Value |
|---|---|
| Base panel JS, gzip (excluding React) | 249.4 kB |
| §18 budget | 250.0 kB |
| Headroom | 0.6 kB (99.8% of budget used) |
| Modules in the base graph | 724 |
| Lazy chunks | 32 (code_editor, rich_text_editor, markdown_preview, image_editor, date_picker, scalar_editor, file_input, relation_input, morph_to_input, bulk_field_set_modal, saved_view_switcher, command_palette, toast + 19 bklit chart and shared chunks), 137.6 kB gzip total |
What accounts for the base graph’s size, and what pays for the rest:
- coss primitives (
@base-ui/react) are half the base chunk. Every interactive element is now library code — focus management, keyboard navigation, portalled overlays — rather than hand-rolled markup. That is ~40 kB gzip over the build it replaced, and it is the reason the §18 budget moved from 180 kB to 220 kB, to 225 kB once the unsaved-changes dialog replacedwindow.confirm, and to 250 kB when observable submission state, saved views, per-user column layouts, inline cell editing, and the command registry joined the eager panel paths. - The navigation icon allowlist (
src/components/chrome/nav_icon.tsx), ~2 kB gzip for 22 Lucide glyphs. Protocol v1 §1 carriesNavNode.iconas a name, so the client has to resolve names to components, and a namespace import of Lucide’s ~1500 icons is not an option. The list is deliberately the common administration vocabulary and nothing more: at ~110 bytes a glyph, it is the cheapest lever when the base graph needs headroom, and an unmapped name already degrades to the neutral fallback icon. - The date picker is lazy, its trigger is not.
@daypicker/reactpullsreact-day-picker,date-fnsand@date-fns/tz— far more than the budget has left — so only the month grid lives in thedate_pickerchunk, and it is rendered inside an open popover. A page with a date field downloads nothing extra until somebody opens a calendar.
Charts (visx + motion) outweigh every editor but are reachable only through
lazyEditor(() => import(…)) from the dashboard widgets — the lazy-chunk
guard covers packages/ui/src/charts/ the same way it covers the editors.
CI re-measures on every run and prints the per-chunk table into the job summary
and a sticky PR comment, so treat the numbers above as a dated snapshot rather
than a source of truth — the gate is the source of truth. The last row is
the exception: packages/ui/tests/bundle_budget.test.ts compares it against a
live buildPanelBundle() measurement, because “how many editors are lazy” is a
§18 claim, not a passing statistic, and this table published 0 for the whole
window between the editors landing and D2-7 being reviewed.
The lazy-chunk assertion
A 250 KB budget with headroom to spare would happily absorb a rich-text editor or a chart module,
which is exactly the failure §18 forbids. So pnpm size also fails — before
size-limit even runs — if any module under
packages/ui/src/components/fields/editors/ is reachable from the panel entry
through a static import. Those modules are reachable only through
lazyField(() => import(…)); turning one of those import() calls into a static
import moves the editor into every panel visit and turns the job red:
❌ Lazy-chunk guard: 1 editor module(s) leaked into the base chunk:
- `packages/ui/src/components/fields/editors/rich_text_editor.tsx`The leak guard matches the directory rather than a hand-maintained file list,
so a new editor is covered the day it lands. A separate mandatory inventory
also fails when any of code_editor, image_editor, or rich_text_editor is
absent from the lazy graph; otherwise deleting an editor import could make a
zero-leak check pass vacuously.
packages/ui/tests/bundle_budget.test.ts proves the guard can fail as well as
pass: it builds a synthetic panel with one editor module behind import(),
asserts the module stays out of the base graph, then rebuilds the same fixture
with that single import() rewritten as a static import and asserts the guard
reports the leak. A guard that only ever runs against a clean tree cannot
distinguish “no leaks” from “no detector”.
Reproducing
pnpm size # build the panel bundle, run the lazy guard, then size-limit
pnpm size:bundle # bundle + report only, no size-limit gateArtifacts land in .size-limit-build/ (gitignored):
| Path | Contents |
|---|---|
base/*.js |
chunks downloaded before first paint — what .size-limit.json gates |
lazy/*.js |
chunks behind an import() boundary — reported, not budgeted |
report.md |
the per-chunk table CI posts on each PR |
report.json |
the same figures for machine consumers |