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

Anonymous Custom Elements #1074

Open
filimon-danopoulos opened this issue Sep 30, 2024 · 8 comments
Open

Anonymous Custom Elements #1074

filimon-danopoulos opened this issue Sep 30, 2024 · 8 comments

Comments

@filimon-danopoulos
Copy link

I would like to discuss the possibility of using custom elements without requiring the custom element registry.

Overview

Allowing a custom element to be used without a custom element registry allows us to sidestep a lot of the issues around the global registry that are discussed in #716. Instead of introducing a new registry we allow a per-element definition.

In order to get the conversation started I propose the working name of anonymous custom elements. The primary drive is to couple custom elements with ESM modules. This would require no changes to the DOM-api as we can simply use a constructor directly.

// my-example.js
export class MyExampleElement extends HTMLElement {
  constructor() {
    super()
    const root = this.attachShadow({ mode: 'open' })
    root.innerHTML = '<span>example</span>'
  }
}

// index.js
import { MyExampleElement } from './my-example.js' 
document.body.appendChild(new MyExampleElement ())

The HTML for this could be

<body>
  <anonymous>
    #shadow
      <span>example</span>
  </anonymous>
</body>

This new <anonymous> element would keep track of the module that defines the custom element in the property module and the constructor in a property definition. In the example above module would be the value of import.meta.url and definition the local name of MyExampleElement in that module.

These two would also allow for an HTML API.

<anonymous module="./my-example.js" definition="MyExampleElement"></anonymous>

This would load the ESM module ./my-example.js and use the constructor defined from that module. A similar thing could be done via DOM

const anonymous = document.createElement('anonymous') 
anonymous.module  = './my-example.js'
anonymous.definition = 'MyExampleElement'
document.body.appendChild(anonymous)

Benefits

  • Utilizing ESM-modules instead of a registry allow for custom elements to be defined more closely to what one would expect coming from other component systems.
  • Since tag name is not relevant a lot of the edge cases that arise with scoped element registries are sidestep entirely.
  • Additionally this adds a much needed feature of on demand loading of custom elements based on usage.

Drawbacks

  • Possibly SSR support for a feature like this could be hard. Should declarative shadow DOM be allowed?
  • Not possible to target specific custom elements with tag selectors.

I am happy to explore this idea with you all. This is my first issue so please guide me if something is missing or is formatted incorrectly.

@keithamus
Copy link
Collaborator

Firstly I want to say great job on thinking through an idea like this! Thanks for presenting it, I hope we get to see more ideas from you.

On the surface this might look like a reasonable API, but from an implementation standpoint this would be a significant amount of work; the class is a backing object for the internal "Custom Element Definition", which gets created and attached to the backing class when define() is called. Avoiding the define() call would mean some kind of "anonymous custom element definition" which would be very difficult to keep track of as most of the custom element APIs are predicated around a custom element definition being attached to a name. This would likely need non-trivial changes to the way HTMLElement constructor runs and require large refactoring of the Custom Element Registry. In addition to those changes, the DOM would need some drastic changes in order to support anonymous HTML elements...

Some things that would be prevented or made significantly more difficult by an anonymous HTML element:

  • Presumably there would be no way to query it via .querySelector or CSS, unless you attach a class name or attribute to each one (kind of defeating some of the point of the anonimity, no?). If you used the name <anonymous> then it would make it difficult at-best and impossible at-worst to differentiate anonymous elements from eachother. Does the use case warrant effectively hampering CSS like this?
  • There would be no tractable way to call outerHTML, or innerHTML on a parent. This would probably break a lot of frameworks and libraries. Using the <anonymous> tag would mean re-attaching it could potentially change or lose its definition which seems dangerous. Additionally it would be very strange for existing APIs (e.g. createElement) to be able to create <anonymous> elements presumably have no definition?
  • It would potentially break the design of scoped registries, as there would likely need to be special handling if your element moved to a new shadow root.

Given the amount of work involved on the implementation side (very large and lots of careful refactors required) I wonder if it would be worthwhile? How problematic is giving a name to an element today? Is it so bad that it warrants such a large amount of work and potentially making other APIs more awkward to use?

@justinfagnani
Copy link
Contributor

If the tag name doesn't matter, then we should be able to use a generated tag name to fit in with all the current custom element APIs.

Is there problem with userland generated APIs? Is it still the possibility of name collisions?

@filimon-danopoulos
Copy link
Author

Thank you for the encouragement @keithamus . I have a couple of follow up questions/thoughts.

This would likely need non-trivial changes to the way HTMLElement constructor runs and require large refactoring of the Custom Element Registry.

