Skip to content
HELiX

ADR: Component Loading

apps/docs/src/content/docs/architecture/adrs/component-loading Click to copy
Copied! apps/docs/src/content/docs/architecture/adrs/component-loading

How do web components get delivered to each page? A single monolithic bundle, individual per-component files, or intelligent context-based groups? The right strategy means fast first paint and minimal wasted bytes — measure the trade-off per-page rather than chasing an absolute “zero waste” target.

Accepted. Per-component libraries is the shipped default — @helixui/drupal-starter generates one Drupal library per hx-* component, plus a core library for tokens/runtime. A single-bundle all library is generated for prototyping. Hybrid component-group bundles (core/navigation/forms/etc.) are a future enhancement; they are documented below as a planned strategy, not a shipped feature.

The architecture decision does not stop at props vs. slots. How you physically load web component JavaScript into Drupal pages has direct impact on performance, cacheability, and maintainability.

HELiX ships as ES modules with per-component entry points (@helixui/library/components/<name>dist/components/<name>/index.js). Drupal consumers declare libraries in helixui.libraries.yml (auto-generated by @helixui/drupal-starter’s pnpm run generate:drupal-libraries) and attach them in Twig with attach_library() using the helixui/ namespace. Three strategies are possible.

One library that pulls in every component entry. Simplest setup, worst per-page footprint. The drupal-starter generator ships an all library for this case.

  • Size per page: the full library; measure with pnpm run check:bundle before committing to a budget rather than quoting a fixed number — per-component sizes shift release-to-release
  • First Contentful Paint impact: Poor — every page downloads every component
  • Cache efficiency: OK — bundle hash invalidates on any component change
  • Setup complexity: Easy
# Single library — every per-component entry is a dep of `all`
all:
version: 3.0.0
dependencies:
- helixui/core
- helixui/hx-accordion
- helixui/hx-action-bar
# … one row per component

Per-Component Libraries — “Surgical Loading”

Section titled “Per-Component Libraries — “Surgical Loading””

Every component is its own library. Minimal per-page weight, more HTTP requests.

  • Size per page: ~5-15KB
  • First Contentful Paint impact: Excellent
  • Cache efficiency: Good — only changed components invalidate
  • Setup complexity: Medium — every component needs a library declaration
# Per-component — surgical loading (real generated shape)
hx-card:
version: 3.0.0
js:
/libraries/helixui/dist/components/hx-card/index.js:
{ attributes: { type: module }, minified: true }
dependencies:
- helixui/core
hx-button:
version: 3.0.0
js:
/libraries/helixui/dist/components/hx-button/index.js:
{ attributes: { type: module }, minified: true }
dependencies:
- helixui/core

Hybrid Groups — “Smart Bundles” (planned)

Section titled “Hybrid Groups — “Smart Bundles” (planned)”

Components grouped by usage context: core, navigation, content, forms. This is a planned enhancement on top of the current per-component model — the package and the Drupal generator do not yet emit per-group bundles. The YAML below describes the intended shape; substitute the existing per-component libraries today.

  • Size per group: depends on the group composition; measure with pnpm run check:bundle
  • First Contentful Paint impact: Great
  • Cache efficiency: Excellent — high cache hit rate after second page if pages share group membership
  • Setup complexity: Moderate — group boundaries must be maintained
# Smart bundles — planned future grouping (NOT currently emitted)
helixui/core:
js:
/libraries/helixui/dist/groups/core.js:
{ attributes: { type: module }, minified: true }
# button, badge, spinner, avatar
dependencies:
- helixui/core # tokens + runtime base
helixui/navigation:
js:
/libraries/helixui/dist/groups/navigation.js:
{ attributes: { type: module }, minified: true }
# nav, top-nav, side-nav, breadcrumb, tabs, pagination
dependencies:
- helixui/core
helixui/content:
js:
/libraries/helixui/dist/groups/content.js:
{ attributes: { type: module }, minified: true }
# card, accordion, dialog, drawer
dependencies:
- helixui/core
helixui/forms:
js:
/libraries/helixui/dist/groups/forms.js:
{ attributes: { type: module }, minified: true }
# text-input, textarea, select, checkbox, radio, switch, date-picker
dependencies:
- helixui/core
MetricSingle BundlePer-ComponentHybrid Groups
Initial page load220KB25KB60KB
HTTP requests13-82-3
Cache hit rate (page 2)100%~60%~90%
FCP impactPoorExcellentGreat

