WICG / webcomponents

Web Components specifications
Other
4.36k stars 370 forks source link

Custom Property + 0 or more Custom Attributes => Custom Enhancement #1000

Open bahrus opened 1 year ago

bahrus commented 1 year ago

Author(s)

Bruce B. Anderson

PR's, Issues welcome

Last update

2024-08-30

This is one of a number of interesting proposals, one of which (or some combination?) can hopefully get buy-in from all three browser vendors. This proposal borrows heavily from the others.

Custom Attributes For Simple Enhancements

Say all you need to do is to create an isolated behavior/enhancement/hook/whatever associated with an attribute, say "log-to-console" anytime the user clicks on elements adorned with that attribute, where we can specify the message. Here's how that would be done with this proposal. It could be done more simply, with hard coded values, and without the commentary noise, so please allow for that when weighing the complexity.

export const enhancementInfo = {
    //name of our "custom prop", accessible via oElement.enhancements[enhancement], 
    //which is where we will find an instance of the class defined below.
    enhKey: 'logger', 
    base: 'log-to-console', //canonical name of our (base) custom attribute.
    enhancer: class extends ElementEnhancement {
        attachedCallback(enhancedElement: Element, enhancementInfo: EnhancementInfo){
            const {base} = enhancementInfo;
            // in this example, base will simply equal 'log-to-console', 
            // but this code is demonstrating how to code defensively, so that
            // the party (or parties) responsible for registering the enhancement 
            // could choose to modify the name(s), either globally, 
            // or inside a scoped registry in a different file.
            enhancedElement.addEventListener('click', e => {
                console.log(
                    enhancedElement.getAttribute(base) 
                    || enhancedElement.getAttribute(`enh-${base}`)); 
            });
        }
    }
}
customEnhancements.define(enhancementInfo);
<svg log-to-console="clicked on an svg"></svg>
    ...
<div log-to-console="clicked on a div"></div>

...

<some-custom-element 
    enh-log-to-console="clicked on some custom element">
</some-custom-element>

Done!

Why the long attribute names?

It would be great if we could use a short attribute name, like "log". That can be done for custom elements, why not custom enhancements? This is especially important to consider because, as we will see, this proposal supports multiple attributes "owned" by an enhancement, so allowing for small names would help reduce carpal syndrome.

While it is a bit dicey to be supporting these single word attributes for custom elements, attributes that could conflict with future global attributes, that ship has sailed, and I view it as similar to key words in JavaScript, just a risk we have agreed is acceptable.

This proposal views the risks of following suit as being too high when we move on to enhancing higher-order components, especially as the platform is happily introducing more of them (šŸ„³). There is an informal understanding that built-in attributes won't have dashes in them (e.g. onclick, etc), except once in a blue moon (aria-*) so insisting on dashes in this context seems prudent.

The extra enh- is there to avoid conflicting with attributes that a custom element author may be using, so one of the aspects of this proposal is to suggest that the platform reserve "enh-" prefix similar to how it reserved "data-".

However, I've become aware that there is another informal understanding -- that the platform will only use ASCII characters for future attributes.

So developers wanting to capitalize on that and benefit from shorter names could define, under this proposal, an alternative mapping. For example:

export const enhancementInfo = {
    enhKey: 'šŸŖµ', 
    base: 'šŸŖµ'
}
<svg šŸŖµ="clicked on an svg"></svg>
    ...
<div šŸŖµ="clicked on a div"></div>

...

Or developers could use single words using the small latin characters. Or an emoji followed by ascii characters. Whatever character sets the platform says it will never tap into.

Some risks to doing this:

  1. It may break xml (like svg tags)
  2. It is even farther away from being "HTML5 compliant"
  3. Clashes between different libraries are extremely likely to occur (the shorter the name, the less the ability to "reserve" the name in npm or some other package manager), which is why we posit that a solution to scoped registry should ideally be shipping and proven before shipping this problem space.

It doesn't seem to me that any of these concerns would "block" the platform from doing its thing, so this proposal opts to empower the developer to take these risks.

A note about naming, part I

Why attachedCallback and not connectedCallback? Advantages of connectedCallback is it perfectly aligns with the terminology used for custom elements, and clearly the custom enhancement class above closely resembles a custom element. I could go with that. It doesn't break the essence of this proposal in any way. This was raised (I don't know by whom, but I think it was a member of the WebKit team, but not the person leading the meeting) during a face-to-face, seemingly as a "showstopper" for this proposal (I think), that it made the proposal seem amateurish (paraphrasing). Yes, I am definitely an "amateur" in this space, and I was quite reluctant to even make this proposal for that very reason. I did so because I was (and am) frustrated by not seeing such a proposal forthcoming from the WebKit team, and I am very eagerly awaiting said proposal. When it does (hopefully) and the name "connectedCallback" appears, which seems likely, I hope they will consider my reasoning, even if I am an amateur. But one thing I will not say is that it is a showstopper or amateurish for whatever choice is made.