I was afraid that from an implementation perspective that this might not be feasible. I might have made it harder for myself by using custom element in the title? If we consider <anonymous> to be a new type of element that is not connected to custom elements at all does that change how this would related to custom element registries? Or would it still be considered a type of custom elements for implementation properties?

Presumably there would be no way to query it via .querySelector or CSS, unless you attach a class name or attribute to each one (kind of defeating some of the point of the anonymity, no?)

I think that this is actually a fine compromise. The primary use case would be as an alternative to scoped element registries. So in practice you would know what the element should be. Anonymity would not be the primary goal so the name is probably not a perfect fit.

There would be no tractable way to call outerHTML, or innerHTML on a parent. This would probably break a lot of frameworks and libraries. Using the tag would mean re-attaching it could potentially change or lose its definition which seems dangerous.

I am not certain I understand what the implication is, or maybe what this means. Could you elaborate?

It would potentially break the design of scoped registries, as there would likely need to be special handling if your element moved to a new shadow root.

I don't really understand how scoped registries would affect this. It seems like the anonymous element would be same regardless of what exists in a custom element registry (scoped or global)? Would this be a consequence of how this feature could be implemented? The intention is that a <anonymous> would be introduced specifically to not be affected by any element registry.

Additionally it would be very strange for existing APIs (e.g. createElement) to be able to create elements presumably have no definition?

In my mind this would not be stranger than <img> without src. It represents something but we don't know what until we provide all the necessary information.

Given the amount of work involved on the implementation side (very large and lots of careful refactors required) I wonder if it would be worthwhile? How problematic is giving a name to an element today?

In my mind yes, it could be worth it. I don't pretend to know how such a thing could be implemented and what the actual cost
is. The fundamental change here is not so much the omission of element name as it is the use ES modules as the primary way to defined elements over element registries.

@filimon-danopoulos
Copy link
Author

If the tag name doesn't matter, then we should be able to use a generated tag name to fit in with all the current custom element APIs.

Is there problem with userland generated APIs? Is it still the possibility of name collisions?

I guess that is one approach that still allows for a DOM-only API. Is finding an appropriate tag name non-trivial? In particular if it supposed to be compatible scoped registries too. Maybe a helper on customElements that does some time-based randomization?

// my-example.js
customElements.define(customElements.randomName(), MyExampleElement);

export class MyExampleElement extends HTMLElement {
  constructor() {
    super()
    const root = this.attachShadow({ mode: 'open' })
    root.innerHTML = '<span>example</span>'
  }
}

// index.js
import { MyExampleElement } from './my-example.js' 
document.body.appendChild(new MyExampleElement ())

It also has the major drawback that we don't get a way to access this element via HTML. We could do

customElements.define(customElements.randomName(), MyExampleElement);
customElements.define('my-example', class extends MyExampleElement {});

But as you note that opens up the door to naming collisions. The idea of creating a dependency to a specific ES module was meant to sidestep any issues with naming.

@keithamus
Copy link
Collaborator

I was afraid that from an implementation perspective that this might not be feasible. I might have made it harder for myself by using custom element in the title? If we consider <anonymous> to be a new type of element that is not connected to custom elements at all does that change how this would related to custom element registries? Or would it still be considered a type of custom elements for implementation properties?

If you want to do the things a custom element does - such as having the callbacks (CEReactions) then you need to have a definition, and the definition is kind of handled by tag name. For the most part it would work internally as elements have an element definition pointer, so all the while the element exists, it knows its definition, and therefore can have a backing class, but the problem comes with how you reference it with the existing HTML APIs which are all predicated on tag names; namely serialisation.

I think that this is actually a fine compromise. The primary use case would be as an alternative to scoped element registries.

Personally I don't think it is a fine compromise. It makes for inconsistent modelling of APIs. It's very strange to not be able to select for an element because it's missing or otherwise has a generic tag name; it makes for an inconsistent API where almost everything works except for this one new thing. Selling this idea to implementers might be a struggle.

In my mind yes, it could be worth it. I don't pretend to know how such a thing could be implemented and what the actual cost is. The fundamental change here is not so much the omission of element name as it is the use ES modules as the primary way to defined elements over element registries.

Implementing such a change could take months of coding, if it were even viable. Analysis would need to be done, each API would need to be assessed and altered. That's if we can get consensus with all the implementers, which alone could take years to come to consensus with all the changes. With the cost of the change should come the scale of the benefit. What comes to mind is a feature like customisable select; a large project that requires significant refactors and has taken years of work. Customisable select has extremely well motivated tangiable benefits though (accessibility, avoiding massive duplication of efforts, etc).

