Skip to content

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.

1parse → DOMupgrade & callbacks2custom el reactionsattachShadow & slots3shadow + flatten4style (Stylo)5layout (frames)reflow & display list6paint (WebRender)
The six stages are colour-coded throughout this module. Custom elements live in stage 2; shadow DOM reshapes the tree in stage 3 before anything is styled or laid out. Click a node to jump.

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.

Three phases assemble the graphdefine() fills the registry → creation wires each element's data → reactions enqueue and drain, FIFO01define()CustomElementRegistry1 / window · window.customElementsmCustomDefinitionsname→defmConstructorsctor→namemCandidatesMapupgradesmWhenDefinedPromiseMapCustomElementDefinition1 / name · immutable once registeredmTypensAtom* "my-card"mLocalNamensAtom*mConstructorJS classmCallbacksLifecycle…mObservedAttributes[nsAtom]mConstructionStack[Element]02create + upgradeElementthe DOM node itselfGetCustomElementData()CustomElementData1 / element · the mutable statemStateenum StatemTypensAtom*mReactionQueue[Reaction]mCustomElementDefinitionmElementInternals(form)03enqueue + drainCustomElementReactionn queued per element▸ CustomElementUpgradeReaction▸ CustomElementCallbackReactionReactions stack1 / agent · per DocGroupmReactionsStackstack of element queuesmBackupQueuemRecursionDepthdrains → mReactionQueue
The lifecycle in three phases: 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.
CustomElementRegistrywindow.customElements · 1 per windowmCustomDefinitionsname→defmConstructorsctor→namemCandidatesMapupgrade waitsmWhenDefinedPromiseMapCustomElementDefinition1 per registered namemTypensAtom* "my-card"mLocalNamensAtom*mConstructorJS classmCallbacksLifecycleCallbacksmObservedAttributes[nsAtom]mConstructionStack[Element]ElementGetCustomElementData() ↓CustomElementData1 per element instancemStateenum StatemTypensAtom*mReactionQueue[Reaction] →mCustomElementDefinitionmElementInternals(form)CustomElementReaction▪ CustomElementUpgradeReaction▪ CustomElementCallbackReactionReactionsStackper agent / DocGroupmReactionsStackstack of element queuesmBackupQueuemRecursionDepthdrains → mReactionQueueownspoints to defholdsappends intoregistered in
The graph a defined, connected custom element forms in memory. Fields shown are load-bearing ones; each object carries more.

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.

C++
// 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.

C++
// 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. A CustomElementRegistry 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:

C++
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:

C++
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:

C++
// 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.

Resolution is monotonicstates only move left → right · eCustom and eFailed are terminal · eFailed doubles as the in-flight state during upgrade01awaiting upgrade?eUndefinedvalid custom name, no def yet:not(:defined)ePrecustomizedcustomized built-in, pre-upgrade:not(:defined)02resolved · terminaleCustomupgraded · callbacks live:definedeFailedctor threw · dead-end, never retried:not(:defined)never upgradeseUncustomizedplain built-in, non-custom name:definedno callbacks · no upgrade path Resolution is monotonicstates only move forward · terminal is forevereFailed doubles as the in-flight upgrade state01awaiting upgrade?eUndefinedvalid custom name, no def yet:not(:defined)ePrecustomizedcustomized built-in, pre-upgrade:not(:defined) · upgrades like eUndefined02resolved · terminaleCustomupgraded · callbacks live:definedeFailedctor threw · dead-end, never retried:not(:defined)never upgradeseUncustomizedplain built-in, non-custom name:definedno callbacks · no upgrade path
Resolution is monotonic — once terminal, always terminal. Each card names the selector the state matches. Gecko sets eFailed provisionally at the start of upgrade and only promotes to eCustom on success; eUncustomized never enters the custom path at all.
eUndefinedvalid name, no def yeteCustomupgraded · callbacks liveeFailedctor threw · dead-endeUncustomizedplain built-in, non-custom nameePrecustomizedcustomized built-in, pre-upgradeupgrade OK →ctor throws →define() / upgrade()/ createElement of defined namenever becomes customupgrades like eUndefined
Resolution flows left→right and is monotonic. The right-hand pair are the "not going to upgrade on the custom-element path" states for ordinary and customized-built-in elements.

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."

  1. constructor()Parser finds a registered name → takes the create-an-element path and constructs synchronously. super() runs; no attrs, not in tree yet.
  2. 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. oldValue is null.
  3. 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.

  1. — created (state eUndefined) —document.createElement("my-card") with no definition yet: a bare HTMLElement, registered in mCandidatesMap, matching :not(:defined). No callbacks.
  2. constructor()Later customElements.define(...) upgrades every candidate: an UpgradeReaction runs DoUpgrade(), which constructs the JS class over the existing element (spec step 6).
  3. 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.
  4. 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.

  1. connectedMoveCallback()When you reposition a connected custom element with Element.moveBefore(), Gecko performs an atomic move and queues a single eConnectedMove reaction — instead of disconnected+connected. State, animations, focus, and popover/dialog openness are preserved.
back-compat rule If your class does not define 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