Reasons I think attachedCallback would be clearer First, let me emphasize that if the reason for the rather pointed comment was that my using "attachedCallback" was interpreted as a slight towards the name used for custom elements, nothing could be further from the truth. If custom elements had chosen "attachedCallback" I might very well have gone for "connectedCallback" in this case. It just feels different to me. When a plane lands, it "connects" to the ground. When we add a wing to the plane, we are "attaching" a wing. Or maybe it makes just as much sense to say when a plane lands, it attaches to the ground. And when we add a wing to the plane, we are "connecting" the wing. Again, different. Using the same term for both would be confusing, I think, for that reason. (In this analogy, the "ground" is the DOM fragment, the wing is the "enhancement"). As this proposal currently stands, the signatures are different, which again, lends itself to thinking the names should also be different. Attaching (connecting?) a cross-cutting concern on top of an existing element feels like attaching Shadow DOM. If we weren't living in such troubled times, I would have seriously considered calling the class ElementHorcrux rather than ElementEnhancement, just to fully cement my amateur status. In addition, it feels like the equivalent of ["attachExternals"](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/attachInternals) Anyway, as I said, I think that decision should be of little consequence, so please replace it with whatever name you like. That's my amateur take.

Why ElementEnhancement and not CustomAttribute? This proposal does "break" if we change it to that name, and the good news is there are some viable, interesting proposals, linked above, which take that approach. I think this naming convention, which may take a little bit of getting used to, based on current parlance, aligns much better with the ultimate goal of this proposal. This proposal sees custom attributes as a means to an end, just as "custom tag name" is a means to a more abstract end: A custom (HTML) Element.

Also, a single element enhancement can "own" multiple attributes (for enhancements that are particularly semantic in nature).

So why not use the customElements' registry, why come up with a new registry, customEnhancements? This point was also raised, quite respectfully, at the face to face, and seems to me like it may have some merit, and I suspect "under the hood" might make a tremendous amount of sense. But from a developer point of view, it seems strange to use the "customElements" object to attach (connect?) things onto built in elements, so I wonder if the "under the hood" considerations could be camouflaged, in the name of clarity? I again eagerly await the formal proposal from the WebKit team(?) where this is spelled out.

The bottom line is I don't think the slight differences with custom elements make this proposal any more complex than defining a custom element.

[!NOTE] I agree 100% with others that scoped registry being fully settled before some combination of these proposals get rolled out into production would appear to be the wise course of action. In the above example, we have two strings that we need to protect from colliding with other enhancements (and with attributes of the (custom) elements themselves): The name of the enhancement - "logger" - and the attribute(s) tied to it, if any: 'log-to-console'. Both will need to be considered as far as best ways of managing these within each Shadow scope. It may be that the easiest solution will require some sort of pattern between the name of the enhancement and the attributes associated with that name (for example, insisting that the name of the enhancement matches the beginning of the camelCased strings of all the "owned" attributes). This proposal, opts to allow the developer to name the enhKey independent of how the attributes are named. The attributes for a single enhancement must share the same base. Other enhancements can share that same base, but base-branch-leaf-prefix combos should be discouraged (but not forbidden) to overlap between different enhancements (it would result in multiple enhancements getting attached (connected?)). Each enhancement must have a unique enhKey value within a Shadow Realm.

ElementEnhancement API Shape

const enhancementInfo: EnhancementInfo = {
    //required
    enhKey: 'greetings',
    //optional
    base: 'my-greetings',
    //optional
    branches: ['', 'hello', 'goodbye'],
    //optional 
    leaves: {
        hello: ['', 'how-are-you', 'hows-it-going'],
        goodbye: ['', 'last-words', 'ps'] 
    },
    //optional
    map: {
        '0.0': {
            instanceOf: 'Object',
            mapsTo: '.'
        },
        '1.0': {
            instanceOf: 'Boolean',
            mapsTo: 'isHello'
        },
        '2.0': {
            instanceOf: 'String',
            mapsTo: 'firstHelloGreeting'
        }
    },
    //entirely optional
    allowedInstanceTypes: [            
        HTMLInputElement, 
        HTMLTextArea, 
        SomeAlreadyLoadedCustomElementClass
    ],
    //entirely optional
    allowedCSSMatches: 'input, textarea',
    //required.
    //Can point directly to an already loaded Class constructor, or
    //as shown below, it can point to an async loader that allows
    //for lazy loading on demand.
    enhancer: async () => {
        return MyEnhancementClassConstructor
    },
    initPropVals: any //set during the runtime handshake
};
type branchitude = number;
type leafitude = number;
type AttrCoordinates = `{branchitude}.{leafitude}`;
class MyEnhancement extends ElementEnhancement {

    static config = {};

    attachedCallback(enhancedElement: Element, enhancementInfo:  EnhancementInfo) { 
        //or connectedCallback if that is clearer
    } 

    detachedCallback(enhancedElement: Element, enhancementInfo:  EnhancementInfo) { 
        //or disconnectedCallback if that is clearer.
    } 

    attributeChangedCallback(
        coordinates: AttrCoordinates,
        oldValue: string, 
        newValue: string.
        attrNode: Node,
        ) { 
        ...
    }

    //  Entirely optional filtering conditions for when the enhancement should be
    // allowed to be attached.
    static supportedInstanceTypes = //entirely optional
        [
            HTMLInputElement, 
            HTMLTextArea, 
            SomeAlreadyLoadedCustomElementClass, 
            SVGElement,
            HTMLMarqueeElement
        ]; //For example

    //Entirely optional
    static supportedCSSMatches = 'textarea, input';

}

At the risk of overwhelming the reader, I want to amend the api above with a little completely optional nuance to allow for different attribute delimiters at different levels of the hierarchy:

const enhancementInfo: EnhancementInfo = {
    base: {
        //prefix assumed to be '-' if not specified
        prefix: '_', 
        name: 'my-greetings',
    },
    //optional
    branches: {
        //prefix assumed to be '-' if not specified
        prefix: ':',
        names: ['', 'hello', 'goodbye'],
    },
    //optional 
    leaves: {
        //prefix assumed to be '-' if not specified
        hello: {
            prefix: '--',
            names: ['', 'how-are-you', 'hows-it-going']
        },
        goodbye:{
            prefix: '---',
            names: ['', 'last-words', 'ps']
        }

    },

};

