DEV Community

Duvan Jamid Vargas
Duvan Jamid Vargas

Posted on

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 = {};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. Coupling to the global DOM. The runtime calls document directly, which constrains other renderers. Direction: abstract the few DOM operations.
  3. Scope by prototype chain and text keys. Fast and compact, but not statically analysable (no template type-checking), and $event / $index can collide with user fields.
  4. Events delegated on document. Great for memory, but doesn't work inside Shadow DOM or iframes and event.currentTarget isn't the element. Direction: delegate on each app's root.
  5. Properties added to elements ($s, $click) can collide with other libraries.
  6. 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.

Top comments (0)