ADR: Component Loading
apps/docs/src/content/docs/architecture/adrs/component-loading Click to copy 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.
Status
Section titled “Status”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.
Context
Section titled “Context”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.
Strategies considered
Section titled “Strategies considered”Single Bundle — “Load Everything”
Section titled “Single Bundle — “Load Everything””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:bundlebefore 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 componentPer-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/coreHybrid 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/corePerformance comparison
Section titled “Performance comparison”| Metric | Single Bundle | Per-Component | Hybrid Groups |
|---|---|---|---|
| Initial page load | 220KB | 25KB | 60KB |
| HTTP requests | 1 | 3-8 | 2-3 |
| Cache hit rate (page 2) | 100% | ~60% | ~90% |
| FCP impact | Poor | Excellent | Great |
Shared core library
Section titled “Shared core library”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 owncore: 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 }How per-component loading works in Drupal
Section titled “How per-component loading works in Drupal”- Content Editor — creates a node with paragraph types (Card, Banner, etc.)
- Twig Template — renders the component and attaches the library:
attach_library('helixui/hx-card') - Drupal Aggregation — combines only the needed assets into optimised bundles
- 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 - Custom Element Upgrade —
customElements.define()registers and upgrades elements in the DOM
Twig integration
Section titled “Twig integration”{# 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`. #}Decision
Section titled “Decision”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).
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- 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.
Negative
Section titled “Negative”- 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.
Real-world patterns
Section titled “Real-world patterns”Three components, three composition strategies, one delivery model:
Card (slot-driven)
Section titled “Card (slot-driven)”<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 →</a></hx-card>Button (property-driven)
Section titled “Button (property-driven)”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>Text Input (hybrid)
Section titled “Text Input (hybrid)”<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>Related ADRs
Section titled “Related ADRs”- Slots vs Props — composition pattern shown in the Twig examples above.
- Light DOM — light-DOM components share the same delivery model.