as this would allow for more readable syntax:

<your-custom-element 
    enh_my-greetings="courtesy of hallmark" 
    enh_my-greetings:hello="select from gloomy section"
    enh_my-greetings:hello--how-are-you="one day closer to death"
    enh_my-greetings:good-bye="select from funny section"
    enh_my-greetings:good-bye---last-words="smell you later"
>
...
</your-custom-element>

Filter support with supportedInstanceTypes, supportedCSSMatches

Having filtering support is there to benefit the developer first and foremost -- the developer is essentially publishing a "contract" of what kinds of elements they can support. The idea for using supportedInstanceTypes, proposed here seems like it has some quite positive benefits:

  1. I think it could help avoid some timing issues of attempting to start enhancing an unknown element, by essentially enforcing a loading sequence of dependencies.
  2. In some cases, especially with custom elements, it could group a bunch of custom elements together based on the base class. CSS currently isn't so good at selecting elements based on a common prefix.
  3. The names can be validated by TypeScript.

Another key reason for adding this filtering capability is performance -- there is a cost to instantiating an enhancement class, adding it to the enhancements gateway, invoking the callback, and holding on to the class instance in memory so anything we can do to declaratively prevent that seems like a win for all involved.

But at what cost? Now, a well designed build process of a closed system web application would theoretically make validations from the platform redundant -- it would generate compile-time errors when it encounters tags that are adorned with an enhancement, when that enhancement has declared such tags as off-limits. Meaning in such a closed, deterministic system, the extra checks that the platform would apply before initiating the run-time handshake would be redundant, and thus wasteful. I guess I'll leave that conundrum as our first open question of the proposal, which doesn't strike me as very significant, but you never know. I *think* the solution for this conundrum would be if the build process also removes the filtering properties (supportedInstanceTypes, supportedCSSMatches) during the build.

[!NOTE] Bear in mind that if no "allowedCSSMatches/allowedInstanceTypes" is specified (the default), and if the "base/branches/leaves" option is also not specified or is empty, the platform will not automatically enhance every element. The platform will only act when it finds a matching attribute pattern. But it will allow enhancements to be programmatically attached by the developer on all element types in that scenario. In fact, the platform will ignore the base/branches/leaves criteria altogether when the developer programmatically attaches (connects?) an enhancement, only using the "allowed" value(s) (combined with the static supported values specified by the enhancement author) to prevent unauthorized enhancements.

What, if any, are the benefits of having a "has" attribute?

[!NOTE] To my great (temporary) relief, the main advocate of the "has" proposal and I seemed, for a short while at least, to have found common ground somewhere in the middle, based on observed attributes (which I recently discovered, was there all along with the has proposal, I missed it because I was so puzzled by the purpose of the "has" attribute). I should also point out that this proposal no longer considers the flat "observedAttributes" array to be the right model for this problem space, meaning a consensus appears even more precarious than before.

From a "developer advocacy" point of view, as the simple example I opened with demonstrates, there doesn't seem to be any benefit to having an extra "has" attribute -- that would just be clumsy and provide more opportunities for conflicts between different teams of developers.

I amended this proposal, though, to support multiple attributes for a single enhancement, in order to accommodate, as best I can, the apparent appeal, which I can definitely relate to that the "has" attribute seemingly provides, kind of a way of grouping related attributes together. I actually do believe there are very strong use cases where we do want one enhancement to be able to break down the "aspects" of the enhancement/behavior into multiple attributes. Benefits are:

  1. The values can be simple strings / numbers / boolean, vs JSON.
  2. Some frameworks may prefer to modify state via attributes instead of properties.
  3. Styling may benefit as well.

However, I think by supporting multiple attributes, requiring that they have dashes or at least one non ascii character, and knowing that developers will go out of their way to avoid clashing with other libraries, we can achieve the same effect without telling the entire IT industry that their way of doing things is wrong. Almost no one is using a "has" attribute, so we should, I think, bend over backwards to not impose a new requirement in order to utilize the platform, without an extremely strong reason. So with this proposal, we can have attributes that naturally group together. To take one very practical example where this makes sense: Suppose we want to provide a userland implementation of this proposal. We could define it like this, which this proposal supports:

<time lang="ar-EG" 
    datetime=2011-11-18T14:54:39.929Z 
    be-intl-weekday=long be-intl-year=numeric be-intl-month=long
    be-intl-day=numeric>
</time>

Or perhaps there's a desire to be even more like the has solution and provide for the base attribute as well:

<time lang="ar-EG" 
    datetime=2011-11-18T14:54:39.929Z
    be-intl 
    be-intl-weekday=long be-intl-year=numeric be-intl-month=long
    be-intl-day=numeric>
</time>

which this proposal also supports.

Even better, this proposal supports emoji's, which allows for quite short attribute names:

<time lang="ar-EG" 
    datetime=2011-11-18T14:54:39.929Z 
    šŸŒ-weekday=long šŸŒ-year=numeric šŸŒ-month=long
    šŸŒ-day=numeric>
</time>

So what would make much more sense to me is rather than having a "has" requirement, to instead insist that all the attributes that a single enhancement "observes" begin with the same base (be-intl or šŸŒ in this case), presumably tied to the package of the enhancement. This proposal is now advocating enforcing such a rule, at least if the developer wishes to receive help from the platform with parsing and automated attachment.

