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.
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.
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.
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.
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.
Related
- Lifecycle Callbacks Deep Dive — the parent topic on all four reactions.
- Core Architecture & Lifecycle Management — the section this deep dive belongs to.
- Understanding connectedCallback Execution Order — the reaction that runs immediately after adoption.
- Sharing Styles with adoptedStyleSheets — why sheets cannot cross a document boundary.
- Observer Teardown & Memory Safety — the release half of the symmetric pattern.