Optional by Design: What Every Core Feature Costs in Bytes (Inside AngeLite, Part 5)
"Optional modules" is easy to say and hard to make true. Most libraries that claim it still end up with a core that drags everything along: a registry that imports the router, a compiler that knows about animations, a component model that assumes forms.
This post is about how AngeLite keeps "you only pay for what you import" literally true, and what each piece of the core costs in bytes.
The registry trick: ops
A template compiles to a list of instructions (see post 2). Some instructions are core: text bindings, property bindings, events. Others are specialised: portals, @defer, template refs, animations. If the runtime's main loop knew about all of them, every app would ship all of them.
Instead, the runtime exposes a registry:
/**
* Registry for optional template instructions (portals, @defer, template refs...).
* A module adds its handler with `ops.portal = (op, node, view, scope, ctx) => {...}`
* when imported, so apps that never use it do not ship it.
*/
export const ops = {};
A module like portal.js is 16 lines. Importing it registers its handler; not importing it means the handler never exists in your bundle.
The runtime's dispatch for unknown ops is the only core cost (about 55 B), and it's what lets portals, @defer, refs and animations weigh nothing in an app that doesn't use them.
flowchart LR
T["Template instructions"] --> R["runtime.js (core)"]
R -- "ops.portal?" --> P["portal.js (only if imported)"]
R -- "ops.defer?" --> D["defer.js (only if imported)"]
R -- "ops.ref?" --> F["refs.js (only if imported)"]
R -- "detach hook" --> A["animate.js (only if imported)"]
The module map
| Import | For |
|---|---|
@duvanjamid/angelite |
signals, templates (@if, @for, @defer), components, DI, pipes, router |
.../runtime |
the runtime only, for precompiled templates |
.../forms |
reactive forms |
.../http |
HTTP client, interceptors and resource() |
.../i18n |
languages, plurals and formats |
.../ui |
library of 33 accessible components |
.../testing |
testing utilities |
.../devtools |
component and signal inspector |
.../hydrate |
hydration of server-rendered HTML |
.../tools/aot.mjs, ssr.mjs, vite.mjs |
build-time compiler, server rendering, Vite plugin |
Each add-on is measured and budgeted (minified, gzip): forms about 0.9 KB, HTTP 1.3 KB, i18n 0.7 KB, DevTools 0.9 KB, hydrate 0.1 KB, testing 0.7 KB.
The UI library is marked pure per component, so a bundler keeps only the ones you import: an app that imports a single Button is about 7 KB gzip smaller than one that imports the whole library (the full UI bundle is about 11.8 KB gzip).
What the core actually pays for
The precompiled table app went from 5,968 B to 6,223 B gzip across two waves of features that added a router with guards and lazy loading, DI with scopes, error boundaries, portals, named slots, @defer, refs, forms, HTTP, i18n, animations, SSR, DevTools and testing utilities. That's +255 B, about 4%.
Where those bytes live:
| Piece | Approx. | Why it's in the core |
|---|---|---|
Component context + reportError (error boundaries) |
~110 B | errors, scoped DI and slots share it |
| The ops registry and its dispatch | ~55 B | lets portals, @defer, refs, animations weigh nothing if unused |
| Named slots | ~40 B | changes how content is projected |
detach (animated exit) and host.$c (DevTools) |
~28 B | minimal hook for two optional modules |
Everything else doesn't show up in that app at all.
Error boundaries, cheaply
An example of a feature small enough for the core. Errors go to the nearest component (or the one wrapping the projected content) that defines ngOnError(error, where):
export function reportError(e, what, ctx) {
for (; ctx; ctx = ctx.up) {
const target = ctx.guard || ctx.self;
if (target && target.ngOnError) {
try { target.ngOnError(e, what); }
catch (err) { logError(err, 'ngOnError'); }
return;
}
}
logError(e, what);
}
It walks the component context chain and falls back to console.error. The same context carries scoped DI providers and slots, which is why the cost is shared.
(An honest limit from the docs: boundaries don't catch errors thrown in the constructor or ngOnInit.)
Dev warnings from the outside
enableDevWarnings() doesn't live in the core: it installs warnings by wrapping internals (A.prop, A.state, ops) from the outside. The production build contains none of those bytes.
This is the same principle as the ops registry: if something is only for development, it must be impossible to ship by accident.
Lazy loading, two different things
@defer delays rendering of a block until a trigger fires (on idle, on viewport, on interaction, on hover, on timer(2s), on immediate, or when expr), and you can combine triggers with ;. It does not split code.
To split the code of a whole screen into another file, use load: () => import('./screen.js') in a route. Conflating the two is a common source of "why didn't my bundle shrink?".
The architectural debt, written down
The project's gap analysis lists real weaknesses of the current design, which is a good test of whether "optional modules" are being honest:
- Module-level global state (pipe registry, DI map, router, delegated events). You can't run two isolated apps on one page or render on a server without care. Direction: a
createApp()object holding registries, injector and router. - Coupling to the global DOM. The runtime calls
documentdirectly, which constrains other renderers. Direction: abstract the few DOM operations. - Scope by prototype chain and text keys. Fast and compact, but not statically analysable (no template type-checking), and
$event/$indexcan collide with user fields. - Events delegated on
document. Great for memory, but doesn't work inside Shadow DOM or iframes andevent.currentTargetisn't the element. Direction: delegate on each app's root. - Properties added to elements (
$s,$click) can collide with other libraries. - No stable-API contract or full types for templates yet. That slows adoption more than any benchmark.
Takeaways
- A tiny registry (
ops) is what turns "optional" from marketing into bytes. - Put development-only code outside the core so it can't ship by accident.
- Publish the cost of every core feature, and publish the known design debt next to it.
Next: how to benchmark and compare honestly, including where AngeLite loses.
Comments
No comments yet. Start the discussion.