Handling adoptedCallback Across Documents: Moving Elements Between Iframes and Templates

adoptedCallback is the lifecycle reaction almost nobody implements and a small number of applications absolutely need. It fires when a custom element is moved from one Document into another — an iframe into the main page, the main page into a print document, a DocumentFragment created by DOMParser into the live tree. When that happens, everything the element captured from its old document is stale: its ownerDocument, its defaultView, the window it registered listeners on, the constructable stylesheets it adopted, and in some engines the registry that defined it.

Applications that build editors, embed third-party canvases, or print rich documents all hit this. Everyone else can safely ignore it — but the ones who hit it usually hit it as a mysterious “my component stopped working after I moved it” bug with no error attached. This deep dive belongs to Lifecycle Callbacks Deep Dive inside Core Architecture & Lifecycle Management.

Problem Statement: The Component That Breaks After document.adoptNode

class ViewportMeter extends HTMLElement {
  #controller = null;
  #view = null;

  connectedCallback() {
    // Captures the window of whichever document this element lived in AT CONNECT TIME.
    this.#view = this.ownerDocument.defaultView;
    this.#controller = new AbortController();
    this.#view.addEventListener('resize', () => this.#measure(), {
      signal: this.#controller.signal
    });
    this.#measure();
  }

  disconnectedCallback() {
    this.#controller?.abort();
    this.#controller = null;
  }

  #measure() {
    // BUG after adoption: reads the OLD document's viewport.
    this.textContent = `${this.#view.innerWidth}px`;
  }
}
customElements.define('viewport-meter', ViewportMeter);
const frame = document.querySelector('iframe');
const meter = frame.contentDocument.querySelector('viewport-meter');

// Move the live element out of the iframe and into the main document.
document.body.append(document.adoptNode(meter));

The element renders, reports a width, and reports the iframe’s width forever. Resizing the main window changes nothing, because the listener is still registered on the iframe’s window. There is no error: #view is a perfectly valid object that simply belongs to a document the element no longer lives in.

What stays behind when an element is adopted into another document The element moves into the new document while its captured window reference, registered listeners and adopted stylesheets still belong to the old document. iframe document (old owner) window — still holds the resize listener adoptedStyleSheets constructed here #view field captured at connect all three survive the move and all three are now wrong main document (new owner) <viewport-meter> ownerDocument updated adoptedCallback fires the element moved; nothing it captured did re-acquire everything in the callback adoptNode

Root-Cause Analysis

The DOM Standard defines document.adoptNode(node) as removing the node from its old tree and setting its node document to the adopting document, recursively for the whole subtree. Node.prototype.appendChild and friends perform an implicit adoption when the node comes from another document, so document.body.append(elementFromIframe) adopts as a side effect — the explicit adoptNode call is optional and frequently omitted, which is why the transition often goes unnoticed.

The HTML Standard adds one custom element reaction to that algorithm: for each custom element in the adopted subtree, enqueue adoptedCallback(oldDocument, newDocument). Both documents are passed, so a component can compare them, and the callback runs after the node document has already changed.

Three facts about the surrounding state explain every symptom.

Adoption does not re-run construction or connection. The element instance is the same object with the same private fields. Anything captured earlier is untouched, which is exactly why the stale #view survives.

Adoption is not connection. adoptNode alone moves the node into the new document without inserting it into a tree, so the element is adopted and disconnected. Only the subsequent insertion fires connectedCallback. When the move is done by append, the order is: disconnectedCallback (leaving the old tree), adoptedCallback, then connectedCallback.

Constructable stylesheets are document-scoped. A CSSStyleSheet constructed in one document cannot be adopted by a shadow root in another; assigning it throws. So a component that shares sheets — the pattern in sharing styles with adoptedStyleSheets — must rebuild them per document.

Debugging Pitfall — assuming the element is defined in the new document. Custom element registries are per-window. An element adopted into a document whose window never called customElements.define for that tag becomes an undefined element: it keeps its class instance and its prototype, but the new document's parser will not upgrade siblings of the same tag, and cloning it there produces a plain HTMLElement. If both documents must render the component, both must register it.
Callback order for the two ways an element changes document An explicit adoptNode fires disconnect and adopted with no reconnection, while an append from another document fires disconnect, adopted and connected in sequence. Two moves, two callback sequences adoptNode alone disconnectedCallback adoptedCallback no connectedCallback cross-document append disconnectedCallback adoptedCallback connectedCallback The trap in the first row An adopted-but-not-inserted element never reconnects, so setup that lives only in connectedCallback never re-runs. The rule that covers both Put document-dependent setup in one method and call it from adoptedCallback as well as connectedCallback.

Production-Safe Pattern

class ViewportMeter extends HTMLElement {
  #controller = null;
  #sheet = null;

  connectedCallback() {
    this.#bindToDocument();
  }

  disconnectedCallback() {
    this.#releaseDocument();
  }

  /**
   * Fires after ownerDocument has already changed. Both documents are passed
   * so the component can release the old one before binding the new.
   */
  adoptedCallback(oldDocument, newDocument) {
    this.#releaseDocument(oldDocument);
    // Only re-bind if we are actually in a tree; adoptNode alone leaves us detached.
    if (this.isConnected) this.#bindToDocument();
    this.dispatchEvent(new CustomEvent('document-change', {
      detail: { from: oldDocument, to: newDocument },
      bubbles: true,
      composed: true
    }));
  }