The reason that the flat observedAttributes approach used for custom elements doesn't quite fit the bill, is that I think it will be quite natural for developers to start by doing something with the base attribute, like supporting a JSON structure for all the properties, then decide "you know, it would be helpful to provide a more semantic vocabulary where the aspects of the enhancement can be specified individually" and kind of slap it on. Such is not the case with custom elements.

Better ergonomics for managing attribute changes

This proposal is focusing somewhat on managing attributes, similar to custom elements.

And for clarity, the "house words" for this proposal are "Custom Prop + 0 or more Custom Attributes => Custom Enhancement". The custom prop refers to the name of the enhancement, which, as we've seen, provides the key off of the "enhancements" sub-object of the element. But within that "custom prop" resides a rich universe of properties defined within the user defined class, and the api shape for that class is quite similar to custom elements.

I agree with others that the support that the platform currently provides for managing attributes with custom elements is insufficient. So further compounding that shortcoming by creating a whole new api without additional support doesn't seem right.

I like the promising ideas presented here as far as providing declarative support for managing properties and attributes. Based on the reasoning above, I think it makes sense to consider such improvements to custom elements themselves, and I see no reason not to carry over such ideas to custom enhancements, which this proposal does in fact do (with some variations where it makes sense).

Or maybe it would make more sense to "pilot" such ideas on custom enhancements, and then apply to custom elements. I think those ideas are 100% compatible with this proposal, and shouldn't break it in any way.

Backdrop

The WebKit team has raised a number of valid concerns about extending built-in elements. I think one of the most compelling is the concern that, since the class extension is linked to the top level of the component, it will be natural for the developer to add properties and methods directly to that component. Private properties and methods probably are of no concern. It's the public ones which are. Why?

Because that could limit the ability for the platform to add properties without a high probability of breaking some component extensions in userland, thus significantly constraining their ability to allow the platform to evolve. The same would apply to extending third party custom elements.

Now why would a developer want to add public properties and methods onto a built-in element? For the simple reason that the developer expects external components to find it beneficial to pass values to these properties, or call the methods. I doubt the WebKit team would have raised this issue, unless they were quite sure there would be a demand for doing just that, and I believe they were right.

So for this reason (and others), the customized built-in standard has essentially been blocked.

And yet the need to be able to enhance existing elements in cross-cutting ways has been demonstrated by countless frameworks, old and new. As the latter link indicates, there are great synergies that can be achieved between the client and the server with these declarative blocks of settings. And making such solutions work across frameworks would be as profound as custom elements themselves. The only alternative, working with nested custom elements, is deeply problematic. And quite critically, some built-in elements can't be wrapped inside a custom element without breaking functionality and proper HTML decorum.

A close examination of these solutions usually indicates that the problem WebKit is concerned about is only percolating under the surface, pushed (or remaining) underground by a lack of an alternative solution. One finds plenty of custom objects attached to the element being enhanced. Just to take one example: "_x_dataStack" is used by Alpine.js.

Another example: Currently if I go to https://walmart.com and right click and inspect their tile elements, I see some "react fiber" objects attached (reactFiber$...), full of properties like memoizedProps, refs (a function) etc. And reactProps (reactProps$...), also a function prototype containing properties and methods.

Other examples include closure, wiz, knockout.js, JQueryUI, HTMX, also using names that typically start with an underscore (HTMX uses dashes in the property name).

Clearly, they don't want to "break the web" with these naming conventions, but combine two such libraries together, and chances arise of a conflict. And such naming conventions don't lend themselves to a very attractive api when being passed values from externally (such as via a framework).

Custom Property Name-spacing

So, for an alternative to custom built-in extensions to be worthwhile, I strongly believe the alternative solution must first and foremost:

  1. Provide an avenue for developers to be able to safely add properties to their class without trampling on any other developer's classes, or the platform's, and
  2. Just as critically, make those properties and methods public in a way that is (almost) as easy to access as the top level properties and methods themselves.

So the bottom-line is that the crux of this proposal is to allow developers to do this (with a little tender loving care):

oInput.enhancements.myEnhancement.foo = bar;
oMyCustomElement.enhancements.yourEnhancement.bar = foo;

in a way that is recognized by the platform.

The most minimal solution, then, is for the web platform to simply announce that no built-in element will ever use a property with name "enhancements", push the message to web component developers not to use that name, that it is a reserved property, similar to dataset, only to be used by third-party enhancement libraries. Of course, the final name would need to be agreed to. This is just my suggestion. Some analysis would be needed to make sure that "enhancements" isn't already in heavy use by any web component library in common usage.

I think that would be a great start. But the rest of this proposal outlines some ways the platform could assist third parties in implementing their enhancements in a more orderly fashion, so they can work together, and with the platform, in harmony.

CustomEnhancement: {CustomProp: string, CustomAttr?: [string, string..., string]}

The next thing beyond that announcement would be what many (including myself) are clamoring for:

The platform informs web component developers to not use any attributes with a prefix that pairs up with the property gateway name, "enhancements"; that that prefix is only to be used by third parties to match up with the sub-property of "enhancements" they claim ownership of. My suggestion is enh-*.

So if server-rendered HTML looks as follows:

<input my-enhancement='{"foo": "bar"}'>
<my-custom-element enh-your-enhancement='{"bar": "foo"}'>

