Search code examples
javascriptecmascript-6scopees6-modulescustom-element

How do JS modules prevent custom elements defined inside from exposing their APIs?


This does not work:

<!-- wtf.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8"/>
  <title></title>
  <script type="module" src="./wtf.js"></script>
</head>
<body>
<script>
  const myElement = document.createElement('my-element')
  document.body.appendChild(myElement)
  myElement.callMe()
</script>
</body>
</html>

 

// wtf.js
customElements.define('my-element', class extends HTMLElement {
  constructor() {
    super()
  }

  callMe() {
    window.alert('I am called!')
  }
})

Firefox throws me a nasty exception on line myElement.callMe(). Apparently, "myElement.callMe is not a function".

I am confused why is that so? To my understanding, as soon as I type const myElement = document.createElement('my-element'), I am receiving an object whose type is not a generic HTMLElement but an object of my class I wrote that extends HTMLElement! And this class exposes callMe.

I have confirmed that my use of modules seems to be the culprit here. This code works as expected:

<!-- wtf.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8"/>
  <title></title>
</head>
<body>
<script>
  customElements.define('my-element', class extends HTMLElement {
      constructor() {
        super()
    }

    callMe() {
        window.alert('I am called!')
    }
  })

  const myElement = document.createElement('my-element')
  document.body.appendChild(myElement)
  myElement.callMe()
</script>
</body>
</html>

Yes I know that things defined in a module are scoped to this module. But here it does not even seem (to me) to be a scoping issue. For example, if I do inside a module something like that:

function callMe() {/*blah blah */}

window.callMe = callMe

then I will be able to use callMe outside of the module anyway, because the module exposed this function through other means than export (this time through assigning it to the global window object).

The same, to my understanding, should be happening in my use case. Even though I define callMe in a class scoped to the module, this class method should be accessible outside of the module by the virtue of it being a property of an object of this class that is exposed by calling document.createElement('my-element'). Yet manifestly, this does not happen.

This is really weird to me. It almost seems as if the module was enforcing its scoping by tangling with types unrelated functions return(!!) - so in this case, it is just as if the module magically forces document.createElement to cast the object it returns up in the inheritance hierarchy (to HTMLElement)?!?! This is mind-blowing to me.

Could someone please clear my confusion?

(And if I define a custom element inside a module, how may I expose its API outside of this module?)


Solution

  • The problem is that a <script type="module"> implicitly has a defer attribute, so it doesn't run immediately.

    Even though I define callMe in a class scoped to the module, this class method should be accessible outside of the module

    Yes, it is. The problem is just that it is defined asynchronously :-) To use stuff from a module, you should explicitly import that module to declare the dependency, which makes sure it is evaluated in the right order. It would also work if your global script was deferred somehow.

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8"/>
      <title></title>
      <script type="module" src="./wtf.js"></script>
    </head>
    <body>
      <script type="module">
        import './wtf.js';
    //  ^^^^^^^^^^^^^^^^^^
        const myElement = document.createElement('my-element')
        document.body.appendChild(myElement)
        myElement.callMe()
      </script>
    </body>
    </html>