Per-component libraries depend on a shared helixui/core library that loads fouc.css and the design-token / core CSS surfaces (dist/css/helix-tokens.css, dist/css/helix-core.css). Lit 3’s runtime is bundled into each per-component entry (so they remain standalone). The core library carries no JS — each per-component module brings the Lit it needs and the browser dedupes via the module cache once dist/components/<name>/index.js shares are loaded.

# Shared core — tokens + base CSS; no JS entry of its own
core:
version: 3.0.0
css:
base:
/libraries/helixui/fouc.css: {}
theme:
/libraries/helixui/dist/css/helix-tokens.css: { minified: true }
/libraries/helixui/dist/css/helix-core.css: { minified: true }
  1. Content Editor — creates a node with paragraph types (Card, Banner, etc.)
  2. Twig Template — renders the component and attaches the library: attach_library('helixui/hx-card')
  3. Drupal Aggregation — combines only the needed assets into optimised bundles
  4. Minimal JS Load — browser downloads dist/components/hx-card/index.js (and any transitive component deps); shared module imports (Lit runtime, base classes) dedupe in the browser module cache
  5. Custom Element UpgradecustomElements.define() registers and upgrades elements in the DOM
{# In paragraph--card.html.twig #}
{{ attach_library('helixui/hx-card') }}
<hx-card variant="featured" elevation="raised" hx-href="{{ url }}">
<img slot="image" src="{{ image_url }}" alt="{{ image_alt }}" />
<h3 slot="heading">{{ title }}</h3>
{{ body }}
</hx-card>
{# Drupal loads /libraries/helixui/dist/components/hx-card/index.js (plus the
helixui/core CSS that hx-card depends on transitively). Page weight depends
on which components attach; measure with `pnpm run check:bundle`. #}

Use hybrid groups as the default. Allow per-component overrides for fine-grained pages.

Hybrid groups give 90%+ of the cache benefit of a single bundle while keeping initial page weight under 60KB. Per-component libraries remain available for pages where every byte counts (landing pages, AMP-style content).

  • Predictable performance. Hybrid groups produce stable bundle sizes that page-builders can reason about.
  • Cache locality. Core components (button, badge) load on every page and stay hot in the browser cache.
  • Drupal-native. attach_library() is the canonical Drupal pattern. No special tooling required.
  • Group boundaries must be maintained. As new components are added, the team must decide which group owns them. A bad placement can move a heavy component into a hot path.
  • Per-component overrides require library discipline. Pages that need fine-grained loading must declare every component library explicitly.

Three components, three composition strategies, one delivery model:

<hx-card variant="featured" elevation="raised" hx-href="{{ url }}">
<img slot="image"
src="{{ file_url(image.uri) }}"
alt="{{ image.alt }}"
loading="lazy" />
<h3 slot="heading">{{ title }}</h3>
<p>{{ body|striptags|truncate(120) }}</p>
<a slot="actions" href="{{ url }}">Read More &rarr;</a>
</hx-card>

hx-button has no icon / icon-position attribute — compose icons via the prefix / suffix slots with hx-icon:

<hx-button
variant="primary"
hx-size="lg"
{% if is_disabled %}disabled{% endif %}
>
{{ button_label }}
<hx-icon slot="suffix" library="default" name="arrow-right"></hx-icon>
</hx-button>
{# Loading state example #}
<hx-button variant="primary" loading>
Submitting...
</hx-button>
<hx-text-input
name="{{ field_name }}"
type="{{ field_type }}"
required
pattern="{{ validation_pattern }}"
maxlength="{{ max_length }}"
>
<span slot="label">
{{ field_label }}
{% if required %}<abbr title="required">*</abbr>{% endif %}
</span>
<span slot="help-text">{{ field_description }}</span>
<span slot="error">{{ error_message }}</span>
</hx-text-input>
  • Slots vs Props — composition pattern shown in the Twig examples above.
  • Light DOM — light-DOM components share the same delivery model.