... we can expect to see a class instance associated with each of those attributes, accessible via oInput.enhancements.myEnhancement and oMyCustomElement.enhancements.yourEnhancement. That simple relationship may not need to be rigid, or maybe it would, depending on how this proposal would integrate with scoped registries.

The requirement for the prefix can be dropped only if built-in elements are targeted, in which case the only requirement is that the attribute(s) contain (a) dash(es).

Another aspect of this proposal that I think should be considered is that as the template instantiation proposal gels, looking for opportunities for these enhancements to play a role in the template instantiation process would be great. Many of the most popular such libraries do provide similar binding support as what template instantiation aims to support. Basically, look for opportunities to make custom element enhancements serve the dual purpose of making template instantiation extendable, especially if that adds even a small benefit to performance.

A note about naming, part II

I started this journey placing great emphasis on the HTML attribute aspect of this, but as the concepts have marinated over time, I think it is a mistake to over emphasize that aspect. The fundamental thing we are trying to do is to enhance existing elements, not attach strings to them.

When we enhance existing elements during template instantiation, the attributes (can) go away, in order to optimize performance. It is much faster to pass data through a common gateway property, not through attributes. For similar reasons, when one big enhancement needs to cobble smaller enhancements together, again, the best gateway is not through attributes, which again would be inefficient, and would result in big-time cluttering of the DOM, but rather through the same common property gateway through which all these enhancements would be linked.

Why "enhancements", and not "behaviors"?

Granted, the majority of enhancements would likely fit our common idea of what constitutes a "behavior".

I think it is quite fine to use the term "behaviors" informally, just as we use "web components" to describe things informally, even though "customElements" is the formal api name.

But enhancements could also include specifying some common theme onto a white label web component, and contorting the language to make those sound like behaviors doesn't sound right: "Be Picasso blue-period looking" for example. I actually think this objection touches on a fairly important concern I have with the term "behavior" as the main term. "Don't judge a book by its cover" seems to run afoul with the idea that affecting how an element looks should be called a "behavior."

The word "behavior" makes what we are doing quite adjacent to things that tie in closely with humans (and other living beings). In fact, I've personally found it somewhat amusing to search for names that apply both to DOM elements as well as (human) behavior, where it makes sense, and where it is appropriate. But this close connection should give us pause. We should acknowledge this connection, especially as it will likely affect our subconscious, including our dreams (or possibly nightmares).

Yes, we should welcome the ability to explain complex things in terms we can relate to as human beings, so use of the term "behaviors" should certainly not be out of bounds. But we should remember that the things we are talking about, even if they have children, siblings and parents, are not in fact human beings, and using a more generic term (enhancements) when it comes to more formal settings (like the actual API) would help with that. It also expands our range of analogies we can reach for. Recall my analogy earlier, that when a plane (DOM element) lands it "connects" to the ground (DOM fragment), and when we "enhance" the plane with a wing, we are attaching the wing. Does it make sense to refer to a wing as a "behavior"?

Some enhancements could be adding some common paragraph containing copyright text. The dictionary defines behaviors as something associated with actions, so does that apply here?

Many are adding binding support to elements, which may or not resonate with developers as being a "behavior".

So "enhancements" seems to cover all bases.

Others prefer "behaviors" (but the others who do seem to think it is of zero consequence, whereas I think there is some substantial consequence to the decision, if that counts for anything). I'm open to both, maybe my reasoning above is wrong (but no one has yet to address my concerns head on).

Choosing the right name seems important, as it ought to align somewhat with the reserved sub-property of the element, as well as the reserved prefix for attributes (think data- / dataset).

Should use of enh-* prefix for server-rendered (progressive) enhancement of custom elements be required (or even strongly suggested?)

The reason I think it would be reasonable for the prefix enh-* to be required, or at least strongly suggested is this:

  1. If enh- is only encouraged the way data- is encouraged, at least we could still count on custom element authors likely avoiding that prefix when defining their custom attributes associated with their element, to avoid confusion, making the "ownership" clear.
  2. But should a custom enhancement author choose a name that happens to coincide with one of the attribute names of another author's custom element, which seems quite likely to happen frequently, it still leaves the messy situation that the custom element's attribute gets improperly flagged as an enhancement.
  3. However, it could be argued, depending on how smoothly working with scoped registry proves to be in this context, that such catastrophes could be averted using the scoped registry. This proposal provides out-of-the-box support for renaming any and all the attributes associated with an enhancement. So maybe it shouldn't be required, and may seem silly for developers working in a closed environment, with enhancements they have no interest in publishing for general consumption. But even so, I think it would be quite useful for the platform to at a minimum provide for a key prefix that developers can use to help avoid having to be always on the watch out for such collisions (which might not become immediately apparent until some user discovers it in production).

Global api's.

All of the customElements methods would have a corresponding method in customEnhancements. For example:

  1. customEnhancements.define
  2. customEnhancements.whenDefined
  3. customEnhancements.upgrade

The same solution for scoped registries would be applied to these methods.

Attachment methods of the enhancements property

Unlike dataset, the enhancements property, added to the Element prototype, would have several methods available, making it easy for developers / frameworks to reference and even attach enhancements (without the need for attributes), for example during template instantiation (or later).

const enhancementInstance = await oElement.enhancements.whenAttached(enhancementInfo);
const enhancementInstance = await oElement.enhancements.whenResolved(enhancementInfo);

Both of these methods would see if the enhancement has already been attached, and if so, pass that back. If not, the method will cause an instance of the class constructor returned by the enhancer option, then call attachedCallback and attributeChangedCallback (if applicable) in the same order as is done with custom elements, before returning the instance.

