---
title: "Bundle budget"
description: "Understand the client bundle gates and lazy-loading boundaries."
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Bundle budget

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](/ci#required-status-checks-branch-protection). 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

```sh
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 |

Source: https://adonia.pages.dev/bundle-budget/index.mdx
