How Firefox renders web components
Specifications describe expected behavior. Here, I traced the machinery behind Firefox's implementation: which objects hold the state, what shape and type each is, the firing order of lifecycle callbacks, how the reaction queue keeps that order deterministic, and finally, how the shadow tree flattens into the thing Stylo styles and WebRender paints.
Stage 01Where custom elements live
Five objects hold everything, and their shapes explain the rest.
tl;dr
A defined custom element is a graph of five C++ objects: one global registry per window (plus any scoped registries), one definition per registered name, one data blob per element instance, a queue of reactions, and one reactions stack that drains those queues in order.
Everything the spec calls abstract state is concrete memory in Gecko. Before any callback fires, these objects exist and point at each other. The graph comes first, then the shapes. The member fields (Mozilla prefixes them m) tell the whole story.
define() hands the registry a definition it owns; element creation exposes per-instance CustomElementData, which points back at its definition once upgraded; reactions enqueue FIFO onto the element's own data and the per-agent stack drains and invokes them. Fields shown are load-bearing ones; each object carries more. Replay assembles the graph in that order.The definition: what customElements.define() builds
When you call define("my-card", MyCard, opts), Gecko validates the name, reads observedAttributes and the callback methods off the prototype, and constructs one CustomElementDefinition. This is immutable for the life of the registry entry.
// dom/base/CustomElementRegistry.h — the definition (abridged)struct CustomElementDefinition { RefPtr<nsAtom> mType; // registered name: "my-card" RefPtr<nsAtom> mLocalName; // tag it customizes; == mType for autonomous int32_t mNamespaceID; RefPtr<CustomElementConstructor> mConstructor; // the JS class nsTArray<RefPtr<nsAtom>> mObservedAttributes; UniquePtr<LifecycleCallbacks> mCallbacks; // connected, disconnected, … UniquePtr<FormAssociatedLifecycleCallbacks> mFormAssociatedCallbacks; // the four form hooks bool mFormAssociated = false; bool mDisableInternals = false; bool mDisableShadow = false; // from define()'s disabledFeatures nsTArray<RefPtr<Element>> mConstructionStack; // reentrancy guard during upgrade};Atoms, not strings. nsAtom is an interned, ref-counted, immutable string. Element names and attribute names are atomized once, so every later "is this in observedAttributes?" or "does this name have a definition?" is a pointer compare, not a string compare (see IsInObservedAttributeList(attrName)). That's why the whole system stays cheap on hot paths.
The instance data: CustomElementData
Every element that is or could become custom carries a CustomElementData, reachable via Element::GetCustomElementData(). This is where the mutable per-instance state lives: the state enum, the pending reaction queue, and (once upgraded) the pointer back to its definition.
// dom/base/CustomElementRegistry.h (abridged)struct CustomElementData { // Only created for a custom element or an upgrade candidate, so an // element with no CustomElementData at all is "uncustomized". enum class State { eUndefined, eFailed, eCustom, ePrecustomized }; State mState; AutoTArray<UniquePtr<CustomElementReaction>, 3> mReactionQueue; private: RefPtr<nsAtom> mType; RefPtr<CustomElementDefinition> mCustomElementDefinition; RefPtr<ElementInternals> mElementInternals;};Why this matters
The reaction queue lives on the element, not on the stack. The reactions stack (stage 04) only holds which elements have pending work, in order. The actual reactions sit in each element's own mReactionQueue and are drained FIFO. That split is what makes reentrancy correct.
Deep dive the registry, the reaction hierarchy, and the callback dictionary
The global registry is one object per inner window (window.customElements). Its two most-hit tables are inverses of each other: mCustomDefinitions (name atom → definition) for lookup by name, and mConstructors (constructor JSObject → name atom) so an HTMLElement subclass constructor can find its own definition through LookupCustomElementDefinition(). mCandidatesMap holds upgrade candidates: elements created before their definition existed, kept as weak refs. Their ElementState::DEFINED bit stays clear until Element::SetDefined(true) runs at the end of a successful upgrade, which is the :not(:defined) you can style.
new since this tree · scoped registries
"One per window" is now only true of the global registry. ACustomElementRegistry can also be constructed directly (new CustomElementRegistry()) and scoped to a subtree via attachShadow({ customElementRegistry }), createElement(name, { customElementRegistry }), or registry.initialize(elementOrShadowRoot). At this commit each registry carries an mIsScoped flag, and the node → registry association lives in one process-wide gScopedRegistryMap read through GetScopedRegistry(nsINode&). Scoped registries reject extends (no customized built-ins) with NotSupportedError, and a node's registry is fixed once initialized. Chromium 146 and Safari 26 ship it. In Gecko the constructor and initialize() are still gated behind the dom.scoped-custom-element-registries.enabled pref in the WebIDL, so Firefox has it in Nightly only.Reactions are a tiny polymorphic hierarchy, one virtual call each:
class CustomElementReaction { public: virtual void Invoke(Element*, ErrorResult&) = 0; bool IsUpgradeReaction() { return mIsUpgradeReaction; } protected: bool mIsUpgradeReaction = false;};// Invoke() runs the whole upgrade algorithmclass CustomElementUpgradeReaction final : public CustomElementReaction { RefPtr<CustomElementDefinition> mDefinition;};class CustomElementCallbackReaction final : public CustomElementReaction { UniquePtr<CustomElementCallback> mCustomElementCallback;};A CustomElementCallback bundles the target element, an ElementCallbackType (eConnected, eDisconnected, eAdopted, eAttributeChanged, eConnectedMove, and the form-associated ones), the JS function to call, and its args. For attributeChanged the args are a fixed shape:
struct LifecycleCallbackArgs { // attributeChangedCallback(name, oldVal, newVal, ns) nsString mName, mOldValue, mNewValue, mNamespaceURI;};And the callback slots on the definition come from two WebIDL dictionaries, one for the lifecycle hooks and one for the form-associated hooks:
// dom/webidl/CustomElementRegistry.webidl (abridged)dictionary LifecycleCallbacks { LifecycleConnectedCallback connectedCallback; LifecycleDisconnectedCallback disconnectedCallback; LifecycleConnectedMoveCallback connectedMoveCallback; // pairs with Element.moveBefore() LifecycleAdoptedCallback adoptedCallback; LifecycleAttributeChangedCallback attributeChangedCallback; [ChromeOnly] LifecycleGetCustomInterfaceCallback getCustomInterfaceCallback;};dictionary FormAssociatedLifecycleCallbacks { LifecycleFormAssociatedCallback formAssociatedCallback; LifecycleFormResetCallback formResetCallback; LifecycleFormDisabledCallback formDisabledCallback; LifecycleFormStateRestoreCallback formStateRestoreCallback;};Stage 02aThe custom element state machine
An element is always in exactly one of five states.
tl;dr
The spec defines five "custom element states." Gecko's CustomElementData::State enum holds four of them; the fifth, uncustomized, is an element with no CustomElementData at all. Transitions are one-way toward resolution: an element resolves to eCustom or dead-ends at eFailed, and never goes back to eUndefined.
State controls two things you can observe: whether the element's callbacks fire, and whether it matches :defined. Gecko stamps the state on the instance's CustomElementData at creation (if it creates one) and mutates it at exactly the points below.
eFailed provisionally at the start of upgrade and only promotes to eCustom on success; an uncustomized element never gets a CustomElementData, so it never enters the custom path at all.eUndefined
Autonomous custom name (has a hyphen) but no definition registered yet. Registered as an upgrade candidate; matches :not(:defined).
eCustom
Constructor ran successfully. mCustomElementDefinition is set, callbacks fire, matches :defined.
eFailed
The upgrade constructor threw, or the constructed object wasn't the element. The reaction queue is emptied; the element never upgrades again.
ePrecustomized
A customized built-in (<button is="x-y">) before upgrade. Upgrades along the same path as eUndefined.
Gotcha
Gecko sets state to eFailed provisionally at the start of upgrade (spec step 3), before running your constructor, and only promotes to eCustom on success. So "failed" doubles as the in-flight state. If a re-entrant lookup happens mid-construction, the element reads as not yet custom, and that's deliberate.
Stage 02blifecycle callbacks, order & relationships
Order is fixed and depends on how the element entered the tree.
tl;dr
Nine callbacks, each bound to one trigger. During an upgrade the order is always constructor → attributeChanged (×N) → connected. The three scenarios below differ in mechanism but converge on that same author-visible order.
Pick the scenario. Each row is one callback in the exact sequence Gecko fires it, with the internal trigger named. This is the answer to "what order, and why."
- constructor()Parser finds a registered name → takes the create-an-element path and constructs synchronously.
super()runs; no attrs, not in tree yet. - attributeChangedCallback(name, null, value, ns)Fires once per parsed attribute that's in
observedAttributes, in source order, as the parser appends each attribute to the new element.oldValueisnull. - connectedCallback()Parser inserts the element into the document →
IsInComposedDoc()becomes true → connected reaction fires. The element is now live.
Mechanism: creation, not upgrade. The definition already existed when the parser reached the tag, so no upgrade reaction is queued.
- (created in state eUndefined)
document.createElement("my-card")with no definition yet: a bareHTMLElement, registered inmCandidatesMap, matching:not(:defined). No callbacks. - constructor()Later
customElements.define(...)upgrades every candidate: anUpgradeReactionrunsDoUpgrade(), which constructs the JS class over the existing element (spec steps 7–9). - attributeChangedCallback(name, null, value, ns)Enqueued during upgrade (step 4) for each observed attribute already present. It is appended to the reaction queue, though, so it runs after the constructor returns.
- connectedCallback()Enqueued during upgrade (step 5) only if the element was already
IsInComposedDoc(). Runs last, after attributeChanged.
Mechanism: upgrade. The attributeChanged and connected reactions are appended to the element's own mReactionQueue before the constructor runs, but the constructor executes synchronously inside DoUpgrade(), so it always runs first.
- connectedMoveCallback()When you reposition a connected custom element with
Element.moveBefore(), Gecko performs an atomic move and queues a singleeConnectedMovereaction instead of disconnected plus connected. State, animations, focus, and popover/dialog openness are preserved.
connectedMoveCallback, a moveBefore() falls back to firing disconnectedCallback() then connectedCallback() (both with isConnected === true), so legacy components still work. Defining an empty connectedMoveCallback(){} is the way to opt out of that init/teardown churn.Mechanism: the DOM move primitive, shipped in Firefox 144 (October 14, 2025) and enabled in release. Ordinary insertBefore()/appendChild() still remove-then-insert, firing disconnected then connected.
The full trigger map: every callback, one trigger
| Callback | Gecko callback type | Fires when |
|---|---|---|
constructor() | none (runs in DoUpgrade or on create) | Element created via defined name, or upgraded. Runs synchronously. |
connectedCallback() | eConnected | Element becomes shadow-including connected (inserted into a document). |
disconnectedCallback() | eDisconnected | Element removed from the document tree. |
connectedMoveCallback() | eConnectedMove | Repositioned via moveBefore() while staying connected (if defined). |
adoptedCallback(oldDoc, newDoc) | eAdopted | Moved into a different document (document.adoptNode). |
attributeChangedCallback(n,o,v,ns) | eAttributeChanged | An attribute in observedAttributes is added/changed/removed. |
formAssociatedCallback(form) | eFormAssociated | Form-associated element's owning form changes. |
formResetCallback() | eFormReset | Owning form is reset. |
formDisabledCallback(disabled) | eFormDisabled | Element's disabled state changes (self or ancestor fieldset). |
formStateRestoreCallback(state,mode) | eFormStateRestore | Browser restores state (autofill / history navigation). |
Stage 02c The reactions stack: why order is deterministic
The mechanism that makes all of the above happen in the right order.
tl;dr
DOM methods marked [CEReactions] in WebIDL open a boundary on entry and drain its element queue FIFO on exit; Gecko only pushes that queue the first time something is enqueued. Reactions enqueued with no boundary open go to a backup queue flushed by a microtask. Gecko's own comments note that upgrade reactions are not scheduled there.
This is the most Gecko-specific piece and the reason lifecycle callbacks never fire in the middle of a DOM mutation you're only halfway through. Step through it.
el.append(childSubtree). append() is annotated [CEReactions], and the subtree holds two custom elements created before their definitions were registered: <a-one> and <a-two>.Reactions stack: element queues
Backup queue
How the boundary is generated
You don't write the push/pop. Gecko's WebIDL bindings generator does. Any operation, setter, or deleter carrying [CEReactions] gets wrapped. The RAII helper AutoCEReaction enters a new recursion level in its constructor (EnterCEReactions()), and its destructor pops and invokes that level's element queue if one was pushed (LeaveCEReactions()). The queue itself is pushed lazily by Enqueue(), so a call that triggers no reactions never creates one.
// what dom/bindings/Codegen.py emits around a [CEReactions] method body{ Maybe<AutoCEReaction> ceReaction; DocGroup* docGroup = self->GetDocGroup(); if (docGroup) { ceReaction.emplace(docGroup->CustomElementReactionsStack(), cx); } // → EnterCEReactions(): depth++, no queue yet self->Append(...); // the real DOM op; may Enqueue reactions} // ~AutoCEReaction → LeaveCEReactions(): pop and drain this level's queue, if any// dom/base/CustomElementRegistry.cpp — where reactions land (abridged)void CustomElementReactionsStack::Enqueue(Element* aElement, CustomElementReaction* aReaction) { CustomElementData* elementData = aElement->GetCustomElementData(); if (mRecursionDepth) { // inside a [CEReactions] boundary if (!mIsElementQueuePushedForCurrentRecursionDepth) { CreateAndPushElementQueue(); // the lazy push } mReactionsStack.LastElement()->AppendElement(aElement); // element → queue elementData->mReactionQueue.AppendElement(aReaction); // reaction → element return; } // no active boundary → backup queue, flushed at the next microtask checkpoint mBackupQueue.AppendElement(aElement); elementData->mReactionQueue.AppendElement(aReaction); if (mIsBackupQueueProcessing) { return; } CycleCollectedJSContext* context = CycleCollectedJSContext::Get(); RefPtr<BackupQueueMicroTask> bqmt = new BackupQueueMicroTask(this); context->DispatchToMicroTask(bqmt.forget());}Element queue (the normal path)
Created per [CEReactions] boundary. When the outermost annotated call returns, InvokeReactions() walks the queue in insertion order and, for each element, drains its mReactionQueue. Deterministic and synchronous.
Backup queue (the fallback)
For reactions triggered with no boundary on the stack, such as a mutation from C++ internals. Guarded by mIsBackupQueueProcessing and flushed once via a microtask, so timing stays predictable.
Deep dive recursion, reentrancy, and why the split queue is safe
Nested [CEReactions] calls (a callback that itself mutates the DOM) are handled by mRecursionDepth plus mIsElementQueuePushedForCurrentRecursionDepth. A new element queue is only pushed once per recursion level; reactions triggered deeper append to the queue already owned by that level. Because reactions live on each element's mReactionQueue (not on the stack), InvokeReactions() can be re-entered safely: it transfers ownership of each entry as it invokes it (reaction = std::move(reactions[j])), leaving a null slot behind, so a callback that enqueues more work on the same element doesn't corrupt the iteration. The stack entry is asserted to be the last one when popped, a hard invariant that catches an unbalanced push/pop in debug builds.
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. Reactions from unrelated internal paths get batched onto the backup queue instead of interleaving.
Stage 03 Shadow DOM & the flattened tree
Before styling, Gecko builds a different tree than the one you wrote.
tl;dr
attachShadow() creates a ShadowRoot (a DocumentFragment subclass). Slot assignment then produces the flattened tree: light-DOM children distributed into <slot>s inside the shadow tree. Stylo and the frame constructor walk that tree, not your DOM.
The shadow root is a real node hung off the host. Its members carry the slot map, the parts list, and its own scoped style data. The mode, focus delegation, and slot-assignment mode are node flags read through accessors, and the host comes from DocumentFragment.
// dom/base/ShadowRoot.h (abridged)class ShadowRoot final : public DocumentFragment, public DocumentOrShadowRoot { // Mode(), DelegatesFocus(), SlotAssignment() read node flags. // GetHost() comes from DocumentFragment. BindgenUniquePtr<StyleAuthorStyles> mServoStyles; // this tree's scoped styles UniquePtr<ServoStyleRuleMap> mStyleRuleMap; // CSSOM rule lookup nsClassHashtable<nsStringHashKey, SlotArray> mSlotMap; // name → slots, tree order nsTArray<const Element*> mParts; // elements with part= RefPtr<nsAtom> mReferenceTarget;};Element::AttachShadow() stashes it in the host's extended DOM slots (ExtendedDOMSlots()->mShadowRoot). A definition with mDisableShadow makes CanAttachShadowDOM() return false, which is how disabledFeatures: ["shadow"] is enforced at the C++ level.
slot attribute to a <slot name>; unmatched children go to the default slot. Dashed slots generate no box of their own (display: contents), so slotted nodes are laid out as children of the slot's parent, while the host stays their DOM parent. The flattened tree is what gets styled and boxed.The two parents every slotted node has
A slotted node lives at a fork: its DOM parent stays the host (that's what .parentNode reports), but its flattened-tree parent is the slot's container in the shadow tree. Gecko exposes this split through dedicated traversals:
nsIContent::GetFlattenedTreeParent(): the parent used for style inheritance and box generation.FlattenedChildIterator/AllChildrenIterator: walk children through slots (the composed view).StyleChildrenIterator: the variant Stylo walks, built onAllChildrenIterator.nsINode::GetFirstChild()/GetNextSibling(): the raw DOM children (the authored view).
Slot assignment changes (ShadowRoot::MaybeReassignContent() in named mode, HTMLSlotElement::Assign() behind slot.assign() in manual mode) run the "assign slottables" algorithm and, when membership changes, "signal a slot change", which Gecko does in HTMLSlotElement::EnqueueSlotChangeEvent(), queuing the slot to fire slotchange at the next microtask checkpoint.
<slot> generates no box of its own. Its UA style is effectively display: contents. Assigned nodes are laid out as if they were children of the slot's parent, which is why slotted content inherits from the shadow side. Separately, the cascade's context step lets the light tree's own normal rules beat ::slotted() regardless of specificity; for !important the order flips.Stage 04–06 Flattened tree → Stylo → frames → WebRender
From here it's the normal engine, with three shadow-aware wrinkles.
tl;dr
Once the flattened tree exists, custom elements and shadow boundaries mostly disappear into the standard pipeline: Stylo computes styles, nsCSSFrameConstructor builds the frame tree, reflow measures it, and WebRender paints. Scoping selectors are the only place the boundary re-enters.
Stylo (Servo style)
Restyles the flattened tree in parallel and produces a ComputedStyle per node. Each shadow root keeps its own author styles (mServoStyles) so its stylesheets only match inside that tree. Inheritance flows across the boundary along flattened-tree parents.
Frame construction
nsCSSFrameConstructor walks the flattened tree and creates nsIFrame subclasses (block, inline, flex…). The <slot>'s display:contents means assigned nodes' frames attach at the slot's position.
Where the boundary re-enters: scoping selectors
Style is the one stage where "which side of the shadow boundary am I on" still matters. Stylo implements these crossings (all but one):
| Selector | Matches, evaluated in Stylo over the flattened tree |
|---|---|
:host / :host(sel) | The shadow host, from a rule written inside the shadow tree. :host(sel) gates on the host also matching sel. |
:host-context(sel) | Not implemented in Gecko, and deprecated. Chromium alone matches the host when an ancestor outside the tree matches sel. |
::slotted(sel) | Light-DOM nodes assigned to a slot, matched from shadow-side rules. Only reaches the directly slotted node. |
::part(name) | Elements the shadow tree explicitly exposed via part=, matched from the outer tree. |
:defined | Elements in eCustom state (plus built-ins). Toggling it on upgrade dirties style, which closes the loop back to section 02. |
The whole loop, in one sentence
Parser builds the DOM → a [CEReactions] boundary drains the reactions stack so constructor → attributeChanged → connected run in order and the element reaches eCustom → attachShadow + slot assignment produce the flattened tree → Stylo styles it (honoring :host/::slotted/:defined) → the frame constructor boxes it → reflow measures it → WebRender paints it.
◆ SOURCE MAP Go deeper at the pinned commit
Every path maps 1:1 into mozilla-firefox @ 73c9f54.
These are the files I built this guide from, pinned to mozilla-firefox @ 73c9f54. The searchfox links open the live firefox-main index (tip), which drifts ahead of this pin over time. When they disagree, the pinned commit is what this guide describes.
| Concern | File @ 73c9f54 | What's in it |
|---|---|---|
| Registry, definition, data, reactions, stack | dom/base/CustomElementRegistry.{h,cpp} | Every object in sections 01–04; Define(), Upgrade(), Enqueue(), InvokeReactions(). |
| Element hooks | dom/base/Element.{h,cpp} | GetCustomElementData(), AttachShadow(), CanAttachShadowDOM(). |
| Create / lookup / enqueue helpers | dom/base/nsContentUtils.cpp | LookupCustomElementDefinition, EnqueueLifecycleCallback, NewXULOrHTMLElement. |
| Shadow root | dom/base/ShadowRoot.{h,cpp} | Section 05 object; slot map, parts, scoped styles, MaybeReassignContent. |
| Slots | dom/html/HTMLSlotElement.{h,cpp} | Assignment, Assign(), EnqueueSlotChangeEvent(). |
| Flattened tree traversal | dom/base/ChildIterator.{h,cpp} | FlattenedChildIterator, AllChildrenIterator, StyleChildrenIterator. |
| [CEReactions] codegen | dom/bindings/Codegen.py | Emits the AutoCEReaction push/pop wrapper around annotated ops. |
| [CEReactions] RAII helper | dom/base/CustomElementRegistry.h | AutoCEReaction, EnterCEReactions(), LeaveCEReactions(). |
| WebIDL surface | dom/webidl/{CustomElementRegistry,Element,ShadowRoot,HTMLSlotElement}.webidl | Where [CEReactions] and the dictionaries are declared. |
| Frame construction | layout/base/nsCSSFrameConstructor.cpp | Flattened tree → nsIFrame tree (sections 05–06). |
| Style system | servo/components/style/ + layout/style/ | Stylo; per-shadow-root author styles, :host/::slotted/::part matching. |
References
- mozilla-firefox @ 73c9f54
- The pinned commit this guide describes.
- searchfox · CustomElementRegistry.h
- The header with every object shape shown in sections 01–04.
- searchfox · CustomElementRegistry.cpp
- The runtime: Upgrade(), Enqueue(), InvokeReactions().
- MDN · Web Components
- The spec-level behavior this guide traces into Gecko.
- MDN · Using custom elements
- Callback list incl. connectedMoveCallback and state-preserving moves.
- HTML spec · Custom elements
- The upgrade algorithm and CEReactions processing model, step-numbered.
- MDN · Element.moveBefore()
- The atomic-move API behind connectedMoveCallback (section 03).
Snapshot
This guide was authored Wednesday, September 30, 2026 and is pinned to mozilla-firefox @ 73c9f54.