The whenResolved promise is returned after the developer sets:

this.resolved = true;

The base class of these enhancements, ElementEnhancement, then, contains a reserved property, resolved:

class ElementEnhancement extends EventTarget {
    #resolved = undefined;
    get resolved(){
        return this.#resolved;
    }
    set resolved(newValue){
        this.#resolved = newValue;
        if(newValue === true){
            this.dispatchEvent(new Event('resolved'));
        }else if(newValue === false){
            this.dispatchEvent(new Event('rejected'));
        }
    }

}

The whenResolved method would throw an error (catcheable via try/catch with await or .catch() if using the more traditional promise approach) when the developer sets this.resolved = false;

The purpose of having this "whenResolved" feature is explained towards the end of this proposal.

I think it would be quite reasonable for these methods to automatically call customEnhancements.define if the platform sees that the enhancementInfo hasn't yet been defined, and has no namespace conflicts with other enhancements.

A helper property to make setting properties easier.

In addition to the two methods above, the enhancements property would contain a lazy property which would return/instantiate a proxy if invoked/retrieved, which can then dynamically return an instance of the enhancement, if the enhancement has already attached. If it hasn't attached yet, it will return either an empty object, or whatever value has been placed there previously.

This would allow consumers of the enhancement to pass property values (and only property values) ahead of the upgrade (or after the upgrade), so that no "await" is necessary:

oElement.enhancements.setPropsFor.steelEnhancer.carbonPercent = 0.2;

These value settings would either get applied directly to oElement.enhancements.withSteel if it has already been attached. Or, if it hasn't been attached yet, the browser would set (or merge) the value into the property, and begin attaching the enhancement in the background:

if(oElement.enhancements.steelEnhancer=== undefined) {
    oElement.enhancements.steelEnhancer = {};
    // invoke some method asynchronously in the background to attach the enhancement.
} 
oElement.enhancements.steelEnhancer.carbonPercent = 0.2;

The object would sit there, ready to be absorbed into the enhancement during the attachedCallback handshake, which could happen right away if already loaded, or whenever the customEnhancements.whenDefined is resolved for this enhancement.

The attaching in the background convenience would only be possible if the developer has already registered the customEnhancement via customEnhancements.define or one of the two methods mentioned above - oElement.enhancements.whenAttached and oElement.enhancements.whenResolved. So the platform could skip that step if no matching enhancement is found in the registry.

Due to this property, setPropsFor, being a proxy, the convenience of this approach likely comes at a cost. Proxies do impose a bit of a performance penalty, so a framework or library that uses this feature would be well-advised to add a little bit of nuance to the code, to set properties directly to the enhancement once it is known that the enhancement has attached. For example, use this property the first time setting a property value, and then more directly for subsequent times. Or, alternatively, implement the identical logic described above within the library code, thus avoiding the use of this special property altogether.

When should the class instance be created by the platform?

If any one of the (enh-*) attributes matching the pattern of base/branch/leaf is found on an element in the live DOM tree, this would cause the platform to instantiate an instance of the corresponding class, attach it to the enhancements sub property, and invoke the attachedCallback method, similar to how custom elements are upgraded.

I also suggest that it would be great if, during template instantiation supported natively by the platform, the platform can do whatever helps in achieving the most efficient outcome as far as recognizing these custom attributes. One key feature this would provide is a way to extend the template instantiation process -- plug-ins essentially. Especially if this means things could be done in "one-pass". I don't claim any expertise in this area. If the experts find little to no performance gain from this kind of integration, perhaps it is asking too much. Doing this in userland would be quite straightforward (on a second pass, after the built-in instantiation has completed).

Another integration nicety I would like to see supported by built-in template instantiation is to be able to bind sub objects from the host to the enhancements gateway. So for example:

<input :enhancements.withSteel.carbonPercent={{carbonPercent}} >

would work (using FAST web component syntax here. Lit uses a . instead).

What follows is going out into uncharted territories, discussing how this proposal might integrate into a work-in-progress spec (template instantiation) that hasn't been fully fleshed out.

Mapping elements contained in the template to enhancement classes during template instantiation.

Suppose we have a template that we want to use for repeated template instantiation:

For example:

<template>
    <div>
        <span></span>
        <button></button>
    </div>
    <section>
        <span></span>
        <button></button>
    </section>
<template>

Now the developer defines a class that provides the ability to keep track of how many times a button has been clicked, and that can broadcast that count to other elements near-by. The class extends ElementEnhancement.

An example, in concept, of such a class, used in a POC for this proposal, can be seen here, just to make the concept less abstract (the POC will not exactly follow what this proposal will outline as far as defining and registering the class), but basically, for server-rendered progressive enhancement, the server-rendered HTML this class expects would look as follows:

<template>
    <div>
        <span></span>
        <button be-counted='{
            "transform": {
                "span": "value"
            }
        }'></button>
    </div>
    <section>
        <span></span>
        <button be-counted='{
            "transform": {
                "span": "value"
            }
        }'></button>
    </section>
<template>

Note that the enhancement class corresponding to this attribute may specify a default count, so that the span would need to be mutated with the initial value, either while it is being instantiated, if the custom enhancement has already been imported, or in the live DOM tree. The decision of whether the enhancement should render-block is, when relevant, up to the developer. If the developer chooses to import the enhancing class synchronously, before invoking the template instantiation, then it will render block, but the span's text will be already set when it is added to the DOM tree. If the developer imports the class asynchronously, then, depending on what is in cache and other things that could impact timing, the modification could occur before or after getting appended to the live DOM tree. Ideally before, but often it's better to let the user see something than nothing.

