Skip to content

Display-Only Elision

A component that does no client-side work renders the same HTML with or without its JavaScript. WebJs proves that statically and then acts on it: the component's import is stripped from the served source, its modulepreload hint and importmap entry go with it, and any vendor package reachable only through it is pruned too. The browser never downloads the module at all.

This is the mechanism that makes progressive enhancement pay. A page whose whole subtree is display-only ships zero application JavaScript, while a page with one interactive leaf ships that leaf and nothing else.

Elision is automatic, and it stays automatic. There is no 'use client' and no per-component annotation to remember, because a directive-based model puts the failure on the author with no compiler to catch a forgotten one. The analyser instead biases toward SHIPPING: a wrong "display-only" verdict breaks a page, a wrong "interactive" verdict only misses an optimization, so anything ambiguous or unreadable keeps its JavaScript.

What keeps a component shipping

A component stays elidable while it has none of the following. Any one of them is a client-work signal and the module ships.

  • An @event binding, or a native handler property like .onclick.
  • A factory-declared reactive property that is not { state: true }.
  • An overridden lifecycle hook, renderFallback() and renderError() included.
  • An imported signal / computed / watch / Task / ref or a streaming directive, or a call to addController / requestUpdate.
  • Code that runs at module load: a top-level call, a non-data new, a dynamic import(...), a top-level await. Only declarations and the register(...) call are inert. TypeScript types are erased before the analyser reads a module, so an annotation is never a signal however call-shaped it looks: readonly (readonly [number, number, number])[] is inert data.
  • A browser global at module scope, or a side-effect import of an npm package.
  • The dynamic slot READ surface (slotchange, assignedNodes / assignedElements / assignedSlot). Merely rendering a <slot> does not ship, because the SSR output already carries the placed children.
  • Being rendered or imported by a component that itself ships.
  • Another module observing its registration: a whenDefined('its-tag'), a CSS its-tag:defined rule in a module the graph reaches, or an instanceof TheClass.

A bare async render() is not a signal on its own. Its SSR pass bakes the resolved data into the first paint, so a light-DOM async leaf with no other signal is elided like any display-only component, which drops the module AND the redundant on-hydration re-fetch.

The two always-ship carve-outs

static shadow = true always ships. Declarative Shadow DOM attaches only during HTML parsing, so a shadow component that arrives through a soft-nav swap or a streamed boundary needs its module to re-run attachShadow.

static interactive = true is the explicit author override. It forces the module to ship when the component's interactivity is invisible to static analysis:

  • An observer that computes the tag it waits for. customElements.whenDefined(TAG) with a variable does not name a tag the analyser can resolve, so the observed component is elided, its registration never runs, and the await never settles. Put the override on the OBSERVED component.
  • A :defined rule in an external stylesheet. A public/app.css is not in the module graph, so my-badge:defined { ... } is invisible. Same fix, on the component the rule names.
  • A consumer that reaches the element through a string selector. The analyser matches whenDefined, :defined, and instanceof, so a document.querySelector('my-wrapper') consumer escapes all three. Same fix, on the component being reached.

A computed registration tag is a different problem

static interactive = true does not rescue a component whose own registration tag is computed:

// Broken: the component scanner requires a literal tag.
const TAG = buildTag();
Badge.register(TAG);          // invisible

// Correct:
Badge.register('my-badge');

A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The registration itself still runs if the module reaches the browser, so what you always lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades comes down to one thing: the importing module has to ship whole. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. A page rendering a real component alongside the orphan is import-only unless it also does its own client work, so shipping whole is the narrower case: assume the element does not upgrade.

webjs dev warns, and webjs elision and webjs doctor report it, as an orphan. That name covers two shapes and they fail differently. A computed tag is the case above. A class with no registration call anywhere in the app is the plainer one, someone forgot to register it, and that element never upgrades. The check is app-wide, so registering the class from a sibling module is fine and is not reported. Both lose the verdict, the registry entry, and the preload hint.

Inspecting the verdict

Elision is the one thing WebJs decides about your code that you did not write down, so it is inspectable rather than something to reason about from the rules above.

webjs elision            # per-module verdict, and the evidence behind every ship
webjs elision --json     # the same object, for a tool or an agent

Every component is reported as elided or shipped. A shipped one carries the evidence that forced it, first match wins:

  • own is its own source, and the reason names the exact signal.
  • observed means another module observes its registration; by names the observer.
  • closure means something it imports does client work; by names the import.
  • render means a shipping component can render its tag.
  • import means a shipping component imports it.
  • unreadable means its source could not be read, so it ships conservatively.

An elided row carries no reason on purpose. Elision is the ABSENCE of every signal, so there is no positive fact to report.

Every page and layout is reported too, as inert (ships nothing), import-only (the boot emits its components directly and drops the module), or shipped (with the first client-effecting blocker that pins it). The same verdict is available to an agent as the MCP list_elision tool, and webjs doctor carries it as a one-line inventory that warns only on an orphan.

Proving it for your own app

webjs elision --verify
webjs elision --verify --routes /,/blog/hello

This renders every static page route with elision on and off in one process and diffs the served bytes with the JavaScript-loaded set masked out. It is the framework's own differential guard pointed at your route table, so your app proves the invariant locally instead of inheriting a guarantee it cannot check. It exits non-zero on a divergence and on a corpus where nothing could be compared, so it is safe to put in CI.

The ON side is forced on with the environment override rather than left to your config, so --verify compares a real on-vs-off pair even in an app that has elision switched off. The run also reports how many modules elision actually dropped across the corpus, because a pass over a corpus where it dropped none is true but trivially so.

What it proves, and what it does not. The mask covers the whole JS-loaded set by construction, so --verify proves elision did not change the bytes your app SERVES. It cannot prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Cover that half by running your own browser or e2e suite twice:

WEBJS_ELIDE=1 npm run test:e2e
WEBJS_ELIDE=0 npm run test:e2e

Dynamic routes are skipped by name, because rendering one would mean inventing param values; pass real ones with --routes. A route whose two same-side renders already differ is reported as nondeterministic and excluded, since a differential over live data proves nothing either way.

Turning it off

Elision is on by default. Disable it app-wide in package.json:

{
  "webjs": {
    "elide": false
  }
}

Or per-run with the environment override, which wins over the config key and is the seam --verify itself uses:

WEBJS_ELIDE=0 npm run start

With elision off, every module ships and webjs elision reports that rather than a verdict. Reach for the switch to isolate a bug, not as a permanent setting: everything it turns off is JavaScript your users would otherwise never download.