-
Notifications
You must be signed in to change notification settings - Fork 12
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
[On-Demand Definitions] Initial draft #67
base: main
Are you sure you want to change the base?
Conversation
Might want to also have a class StopWatchElement extends HTMLElement {
static define(tag = "stop-watch", registry = customElements) {
registry.define(tag, this)
}
} |
@keithamus, I haven't had much interaction with scoped registries, but I think supporting any registry via a parameter probably a reasonable thing to do. I'll have to learn more about they work to develop a stronger opinion on it. I also see you have a |
FAST uses a very similar pattern to this, as does my Web Component Engineering course. There's some extra code to guard against the same instance with different tag names (solvable by dynamically inheriting a new base class from the original before defining the element). You might want to also have a third parameter for options to the define call (or library/component specific options). |
I know I've seen a similar pattern around but was struggling to remember exactly what those were. Glad to see there's some prior art here!
Are you thinking something like: export class MyElement extends HTMLElement {
static define(tagName) {
if (customElements.get(tagName)) return;
// Define a unique subclass for a non-default tag name.
const clazz = tagName ? class extends MyElement {} : MyElement;
customElements.define(tagName ?? 'my-element', clazz);
}
} I'm a bit hesitant to go that direction as some of the consequences seem very unintuitive: MyElement.define('my-other-element');
customElements.getName(MyElement) === 'my-element'; // Expected `my-other-element`.
customElements.get('my-other-element') !== MyElement; // Expected `===`. Also as mentioned in the proposal, some component authors might want static knowledge of their tag name. I'm not sure it's reasonable to restrict this proposal only to components which are willing to give up that knowledge. Beyond that, what exactly is the benefit of having a dynamic tag name? Is the goal just to avoid name conflicts? If so, would that be better solved by scoped registries rather than dynamic tag names?
Ah yes, I always forget |
Once scoped registries are common I think the best pattern will be for elements to just not self register at all - or do so in a separate side-effectful module from the component class. Then the consumer can follow whatever pattern it wants for registering, and there are no more issues with tree-shaking, etc. For example, in Lit we support adding elements to the scoped registry declaratively: import {LitElement, html} from 'lit';
import {ScopedRegistryHost} from '@lit-labs/scoped-registry-mixin';
import {FooElement} from './foo-element.js';
class MyElement extends ScopedRegistryHost(LitElement) {
static elementDefinitions = {
'foo-element': FooElement,
};
render() {
return html`<foo-element></foo-element>`;
}
} I'm not sure that the static define pattern does anything for us on top of this. |
Network of tooling question:Can the following in TS be done lazily? declare global {
interface HTMLElementTagNameMap {
"my-element": MyElement;
}
}
Clean up process:If you have this usage: import { MyElement } from 'my-element';
// ...
MyElement.define();
// ...
const template = `<my-element></my-element>`; When taking import 'my-element/defined.js';
// ...
const template = `<my-element></my-element>`; Does this realistically free tooling to tree shake without the wider scale pattern you outline in HydroActive? Class extensionsShould class extension be addressed in some way in this proposal? Maybe having the protocol documented here and then the element documented as leveraging the protocol would position an extending developer for success. Other thoughts on what could support someone in this area? You begin to address something in this realm when you choose to noop on subsequent registrations, which can be problematic in it's own right (unexpected versions being registered, etc.), but directly addressing notes above around scoped registries should position a protocol with this shape to avoid those sorts of issues. |
@justinfagnani / @Westbrook, thanks for all the feedback here! Clearly this proposal would probably benefit from a deeper dive into scoped registries. From a quick look at the proposal I have some immediate thoughts, but I might just need to be educated on some of this.
@justinfagnani, I can definitely see the appeal of a world where every component gets its own registry, therefore there is no global side-effect at all (or at least minimal side-effects, entry point elements would likely still rely on them). I do have some specific concerns though: First, scoped registries seems coupled to shadow DOM, which feels like a weird constraint to put on affected components. Light DOM components would still benefit from the tree-shaking and tooling benefits of static Second, scoped registries are solving a different problem with a different set of requirements. In your example with Lit, import {LitElement, html} from 'lit';
import {FooElement} from './foo-element.js';
class MyElement extends LitElement {
// Lit could call `.define()` automatically.
static definitions = [FooElement];
render() {
return html`<foo-element></foo-element>`;
}
} This reduces repetition of the To illustrate a related concern, I could add scoped registry support to HydroActive (and probably should at some point), but that would require HydroActive to have a similar mapping of Third, how do scoped registries work for pre-rendered DOM elements? The proposal mentions adding If I'm understanding this correctly, it's probably workable as specified. I'm just not entirely sure I'm completely understanding how scoped registries interact with pre-rendered DOM. Fourth, even in a scoped registry world, wouldn't a const registry = new CustomElementRegistry();
MyElement.define(registry, 'other-element'); Slight tangent: @EisenbergEffect suggested passing through options but I'm actually wondering if that's necessarily useful. Currently the only supported option is export class MyParagraphElement extends HTMLParagraphElement {
static define(registry = customElements, tagName = 'my-p-element') {
if (registry.get(tagName)) return;
// Hard-coded `extends: 'p'`, don't want callers to change this.
registry.define(tagName, MyParagraphElement, { extends: 'p' });
}
} I'm thinking this can still provide a useful abstraction over the custom element definition, even when used in a scoped registry. Exposing options also gets weird for cases where different consumers might disagree on the import {MyElement} from './my-element.js';
MyElement.define();
MyElement.define(undefined, undefined, { extends: 'p' }); // No-op means it does not use `extends: 'p'`. This again reinforces that maybe I'm thinking this is a problem with
@Westbrook, I'm not entirely sure I follow what you're getting at. Can you elaborate on how If your concern is that the type assumes the element is defined, that is an existing issue which isn't really affected by this proposal. HydroActive actually helps with that to a certain extent. How TS handles scoped registries is maybe a related problem.
This is a fair concern, but I do think this pattern is still helpful for a few reasons:
That said, I do understand that this can have relatively minimal value for simple cases like the one you mention. I think the power of this API really comes from custom element libraries and frameworks which directly integrate with it, automatically defining elements before interacting with them. This is why I called out HydroActive and Lit as motivating use cases. In those contexts, removing the element's usage also implicitly allows its import to be tree-shaken or flagged by a linter. That's where the real power of this proposal comes from.
Can you define what you mean by "class extension"? What are you referring to?
Hmm, if multiple components attempted to claim the same tag name that should probably fail like export class MyElement extends HTMLElement {
static define(registry = customElements, tagName = 'my-element') {
const existing = registry.get(tagName);
if (existing) {
if (existing === MyElement) {
return; // Already defined, no-op.
} else {
throw new Error(`Tag name \`${tagName}\` already defined as \`${existing.name}\``);
}
}
registry.define(tagName, MyElement);
}
} The one other issue I can think of is the one I mentioned earlier about multiple Does that address your concern? Are there other issues with noop-ing duplicate definitions I'm overlooking? |
Just noting that I also add the tagName as a static property. I like distributing elements that have a certain name, but is easily overridable. It could also be helpful to be able to read the tagName for scoped registries or other things. export class MyElement extends HTMLElement {
static tagName = ‘my-element’;
static define() {
if (customElements.get(this.tagName)) return;
customElements.define(this.tagName, MyElement);
}
} |
This follows the initial draft of the [static `define` community protocol](webcomponents-cg/community-protocols#67). Each component now automatically includes a static `define` property which defines the custom element. `Dehydrated.prototype.access` and `Dehydrated.prototype.hydrate` both call this `define` function for the custom element class to ensure that it is defined before the element is used. The major effect of this is that `define*Component` no longer automatically defines the provided element in the global registry. This allows elements to be tree shaken when they are unused, but does mean users need to manually define them in the top-level scope if their application relies on upgrading pre-rendered HTML. I'm not a huge fan of this as it is quite easy for users to forget to call `define`, which was half the point of defining all components automatically. However, HydroActive should naturally do the right thing when depending on another component due to `Dehydrated` auto-defining dependencies. Defining entry point components is unfortunately not a problem HydroActive can easily solve. The current implementation does presumably support scoped custom element registries, but this is not tested as the spec has not been implemented by any browser just yet. The polyfill can potentially be used to verify behavior.
This follows the initial draft of the [static `define` community protocol](webcomponents-cg/community-protocols#67). Each component now automatically includes a static `define` property which defines the custom element. `Dehydrated.prototype.access` and `Dehydrated.prototype.hydrate` both call this `define` function for the custom element class to ensure that it is defined before the element is used. The major effect of this is that `define*Component` no longer automatically defines the provided element in the global registry. This allows elements to be tree shaken when they are unused, but does mean users need to manually define them in the top-level scope if their application relies on upgrading pre-rendered HTML. I'm not a huge fan of this as it is quite easy for users to forget to call `define`, which was half the point of defining all components automatically. However, HydroActive should naturally do the right thing when depending on another component due to `Dehydrated` auto-defining dependencies. Defining entry point components is unfortunately not a problem HydroActive can easily solve. The current implementation does presumably support scoped custom element registries, but this is not tested as the spec has not been implemented by any browser just yet. The polyfill can potentially be used to verify behavior.
This follows the initial draft of the [on-demand definitions community protocol](webcomponents-cg/community-protocols#67). Each component now automatically includes a static `define` property which defines the custom element. `Dehydrated.prototype.access` and `Dehydrated.prototype.hydrate` both call this `define` function for the custom element class to ensure that it is defined before the element is used. The major effect of this is that `define*Component` no longer automatically defines the provided element in the global registry. This allows elements to be tree shaken when they are unused, but does mean users need to manually define them in the top-level scope if their application relies on upgrading pre-rendered HTML. I'm not a huge fan of this as it is quite easy for users to forget to call `define`, which was half the point of defining all components automatically. However, HydroActive should naturally do the right thing when depending on another component due to `Dehydrated` auto-defining dependencies. Defining entry point components is unfortunately not a problem HydroActive can easily solve. The current implementation does presumably support scoped custom element registries, but this is not tested as the spec has not been implemented by any browser just yet. The polyfill can potentially be used to verify behavior.
This follows the initial draft of the [on-demand definitions community protocol](webcomponents-cg/community-protocols#67). Each component now automatically includes a static `define` property which defines the custom element. `Dehydrated.prototype.access` and `Dehydrated.prototype.hydrate` both call this `define` function for the custom element class to ensure that it is defined before the element is used. The major effect of this is that `define*Component` no longer automatically defines the provided element in the global registry. This allows elements to be tree shaken when they are unused, but does mean users need to manually define them in the top-level scope if their application relies on upgrading pre-rendered HTML. I'm not a huge fan of this as it is quite easy for users to forget to call `define`, which was half the point of defining all components automatically. However, HydroActive should naturally do the right thing when depending on another component due to `Dehydrated` auto-defining dependencies. Defining entry point components is unfortunately not a problem HydroActive can easily solve. The current implementation does presumably support scoped custom element registries, but this is not tested as the spec has not been implemented by any browser just yet. The polyfill can potentially be used to verify behavior.
This follows the initial draft of the [on-demand definitions community protocol](webcomponents-cg/community-protocols#67). Each component now automatically includes a static `define` property which defines the custom element. `Dehydrated.prototype.access` and `Dehydrated.prototype.hydrate` both call this `define` function for the custom element class to ensure that it is defined before the element is used. The major effect of this is that `define*Component` no longer automatically defines the provided element in the global registry. This allows elements to be tree shaken when they are unused, but does mean users need to manually define them in the top-level scope if their application relies on upgrading pre-rendered HTML. I'm not a huge fan of this as it is quite easy for users to forget to call `define`, which was half the point of defining all components automatically. However, HydroActive should naturally do the right thing when depending on another component due to `Dehydrated` auto-defining dependencies. Defining entry point components is unfortunately not a problem HydroActive can easily solve. The current implementation does presumably support scoped custom element registries, but this is not tested as the spec has not been implemented by any browser just yet. The polyfill can potentially be used to verify behavior.
This follows the initial draft of the [on-demand definitions community protocol](webcomponents-cg/community-protocols#67). Each component now automatically includes a static `define` property which defines the custom element. `Dehydrated.prototype.access` and `Dehydrated.prototype.hydrate` both call this `define` function for the custom element class to ensure that it is defined before the element is used. The major effect of this is that `define*Component` no longer automatically defines the provided element in the global registry. This allows elements to be tree shaken when they are unused, but does mean users need to manually define them in the top-level scope if their application relies on upgrading pre-rendered HTML. I'm not a huge fan of this as it is quite easy for users to forget to call `define`, which was half the point of defining all components automatically. However, HydroActive should naturally do the right thing when depending on another component due to `Dehydrated` auto-defining dependencies. Defining entry point components is unfortunately not a problem HydroActive can easily solve. The current implementation does support scoped custom element registries, but this will be tested in a follow up commit.
define
] Initial draft
I just pushed some changes which:
Simultaneously, I took the opportunity to implement this proposal in HydroActive and it seems to work exactly like I hoped it would. I'll need to continue playing around with it to see if any new challenges or limitations can be identified. Please take a look and share any feedback, suggestions, or concerns you may have! 🙏 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you available for the December WCCG meeting I'd be happy to make time to bring this up at that time and query the group in person about any questions/concerns for this addition.
Sure, happy to chat about it. Is this on Dec 16th or 17th? The Discord and Google Calendar events seem to disagree. |
@dgp1130 thanks for pointing that out! I'm not in my normal timezone and it'm made working with calendars even harder than expected. It should be Monday the 16th at 7pm ET in both places now. Let me know if I've still not quite gotten it correct. 🙇🏼♂️ |
…ge of a community protocol.
…ntion which inlines the `define` implementation at its usage sites, and an alternative using `customElements.whenDefined`. Also makes various minor readability improvements and acknowledges the new scoped custom element registries proposal.
349a088
to
c96b00f
Compare
After the community group discussion today I've updated the proposal to address the oversights mentioned by @sorvell (and gave better justification for why we should prefer this over inlining Summary of changes:
Definitely interested in hearing any more feedback about this proposal! 😁 |
As discussed in today's WCCG meeting, we will be opening a month long time box for reviewing the promotion of this protocol to "Proposal" status by ay on merging this PR which will close on January 17th. Review status levels and their requirements here. |
In TS this is a single source of truth defined up front, ahead of time. It cannot be done lazily. I.e., it will not update itself based on whichever arbitrary element names are actually sent into customElements.define. In TS JSX, it relies on a So, in either case, name collisions can still happen, and this will not pair well with scoped registries. However! For JSX specifically (out of luck with /* @jsxImportSource some-lib/some-file */
// The JSX type checking in this file will specifically rely on the JSX namespace from some-lib/some-file and another file: /* @jsxImportSource some-lib/some-file */
// The JSX type checking in this file will specifically rely on the JSX namespace from other-lib/other-file This means that, using scoped registries, an end user does have the opportunity to define the type checking source per file to align that with whatever elements they are using in the file's JSX templates. However Well, the |
I coincidentally also have my own approach to this (called Seems like the sort of thing a lot of us have naturally come up with. |
Not something that would necessarily need to be worked out here, but @trusktr do you have thoughts on this Protocol using |
This is an initial draft of the On-Demand Definitions proposal: A protocol for defining custom elements when needed without relying on top-level side-effects.
TL;DR:
Problems:
Proposal:
Instead of writing
customElements.define('my-element', MyElement)
as an adjacent top-level side-effect, put it in a staticdefine
property of the class:This removes top-level side-effects making the element tree-shakable. Any consumers of this class can call
MyElement.define()
to ensure it is defined before they attempt to use it. This decentralizes the process of defining a custom element and more closely couples the definition with its specific usages.