Distribution, Testing & Tooling

Building a custom element is only half of shipping a framework-agnostic UI system. The other half is the pipeline that turns source modules into a versioned package, proves the component’s contract under automated tests, degrades gracefully on older engines, and renders correctly on the server. This domain governs everything that happens after a component works on your laptop and before it works in a thousand consuming applications you will never see. Treating distribution, testing, and tooling as a first-class architectural concern is what separates a demo from a dependency teams can build on.

Web component distribution and quality pipeline Authored custom elements flow through build and packaging, a registry, and into consumer applications, gated by contract tests, visual regression tests, polyfills, and server-side rendering with declarative Shadow DOM. Author Custom elements Build & package ESM + exports map Registry npm / CDN Consumer app Any framework QUALITY & COMPATIBILITY GATES Contract tests Event & prop schemas Visual tests Shadow DOM snapshots Polyfills Legacy engine support SSR & hydration Declarative Shadow DOM

This domain is the natural counterpart to Core Architecture & Lifecycle Management and Styling, Theming & CSS Encapsulation: those define what a component is, while this defines how it travels. A registration pattern that is flawless in isolation can still corrupt a consumer’s bundle if the package’s exports map is wrong, and a perfectly encapsulated style can still flash unstyled content if the component is server-rendered without declarative Shadow DOM.

Spec & ecosystem authority

Unlike the rendering primitives, distribution is governed by a mix of formal specifications and de facto ecosystem standards. The authoritative references for this domain are:

These are not optional reading. A package published without a verified exports map, or a server-rendered component that ignores the declarative Shadow DOM parsing rules, will fail in ways that are invisible during local development and only surface in a consumer’s production build.

Packaging & publishing

The most common way to break a component library is to publish it incorrectly. A component can be architecturally pristine and still be unusable if its package metadata misroutes the import, ships CommonJS that defeats tree-shaking, or omits type declarations. Packaging & Publishing treats the package.json exports field as the public API surface of the library — every entry point a consumer can reach must be declared, typed, and side-effect annotated.

{
  "name": "@acme/elements",
  "version": "1.4.0",
  "type": "module",
  "sideEffects": ["**/define-*.js"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./button": {
      "types": "./dist/button/index.d.ts",
      "default": "./dist/button/index.js"
    },
    "./package.json": "./package.json"
  },
  "files": ["dist"]
}

Debugging Pitfall: Setting a blanket "sideEffects": false on a web component library is a frequent and silent error. The customElements.define() call is a side effect — it mutates the global registry. If a bundler tree-shakes away a module whose only purpose is to register an element, the tag silently never upgrades. Scope sideEffects to the registration entry points (as above) so the definitions survive while pure utility modules stay shakeable.

Contract & visual testing

A published component is a contract: a set of attributes, properties, slots, CSS custom properties, and events that consumers depend on. Contract & Visual Testing verifies that contract on every commit, asserting both the shape of emitted event payloads and the rendered pixels of the Shadow DOM. Because Shadow DOM is invisible to jsdom-style mocks, these tests must run in a real browser engine.

import { test, expect } from '@playwright/test';

test('ds-stepper emits a typed change payload', async ({ page }) => {
  await page.setContent('<ds-stepper value="2"></ds-stepper>');
  const detail = await page.evaluate(() => new Promise((resolve) => {
    const el = document.querySelector('ds-stepper');
    el.addEventListener('change', (e) => resolve(e.detail), { once: true });
    el.shadowRoot.querySelector('[part="increment"]').click();
  }));
  // The contract: { value: number, delta: number }
  expect(detail).toEqual({ value: 3, delta: 1 });
});

Debugging Pitfall: Snapshotting a component immediately after insertion captures it mid-construction. Custom elements upgrade asynchronously when defined after parse, and slotchange fires on a microtask. Always await customElements.whenDefined('ds-stepper') and await a layout frame before asserting pixels or geometry, or the snapshot races the upgrade and produces flaky diffs.

Polyfills & progressive enhancement

Native Custom Elements and Shadow DOM are supported across all current evergreen browsers, but the trailing edge of locked-down enterprise environments, embedded webviews, and email clients still demands a degradation strategy. Polyfills & Progressive Enhancement covers when to ship the @webcomponents/webcomponentsjs loader versus when to design components that remain meaningful as plain HTML before any script runs.

// Load the polyfill bundle only when a primitive is missing — never ship it to engines
// that already implement the spec natively.
if (!('attachShadow' in Element.prototype) || !('customElements' in window)) {
  await import('@webcomponents/webcomponentsjs/webcomponents-bundle.js');
}
// WebComponentsReady fires once polyfills (if any) are installed.
window.addEventListener('WebComponentsReady', () => {
  document.documentElement.removeAttribute('hidden');
});

