Serializing Shadow Roots with getHTML(): Capturing Shadow Content as Markup
Element.getHTML({ serializableShadowRoots: true }) is the only standard API that serializes a live element together with the contents of its shadow roots, emitting them as declarative <template shadowrootmode> markup. A plain innerHTML read walks the light-DOM child list only, so it returns the host’s projected children and nothing of the encapsulated shadow tree — which is exactly why naive snapshot and server-render flows lose all shadow content.
The gotcha bites whenever you try to capture a component’s rendered output for SSR, caching, or a test snapshot: host.innerHTML comes back empty (or shows only slotted light-DOM nodes) even though the component renders perfectly on screen. The shadow tree is intact in the DOM; it is simply not part of light-DOM serialization.
This deep-dive sits under Shadow DOM Construction & Modes in Core Architecture & Lifecycle Management. We reproduce the empty serialization, ground it in the HTML serialization algorithm, then apply getHTML with serializable roots and verify a round trip.
Minimal Reproduction Case
A component renders its UI inside a shadow root, then a snapshot routine reads innerHTML to persist the rendered markup.
class StatusBadge extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>:host { display: inline-block; } b { color: #29c587; }</style>
<b>Online</b>`;
}
}
customElements.define('status-badge', StatusBadge);
const badge = document.createElement('status-badge');
document.body.append(badge);
// Naive snapshot for SSR cache / test fixture:
console.log(badge.innerHTML); // → "" (empty — the <b>Online</b> lives in the shadow root)
console.log(badge.outerHTML); // → "<status-badge></status-badge>" (no shadow content)
The badge displays “Online” on screen, yet innerHTML is an empty string and outerHTML shows a bare host. Persist that snapshot and reload it and the component is blank until JavaScript re-runs — the entire visual payload was dropped at serialization time.
Root-Cause Analysis
HTML serialization is defined over a node’s child list. The fragment serialization algorithm that backs the innerHTML getter iterates the element’s children — its light-DOM child nodes — and never descends into an attached shadow root. By specification a shadow root is not a child of its host; it is a separate node tree hanging off the host’s internal shadow-root slot. So innerHTML and outerHTML are working correctly: they serialize the light tree, and the shadow tree is simply outside their scope. For StatusBadge the host has no light-DOM children at all, which is why the result is the empty string.
To serialize shadow content you need an algorithm that opts into walking shadow roots and a way to emit them in a form the parser can later re-attach. That is Element.prototype.getHTML(options). When called with { serializableShadowRoots: true }, the serializer, on reaching a host, emits each qualifying shadow root as a <template shadowrootmode="open|closed"> element whose contents are the serialized shadow tree — precisely the Declarative Shadow DOM form the HTML parser knows how to attach.
A shadow root qualifies for serialization only if it is serializable. The spec gates this on the root’s serializable flag, set when the root is created with the serializable: true option of attachShadow (or the declarative shadowrootserializable attribute). getHTML will not blindly expose every shadow root: it serializes the roots you explicitly list in the shadowRoots option array, plus — when serializableShadowRoots: true — every root anywhere in the subtree whose serializable flag is set. A root created without that flag is skipped, preserving the privacy expectation that closed or unmarked roots are not silently leaked into markup. This is what makes the API safe for server-side rendering and hydration, where you serialize on the server and re-attach on the client.
The serializability flag is orthogonal to the open/closed mode. A closed root can still be marked serializable: true and will then serialize as <template shadowrootmode="closed">; conversely an open root left unmarked is excluded from getHTML output even though scripts can reach its shadowRoot. The two flags answer different questions: mode controls script access to the live root, serializability controls markup export of its contents. Treat them independently — a private widget can keep a closed root yet opt into serialization for snapshot testing, and a publicly inspectable open root can deliberately stay out of serialized markup.
One more boundary: getHTML serializes the node tree, not runtime style state held outside it. Rules applied through adoptedStyleSheets — covered in sharing styles with adoptedStyleSheets — are not inlined into the emitted template, because an adopted sheet is a constructable object with no owner node to serialize. If first paint after re-hydration must look correct before scripts run, the shadow tree you serialize has to contain a real <style> element, not rely solely on an adopted sheet that only exists at runtime.
Production-Safe Fix
Opt the shadow root into serialization at creation, then snapshot with getHTML.
class StatusBadge extends HTMLElement {
constructor() {
super();
// serializable: true sets the flag getHTML() looks for.
const root = this.attachShadow({ mode: 'open', serializable: true });
root.innerHTML = `
<style>:host { display: inline-block; } b { color: #29c587; }</style>
<b>Online</b>`;
}
connectedCallback() {
// Idempotent hydration: adopt a parser-attached root if one already exists.
if (this.shadowRoot && !this.shadowRoot.querySelector('b')) {
this.shadowRoot.innerHTML = `
<style>:host { display: inline-block; } b { color: #29c587; }</style>
<b>Online</b>`;
}
}
}
customElements.define('status-badge', StatusBadge);
const badge = document.querySelector('status-badge');
// Full serialization including the shadow tree:
const snapshot = badge.getHTML({ serializableShadowRoots: true });
// → <template shadowrootmode="open"><style>...</style><b>Online</b></template>
// Or capture the whole host element with its shadow content:
const full = badge.outerHTML.replace(
/^<status-badge>/,
`<status-badge>${snapshot}`
);
For a document-wide snapshot — the typical SSR case — document.body.getHTML({ serializableShadowRoots: true }) walks the entire tree and inlines every serializable root as a declarative template, producing markup the parser re-hydrates on the next load.
This is also the cleanest path to deterministic snapshot tests across frameworks. A React, Vue, or Angular test that mounts a custom element and snapshots container.innerHTML will record an empty host and silently pass even when the shadow rendering is broken; switching the assertion to getHTML({ serializableShadowRoots: true }) captures the real rendered output and makes shadow regressions visible. Because the output is declarative markup rather than a framework-specific tree, the same fixture round-trips through any environment with a compliant HTML parser, which keeps server snapshots and client snapshots comparable.
Verification
Confirm getHTML captures the shadow tree and that the output round-trips through the parser:
const badge = document.querySelector('status-badge');
const html = badge.getHTML({ serializableShadowRoots: true });
console.assert(html.includes('shadowrootmode="open"'), 'must emit declarative template');
console.assert(html.includes('Online'), 'shadow content must be present');
// Round trip: re-parse via the declarative opt-in and confirm the root re-attaches.
const host = document.createElement('div');
host.setHTMLUnsafe(`<status-badge>${html}</status-badge>`);
const restored = host.querySelector('status-badge');
console.assert(restored.shadowRoot !== null, 'shadow root must re-attach on parse');
console.log(restored.shadowRoot.querySelector('b').textContent); // → "Online"
In the DevTools console, compare badge.innerHTML (empty) against badge.getHTML({ serializableShadowRoots: true }) (the full <template shadowrootmode> block). Seeing the template appear only in the second call confirms the serializable flag is set and the serializer is descending into the shadow root.
A second diagnostic catches the most common silent failure — a root that was created without serializable: true. Call getHTML({ serializableShadowRoots: true }) and check whether the expected <template> is present. If the shadow content is missing while badge.shadowRoot is non-null, the root exists but its serializable flag is unset, so the serializer skipped it by design. The fix is at the attachShadow call site, not at the serialization call: there is no per-call override that forces an unmarked root into the output, because that would defeat the privacy guarantee. Auditing every attachShadow in a component library for the serializable option is therefore the reliable way to make a whole design system snapshot-ready.
When to Use vs When to Avoid
| Situation | getHTML({ serializableShadowRoots: true }) |
Plain innerHTML / outerHTML |
|---|---|---|
| Snapshotting rendered shadow content | Use — captures shadow tree | Avoid — returns light DOM only |
| Server-side render of declarative roots | Use — emits re-hydratable markup | Avoid — drops shadow content |
| Reading only light-DOM children | Either | Use — simpler, universal |
| Closed/private roots you must not expose | Avoid unless marked serializable | Use — never leaks shadow |
| Persisting state-dependent shadow markup | Use — pair with serializable: true |
Avoid — loses everything |
| Targeting engines before late 2023 | Avoid — feature-detect first | Use — universal support |
Browser support for Element.getHTML() with serializableShadowRoots and the serializable option of attachShadow: Chromium 125 (May 2024), Safari 18.2 (December 2024), Firefox 128 (July 2024). Feature-detect with 'getHTML' in Element.prototype and fall back to manually concatenating shadowRoot.innerHTML for known-open roots where the API is unavailable.
Related
- Shadow DOM Construction & Modes — parent topic on creating and configuring shadow roots.
- Using Declarative Shadow DOM — the
<template shadowrootmode>formgetHTMLemits. - Sharing Styles with adoptedStyleSheets — note adopted sheets are not inlined by serialization.
- Server-Side Rendering & Hydration — the pipeline that serializes on the server and re-attaches on the client.