skip to content
dz
10 min read

Writing robust Web Components: a guide from the Firefox source code

Auf Deutsch lesen โ†’

Web Components do something other than what you expect in a few places. The constructor sees an attribute once as "Ada" and once as null. A component stays empty without the page showing an error. A padding on a <slot> does nothing. These are not browser bugs, it is specified that way, and it applies in every browser.

The reason for this guide is an article by Cassondra Roberts: in How Firefox renders Web Components she reads the Firefox source code and shows which objects and fields in Gecko are behind this behavior, interactive replay of the reactions included. Names like eFailed are internal to Gecko, the behavior itself is in the specifications. While reading I wondered what that means for my own work, and I wanted to write it down, for my future self too. Each section has a problem, the reason for it, the rules that follow and alternatives for when a rule does not fit. These are the five rules:

  1. Never touch attributes or children in the constructor, read attributes in the attributeChangedCallback instead.
  2. Never dispatch events or listen on document and window in the constructor, register in the connectedCallback and unregister in the disconnectedCallback instead.
  3. Never wait for a callback with setTimeout, keep working right after the DOM call instead.
  4. Never run code in the constructor that can throw an error, put it in the connectedCallback instead.
  5. Never style the <slot>, put a wrapper around it instead.
  6. Never create content with JavaScript alone, deliver meaningful markup as children in the light DOM that the component enhances.

Below each code block a link leads to the running example (the example pages are in English). The output appears in the console of the developer tools and also in a box on the page.

Rule 1: Don't touch attributes or children in the constructor

Instead: read attributes in the attributeChangedCallback.

The example is a <hello-name> that builds a greeting from its name attribute. The constructor reads the attribute and sets the text. The first element is in the HTML, and the script is a module, so it only runs after parsing. The script creates the second element with document.createElement() and sets the attribute afterwards with setAttribute(), a common pattern, frameworks included:

<p><hello-name name="Ada"></hello-name></p>

<script type="module">
	class HelloName extends HTMLElement {
		static observedAttributes = ['name'];

		constructor() {
			super();
			console.log('constructor', this.getAttribute('name'));
			// The mistake: the attribute is read in the constructor.
			this.attachShadow({ mode: 'open' }).textContent = `๐Ÿ‘‹ Hello, ${this.getAttribute('name')}!`;
		}

		attributeChangedCallback(name, oldValue, newValue) {
			console.log('attributeChangedCallback', name, oldValue, newValue);
		}

		connectedCallback() {
			console.log('connectedCallback');
		}
	}

	customElements.define('hello-name', HelloName);

	const created = document.createElement('hello-name');
	created.setAttribute('name', 'Grace');
	document.body.append(created);
</script>

Run example 01 โ†’

The console shows two runs: the first three lines belong to the element from the HTML, the next three to the one created with document.createElement():

constructor Ada
attributeChangedCallback name null Ada
connectedCallback
constructor null
attributeChangedCallback name null Grace
connectedCallback

Both elements use the same code. The first says "๐Ÿ‘‹ Hello, Ada!", the second says "๐Ÿ‘‹ Hello, null!", although setAttribute('name', 'Grace') ran for it as well. Where the difference comes from shows in the upgrade of the first element.

When define() is called, the browser upgrades the existing element. The sequence:

  1. The browser creates two reactions, that is jobs for later callbacks: "call attributeChangedCallback for label" and "call connectedCallback". They end up in a queue on the element.
  2. It calls the constructor and waits until it is done.
  3. Only then does it work through the queue.

So the queued callback calls, the reactions, are created before the constructor but run after it.

JavaScriptRegistryQueueElementdefine()enqueueconstructor()attributeChanged-CallbackconnectedCallback

Cassondra Roberts' article shows this sequence as an interactive replay.

During the upgrade of the first element the attributes are already on the element, so the constructor sees "Ada". Only the attributeChangedCallback has not run yet. (Reading it in the constructor is the mistake rule 1 is about, here it only happens to work.)

The second element is different: document.createElement() calls the constructor immediately, and the attribute only comes afterwards, so the constructor sees null. The same happens with new and with elements the parser creates before the script has called define(). The order is always the same: constructor, attributes, connectedCallback. A component has to cope with both paths.

