W3C home > Mailing lists > Public > public-webapps@w3.org > January to March 2015

Custom element design with ES6 classes and Element constructors

From: Domenic Denicola <d@domenic.me>
Date: Sun, 11 Jan 2015 20:13:19 +0000
To: WebApps WG <public-webapps@w3.org>
Message-ID: <CY1PR0501MB13690F3DEE45DF736DBF22ECDF420@CY1PR0501MB1369.namprd05.prod.outlook.com>
This is a spinoff from the thread "Defining a constructor for Element and friends" at http://lists.w3.org/Archives/Public/public-webapps/2015JanMar/0038.html, focused specifically on the design of custom elements in that world.

>> The logical extension of this, then, is that if after that 
>> `document.registerElement` call I do `document.body.innerHTML = <my-q 
>> cite="foo">blah</my-q>`
> Ah, here we go.  This is the part where the trouble starts, indeed.
> This is why custom elements currently uses <q is="my-q"> for creating custom element subclasses of things that are more specific than HTMLElement.  Yes, it's ugly.  But the alternative is at least major rewrite of the HTML spec and at least large parts of Gecko/WebKit/Blink. 
>  :( I can't speak to whether Trident is doing a bunch of localName checks internally.

So, at least as a thought experiment: what if we got rid of all the local name checks in implementations and the spec. I think then `<my-q>` could work, as long as it was done *after* `document.registerElement` calls.

However, I don't understand how to make it work for upgraded elements at all, i.e., in the case where `<my-q>` is parsed, and then later `document.registerElement("my-q", MyQ)` happens. You'd have to somehow graft the internal slots onto all MyQ instances after the fact, which is antithetical to the ES6 subclassing design and to how implementations work. __proto__ mutation doesn't suffice at all. Is there any way around this you could imagine?

Assuming that there isn't, I agree that any extension of an existing HTML element must be used with `is=""` syntax, and there's really no way of fixing that. (So, as a side effect, the local name tests can stay. I know how seriously you were considering my suggestion to rewrite them all ;).)

This makes me realize that there are really two possible operations going on here:

- Register a "custom element," i.e. a new tag name, whose corresponding constructor *must* derive *directly* from HTMLElement.
- Register an "existing element extension," i.e. something to be used with is="", whose corresponding constructor can derive from some other constructor.

I'd envision this as

document.registerElement("my-el", class MyEl extends HTMLElement {});
document.registerElementExtension("qq", class QQ extends HTMLQuoteElement {});

This would allow <my-el>, plus <q is="qq"> and <blockquote is="qq">, but not <qq> or <q is="my-el"> or <span is="qq">. (Also note that element extensions don't need to be hyphenated, and there's no need for "extends" since you can get the appropriate information from looking at the prototype chain of the passed constructor.) document.registerElement could even throw for things that don't directly extend HTMLElement. And document.registerElementExtension could throw for things which don't derive from constructors that are already in the registry. (BTW, as noted in the existing spec, for SVG you always want to use element extensions, not custom elements.)


The story is still pretty unsatisfactory, however. Consider the case where your document consists of `<my-el></my-el>`, and then later you do `class MyEl extends HTMLElement {}; document.registerElement("my-el", MyEl)`. (Note how I don't save the return value of document.registerElement.) When the parser encountered `<my-el>`, it called `new HTMLUnknownElement(...)`, allocating a HTMLUnknownElement. The current design says to `__proto__`-munge the element after the fact, i.e. `document.body.firstChild.__proto__ = MyEl.prototype`. But it never calls the `MyEl` constructor!

This is troubling in a couple ways, at least:

- It means that the code `class MyEl extends HTMLElement {}` is largely a lie. We are just using ES6 class syntax as a way of creating a new prototype object, and not as a way of creating a proper class. At least for upgrading purposes.
- It means that what you get when doing `new MyEl()` is different from what you got when parsing-then-upgrading `<my-el></my-el>`.
- If we decide for the parser and/or for document.createElement to use the custom element registry when deciding how to instantiate elements, as was my plan in [1], it means that you'll get different results before registering the element as you would post-upgrade.

[1]: https://github.com/domenic/element-constructors/blob/master/element-constructors.js#L166-L189

(The same problems apply with <q is="qq">, by the way. It needs to be upgraded from HTMLQuoteElement to QQ, but we can only `__proto__`-munge, not call the constructor.)

I guess the current design solves this all by saying that indeed, the use of ES6 class syntax is a lie; you are only using it to create a prototype object. And indeed, you can't have a proper constructor, so use this createdCallback thing, which we will use to *make* a proper constructor for you, which behaves essentially like an upgrade does. So you have to save the return value of document.registerElement, and ignore the original constructor from your so-called class declaration. I guess those guys who put together the spec in the first place knew what they were doing ;).

I was hopeful that ES6 would give us a way out of this, but after thinking things through, I don't see any improvement at all. In particular it seems you're always going to have to have `var C2 = document.registerElement("my-el", C1)` giving `C2 !== C1`. And you're always going to have `createdCallback` sticking around, with its awkward relationship to `constructor`. And you'll never be able to write anything sensible inside your class's constructor body, since it's not going to be used.

The only alternative I can envision is some kind of overhaul of what "upgrading" means, changing it to be e.g. removing and re-creating all relevant elements using the appropriate constructor this time before re-inserting them. But that seems drastic, and potentially error-prone (e.g. if you saved a reference to the pre-upgrade element as a JS variable, all of a sudden you are holding a dangling HTMLUnknownElement that was removed from the tree. Do we try to transfer over all attributes, event listeners, etc. that might have been set on it? Ick.).

Well. At least now I understand the problem better.
Received on Sunday, 11 January 2015 20:13:49 UTC

This archive was generated by hypermail 2.3.1 : Friday, 27 October 2017 07:27:25 UTC