The problem with using this inline binding in our template, which we might want to repeat hundreds or thousands of times in the document, is that each time we clone the template, we would be copying that attribute along with it, and we would need to parse the values.

Because this proposal is advocating that the EnhancementInfo interface that is passed into the define method has enough information to map from the attribute to the parsed properties, it's my view that this would allow template instantiation supported by the platform (or userland implementations) to avoid unnecessary string parsing, by making judicious use of caching.

DetachedCallback lifecycle event

When would the detachedCallback method be called?

This is an area likely to require some critical feedback from browser vendors, but I will nevertheless express some thoughts on the matter.

One time it definitely would not be called is if the (enh-*) attributes, if present, are removed from the enhanced element, since as we've discussed, the custom attribute aspect is only one way to attach an enhancement. A developer may want to remove the attributes to reduce clutter, or before transferring to another Shadow DOM realm to avoid unexpected side effects of being transported in.

I do think the detachedCallback should be associated in some way with the disconnectedCallback method of the enhanced custom element (or the equivalent for built-in elements). However, there's a scenario where a custom element's disconnectedCallback is called, where we don't necessarily want to fully "dump" the enhancement -- when the element is moved from one parent container to another (within a Shadow DOM realm or even crossing Shadow DOM boundaries.) To me, it would be ideal if the enhancement could remain attached in this circumstance, as if nothing happened.

On the other hand, I could see scenarios where the enhancement would want to know that its host has been disconnected. So the custom enhancement should have a way of being notified that this transfer took place.

My (naive?) recommendation is that the platform add an event that can be subscribed to for elements: Elements currently have a built-in property, "isConnected". It would be great if the elements also emitted a standard event when the element becomes connected and (possibly another) event or signal when it becomes disconnected.

How to programmatically detach an enhancement

I'm encountering a small number of use cases where we want enhancements to "do its thing", and then opt for early retirement. The use cases I've encountered this with is primarily focused around an enhancement that does something with server-rendered HTML, which then goes idle afterwards, possibly to be replaced by a different kind of enhancement during template instantiation. So I think it should be possible to do this via:

const detachedEnhancement = await oElement.enhancements.whenDetached(enhancementInfo);

I think we would want this to remove the associated attribute(s) also, if applicable.

How an enhancement class indicates it has hydrated

Earlier in this document, I mentioned a feature built in to the base class, that indicates a state of "resolved". Here's the explanation for one use case:

In many cases, multiple enhancements are so loosely coupled, they can be run in parallel.

However, suppose we want to apply three enhancements to an input element, each of which adds a button:

  1. One that opens a dialog window allowing us to specify what type of input we want it to be (number / date, etc).
  2. One that allows us to clone the input element.
  3. One that allows us to delete the input element.

If the three enhancements run in parallel, the order of the buttons will vary, which could confuse the user.

In order to avoid that, we need to schedule them in sequence. This means that we need a common way each enhancement class instance can signify it either succeeded, or failed, either way you can proceed. That is why we should have this ability to specify whether the hydration has completed.

I have a heavy suspicion that as the platform builds out template instantiation and (hopefully) includes something close to this solution as far as plug-in's, there will arise other reasons to support this feature.

But for now, the way this feature can be used is with a bespoke custom enhancement, such as be-promising.

Support for a view model web component / enhancement tied to the itemscope attribute.

There are many scenarios where it makes sense to have one "central" enhancement, that frameworks / libraries can expect, that manages the data or view model for the built-in element -- scenarios where we can't wrap the element inside a custom element.

This proposal is advocating enhancing the itemscope attribute, so that it can optionally specify the name of a custom element or custom enhancement to automatically attach to the top level of the element to a officially recognized property name (name tbd), which frameworks could then easily pass values to. For example, with lit-html:

html`
<table>
    <thead><th>Name</th><th>SSN Number</thead>
    <tbody>
${myList.map(item => html`
    <tr itemscope=my-item .tbd=${item}>
        <td itemprop=name>
            ${item.name}
        </td>
        <td itemprop=ssn>${item.ssn}</td>
    </tr>
`)}
    </tbody>
</table>
`

What this would do:

  1. If my-item is a custom element, use that. Otherwise, check if a custom enhancement with that name exists in the registry.
  2. In the case that my-item is a custom element, use document.createElement('my-item') to generate the custom element. The custom element's attach method could choose to place itself somewhere inside the element it is adorning.
  3. Before attaching, merge whatever properties were passed to the "tbd" placeholder.
  4. After the attachment, "setting" the property to an object would not replace the custom element/enhancement with the object, but rather the setter for "tbd" would interject, and do an Object.assign of the passed in object into the custom element/enhancement instance.

What the real name of "tbd" should be is completely open in my mind. Nothing jumps out at me as the "correct" answer. Names that would make sense to me are: "host", "vm", "viewModel", "scope", or "ish" -- short for itemscope host. I guess I'm leaning towards the latter -- it is short, and is kind of a play on "is".

Namespacing events

Because custom enhancements extend the EventTarget, it is quite possible (and probably optimal) to subscribe to events directly from the enhancement, as we've seen above with the "resolved" event.

However, I've encountered quite a few use cases where we want the enhancement to dispatch an event from the element it adorns.

To be able to distinguish that:

  1. The event was initiated by an enhancement
  2. Uniquely identify which enhancement issued the event within a ShadowDOM realm