That is exactly why the specification requires that the constructor does not inspect attributes and children: without an upgrade neither is there yet, and relying on upgrades makes the element less usable. It should not add attributes or children either, because whoever calls document.createElement() expects an empty element. What the constructor may do: set up initial state and default values and create a shadow root. Heavy work belongs in the connectedCallback. Rule 1 follows from that: read attributes in the attributeChangedCallback, which fires for every observed attribute. That way the greeting is right on both paths:

class HelloName extends HTMLElement {
	static observedAttributes = ['name'];

	constructor() {
		super();
		this.attachShadow({ mode: 'open' });
	}

	attributeChangedCallback(name, oldValue, newValue) {
		this.shadowRoot.textContent = `๐Ÿ‘‹ Hello, ${newValue ?? 'stranger'}!`;
	}
}

Run example 02 โ†’

To test, it is enough to create each component both ways: as an element in the HTML that is upgraded when define() is called, and with document.createElement(). If the console shows the same in both cases, the component does not depend on when it is created. No script has to block the page for that.

If you need a value early, there are alternatives:

  • Read it in the connectedCallback: the attributes are there by then. While parsing, however, the children may still be missing: the connectedCallback does not see any children yet. If you need children, listen to the slotchange event of the slot, which also fires on the first assignment.
  • Render idempotently: attributeChangedCallback and connectedCallback call the same render() method, which copes with any initial state.
  • Properties instead of attributes: getters and setters manage the value regardless of when the element is created. If a property is set before the upgrade, though, it shadows the setter of the class. The connectedCallback then has to set the value again:
connectedCallback() {
	if (Object.hasOwn(this, 'value')) {
		const v = this.value;
		delete this.value;
		this.value = v;
	}
}

Run example 03 โ†’

Rule 2: Don't dispatch events or listen on document in the constructor

Instead: register in the connectedCallback and unregister in the disconnectedCallback.

The constructor runs before the element has attributes, and possibly before it is in the document. A dispatchEvent() there therefore reports a state the element does not have yet. Events come from the connectedCallback at the earliest.

Listeners on document or window have a second problem: if you remove the element, the listener stays and keeps running. In the example two counters count key presses on the page. One registers its listener in the constructor, the other in the connectedCallback and unregisters it again in the disconnectedCallback:

class LeakyCounter extends HTMLElement {
	#count = 0;

