3

I'm trying to create a custom element with most of the javascript encapsulated/referenced in the template/html itself. How can I make that javascript from the template/element to be executed in the shadow dom? Below is an example to better understand the issue. How can I make the script from template.innerHTML (<script>alert("hello"); console.log("hello from tpl");</script>) to execute?

Currently I get no alert or logs into the console. I'm testing this with Chrome.

class ViewMedia extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({mode: 'closed'});
    var template = document.createElement( 'template' );
    template.innerHTML = '<script>alert("hello"); console.log("hello from tpl")';
    shadow.appendChild( document.importNode( template.content, true ) ); 
  }
}

customElements.define('x-view-media', ViewMedia);
<x-view-media />
sergeykish
  • 181
  • 1
  • 10
mike
  • 533
  • 1
  • 7
  • 24

2 Answers2

2

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.

Besworks
  • 4,123
  • 1
  • 18
  • 34
1

A few points:

  1. Browsers no longer allow you to add script via innerHTML
  2. There is no sand-boxing of script within the DOM a web component like there is in an iFrame.

You can create script blocks using var el = document.createElement('script'); and then adding them as child elements.

class ViewMedia extends HTMLElement {
   constructor() {
      super();
      const shadow = this.attachShadow({mode: 'closed'});
      const s = document.createElement('script');
      s.textContent = 'alert("hello");';
      shadow.appendChild(s);
    }
}

customElements.define('x-view-media', ViewMedia);
<x-view-media></x-view-media>
Intervalia
  • 10,248
  • 2
  • 30
  • 60
  • There are libraries that were not designed to be part of a Web Component class (i.e. I'm using a spatial navigation lib). Loading the scripts from the html template/document instead of the web component class would also make my code easier to manage and less ceremonial. I will try to create a 'script loader' based on createElement to see if it works. The sandboxing level is good enough for me (i.e. document.querySelector is selecting only elements from the shadow dom). – mike Dec 10 '18 at 03:21
  • Can you just load the library on the page and use it from the Web Component?? – Intervalia Dec 10 '18 at 03:38
  • No, it's not working. I already tried. Also I would like to have the web component more encapsulated(i.e. have its dependencies in the web component itself rather than to load all kind of dependencies in the parent component. – mike Dec 10 '18 at 03:42
  • You may not even need a Web Component. Why not just have a small chunk of script that generates all of the needed ` – Intervalia Dec 10 '18 at 16:20
  • 1
    Of course I need a component...how else am I supposed to get CSS isolation? – mike Dec 10 '18 at 18:55