Custom element lifecycle callbacks and their Gecko callback types
CallbackGecko callback typeFires when
constructor()— (DoUpgrade / create)Element created via defined name, or upgraded. Runs synchronously.
connectedCallback()eConnectedElement becomes shadow-including connected (inserted into a document).
disconnectedCallback()eDisconnectedElement removed from the document tree.
connectedMoveCallback()eConnectedMoveRepositioned via moveBefore() while staying connected (if defined).
adoptedCallback(oldDoc, newDoc)eAdoptedMoved into a different document (document.adoptNode).
attributeChangedCallback(n,o,v,ns)eAttributeChangedAn attribute in observedAttributes is added/changed/removed.
formAssociatedCallback(form)eFormAssociatedForm-associated element's owning form changes.
formResetCallback()eFormResetOwning form is reset.
formDisabledCallback(disabled)eFormDisabledElement's disabled state changes (self or ancestor fieldset).
formStateRestoreCallback(state,mode)eFormStateRestoreBrowser 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.

▸ Idle — press Step
A script calls el.append(child) where el's subtree contains two custom elements. append() is annotated [CEReactions].

Reactions stack — element queues

stack empty

Backup queue

empty
0 / 5

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.

C++
// 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
C++
// 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.

C++
// 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.

Two trees in, one tree outcolour marks origin: blue = light DOM, teal = shadow tree · dashed slots generate no box of their own01light DOM · what you wrote<my-card> (host)<h2 slot="title"><p> (no slot attr)02shadow tree · attachShadow()#shadow-root<header><slot name="title"><section><slot> (default)03flattened tree · what renders<my-card><header><slot name="title">↳ <h2> slotted<section><slot> (default)↳ <p> slotted
Named assignment matches each light child's 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.
LIGHT DOM (what you wrote)SHADOW TREE (attachShadow)FLATTENED TREE (rendered)<my-card> (host)<h2 slot="title"><p> (default slot)#shadow-root<header><slot name="title"><section><slot> (default)<my-card><header><slot title>↳ <h2> slotted<section><slot default>↳ <p> slottedslotted nodes keep their DOM parent (the host) but take the slot's position in the flattened tree
Named assignment matches each light child's 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.

rendering consequence Because the flattened tree is the input to both style and layout, a <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.

After the flatten, it's the normal enginethe shadow boundary is resolved before Stylo · scoping selectors (:host, ::slotted, ::part, :defined) are the only re-entryoutput of 03flattened treecomposed nodeslight + shadow merged04styleStyloComputedStyle / nodeparallel · Rust05layoutFrameConstructornsIFrame treeboxes per nodereflowgeometrymeasures frames06paintdisplay list→ WebRenderGPU compositea custom element reaching eCustom toggles :defined → NS_EVENT_STATE_DEFINED → a restyle re-enters at Stylo After the flatten, it's the normal engineboundary resolved before Stylo · scopingselectors are the only re-entryoutput of 03flattened treecomposed nodeslight + shadow merged04styleStyloComputedStyle / nodeparallel · Rust05layoutFrameConstructornsIFrame treeboxes per nodereflowgeometrymeasures frames06paintdisplay list→ WebRenderGPU compositereaching eCustom toggles :defined→ a restyle re-enters at Stylo
The shadow boundary is fully resolved before Stylo runs; from Stylo onward the tree is just boxes. Wrapper numbers match this guide's modules — the flattened tree is stage 03's output.
flattened treecomposed nodesStyloComputedStyleparallel restyle (Rust)FrameConstructornsIFrame treeboxes per nodereflowgeometrydisplay list→ WebRenderGPU compositea custom element's :defined state toggles NS_EVENT_STATE_DEFINED → can trigger a restyle back at Stylo
The shadow boundary is fully resolved before Stylo runs; from Stylo onward the tree is just boxes.

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:

Selectors and how they are evaluated in Stylo over the flattened tree
SelectorMatches, 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.
:definedElements 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 eCustomattachShadow + 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.

Source files by concern in mozilla-firefox at commit 73c9f54
ConcernFile @ 73c9f54What's in it
Registry, definition, data, reactions, stackdom/base/CustomElementRegistry.{h,cpp}Every object in modules 1–4; Define(), Upgrade(), Enqueue(), InvokeReactions().
Element hooksdom/base/Element.{h,cpp}GetCustomElementData(), AttachShadow(), CanAttachShadowDOM().
Create / lookup / enqueue helpersdom/base/nsContentUtils.cppLookupCustomElementDefinition, EnqueueLifecycleCallback, NewXULOrHTMLElement.
Shadow rootdom/base/ShadowRoot.{h,cpp}Module 5 object; slot map, mode, scoped style state, AssignSlot.
Slotsdom/html/HTMLSlotElement.{h,cpp}Assignment, assign(), slotchange signalling.
Flattened tree traversaldom/base/ChildIterator.{h,cpp}FlattenedChildIterator, AllChildrenIterator, ExplicitChildIterator.
[CEReactions] codegendom/bindings/Codegen.pyEmits the AutoCEReaction push/pop wrapper around annotated ops.
Binding gluedom/bindings/BindingUtils.{h,cpp}GetCustomElementReactionsStack, AutoCEReaction.
WebIDL surfacedom/webidl/{CustomElementRegistry,Element,ShadowRoot,HTMLSlotElement}.webidlWhere [CEReactions] and the dictionaries are declared.
Frame constructionlayout/base/nsCSSFrameConstructor.cppFlattened tree → nsIFrame tree (modules 5–6).
Style systemservo/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 Wednesday, September 9, 2026 and is pinned to mozilla-central @ 73c9f54.

← All guides

Contact

A question about a post, a project, or working together — send it here. I try to reply within a couple of weekdays.