Skip to content

Bundle budget

Understand the client bundle gates and lazy-loading boundaries.

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, useFormEngine and useTableState.
  • Source, not dist: packages/ui builds with tsc -b, so dist/index.js is a barrel of re-exports whose file size means nothing. Bundling src directly keeps the check hermetic — it needs no prior pnpm 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 replaced window.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 carries NavNode.icon as 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/react pulls react-day-picker, date-fns and @date-fns/tz — far more than the budget has left — so only the month grid lives in the date_picker chunk, 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 gate

Artifacts 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
Navigation

Type to search…

↑↓ navigate↵ selectEsc close