Debugging Pitfall: Unconditionally importing the polyfill bundle is a measurable regression on modern browsers — it patches Element.prototype.attachShadow and the parser even when native support exists, adding both bytes and runtime cost. Feature-detect first. Equally, never rely on the polyfill for ::part or ::slotted styling fidelity; the ShadyCSS shim approximates scoping and diverges on edge cases, so treat polyfilled environments as a graceful-degradation tier, not a pixel-parity tier.

Server-side rendering & hydration

For content-driven and SEO-sensitive products, a component must produce meaningful markup before its JavaScript executes. Server-Side Rendering & Hydration is built on declarative Shadow DOM: the server emits a <template shadowrootmode="open"> that the HTML parser attaches as a real shadow root, so the encapsulated content is painted on first byte and the client only needs to adopt it.

<!-- Server output: the parser attaches this as a shadow root, no JS required -->
<ds-card>
  <template shadowrootmode="open">
    <style>:host { display: block; border: 1px solid var(--ds-border, #2b3d73); }</style>
    <slot name="title"></slot>
    <slot></slot>
  </template>
  <h2 slot="title">Quarterly report</h2>
  <p>Revenue grew 18% year over year.</p>
</ds-card>

Debugging Pitfall: A constructor that calls this.attachShadow() unconditionally throws NotSupportedError when it runs against an element that already has a declaratively-attached shadow root. Hydration-aware components must check this.shadowRoot first and adopt the existing tree instead of re-creating it — otherwise every server-rendered instance crashes on upgrade, defeating the entire purpose of rendering it on the server.

Types, manifests, and editor data

A published component library ships code, and a usable one ships a description of that code. Without it, editors offer no completion for tag names or attributes, TypeScript reports every element as Element, JSX rejects tags it has never heard of, and documentation has to be written by hand and kept in sync by discipline alone. Types & the Custom Elements Manifest covers the three artefacts that solve it, and the important property they share is that all three are generated from one source rather than maintained separately.

The manifest is the widest of the three, because it describes things the type system cannot represent at all — slots, parts, and CSS custom properties are a large part of a component’s public contract and have no TypeScript expression.

{
  "name": "@wfc/components",
  "type": "module",
  "customElements": "custom-elements.json",
  "files": ["dist", "custom-elements.json", "vscode.html-custom-data.json"],
  "exports": {
    ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
    "./custom-elements.json": "./custom-elements.json"
  },
  "scripts": {
    "analyze": "custom-elements-manifest analyze",
    "prepack": "npm run build && npm run analyze",
    "verify:manifest": "npm run analyze && git diff --exit-code custom-elements.json"
  }
}

Three fields carry the integration. customElements is the conventional pointer tooling looks for; files decides whether the artefact ships at all; and the explicit exports entry is what lets a consumer’s tooling read it, because an exports map blocks deep imports by default. The verify:manifest script is what keeps the committed artefact honest — regenerate in CI, fail on a diff, exactly as a lockfile check works.

Debugging Pitfall: Pointing the analyser at dist/ instead of src/ produces a manifest that is valid and nearly empty. Bundlers strip the JSDoc comments the analysis depends on and transpilers rewrite class fields into forms it no longer recognises, so every annotation-derived field — events, slots, parts, CSS properties — silently disappears, leaving a document that lists tag names and little else.

Deferred hydration and island loading

A page with forty components does not need forty definitions before it is useful. Most are below the fold, several are never interacted with, and a few carry more JavaScript than the rest of the page combined. Custom elements make deferring them unusually safe, because an element that has not been upgraded is still in the DOM, still styled, and still readable — customElements.define upgrades every matching element already present, so a late definition is late rather than missing.

Deferring hydration with islands develops the pattern: each server-rendered region is complete without script, and its definition is fetched on a trigger.

const island = document.querySelector('wfc-island');
const load = async () => {
  await import(island.dataset.src);
  await customElements.whenDefined(island.dataset.tag);
};

// Visibility covers scrolling readers…
new IntersectionObserver(([entry], observer) => {
  if (!entry.isIntersecting) return;
  observer.disconnect();
  load();
}, { rootMargin: '200px' }).observe(island);

// …and focusin covers keyboard users, who never trigger an intersection.
island.addEventListener('focusin', load, { once: true });

Debugging Pitfall: A visibility trigger alone is an accessibility failure, not a performance nicety. Tabbing into an off-screen island moves focus into an inert control and nothing loads, because the region never intersected the viewport. Every island needs a spatial trigger and an interaction trigger, and focusin is the one most often forgotten because mouse testing never reveals its absence.

Cross-domain integration

The pipeline only holds together when these gates respect the primitives defined elsewhere on the site. Contract tests assert the Event Composition & Bubbling payloads that components emit, so a breaking change to an event’s detail shape is caught before publish, not after a consumer upgrades. Server-side rendering depends directly on Shadow DOM Construction & Modes: only serializable shadow roots survive getHTML(), and only declaratively-attached roots hydrate without a flash. Styling travels too — a package that ships scoped styles via constructable stylesheets must guarantee those sheets are reachable both at runtime and in the server-rendered output, or the SSR tier and the CSR tier will visibly disagree.

The same applies to framework integration: the adapters that bridge a component into React, Vue, or Angular are themselves a versioned part of the package surface, and their compatibility must be asserted by the same contract suite that guards the core element.

Production validation & contract testing

The quality gates in this domain are most valuable when wired into continuous integration as blocking checks. A mature pipeline runs, on every pull request: a publint and @arethetypeswrong/cli audit of the package metadata, a Playwright contract suite across Chromium, WebKit, and Firefox, a visual-regression diff with a small pixel threshold, and an axe-core accessibility audit that pierces Shadow DOM. Each gate maps to a failure that is otherwise discovered by a consumer in production.

// CI gate: fail the build if the package would resolve incorrectly for consumers.
import { execSync } from 'node:child_process';
execSync('npx publint --strict', { stdio: 'inherit' });
execSync('npx @arethetypeswrong/cli --pack', { stdio: 'inherit' });

Debugging Pitfall: Running the test matrix only on Chromium gives false confidence. WebKit historically diverges on form-associated custom elements and on ::part inheritance, and Firefox enabled declarative Shadow DOM later than Chromium. A green Chromium run is necessary but not sufficient — gate the merge on all three engines or document the unsupported tier explicitly.

Distribution & publishing implications

Every architectural decision in the other two sections has a packaging consequence. Components that register themselves on import need their registration modules excluded from tree-shaking; components that ship CSS need that CSS expressed as constructable stylesheets or inline <style> rather than separate files a bundler might drop; components that support SSR need their declarative Shadow DOM templates serialized with the serializable: true option so getHTML({ serializableShadowRoots: true }) captures them. The exports map should expose a single side-effect-free entry that defines nothing, plus per-component registration entries, letting consumers choose between auto-registration and manual control.

Performance & bundle implications

Distribution decisions are where a component library’s runtime cost is actually set, and three of them dominate.

Entry-point granularity decides what a consumer can avoid. A package with a single barrel export forces every consumer to reach for a bundler’s tree-shaking to avoid shipping components they never use — and that only works if every module is genuinely side-effect free apart from the registrations, which is precisely the part that is not. Per-component entry points in the exports map let a consumer import one component and its dependencies, with no analysis required and no way for the bundler to be wrong.

Registration and implementation should be separable. A module that exports a class and a sibling module that calls define on it costs one extra file per component and buys two things: a consumer can subclass without registering the base tag, and a scoped-registry consumer can register the class under a name of their own. Bundling the two together forecloses both, permanently.

Styles travel differently from code. A constructable stylesheet shared at module scope is parsed once for every instance on the page; a <style> element cloned into each shadow root is parsed once per instance. For a list of two hundred rows the difference is measurable, and it is decided entirely by how the package authored its styling — a distribution-layer choice with a rendering-layer cost. Where the support floor allows it, adopt a shared sheet and keep the <style> route as the detected fallback.

Deferred loading multiplies the value of the first two. Per-component entry points only help if something decides not to load one, and that decision belongs to the page rather than the bundler: an island trigger, a route split, a media gate. A library that ships granular entry points and documents which components are heavy enough to defer gives an application team the information they need to make that call, instead of leaving them to discover it in a performance audit.

The general shape of all four: the package decides what the consumer can optimise. A library that ships one file, one entry point, and one bundle has made every one of these decisions on the consumer’s behalf, and made them badly for anyone whose usage is narrower than the library’s full surface.

A last observation about all of this: none of the checks in this section are expensive, and every one of them fails silently when it is missing. A tree-shaken registration produces inert markup rather than an exception. A stale manifest produces confident autocomplete for an attribute that no longer exists. A misordered types condition produces a package that resolves at runtime and not at compile time. An island with only a visibility trigger produces a control that is unreachable by keyboard. In each case the library’s own test page passes, the consuming application is where the failure appears, and the report arrives as “your component does not work” with no further detail. Automating the five gates on this page is the difference between finding those in CI and finding them in someone else’s issue tracker.

Conclusion

Distribution, testing, and tooling convert a working component into a dependable one. The registry contract guarantees the right code reaches the consumer; the test gates guarantee that code keeps its promises across engines; the polyfill strategy guarantees a sane experience across the residual set of older environments; and server-side rendering guarantees the component is useful before its script arrives. Together they close the loop opened by the architecture and styling primitives — a component that is correct, encapsulated, and shippable is what makes a framework-agnostic design system something other teams can actually adopt.

The release pipeline from source to consumer, with the gate at each stage Build produces modules and declarations, analysis produces the manifest and editor data, verification checks drift and packaging, and only then does publishing happen. Each stage has one gate; skipping a gate ships a package that describes something else build ESM modules .d.ts declarations analyze custom-elements.json editor custom data verify no manifest drift types resolve from a tarball publish tarball describes the code inside it Failures each gate catches tree-shaken registration, so nothing upgrades manifest describing last month's API types condition after default, so it is ignored artefacts missing from the files list Why a workspace check is not enough path mapping resolves types regardless of the exports map, so a broken package passes locally and fails for everyone else install the tarball into a scratch project

Feature detection as a distribution strategy

“Does this browser support Web Components” is the wrong question, and every codebase that asks it ends up with a boolean that is true on engines missing half the API. There is no single feature: customElements, attachShadow, ElementInternals, CustomStateSet, constructable stylesheets, declarative shadow roots, manual slot assignment, and scoped registries each shipped on their own timeline and each degrade differently.

Testing them individually costs a few lines and is dramatically more reliable, because it tests what the component actually needs. The four detection shapes cover everything: property presence for members that exist or do not, behavioural probing for parser features with no reflected property, CSS.supports() for selectors, and a try/catch construction for APIs that throw rather than being absent.

// capabilities.js — evaluated once, at module scope, exported frozen.
const detectDeclarativeShadowDom = () => {
  const probe = document.createElement('div');
  const markup = '<x-probe><template shadowrootmode="open"></template></x-probe>';
  if (typeof probe.setHTMLUnsafe === 'function') probe.setHTMLUnsafe(markup);
  else probe.innerHTML = markup;
  return Boolean(probe.firstElementChild?.shadowRoot);
};

export const CAPABILITIES = Object.freeze({
  shadowDom: 'attachShadow' in Element.prototype,
  declarativeShadowDom: detectDeclarativeShadowDom(),
  elementInternals: 'attachInternals' in HTMLElement.prototype,
  constructableStyleSheets: 'adoptedStyleSheets' in Document.prototype,
  manualSlotAssignment: 'assign' in HTMLSlotElement.prototype
});

Debugging Pitfall: Never let the API call itself be the detection. A custom element constructor that throws leaves the element permanently in the failed state — it never upgrades, :defined never matches, and the region renders empty with no error naming the component. Detect before the class is defined, store the results, and branch on the cached value inside the constructor, where it can never throw. The full technique is in feature-detecting shadow DOM support.

Two habits make the results usable. Compute the capability object once, at module scope: a declarative-shadow-DOM probe parses a fragment, and repeating that per instance is a measurable cost on a page with hundreds of components, with no possible change in the answer. And make every branch degrade to something that works — a <style> element where constructable sheets are absent, a data attribute where custom states are, a hidden input where form association is — so a test can assert the outcome rather than the mechanism and one test covers both paths.

The distribution consequence is that a library’s support matrix stops being a version table and becomes a capability table. That is a more honest artefact: it tells a consuming team exactly which behaviour they lose on their oldest supported engine, instead of a binary that was never accurate.

A capability table instead of a browser-version support matrix Each platform capability is paired with the fallback the library ships, so a consuming team can read exactly what behaviour is lost on their oldest supported engine. What a consuming team actually needs to know capability fallback shipped constructable stylesheets a <style> element per shadow root — same visuals, more parses custom states a data attribute consumers can style — same hooks, different selector declarative shadow DOM imperative attach at connect — content readable, first paint unstyled manual slot assignment named assignment — works, but the component writes consumer attributes scoped registries prefixed global tags — no collision, but the prefix is visible in markup

Frequently Asked Questions

Why do my components stop working after enabling tree-shaking?

A module whose job is to call customElements.define is a side effect by definition, so "sideEffects": false lets the bundler delete it. The registration disappears, the elements never upgrade, and the page renders inert markup. List the registration modules explicitly in sideEffects instead of marking the whole package pure.

Should the generated manifest be committed?

Yes, with a CI check that regenerating it produces no diff. Committing makes the artefact reviewable and readable by consumers of a git dependency; the check is what stops it drifting from the source it claims to describe.

Why do types resolve in my monorepo but not for consumers?

Workspace path mapping resolves declarations directly from source and never consults the exports map, so a missing or misordered types condition is invisible locally. Test by packing the tarball, installing it into a scratch project, and compiling there.

Can component tests run in a DOM emulation instead of a browser?

Only the parts that do not depend on layout, the cascade, or real parser behaviour — which excludes most of what matters for components. Container queries, declarative shadow roots, computed styles, and focus behaviour all need a real engine, so the useful component suite runs in at least two.

What is the minimum a component package should publish?

Standard ESM with per-component entry points, a types condition first in every export, a generated custom elements manifest, and editor custom data. Those four cover script consumers, TypeScript consumers, tooling, and the large group of consumers who only ever write HTML.