webcomponents-cg / community-protocols

Cross-component coordination protocols
175 stars 10 forks source link

[Proposal] Registration API #52

Open oscarotero opened 10 months ago

oscarotero commented 10 months ago

Hi. I'd like to propose a generic API to register custom elements easier, and allow to customize the tag name if it's needed. I got the inspiration from this article from @mayank99.

The code:

class MyComponent extends HTMLElement {
  static tagName = "my-component";

  static register(tagName = this.tagName) {
    customElements.define(this.tagName = tagName, this);
  }
}

This would allow to register the element in different ways:

import MyComponent from "./my-component.js";

MyComponent.register(); // Register as `<my-component>`.
import MyComponent from "./my-component.js";

MyComponent.register("your-component"); // Register as `<your-component>`.
import MyComponent from "./my-component.js";

MyComponent.tagName = "your-component";
MyComponent.register(); // Register as `<your-component>`.
thepassle commented 10 months ago

What is the benefit of this over just doing customElements.define?

oscarotero commented 10 months ago

I can think of some benefits:

  1. Provides a consistent way to register web components. Currently, some custom elements are registered automatically on importing the javascript code, others need to do customElements.define. With this protocol, all components will work in the same way.
  2. Allows to edit the tag name in case of conflict with other custom element registered with the same name.
  3. Allows to add a prefix of the tag name of all components:

    import Button from "./components/button.js";
    import Icon from "./components/icon.js";
    import Selector from "./components/selector.js";
    
    [Button, Icon, Selector].forEach((comp) => {
     comp.tagName = "prefix-" + comp.tagName;
     comp.register();
    });
  4. Provides a way to know the tag name used by a component from other component:

    import Button from "./components/button.js";
    
    class ButtonGroup extend HTMLElement {
      constructor() {
         const buttons = this.querySelectorAll(Button.tagName);
      }
    }
thepassle commented 10 months ago

But you can just do this as well? Im trying to understand what the benefit of having a .register method is.

import Button from "./components/button.js";
import Icon from "./components/icon.js";
import Selector from "./components/selector.js";

[Button, Icon, Selector].forEach((comp) => {
  comp.tagName = "prefix-" + comp.tagName;
-  comp.register();
+  customElements.define(comp.tagName, comp);
});
Westbrook commented 10 months ago

@oscarotero thanks for starting this conversation!!

The "Goals" section proposed in @matthewp's protocol proposal template will be a useful piece of work to smooth the discussion around any proposal. Would be great to start there (even if it's just one or two to get the conversation started) in issues opened as well, so we can all be sure to be on the same page when discussing something like this.

We will be discussing the template ratification at next week's Web Components Community Group meeting, feel free to join in the conversation.

oscarotero commented 10 months ago

Thanks @Westbrook I don't think I can be in the meeting, but will wait to the template ratification in order to use it for this proposal.

keithamus commented 10 months ago

I've used a similar pattern in WebComponents.guide.

One of the benefits to having a static method call is that components can more easily register their dependants, and I imagine it'll be more useful when we have scoped registries:

class MyComponent extends HTMLElement {
  static define(tagName = 'my-component', registry = customElements) {
    registry.define(tagName, this);
    const myreg = new CustomElementRegistry()
    MyDependant.define('my-dependant', myreg);
    MyOtherDependant.define('my-other', myreg);
  }
}

This allows consumers to call MyComponent.define() without thinking about the internal structures/dependents of that component.

thepassle commented 10 months ago

Shouldnt an element that uses other elements internally via scoped registries just define them by default? If you forget calling .define, the element doesnt render anything? Why not do it automatically?

keithamus commented 10 months ago

You mean to move it into the constructor or something? So the code in my comment would instead be:

let registry = new CustomElementRegistry()
class MyComponent extends HTMLElement {
  constructor() {
    super()
    if (!registry.get('my-dependent')) registry.define('my-dependent', MyDependent)
    if (!registry.get('my-other')) registry.define('my-other', MyOtherDependent)
  }
}
thepassle commented 10 months ago