  /** Everything document-dependent, acquired in exactly one place. */
  #bindToDocument() {
    const view = this.ownerDocument.defaultView;
    if (!view) return;                       // adopted into an inert document

    this.#controller = new AbortController();
    view.addEventListener('resize', () => this.#measure(), {
      signal: this.#controller.signal,
      passive: true
    });

    // Constructable sheets belong to the document that constructed them.
    if (this.shadowRoot && 'CSSStyleSheet' in view) {
      this.#sheet = new view.CSSStyleSheet();
      this.#sheet.replaceSync(':host { display: block; font-variant-numeric: tabular-nums; }');
      this.shadowRoot.adoptedStyleSheets = [this.#sheet];
    }

    this.#measure();
  }

  /** Symmetric release. Takes the document explicitly because ownerDocument
      has already moved on by the time adoptedCallback runs. */
  #releaseDocument() {
    this.#controller?.abort();
    this.#controller = null;
    if (this.shadowRoot) this.shadowRoot.adoptedStyleSheets = [];
    this.#sheet = null;
  }

  #measure() {
    const view = this.ownerDocument.defaultView;
    this.textContent = view ? `${view.innerWidth}px` : '';
  }
}
customElements.define('viewport-meter', ViewportMeter);
// Both documents must register the tag for the component to work in both.
const frame = document.querySelector('iframe');
frame.contentWindow.customElements.define('viewport-meter', ViewportMeter);

Three decisions carry the correctness. All document-dependent state is acquired in one method, so there is exactly one place to call from both connectedCallback and adoptedCallback. The stylesheet is constructed from the adopting window’s CSSStyleSheet constructor, which is what makes it adoptable there. And #measure reads ownerDocument.defaultView fresh rather than a captured field, so even a code path that forgets to re-bind reports the right number.

The isConnected guard matters more than it looks: an adoptNode without insertion leaves the element adopted and detached, and binding a resize listener from a detached element in a document it may never be inserted into is a leak waiting to happen.

Verification

const frame = document.querySelector('iframe');
frame.contentWindow.customElements.define('viewport-meter', ViewportMeter);
frame.contentDocument.body.innerHTML = '<viewport-meter></viewport-meter>';
const meter = frame.contentDocument.querySelector('viewport-meter');

const events = [];
meter.addEventListener('document-change', (e) => events.push(e.detail));

const before = meter.ownerDocument;
document.body.append(meter);            // implicit adoption + reconnection

// 1. The callback fired with both documents.
console.assert(events.length === 1, 'adoptedCallback ran once');
console.assert(events[0].from === before, 'old document reported');
console.assert(events[0].to === document, 'new document reported');

// 2. ownerDocument really moved.
console.assert(meter.ownerDocument === document, 'adopted into the main document');

// 3. The measurement now tracks the MAIN window, not the iframe.
const reported = parseInt(meter.textContent, 10);
console.assert(Math.abs(reported - window.innerWidth) < 2, 'reads the new viewport');

// 4. No listener was left on the old window.
console.assert(
  (frame.contentWindow.getEventListeners?.(frame.contentWindow).resize ?? []).length === 0,
  'old document listener released'
);

The most reliable manual check is the resize test: move the element, then resize the main window and confirm the number changes. If it does not, the listener is still on the old window. In DevTools, switching the console’s execution context to the iframe and inspecting getEventListeners(window) shows the leftover registration directly — the same technique used for the general case in avoiding leaks from event listeners on window.

When to Use vs When to Avoid

Situation Implement adoptedCallback?
Component moved between iframes and the main page Yes — this is the case it exists for
Component rendered into a print or export document Yes
Component built from DOMParser output then inserted Yes, if it captured anything at construction
Component that only ever lives in one document No — the callback never fires
Component with no document-dependent state No
Component using constructable stylesheets across documents Yes — sheets are document-scoped and must be rebuilt
Template content instantiated with importNode No — importNode clones rather than adopting the original

The last row is a common false alarm. document.importNode(node, true) creates a copy owned by the target document, so the original stays put and no adoption reaction fires on it — the clone runs its constructor instead. Only adoptNode, and the implicit adoption performed by insertion methods, move the live element.

Which move operations adopt the live element and which clone it adoptNode and cross-document insertion move the original element and fire the adopted reaction, while importNode and cloneNode create copies that run a constructor instead. Moves the original, or makes a copy? Adopts the live element document.adoptNode(el) parent.append(elFromOtherDoc) parent.insertBefore(el, ref) same instance, same private fields adoptedCallback fires Creates a copy document.importNode(el, true) el.cloneNode(true) template.content instantiation new instance, constructor runs original stays where it was

Frequently Asked Questions

Does adoptedCallback fire when I append an element from another document?

Yes. Insertion methods perform an implicit adoption when the node comes from a different document, so the sequence is disconnectedCallback, adoptedCallback, connectedCallback — even though no adoptNode call appears in the code.

Is the element connected when adoptedCallback runs?

Not necessarily. adoptNode on its own adopts without inserting, leaving the element detached, so guard any tree-dependent work with isConnected and let the subsequent connectedCallback handle insertion.

Do I need to define the element in both documents?

If both documents render it, yes — registries are per-window. An adopted instance keeps working, but the new document will not upgrade other elements of the same tag and clones there will be plain HTMLElement.

Why does assigning my constructable stylesheet throw after adoption?

Because a CSSStyleSheet belongs to the document that constructed it, and a shadow root can only adopt sheets from its own document. Construct a fresh sheet using the new window’s CSSStyleSheet constructor inside adoptedCallback.