The reason this fails is because importNode
does not evaluate scripts that were imported from another document, which is essentially what's happening when you use innerHTML
to set the template content. The string you provide is parsed into a DocumentFragment
which is considered a separate document. If the template element was selected from the main document, the scripts would be evaluated as expected :
<template id="temp">
<script> console.log('templated script'); </script>
</template>
<div id="host"></div>
<script>
let temp = document.querySelector('#temp');
let host = document.querySelector('#host');
let shadow = host.attachShadow({ mode:'closed' });
shadow.append(document.importNode(temp.content, true));
</script>
One way to force your scripts to evaluate would be to import them using a contextual fragment :
<div id="host"></div>
<script>
let host = document.querySelector('#host');
let shadow = host.attachShadow({ mode:'closed' });
let content = `<script> console.log(this); <\/script>`;
let fragment = document.createRange().createContextualFragment(content);
shadow.append(document.importNode(fragment, true));
</script>
But, this breaks encapsulation as the scripts inside your shadowRoot will actually be evaluated in the global scope with no access to your closed shadow dom. The method that I came up with to deal with this issue is to loop over each script in the shadow dom and evaluate it with the shadowRoot as it's scope. You can't just pass the host object itself as the scope because you'll lose access to the closed shadowRoot. Instead, you can access the ShadowRoot.host
property which would be available as this.host
inside the embedded scripts.
class TestElement extends HTMLElement {
#shadowRoot = null;
constructor() {
super();
this.#shadowRoot = this.attachShadow({ mode:'closed' });
this.#shadowRoot.innerHTML = this.template
}
get template() {
return `
<style>.passed{color:green}</style>
<div id="test"> TEST A </div>
<slot></slot>
<script>
let a = this.querySelector('#test');
let b = this.host.firstElementChild;
a && a.classList.add('passed');
b && (b.style.color = 'green');
<\/script>
`;
}
get #scripts() {
return this.#shadowRoot.querySelectorAll('script');
}
#scopedEval = (script) =>
Function(script).bind(this.#shadowRoot)();
#processScripts() {
this.#scripts.forEach(
s => this.#scopedEval(s.innerHTML)
);
}
connectedCallback() {
this.#processScripts();
}
}
customElements.define('test-element', TestElement);
<test-element>
<p> TEST B </p>
</test-element>
Do not use this technique with an open shadowRoot as you will leave your component vulnerable to script injection attacks. The browser prevents arbitrary code execution for a reason: to keep you and your users safe. Do not inject untrusted content into your shadow dom with this enabled, only use this to evaluate your own scripts or trusted libraries, and ideally avoid this trick if at all possible. There are almost always better ways to execute scripts that interact with your shadow dom, like scoping all your logic into your custom element definition.
Side note: Element.setHTML is a much safer method for importing untrusted content which is coming soon as part of the HTML Sanitizer API.