So the challenge with this proposal is if you're proposing a roughly eqiuvalent amount of work, can you advocate that it'll give a roughly equivalent amount of benefit? Having been in a few conversations around standards, I imagine some questions you'd need to answer:

  • Are many people trying to do this today?
  • If so why? What problem does it solve? Are you prepared to talk about the problem space in abstract (without mentioning anonymous elements) that makes a convincing case?
  • How do people solve this today? Are there very popular userland libraries that solve for this today? Are there many smaller libraries that solve this in slightly different ways that deserve convergance?

Is there problem with userland generated APIs? Is it still the possibility of name collisions?
Is finding an appropriate tag name non-trivial? In particular if it supposed to be compatible scoped registries too. Maybe a helper on customElements that does some time-based randomization?

You could probably use crypto.randomUUID() to achieve something close with incredibly low risk of collisions that would be compatible and usable today:

function getAnonymousName() {
  return 'anonymous-' + crypto.randomUUID()
}
customElements.define(getAnonymousName(), MyExampleElement);

I think it would be interesting to find evidence of people using patterns like this (generating random names) which could provide some evidence to this feature being useful. Of course, if you find little or no evidence, it might be a sign that this isn't a feature that people really need or that it really solves a problem for people.

It's an interesting idea with some great out of the box thinking, but browser vendors, in general, want to prioritise ideas that have a good chance of broad adoption that improves the lives of web users; which is their success metric. They're unlikely to pick ideas that offer speculative/specuous returns.

I don't want to discourage you from coming up with many more excellent ideas, I hope you don't see this as me pooh-poohing this one. Just trying to offer some constructive feedback.

@justinfagnani
Copy link
Contributor

I have personally heard of people using the generated tagname approach. It definitely works, but has one big limitation in that generating a name for your root component doesn't generate names for the custom elements it in turns uses.

It's often a bit trickier to use generated names in template systems and CSS, and if you use 3rd party dependencies, they won't be using the generated name strategy.

Generated names would benefit greatly from scoped element registries, so that you use a generated name for the root element that may appear in the main document, and it in turns uses scoped registries for its dependencies.

@Jamesernator
Copy link

Even with scoped custom element registries this would be a potential way to deal with moving elements between roots as I've suggested before.

@filimon-danopoulos
Copy link
Author

@keithamus Thank you for the in depth reply. It provides the context I need to understand what I am asking about. Being new to this space there is a lot arcana to try and figure out.

  • Are many people trying to do this today?
  • If so why? What problem does it solve? Are you prepared to talk about the problem space in abstract (without mentioning anonymous elements) that makes a convincing case?

I have personally done random tag name generation before. The primary use case was as in order to provide a wrapper for frameworks to use scoped elements. Assume I have the component <some-element> that internally uses some scoping, it can be the scoped element registry or some other mechanism. In my case I did a transform of tag names in bundle time. The end result is <some-element> that for all intents is "scoped". If I distribute this as an NPM package I can easily get a situation where multiple people register the same constructor and same tag name, in particular in MFE-type settings where multiple bundles are used on the same page.

This effect is only amplified by import maps since I then can into the situation that multiple bundles can use the same ES module and constructor so we don't only get tag name collisions but also constructor collisions in the registry.

So I'd essentially generate (JSX for the example)

import { SomeElement as SomeElementBase } from '@elements/some-element.js'
custom.elementsDefine('some-element-1234', class extends SomeElementBase {})

export function SomeElement() {
  return <some-element-1234></some-element-1234>
}

This combined with import maps can create essentially ESM scoped web components. At the same time this is sort of defeats the point of using web components in the first place since we are forced to produce framework wrappers.

The abstract need without discussing specifics would be to import a custom element constructor into an ES module and attach an instance of that to the DOM.

We can always generate tag names at each call location:

// my-example.js
export class MyExampleElement extends HTMLElement {
  static getTag() {
    const tagName = `my-example-${crypto.randomUUID()}`
    customElements.define(tagName, class extends MyExampleElement {})
    return tagName
  }
}

// index.js
import { MyExampleElement } from './my-example'
const tagName = MyExampleElement.getTagName()
const myExample = document.createElement(tagName)
// ...

All that being said @justinfagnani makes a good point regarding generated names

It's often a bit trickier to use generated names in template systems and CSS

This would probably require some framework specific wrapper regardless, rendering the entire concept invalid.

@Jamesernator I assumed this had been brought up before. I was a little hesitant to raise an issue but figured that there are no stupid questions :)

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