Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTML Modules: JavaScript Scoping of ShadowDom Children #959

Open
Swivelgames opened this issue May 2, 2022 · 10 comments
Open

HTML Modules: JavaScript Scoping of ShadowDom Children #959

Swivelgames opened this issue May 2, 2022 · 10 comments

Comments

@Swivelgames
Copy link

Swivelgames commented May 2, 2022

This may require its own champion and proposal/explainer, but I feel like it falls well enough in line that it might be worth mentioning in relation to HTTP Modules.

The idea is, essentially, a mechanism to allow children of a ShadowRoot to access the instance of the element intuitively. This may include waiting to evaluate JS references in attributes for children of a template tag until the ShadowDom is registered. Even if all we do is set the context (this) to the currently relevant instance, or even provide a getter on all children for retrieving the instance of the element. (i.e., this.shadowRoot, this.root, this.context, this.parent, or something similar).

This would alleviate what is, in my opinion, under-utilization of inline JavaScript as it is right now. Being able to use on*= attributes, and others, while referencing the included class would be incredibly useful.

<template id="myCustomElementTemplate">
    <button onclick="this.#handleClick">Click me!</button>
</template>

<script type="module">
    /* ... */
    class myCustomElement extends HTMLElement {
        constructor() {
            super();
            // ShadowDOM is attached to instance of myCustomElement here
            let shadowRoot = this.attachShadow({ mode: "open" });
            let template = importDoc.getElementById("myCustomElementTemplate");
            // The `this` of any children of a ShadowRoot will be equal to the instance of the
            // element, allowing access to public **and private** methods
            shadowRoot.appendChild(template.content.cloneNode(true));
        }
        #handleClick(e) {
            console.log("Clicked!", e);
        }
    }
    /* ... */
</script>

Again, this may very well need to be in its own proposal.

This could potentially pave the way for other mechanisms, including (one which is certainly out of the scope of this proposal): binding attribute values to instance properties, and so on.

Originally posted by @Swivelgames in #645 (comment)

@wisniewski94
Copy link

wisniewski94 commented Jul 28, 2022

Correct me if I am wrong - importDoc refers to #645 spec but ultimately we are ignoring it.

The funny part to me is that this issue made me aware that this is actually not possible. As a regular developer, I expected the below code to work.

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Hello World!</title>

    <script src="/script.js" type="module"></script>

  </head>
  <body>
    <template id="buttonTemplate">
      <button onclick="this.clickHandler()">My button</button>
    </template>
    <my-button>Button</my-button>
  </body>
</html>
customElements.define('my-button',
  class extends HTMLElement {
    constructor() {
      super();
      let template = document.getElementById('buttonTemplate');
      let templateContent = template.content;

      const shadowRoot = this.attachShadow({mode: 'open'});
      shadowRoot.appendChild(templateContent.cloneNode(true));
    }
    
    clickHandler() {
      console.log(true)
    }
  }
);

I think this is a great idea. That should allow to write less code in the component's class (making them look cleaner) and result in expected (?) behavior.

EDIT: okay I can see in the title now that it explicitly intends to refer to the HTML Modules. I still think that the code above should work.

I think in this example this refers to the element itself so I guess this.context.clickHandler() could do the trick

@Swivelgames
Copy link
Author

From @Jamesernator in #645:

This would alleviate under-utilization of inline JavaScript as it is right now. Being able to use on*= attributes, and others, while referencing the included class would be incredibly useful.

This sort've concept is interesting, but web specs don't add features that have special powers to be able to do things in other JS scopes. Like in order to resolve this.#handleClick somehow shadowRoot.appendChild(template.content.cloneNode(true)) would have to be able to look into the constructor of this to find private fields, this isn't a capability the language offers.

A general rule of thumb, is that web objects don't really have more power than JS objects, they are just (potentially) implemented in a different language. In this case there would be no way to implement something like what you were describing:

class ShadowRoot extends DocumentFragment {
    appendChild(node) {
        if (node.hasAttribute("onclick")) {
            // No way to access this.#handleClick in this method
            // so web specs can't either
        }
    }
}