Yeah, or something like we do in scoped-elements: https://github.com/open-wc/open-wc/blob/master/packages/scoped-elements/html-element.js

im not sure what the benefit of requiring a consumer of your element to manually call .define before being able to use the element is?

I do like a static property to suggest a default tagName (even though its just that; a suggestion, because a consumer of your class may register it under a different name) I also do this in generic-components, I just dont think a lot of abstractions over customElements.define actually provide anything over… just calling customElements.define.

trusktr commented 10 months ago

I believe scoped registries solve all use cases listed so far.

The only difficulty is getting all library authors to not automatically define their elements. And what about existing libraries that authors don't have time to update existing libraries?

There are many custom elements today using a class decorator like @element('some-el'), which automatically defines. They are likely encouraged to leave things as is unless the upstream decorator makes definition not happen by default.

Maybe that's what frameworks similar to Lit should do in that case, is release a breaking major version at some point where the decorator does not define by default? Those decorators can return a subclass with a method similar to above .register (I use .defineElement in my lib to make it less ambiguous) that users can call, optionally taking in a registry arg (or maybe requiring the registry arg is better, forcing people to think about which registry), f.e.

import {element, Element} from '@lume/element'

@element('my-el')
export class MyEl extends Element {...}
import {MyEl} from './MyEl.js'

// ... inside other element class ...
const reg = new CustomElementsRegistry()
this.attachShadow({mode: 'open', customElements: reg})
MyEl.defineElement() // error, no reg provided 
MyEl.defineElement(reg) // ok
MyEl.defineElement(reg, 'other-name') // custom name
mattlucock commented 10 months ago

I agree that a custom element shouldn't force a user to have that custom element defined by a particular name, and I think it's unfortunate that that is what typically happens. Of course, a big benefit of the custom element defining itself is that a user who doesn't care about the name and just wants to use the default name can do so. I like the idea of a custom element class having a static property that contains an author-specified default name that a user can use if they don't want to fuss; that seems like quite a good idea, actually.

But I strongly agree with @thepassle that abstracting customElements.define like this is a mistake. Also, I think the idea of this tag name being mutable (as opposed to an author-specified constant) is a mistake, since it implies that an entire custom element class is only allowed to be registered once under one particular name, which isn't a constraint that custom elements normally have (and in general I don't think the benefit of this has been justified).

The biggest problem I have with this protocol proposal is: I don't think this is a protocol, since it doesn't relate to interaction between components. A component author could simply choose to expose this or some variation of it as part of a component's public API, and indeed, the only way you could use this is if the component author chose to make it part of the public API—in which case, this isn't some standard 'protocol'; it's just that component's particular API.

This proposal cannot achieve "a consistent way to register web components" for "all components", since in general, most components will not support this. And indeed, we already have a consistent way to register web components, that all components support: it's customElements.define.

keithamus commented 10 months ago

FWIW instances can access this.localName or customElements.getName(MyClassElement) to get the name as defined, so I agree that static tagName is of limited value.

mattlucock commented 10 months ago

I was mistaken in my previous comment; I got confused and thought you could register a custom element twice under different names, but it's fairly intuitive that you can't, since if you instantiated a custom element directly, but it has multiple names, what the tag name would be for that instance is indeterminate.

Given that, I don't think it's obvious that user-customized tag names actually are a good idea. If two different implementations try and define the same custom element on the same page under different names, the one that goes second will fail, which is unpredictable since in general the ordering is arbitrary. Of course, scoped registries would theoretically solve this problem, and the fact that this limitation exists at all is silly (I think all the limitations of custom element registries are silly, but that's a matter for another time), but the single global registry is currently the status quo. The linked article justifies user-customized tag names by saying

However, there is still no way to customize the “tag name”. What if my-butt is already occupied? A reusable element needs to allow registering itself with a different tag name.

But I think the flexibility the author desires simply isn't possible with a single global registry. Customizing the tag name solves one problem but creates another, equivalent problem.

EDIT: Also, how do we know whether my-butt is already occupied, and what do we do in response? If we change the name under which we define it, what if a different implementation tries to define a custom element under that name? It's naming conflicts all the way down.