I propose:

  1. The base Event object gets an additional property: "enh", which is where we pass in the enhKey mentioned earlier.
  2. The base Enhancement class has a method "channelEvent" that is a simple wrapper around "dispatchEvent" but inserts the name of the enhKey into the enh property of the event.
bahrus commented 1 year ago

Updated with my latest thoughts.

bahrus commented 1 year ago

Added thoughts on event namespacing, attachAndSetPropsFor, detachedCallback.

bahrus commented 1 year ago

Updated 8/16/2023

bahrus commented 1 year ago

Some feedback from the meeting, which I hope to address (assuming this proposal isn't dead in the water):

  1. Preference for sticking with the same names as custom elements as far as lifecycle events. (I don't care too much about names. I used attached vs connected, because I thought connected had a special meaning related to being connected to the live DOM tree, and I think it's important for the enhancements to be able to be active prior to being to added to the live DOM tree, during template instantiation. If I'm wrong about the special meaning of "connected", or about the utility of becoming active prior to being added to the live DOM tree, it makes sense to make the names match.
bahrus commented 1 year ago
  1. Desire to go more with a mixin model (where, I assume, "this" refers to the element being enhanced), vs having the element being enhanced passed in as part of the callback. I'm on the fence on that one, it's a nice idea to think about).
bahrus commented 1 year ago
  1. In the case of enhancing custom elements, I did not factor in how to take into account if the attachment should happen prior to the upgrade, after the upgrade, if the enhancement can be notified of the upgrade, etc.
bahrus commented 1 year ago
  1. It was argued that we could leave it up to the code in the callback to decide if it wants to take action, based on what type of element is being enhanced. I don't think this was being advocated as the correct solution, just a thought experiment. I think it would incur a bit of a performance hit to unnecessarily instantiate and attach the class, and go through that entire handshake, if we can filter this out declaratively. I agree the class itself should have a say-so in what elements it doesn't want have any interest in enhancing. I proposed a static property (maybe in addition to the registration, both have to opt in).
bahrus commented 1 year ago
  1. A discussion point about if tag names and/or instanceof tests should be used for this filtering.
bahrus commented 1 year ago
  1. Because of the timing concerns with enhancing a custom element, it was proposed perhaps the registration of the enhancement should not be separated from the registration methods for custom elements, as they are too tightly linked to separate.
bahrus commented 1 year ago
  1. I think there were some thoughts expressed about Shadow DOM realms being able to "opt-in/opt-out" of enhancements / custom attributes, but not sure.
bahrus commented 11 months ago

I've updated this proposal to address as best I can two points raised during the face-to-face:

Why attachedCallback and not connectedCallback? Advantages of connectedCallback is it perfectly aligns with the terminology used for custom elements, and clearly the custom enhancement class above closely resembles a custom element. I could go with that. It doesn't break the essence of this proposal in any way. This was raised (I don't know by whom, but I think it was a member of the WebKit team, but not the person leading the meeting) during a face-to-face, seemingly as a "showstopper" for this proposal (I think), that it made the proposal seem amateurish (paraphrasing). Yes, I am definitely an "amateur" in this space, and I was quite reluctant to even make this proposal for that very reason. I did so because I was (and am) frustrated by not seeing such a proposal forthcoming from the WebKit team, and I am very eagerly awaiting said proposal. When it does (hopefully) and the name "connectedCallback" appears, which seems likely, I hope they will consider my reasoning, even if I am an amateur. But one thing I will not say is that it is a showstopper or amateurish for whatever choice is made.

Reasons I think attachedCallback would be clearer:

First, let me emphasize that if the reason for the rather pointed comment was that my using "attachedCallback" was a slight towards the name used for custom elements, nothing could be further from the truth. If custom elements had chosen "attachedCallback" I might very well have gone for "connectedCallback" in this case.

It just feels different to me. When a plane lands, it "connects" to the ground. When we add a wing to the plane, we are "attaching" a wing. Or maybe it makes just as much sense to say when a plane lands, it attaches to the ground. And when we add a wing to the plane, we are "connecting" the wing. Again, different. Using the same term for both would be confusing, I think, for that reason. (In this analogy, the "ground" is the DOM fragment, the wing is the "enhancement").

As this proposal currently stands, the signatures are different, which again, lends itself to thinking the names should also be different.

Attaching (connecting?) a cross-cutting concern on top of an existing element feels like attaching Shadow DOM. If we weren't living in such troubled times, I would have seriously considered calling the class ElementHorcrux rather than ElementEnhancement, just to fully cement my amateur status.

Anyway, as I said, I think that decision should be of little consequence, so please replace it with whatever name you like. That's my amateur take.

And I will add my voice to many, please provide the non-amateur version ASAP, WebKit!

Also raised during the face-to-face by @rniwa, seconded by @justinfagnani:

So why not use the customElements' registry, why come up with a new registry, customEnhancements? This point was also raised, quite respectfully, at the face to face, and seems to me like it may have some merit, and I suspect "under the hood" might make a tremendous amount of sense. But from a developer point of view, it seems strange to use the "customElements" object to attach (connect?) things onto built in elements, so I wonder if the "under the hood" considerations could be camouflaged, in the name of clarity? I again eagerly await the formal proposal from the WebKit team(?) where the reasoning for this is spelled out.

The closest thing I could see without causing confusion to amateur developers like me is if the method name for customElements was different from define, that clarified that the scope was much broader than custom (HTML) elements.

Again, not a showstopper in my mind, and I suspect I'm missing something.