The exact reasons for this general rule of thumb are varied, some reasons include the ability to separate the JS engine from the browser, some are to allow the engine to optimize things it otherwise couldn't, some are just to respect the intended design of features (i.e. private means private), some are to allow JS to faithfully mock APIs in tests, etc.

@Jamesernator made a good point about private members. However, I still feel that this may still be viable for public members.

@Swivelgames
Copy link
Author

From @clshortfuse in #645:

You can already create this monstrosity:

<template id="myCustomElementTemplate">
    <button onclick="this.getRootNode().host.constructor.handleClick.call(this, event)">Click me!</button>
</template>

And yes, it works and is cloneable. Though on* has the browser construct a new function and bind it with a EventListener that cannot be removed from JS. That doesn't scale well for very large DOMs (eg: table grid cells with click).

Quoting this in here for posterity's sake.

@Swivelgames
Copy link
Author

@clshortfuse The only problem with this is that it still scopes the event to the element/node, and not the class itself. At that point, any reference to this inside handleClick (like this.showLoadingSpinner() as an example) would fail because the <button> element does not an element this.showLoadingSpinner().


I also wanted to address one point that someone else brought up in a different conversation. The concern was that you would lose access to the <button> element via this. But given the fact that you have access to event.target, this won't be a problem.

The general problem right now is that there is no intuitive way to reference the class associated with the custom element inside the DOM of the custom element. The class can reference the DOM, but the DOM cannot reference the class instance. This makes libraries like React, Vue.js, and others far more attractive to developers, which becomes problematic when trying to make the argument for leveraging agnostic web components.

In my personal opinion, for whatever its worth, this is probably one of the single biggest pieces that could be introduced to the web component standards to bolster the adoption of pure web components: Intuitive access to the class instance via this from within the DOM.

@clshortfuse
Copy link

clshortfuse commented Dec 29, 2022

@Swivelgames in your case you can just do this.getRootNode().host.showLoadingSpinner().

