Search code examples
web-componentshadow-domcustom-elementlit

Shadow DOM innerHTML with slots replaced by assignedNodes()


I'm working with a customElement using Shadow DOM like:

<hello-there><b>S</b>amantha</hello-there>

And the innerHTML (generated by lit/lit-element in my case) is something like:

<span>Hello <slot></slot>!</span>

I know that if const ht = document.querySelector('hello-there') I can call .innerHTML and get <b>S</b>amantha and on the shadowRoot for ht, I can call .innerHTML and get <span>Hello <slot></slot>!</span>. But...

The browser essentially renders to the reader the equivalent of if I had expressed (without ShadowDOM) the HTML <span>Hello <b>S</b>amantha!</span>. Is there a way to get this output besides walking all the .assignedNodes, and substituting the slot contents for the slots? Something like .slotRenderedInnerHTML?

(update: I have now written code that does walk the assignedNodes and does what I want, but it seems brittle and slow compared to a browser-native solution.)

class HelloThere extends HTMLElement {
   constructor() {
      super();
      const shadow = this.attachShadow({mode: 'open'});
      shadow.innerHTML = '<span>Hello <slot></slot>!</span>';
   }
}

customElements.define('hello-there', HelloThere);
<hello-there><b>S</b>amantha</hello-there>
<div>Output: <input type="text" size="200" id="output"></input></div>
<script>
const ht = document.querySelector('hello-there');
const out = document.querySelector('#output');

</script>
<button onclick="out.value = ht.innerHTML">InnerHTML hello-there</button><br>
<button onclick="out.value = ht.outerHTML">OuterHTML hello-there</button><br>
<button onclick="out.value = ht.shadowRoot.innerHTML">InnerHTML hello-there shadow</button><br>
<button onclick="out.value = ht.shadowRoot.outerHTML">OuterHTML hello-there shadow (property does not exist)</button><br>
<button onclick="out.value = '<span>Hello <b>S</b>amantha!</span>'">Desired output</button>


Solution

  • Since there doesn't seem to be a browser-native way of answering the question (and it seems that browser developers don't fully understand the utility of seeing a close approximation to what the users are approximately seeing in their browsers) I wrote this code.

    Typescript here, with pure-Javascript in the snippets:

    const MATCH_END = /(<\/[a-zA-Z][a-zA-Z0-9_-]*>)$/;
    
    /**
     * Reconstruct the innerHTML of a shadow element
     */
    export function reconstruct_shadow_slot_innerHTML(el: HTMLElement): string {
        return reconstruct_shadow_slotted(el).join('').replace(/\s+/, ' ');
    }
    
    export function reconstruct_shadow_slotted(el: Element): string[] {
        const child_nodes = el.shadowRoot ? el.shadowRoot.childNodes : el.childNodes;
        return reconstruct_from_nodeList(child_nodes);
    }
    
    function reconstruct_from_nodeList(child_nodes: NodeList|Node[]): string[] {
        const new_values = [];
        for (const child_node of Array.from(child_nodes)) {
            if (!(child_node instanceof Element)) {
                if (child_node.nodeType === Node.TEXT_NODE) {
                    // text nodes are typed as Text or CharacterData in TypeScript
                    new_values.push((child_node as Text).data);
                } else if (child_node.nodeType === Node.COMMENT_NODE) {
                    const new_data = (child_node as Text).data;
                    new_values.push('<!--' + new_data + '-->');
                }
                continue;
            } else if (child_node.tagName === 'SLOT') {
                const slot = child_node as HTMLSlotElement;
                new_values.push(...reconstruct_from_nodeList(slot.assignedNodes()));
                continue;
            } else if (child_node.shadowRoot) {
                new_values.push(...reconstruct_shadow_slotted(child_node));
                continue;
            }
            let start_tag: string = '';
            let end_tag: string = '';
    
            // see @syduki's answer to my Q at
            // https://stackoverflow.com/questions/66618519/getting-the-full-html-for-an-element-excluding-innerhtml
            // for why cloning the Node is much faster than doing innerHTML;
            const clone = child_node.cloneNode() as Element;  // shallow clone
            const tag_only = clone.outerHTML;
            const match = MATCH_END.exec(tag_only);
            if (match === null) {  // empty tag, like <input>
                start_tag = tag_only;
            } else {
                end_tag = match[1];
                start_tag = tag_only.replace(end_tag, '');
            }
            new_values.push(start_tag);
            const inner_values: string[] = reconstruct_from_nodeList(child_node.childNodes);
            new_values.push(...inner_values);
            new_values.push(end_tag);
        }
        return new_values;
    }
    

    Answer in context:

    const MATCH_END = /(<\/[a-zA-Z][a-zA-Z0-9_-]*>)$/;
    
    
    /**
     * Reconstruct the innerHTML of a shadow element
     */
    function reconstruct_shadow_slot_innerHTML(el) {
        return reconstruct_shadow_slotted(el).join('').replace(/\s+/, ' ');
    }
    
    function reconstruct_shadow_slotted(el) {
        const child_nodes = el.shadowRoot ? el.shadowRoot.childNodes : el.childNodes;
        return reconstruct_from_nodeList(child_nodes);
    }
    
    
    function reconstruct_from_nodeList(child_nodes) {
        const new_values = [];
        for (const child_node of Array.from(child_nodes)) {
            if (!(child_node instanceof Element)) {
                if (child_node.nodeType === Node.TEXT_NODE) {
                    new_values.push(child_node.data);
                } else if (child_node.nodeType === Node.COMMENT_NODE) {
                    const new_data = child_node.data;
                    new_values.push('<!--' + new_data + '-->');
                }
                continue;
            } else if (child_node.tagName === 'SLOT') {
                const slot = child_node;
                new_values.push(...reconstruct_from_nodeList(slot.assignedNodes()));
                continue;
            } else if (child_node.shadowRoot) {
                new_values.push(...reconstruct_shadow_slotted(child_node));
                continue;
            }
            let start_tag = '';
            let end_tag = '';
    
            const clone = child_node.cloneNode();
            // shallow clone
            const tag_only = clone.outerHTML;
            const match = MATCH_END.exec(tag_only);
            if (match === null) {  // empty tag, like <input>
                start_tag = tag_only;
            } else {
                end_tag = match[1];
                start_tag = tag_only.replace(end_tag, '');
            }
            new_values.push(start_tag);
            const inner_values = reconstruct_from_nodeList(child_node.childNodes);
            new_values.push(...inner_values);
            new_values.push(end_tag);
        }
    
        return new_values;
    }
    
    class HelloThere extends HTMLElement {
       constructor() {
          super();
          const shadow = this.attachShadow({mode: 'open'});
          shadow.innerHTML = '<span>Hello <slot></slot>!</span>';
       }
    }
    
    customElements.define('hello-there', HelloThere);
    <hello-there><b>S</b>amantha</hello-there>
    <div>Output: <input type="text" size="200" id="output"></input></div>
    <script>
    const ht = document.querySelector('hello-there');
    const out = document.querySelector('#output');
    
    </script>
    <button onclick="out.value = ht.innerHTML">InnerHTML hello-there</button><br>
    <button onclick="out.value = ht.outerHTML">OuterHTML hello-there</button><br>
    <button onclick="out.value = ht.shadowRoot.innerHTML">InnerHTML hello-there shadow</button><br>
    <button onclick="out.value = ht.shadowRoot.outerHTML">OuterHTML hello-there shadow (property does not exist)</button><br>
    <button onclick="out.value = reconstruct_shadow_slot_innerHTML(ht)">Desired output</button>