What actually happens when Gecko renders a web component — from bytes to pixels.
Specifications describe expected behaviour. This module, however, traces 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. Learn their shapes and the rest follows.
tldr;
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. Read the graph once, then the shapes — the member fields (Mozilla prefixes them m) are 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 (load-bearing fields)struct CustomElementDefinition { RefPtr<nsAtom> mType; // registered name: "my-card" RefPtr<nsAtom> mLocalName; // tag it customises; == mType for autonomous RefPtr<CustomElementConstructor> mConstructor; // the JS class nsTArray<RefPtr<nsAtom>> mObservedAttributes; UniquePtr<LifecycleCallbacks> mCallbacks; // the 8–9 callback slots nsTArray<RefPtr<Element>> mConstructionStack; // reentrancy guard during upgrade bool mFormAssociated; bool mDisableShadow; // from define()'s disabledFeatures bool mDisableInternals;};Atoms, not strings. nsAtom is an interned, ref-counted, immutable string. Element names and attribute names are atomised 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.hclass CustomElementData { enum class State { eUndefined, eUncustomized, ePrecustomized, eCustom, eFailed }; State mState; RefPtr<nsAtom> mType; nsTArray<UniquePtr<CustomElementReaction>> mReactionQueue; 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. Keep that split in mind — it's the thing that 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 via DefinitionForConstructor(). mCandidatesMap holds upgrade candidates — elements created before their definition existed, kept as weak refs and flagged NS_EVENT_STATE_UNRESOLVED (that's 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 the engine level the lookup moved up to the agent: a similar-origin window agent holds a constructor → registry map, and each registry now carries an is scoped flag plus a scoped-document set. Scoped registries reject extends (no customized built-ins) with NotSupportedError, and a node's registry is fixed once initialized. Supported across current engines (Chromium 146, WebKit, Gecko); confirm the exact enablement in your build (grep -r IsScoped dom/base/CustomElementRegistry.h and the customElementRegistry WebIDL on ShadowRootInit/ElementCreationOptions).Reactions are a tiny polymorphic hierarchy — one virtual call each:
class CustomElementReaction { virtual void Invoke(Element*, ErrorResult&) = 0; virtual bool IsUpgradeReaction() { return false; }protected: CustomElementDefinition* mDefinition;};class CustomElementUpgradeReaction // Invoke() runs the whole upgrade algorithmclass CustomElementCallbackReaction { UniquePtr<CustomElementCallback> mCallback; };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 a WebIDL dictionary — this is the canonical list of every lifecycle hook Gecko knows about:
// conceptually, dom/webidl — the LifecycleCallbacks dictionarydictionary LifecycleCallbacks { Function connectedCallback; Function disconnectedCallback; Function connectedMoveCallback; // newest — pairs with Element.moveBefore() Function adoptedCallback; Function attributeChangedCallback; Function formAssociatedCallback; Function formResetCallback; Function formDisabledCallback; Function formStateRestoreCallback;};Stage 02aThe custom element state machine
An element is always in exactly one of five states.
tldr;
The spec's five "custom element states" are Gecko's CustomElementData::State enum. 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 and mutates it at exactly the points below.
eFailed provisionally at the start of upgrade and only promotes to eCustom on success; eUncustomized 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" is also the transient in-flight state — if a re-entrant lookup happens mid-construction, the element reads as not-yet-custom, which is deliberate.
Stage 02blifecycle callbacks, order & relationships
Order is fixed and depends on how the element entered the tree.
tldr;
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 sets each via a[CEReactions]attribute setter.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 (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 step 6). - attributeChangedCallback(name, null, value, ns)Enqueued during upgrade (step 4) for each already-present observed attribute — but appended to the reaction queue, 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 is always first.
- connectedMoveCallback()When you reposition a connected custom element with
Element.moveBefore(), Gecko performs an atomic move and queues a singleeConnectedMovereaction — instead of disconnected+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 (14 Oct 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() | — (DoUpgrade / 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.
tldr;
DOM methods marked [CEReactions] in WebIDL push an element queue on entry and drain it FIFO on exit. Reactions enqueued with no active queue go to a backup queue flushed by a microtask. Upgrade reactions are never allowed in the backup queue.
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(child) where el's subtree contains two custom elements. append() is annotated [CEReactions].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 pushes a fresh element queue in its constructor and pops-and-invokes in its destructor, so the queue drains exactly when the outermost annotated call returns.
// what dom/bindings/Codegen.py emits around a [CEReactions] method body{ Maybe<AutoCEReaction> ceReaction; if (CustomElementReactionsStack* s = GetCustomElementReactionsStack(obj)) ceReaction.emplace(s, cx); // → s->CreateAndPushElementQueue() self->Append(...); // the real DOM op; may Enqueue reactions} // ~AutoCEReaction → s->PopAndInvokeElementQueue(): drain this queue FIFO// dom/base/CustomElementRegistry.cpp — where reactions landvoid CustomElementReactionsStack::Enqueue(Element* el, CustomElementReaction* r) { if (mRecursionDepth) { // inside a [CEReactions] boundary // ... push queue for this depth if needed ... mReactionsStack.LastElement()->AppendElement(el); // element → current queue el->GetCustomElementData()->mReactionQueue.AppendElement(r); // reaction → element return; } // no active boundary → backup queue + schedule a microtask MOZ_ASSERT(!r->IsUpgradeReaction(), "upgrade reactions must not hit backup queue"); mBackupQueue.AppendElement(el); // ... EnsureBackupQueueMicrotask() ...}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 — e.g. 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 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 — and 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.
tldr;
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 shape carries the mode, the slot map, and its own scoped style state.
// dom/base/ShadowRoot.hclass ShadowRoot : public DocumentFragment { Element* mHost; // GetHost() — the shadow host ShadowRootMode mMode; // Open | Closed SlotAssignmentMode mSlotAssignment; // Named | Manual bool mDelegatesFocus; nsTHashMap<nsString, nsTArray<HTMLSlotElement*>> mSlotMap; // name → slots UniquePtr<ServoStyleRuleMap> mStyleRuleMap; // this tree's scoped rules};Element::AttachShadow() stashes it in the host's extended DOM slots (ExtendedDOMSlots()->mShadowRoot). A definition with mDisableShadow makes CanAttachShadowDOM() return false — that's 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 keeping the host as their DOM parent. The flattened tree is what gets styled and boxed.slot attribute to a <slot name>; unmatched children go to the default slot. 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).ExplicitChildIterator— walks the raw DOM children (the authored view).
Slot assignment changes (ShadowRoot::AssignSlot, or HTMLSlotElement.assign() in manual mode) run the "assign slottables" algorithm and, when membership changes, "signal a slot change" — which enqueues the slot on a set flushed as a microtask to fire slotchange.
<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 shadow-side styles but keeps light-side authored styles at higher specificity.Stage 04–06 Flattened tree → Stylo → frames → WebRender
From here it's the normal engine — with three shadow-aware wrinkles.
tldr;
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 scoped rule map 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 the crossings:
| 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) | 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 — closing the loop back to module 2. |
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 (honouring :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 the module is built from, pinned to mozilla-central @ 73c9f54. Paths under dom/base/ and layout/ are identical. 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 modules 1–4; 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} | Module 5 object; slot map, mode, scoped style state, AssignSlot. |
| Slots | dom/html/HTMLSlotElement.{h,cpp} | Assignment, assign(), slotchange signalling. |
| Flattened tree traversal | dom/base/ChildIterator.{h,cpp} | FlattenedChildIterator, AllChildrenIterator, ExplicitChildIterator. |
| [CEReactions] codegen | dom/bindings/Codegen.py | Emits the AutoCEReaction push/pop wrapper around annotated ops. |
| Binding glue | dom/bindings/BindingUtils.{h,cpp} | GetCustomElementReactionsStack, AutoCEReaction. |
| 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 (modules 5–6). |
| Style system | servo/components/style/ + layout/style/ | Stylo; scoped rule maps, :host/::slotted/::part matching. |
References
- mozilla-firefox @ 73c9f54
- The pinned commit this guide describes — the revision your fork is aligned with.
- searchfox · CustomElementRegistry.h
- The header with every object shape shown in modules 1–4.
- searchfox · CustomElementRegistry.cpp
- Upgrade(), Enqueue(), InvokeReactions() — the runtime.
- MDN · Web Components
- The spec-level behaviour this module 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 (module 3).
Snapshot
This guide was authored Saturday, September 12, 2026 and is pinned to mozilla-central @ 73c9f54.