Script tags scoped to shadow root, <script scoped src="...">

It’d be neat if we could scope scripts to shadow roots, like we can scope CSS to shadow roots.

F.e. inside a shadow Root’s HTML (naming bikesheddable):

<script scoped>
  function foo() {...}
  var bar = 123
</script>

Then in a custom element, for example, we could access that stuff, which is scoped to the shadow root instead of window:

this.shadowRoot.scope.foo()
console.log(this.shadowRoot.scope.bar) // 123

Use case: it would allow us to very easily wrap and scope markup managed by tools like Alpine.js or Mavo as custom elements, and in general would allow devs to be more inventive on what sorts of component systems (custom element systems) they can create.

F.e., we could make simple markup like that of Svelte with lets and functions, but still having a scope object for any given custom element (that has a shadow root).

It would mean we could take advantage of simple features of DOM that we already have. F.e. writing the following within a shadow root without muddying the global scope:

<!-- inside the shadow DOM -->
<div onclick="foo()"></div>
<script scope="shadow">
  // foo does not leak to global scope, but it can shadow a global foo var.
  function foo() {...}
</script>

The builtin onclick attribute handler of the DOM would run in scope of the shadow root, which means it’ll use any variables defined in that “lexical scope”. Any variables in that scope can shadow global variables, just like we’re accustomed to inside JavaScript functions. The only difference between this new scope and a normal function scope is that the variables are also exposed on an object on the shadow root.

Now we can imagine a custom element framework, might prescribe for a user to write HTML files like this:

<!-- my-element.html -->
<div onclick="foo()">
  <slot></slot>
</div>
<script>
  function foo() {...}
  function connected(ce) {...}
  function disconnected(ce) {...}
</script>

Then the framework takes this HTML, adds the scope="shadow" attribute to it, gets the life cycle functions from the scope object, and hooks them up to the custom element class.

Just like that we’d have an easy way to create “single-file component” format (like Vue). This would be alternative to the Declarative Custom Elements proposal, but this does not require ES Modules, and it works with the existing and simple functionality that HTML has already given us for many years (f.e. onclick="foo()"), which is something we can’t really use with custom elements.

Basically, this could allow possibilities that requires a lower learning curve for new developers.

And even CE authors could hook things up pretty easily:

// fetch the above HTML file
const html = fetch('./my-element.html')
  .then(r => r.text().replace('<script', '<script scope="shadow"'))

class MyElement extends HTMLElement {
  async connectedCallback () {
    const htm = await html
    if (!this.isConnected) return

    const root = this.attachShadow(...)
    // (Let's update the HTML parser for this)
    root.innerHTML = htm
    this.scope = root.scope
    root.scope.connected?(this)
  }

  disconnectedCallback() {
    this.scope?.disconnected?(this)
  }
}

With this you could imagine it would be trivial for an author to reduce that boilerplate to the following (with a simple monkey patch):

customeElements.define('my-element', './my-element.html')

If a script is type=module, then maybe in that case root.scope is a promise that resolves to the scope (not the module). In that case, the end user (that is using the above framework) would write:

<!-- my-element.html -->
<div onclick="foo()">
  <slot></slot>
</div>
<script>
  import something from 'somewhere'
  function foo() {...}
  function connected(ce) {...}
  function disconnected(ce) {...}
</script>

The idea here is for things like onclick="foo()" to still work in a simple way.

If we resolve the promise to a module, then how would it work? We would need to update onclick machinery to look for exports if script modules. That could work too. In that case, the user would write:

<!-- my-element.html -->
<div id="el" onclick="foo()" class="loading">
  <slot></slot>
</div>
<style>.loading {...}</style>
<script type="module">
  import something from 'somewhere'
  el.classList.remove('loading')
  export function foo() {...}
  export function connected(ce) {...}
  export function disconnected(ce) {...}
</script>

Now just imagine, the end user can use something very simple like Alpine.js to further augment that DOM to add data binding, list iteration, etc. Or they can use anything really.