	constructor() {
		super();
		// Rule 2: registered in the constructor, never removed
		document.addEventListener('keydown', () => console.log('leaky counter:', ++this.#count));
	}
}

class CleanCounter extends HTMLElement {
	#count = 0;
	#onKey = () => console.log('clean counter:', ++this.#count);

	connectedCallback() {
		document.addEventListener('keydown', this.#onKey);
	}

	disconnectedCallback() {
		document.removeEventListener('keydown', this.#onKey);
	}
}

customElements.define('leaky-counter', LeakyCounter);
customElements.define('clean-counter', CleanCounter);

Run example 04 โ†’

If you press a key, both count. After the removal only the leaky counter keeps counting (leaky counter: 2).

One-time initialization in the connectedCallback needs a guard, because it can run more than once, for example when the element is moved.

Rule 3: Don't wait for callbacks with setTimeout

Instead: keep working right after the DOM call.

If you do not know when a component is ready, it is tempting to add a timer. Usually it is unnecessary, because reactions run synchronously with the DOM call that triggers them. The specification handles this through the WebIDL annotation [CEReactions] on DOM methods such as append(): whatever happens meanwhile is collected and run in a fixed order when the call returns.

Cassondra Roberts puts it like this:

Net effect: from JS you get the guarantee that after any single DOM API call, all resulting reactions have fully run, in a stable order, before the call returns.

So after append() the connectedCallback has already run, provided the parent is in the document, and no callback sees the DOM half changed. In the example the connectedCallback sets a ready attribute, and the script reads it right after append():

const light = document.createElement('status-light');
document.body.append(light);
console.log('after append, ready:', light.hasAttribute('ready'));

Run example 05 โ†’

The console shows:

connectedCallback
after append, ready: true

So the connectedCallback runs before the line after append() is executed, without any requestAnimationFrame or setTimeout.

The rule: never wait for the connectedCallback with setTimeout or requestAnimationFrame, keep working right after the DOM call, provided the parent is in the document. If you render in the connectedCallback, you can read the result immediately.

The specification itself names a limit in the same section, with an example: in callbacks you should avoid changing the node tree where possible. If the connectedCallback of a parent removes its child, the connectedCallback of the child still runs, and this.isConnected is already false there (Example 06).

There are two cases in which you really do have to wait:

  • The component is not defined yet because the script loads later. await customElements.whenDefined('hello-name') waits until define() has been called. Existing elements have had their upgrade by then, in the test the constructor ran before the then.
  • The children are not there yet: the slotchange event of the slot reports when nodes are assigned, also on the first assignment.

Rule 4: Don't run code in the constructor that can throw

Instead: do work that might fail in the connectedCallback.

A component stays empty or unstyled, and the page shows no error apart from an entry in the console. If the constructor throws an exception, the element stays in the failed state for good (that is what the specification calls it, in Gecko it is eFailed), and there is no second attempt. A typical trigger is localStorage, which can be blocked, for example in private mode or with blocked cookies. In the example <saved-note> reads from localStorage in the constructor, and the page simulates blocked storage:

<saved-note></saved-note>

<script type="module">
	// Simulates blocked storage (private mode, blocked cookies).
	Storage.prototype.getItem = () => {
		throw new DOMException('Storage is blocked', 'SecurityError');
	};

	class SavedNote extends HTMLElement {
		constructor() {
			super();
			// Rule 4: reads storage in the constructor, which can throw
			const note = localStorage.getItem('note') ?? 'Nothing saved yet';
			this.attachShadow({ mode: 'open' }).innerHTML = `<p>${note}</p>`;
		}
	}

	customElements.define('saved-note', SavedNote);
	console.log('defined:', document.querySelector('saved-note').matches(':defined'));
</script>

Run example 07 โ†’

Uncaught SecurityError: Storage is blocked
defined: false

The console reports the error, but define() does not throw an error, and the script keeps running. The element stays without an upgrade, and :defined does not select it.

So the constructor only builds what cannot fail, here the shadow root with a default text. Everything that can fail (querying the DOM, parsing data, calling third-party code, reading storage) belongs in the connectedCallback. An error there is reported, but the element stays custom:

class SavedNote extends HTMLElement {
	constructor() {
		super();
		this.attachShadow({ mode: 'open' }).innerHTML = '<p>Nothing saved yet</p>';
	}

	connectedCallback() {
		try {
			const note = localStorage.getItem('note');
			if (note) this.shadowRoot.querySelector('p').textContent = note;
		} catch (error) {
			console.log('storage blocked:', error.name);
		}
	}
}

Run example 08 โ†’

storage blocked: SecurityError
defined: true

Rule 5: Don't style the <slot>

Instead: style a wrapper around it.

A padding or background on the <slot> does nothing, and colors come from places where you did not set them. The reason: before anything is styled, the browser builds a different tree from the one I wrote, the flat tree, in which the children from the light DOM hang below the slots of the shadow tree. The flat tree (called "flattened element tree" there) is defined by the CSS Shadow Module, and the assignment of children to slots by the DOM Standard. So it is not a quirk of one browser. A small alert component that gets its content through a slot shows it. It may create its shadow root in the constructor, which is part of its own state.

The obvious move is to style the slot in the shadow tree:

<my-alert> <strong>Warning:</strong> The build is still running. </my-alert>

<script type="module">
	class MyAlert extends HTMLElement {
		constructor() {
			super();
			this.attachShadow({ mode: 'open' }).innerHTML = `
				<style>
					slot { padding: 1rem; background: #fee; }
				</style>
				<slot></slot>
			`;
		}
	}

	customElements.define('my-alert', MyAlert);
</script>

Run example 09 โ†’

That does nothing. According to the browser's default style a slot has display: contents and generates no box of its own, so there is nothing that could receive the padding or background. A wrapper element works. Replace the template in the constructor with this:

this.attachShadow({ mode: 'open' }).innerHTML = `
	<style>
		.box { padding: 1rem; background: #fee; color: darkred; }
	</style>
	<div class="box"><slot></slot></div>
`;

Run example 10 โ†’

Now the component has a pink background, and color: darkred takes effect too, although <strong> and the text come from the light DOM and the rule only exists in the shadow tree. Inheritance follows the flat tree: a node in a slot has two parents, the host in the DOM and the slot in the flat tree. That slot hangs below .box, so the content inherits from there. (If the page itself sets a color for strong, that rule wins.)

Light DOMShadow TreeFlat Treemy-alertstrongTextShadow Rootdiv.boxslotmy-alertdiv.boxslotstrongText

The dashed slot is still there in the flat tree, only its box is not. After that, style engine, layout and renderer work on the flat tree like on any other element. Only selectors such as :host, ::slotted, ::part and :defined still know about the boundary between the trees.

The rule: never set padding, border or background on the <slot>, set them on a wrapper element around the slot instead. What slot content can inherit, such as color and font, you also set on the wrapper.

For everything inheritance does not cover, there are three tools. In the example the component should highlight the bold word itself, and the page should be able to change the background and border of the box:

this.attachShadow({ mode: 'open' }).innerHTML = `
	<style>
		.box { padding: 1rem; background: var(--alert-bg, #fee); color: darkred; }
		::slotted(strong) { background: gold; }
	</style>
	<div class="box" part="box"><slot></slot></div>
`;

Run example 11 โ†’

my-alert {
	--alert-bg: #eef;
}

my-alert::part(box) {
	border: 2px solid navy;
}

In the example, the page CSS sits in a <style> in the same document.

  • ::slotted(strong) styles the child from the light DOM from the inside. It only matches nodes that land directly in the slot: a <b> inside the <strong> does not get the background, but it does inherit inheritable properties such as color. If the page sets the same property on strong, its rule beats ::slotted(), even a universal reset such as * { border: 0 solid } is enough.
  • The page sets --alert-bg, and the custom property passes through the shadow boundary. var(--alert-bg, #fee) falls back to #fee when the page sets nothing.
  • part="box" exposes the box to the outside. The page can then style it specifically with ::part(box).

As a rule of thumb, custom properties suit the values every page should be able to adjust, such as colors and spacing. ::part() is for everything you do not want to offer a property for, and ::slotted() for children the component styles itself. ::part() has worked in all major browsers since 2020 (MDN).

Rule 6: Don't create content with JavaScript alone

Instead: deliver meaningful markup as children in the light DOM that the component enhances.

If the constructor fails (rule 4), the script loads late or does not load at all, what remains of the element is an unknown inline element with its content. If that content makes sense, the page stays usable, and the component only enhances it. :defined only selects a custom element after a successful upgrade and so separates the state before from the state after. Without a definition the element is display: inline, the finished component is probably a block, and that switch makes the layout jump. You style the state before with :not(:defined):

my-alert:not(:defined) {
	display: block;
}

In Example 12 the component is deliberately defined only after two seconds: until then a dashed border shows the placeholder.

If you want to hide the intermediate state completely, only do so when JavaScript is on:

@media (scripting: enabled) {
	my-alert:not(:defined) {
		visibility: hidden;
	}
}

All major browsers have supported scripting since the end of 2023 (MDN), and where the query is missing the content stays visible. Never display: none, because then the space is missing. If the script never loads even though JavaScript is on, the content stays invisible though.

Some components make no sense without JavaScript, such as a map or an editor. A placeholder, a <noscript> hint in the light DOM or Declarative Shadow DOM help there: the server delivers the shadow root in the HTML, <template shadowrootmode="open"> creates it while parsing, without any JavaScript, and the component shows its content even before the upgrade. According to caniuse that works in Chrome and Edge from version 111, in Safari from 16.4 and in Firefox from 123. In older browsers the <template> goes unused, you only see the content in the light DOM until JavaScript builds the component:

<my-alert>
	<template shadowrootmode="open">
		<span>โš </span>
		<slot></slot>
	</template>
	The build is still running.
</my-alert>

Run example 13 โ†’

This works when the component is defined after the HTML: during the upgrade the shadow root already exists, and this.shadowRoot ?? this.attachShadow({ mode: 'open' }) in the constructor keeps it. If the component is defined before the HTML, the constructor runs before the parser has seen the <template>. Chromium then reports "A second declarative shadow root cannot be created on a host", the <template> stays in the light DOM, and the shadow root built with JavaScript wins (Example 14). So Declarative Shadow DOM fits where the script only runs after the HTML, for example with type="module" or defer.