My use case was ButtonComponent.onButtonClick(this: HTMLButtonElement) which is a static function. But yeah, it makes more sense to use a method since JS will add it to the prototype once anyway (wasn't sure when I wrote that).

With events this is always the element the listener is attached to, which is the same as event.currentTarget. Also, in that inline code (event) is available in the scope, even if you never declared it.

Given any ShadowDOM element, you can get the ShadowRoot of the Web Component in question with this.getRootNode(). After that you can now get the ShadowRoot's host element with .host.

If you want to access the Web Component's static fields, you access .constructor. But if you want to access the prototype (class fields), .host would suffice.

Still, I don't recommend cloning templates like that. I moved to setting up a list of events to be bound on constructor(). There's no clear way to not iterate all the event listeners related to your ShadowRoot tree while having all instances share bind to the same function. It's either iterate or browser built anonymous functions.

@Swivelgames
Copy link
Author

Swivelgames commented Dec 29, 2022

@clshortfuse You're absolutely correct. Oops. Thanks for calling me out on that haha.

So, really, to boil down the request, it would be to change the context (this) of children of a ShadowDOM element to the ShadowRoot. This would make it more intuitive to reference public class members.

@Swivelgames
Copy link
Author

Swivelgames commented Dec 29, 2022

This would actually simplify your previous example down to:

<template id="myCustomElementTemplate">
    <button onclick="this.handleClick.call(event.currentTarget, event)">Click me!</button>
</template> 

But yeah, I would agree that just using it as a prototype method would be more intuitive, like

<template id="myCustomElementTemplate">
    <button onclick="this.handleClick(event)">Click me!</button>
</template> 

@justinfagnani
Copy link
Contributor

I don't think any proposal here should be part of HTML modules, which don't really have a lot to do with custom elements other than being a container for templates and possibly declarative custom element declarations.

Binding expressions in templates to a JS objects should be done at the template instantiation and declarative custom elements layers.

@clshortfuse
Copy link

clshortfuse commented Dec 29, 2022

Direct import of HTML templates as DocumentFragment would be fine for me and my use cases. I do similar for CSS imports. Right now mine are written like this (I've stripped class extensions to a declarative functional style), so here's a real-world example of how it would help me:

import styles from './Card.css' assert { type: 'css' };
+import template from './Card.html' assert { type: 'html' };
import Container from './Container.js';

export default Container
  .extend()
  .css(styles)
-  .html`
-   <slot id=primary-action name=primary-action></slot>
-   <div id=outline></div>
- `
+ .html(template) 
  .autoRegister('mdw-card');

Perhaps extending onclick to export host as a parameter could be a tangential solution. Currently, it exposes this and event. Why not host as well? this is actually unclear anyway (element, static, or instance?), and I personally try to avoid the reference whenever possible.

<template id="myCustomElementTemplate">
-  <button id=button><slot></slot></button> // Manually bind in constructor
-  <button on-click="{handleClick}"><slot></slot></button> // Custom attribute requiring interpolation
-  <button onclick="this.getRootNode().host.handleClick()"><slot></slot></button> // Unintuitive way to auto-bind
+  <button onclick="host.handleClick()"><slot></slot></button> // Clear
</template>

<script type="module">
    /* ... */
    class myCustomElement extends HTMLElement {
       static {
         customElements.define('x-button', this);
       }
        constructor() {
            super();
            const shadowRoot = this.attachShadow({ mode: "open" });
            const template = document.getElementById("myCustomElementTemplate");
            shadowRoot.appendChild(template.content.cloneNode(true));
-           // Manual bind
-           shadowRoot.getElementById('button').addEventListener('click', this.handleClick);
-           // Interpolate
-           for (const el of shadowRoot.querySelectorAll('[on-click]')) {
-             el.addEventListener('click', this[
-               /^{(.+)}$/.exec(el.getAttribute('on-click'))[1]
-             ]);
-           }
        }
        handleClick() {
            window.alert('You clicked me!');
        }
    }
    /* ... */
</script>

<x-button>Click me</x-button>

This way the browser doesn't really have to parse anything or walk the tree. It just uses the standard onclick. Browser implementers can choose to optimize by sharing a single anonymous function as long as the template being cloned is the same. You still don't have the ability to remove the event listener, but I'll be honest, authors can tap into connectedCallback() or constructor() if they want that level of control. Same with passive events.

Edit: Removing the event listener is simply done by removing the attribute, or setting element.onclick = null, which is standard anyway. Just leaves passive listeners...

@clshortfuse
Copy link

clshortfuse commented Jan 11, 2023

host in scope can be polyfilled like this:

+ <template id="hostScopeTemplate">
+   <div>
+     <script>var host = null</script>
+     <button hidden onclick="host = this.getRootNode().host" />
+   </div>
+ </template>
<template id="myCustomElementTemplate">
  <button onclick="host.handleClick()"><slot></slot></button>
</template>

<script type="module">
+  let scopeTemplate;

+  function setHostInScope(shadowRoot) {
+    scopeTemplate = scopeTemplate ?? document.getElementById('hostScopeTemplate');
+    shadowRoot.appendChild(scopeTemplate.content.cloneNode(true));
+    shadowRoot.querySelector('button').click()
+    shadowRoot.querySelector('div').remove();
+  }

  /* ... */
  class myCustomElement extends HTMLElement {
    static {
      customElements.define('x-button', this);
    }
    constructor() {
      super();
      const shadowRoot = this.attachShadow({ mode: "open" });
+      setHostInScope(shadowRoot);
      const template = document.getElementById("myCustomElementTemplate");
      shadowRoot.appendChild(template.content.cloneNode(true));
    }
    handleClick() {
      window.alert('You clicked me!');
    }
  }
  /* ... */
</script>

<x-button>Click me</x-button>

Hacky, but it works. It's probably cleaner to have myCustomElement extend something other than HTMLElement for multiple components. There's no access to private fields though.

The click workaround is because scripts can't know it's element/context.

whatwg/html#1013

If it wasn't for that, we could eliminate the hidden click and it would be one liner that doesn't need to be removed.

In practice, I can strip out of a bunch of JS code related to interpolation (no passive though).


Edit:

Turns out scripts aren't sandboxed in shadow DOM.

Related: https://discourse.wicg.io/t/script-tags-scoped-to-shadow-root-script-scoped-src/4726/12

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants