Closed tkent-google closed 5 years ago
I have a similar realization the other day when polyfilling shadowRoot.ariaLabel
and co., which can fall into the same category. shadowRoot
becoming a kitchen sink will be unfortunately, but considering that WC APIs are very low level, it might be ok.
I don't quite understand why delivering Something
via a callback is more expensive than new Something()
. (A way to avoid the "cons" of new Something()
is perhaps to allow declaration ahead of time that the custom element won't use it, or indeed, require explicit opt-in that it's going to be used.)
(Another "cons" of ShadowRoot
is that it's not limited to custom elements.)
I'm writing up the properties @caridy mentioned.
The design problem we're trying to solve there is:
ariaChecked
, which represents the current "checked" stateariaChecked
on the host element, as well as the shadow root, the version on the host element should override whatever was set on the shadow root - but then if I delete the ariaChecked
property on the host, it should revert to using the version on the shadow rootShadowRoot
really seems like a good fit for this problem in some ways, since it's analogous to using :host
in a <style>
element within the shadow root - but unlike style, the properties on the host element can only be set directly on the host element (i.e. can not be defined anywhere but directly on the affected element), so there's no logical place to put the :host
equivalent.
It also means that you can encapsulate semantic information about the host element without needing to register a custom element.
However, I can imagine asking the question "why can't I set shadowed versions of any element property on the shadow root?" - which definitely sets us on a road to a kitchen sink scenario.
Also, if your custom element doesn't otherwise need a shadow root, it seems like a shame to need to attach one for this purpose.
We could theoretically create a new type of object to hold semantic properties, analogous to a constructable stylesheet, but unlike constructable stylesheets there's no precedent for the type of object this would need to be - it's really just a simple, small map of properties.
Does anyone have any ideas for alternatives to using ShadowRoot
here?
@alice @tkent-google's post has a number of alternatives, no? We'd need a pick a better name than Something
, but otherwise one of those approaches would work I think.
@annevk I should have addressed those better, sorry!
Here are my concerns with the those approaches (I count only two, really - the first alternative is just to use ShadowRoot
):
AccessibilityRole
and AriaAttributes
mixed in, which is kind of awkward. new Something(element)
option seems like a deal breaker to me (that anyone can construct one for an element which doesn't already have one) - it's the opposite of encapsulation, which is what we're aiming for. (Presumably constructing a Something
for a built-in element would be forbidden). Do authors need to construct a new Something
defensively whenever they create a custom element, for all possible values of Something
(if many APIs use this strategy)?ElementConfiguration
which can grow to hold all of these various objects (ElementStates
, HTMLElementPrimitives
, etc.) which is passed in in a single lifecycle event. Otherwise, it seems like either each new API has to create a new lifecycle event (?) or else the createdCallback
ends up with a mess of arguments.but considering that WC APIs are very low level, it might be ok.
That may be an overstatement. Web devs want to make components, so if they move off React or Vue (or etc) to Web Components, they will use ShadowDOM. It's not very low level, it's a normal part of organizing web UI. Any serious web developer should be expected to organize code with components of nested trees; it's standard.
I'm in favor not to use it as a kitchen sink for component parts. Rather, ShadowDOM is it self a component part, and should keep it's concerns specific. Other parts can have other jobs.
@tkent-google About new Something (element)
, what's that idea? Is that a suggestion for an entity-component system?
@annevk and @tkent-google I just raised https://github.com/WICG/aom/issues/127 to try and avoid further rabbit-holing here on this specific issue, but I'd appreciate your thoughts over there if you have time!
One benefit of the createdCallback
(presumably?) option is that, if we do combine all possible settings into something like ElementConfiguration
, the API gives you an opportunity to learn by exploration of that object.
With new MyThing(element)
, authors would have to learn about each MyThing
separately (although obviously good docs can help with this).
Also, if new MyThing
s keep being added, custom elements which were written before they existed run the risk of having MyThing
s being defined by the page instead. This may not be a concern, but it seems awkward to me.
@tkent-google About
new Something (element)
, what's that idea?
Oops, nevermind, it was based on the idea of new ElementStates(this)
which you linked to. Sorry for the noise.
Deliver a Something instance by a custom element callback
I think that's my favorite idea.
I added an example using that idea here: https://github.com/w3c/webcomponents/issues/738#issuecomment-412694539
To avoid enabling all features (and avoid runtime cost) maybe they can be opt-in? f.e.,
import Privates from './privates-helper'
const _ = new Privates
class MyEl extends HTMLElement {
useFeatures(features) {
_(this).cssStates = features.use('cssstates')
// or
_(this).cssStates = features.cssStates()
}
connectedCallback() {
// blink every second
setInterval(() => {
_(this).cssStates.has('enabled') ?
_(this).cssStates.remove('enabled') :
_(this).cssStates.add('enabled')
}, 1000)
}
}
We could achieve this example with CSS, but this example is similar to how a div
element doesn't add a hover
class for you, but instead a :hover
state, so it doesn't interfere with the div user's class list.
@tkent-google,
Deliver a Something instance by a custom element callback
It looks this idea is the most favored. Something might become yet another kitchen-sink, but I think that would be much better than using ShadowRoot here.
Could you have a chance to make the idea more concrete one or prototype? Several new features are waiting for this idea, I guess.
Many people are favor of the third one. Let's proceed with the third one if no one has a strong opinion against it.
We need to define the followings:
HTMLElementPrimitives
(drop HTML
?), ElementSemantics
, etc.
Please reply your ideas.
First I thought we could add a Something
argument to connectedCallback
, but I found that custom element implementations needed to call a protected API before connecting to a document tree in Form Participation API. So we should introduce new callback.
My current proposal is createdCallback
, which is called just after upgrade, maybe before attributeChangedCallback
.
@annevk
I don't quite understand why delivering Something via a callback is more expensive than new Something().
I guess adding a new custom element callback requires larger code in UA than allowing new Something(element)
just once. We already have four callbacks, and adding another would not be a big issue.
Another name idea: ElementInternals
(after internal slots). Without HTML
seems reasonable as we'd reuse this if we ever added custom elements in other namespaces. createdCallback
also seems reasonable, though a bit unfortunate we then have both that and a constructor.
ElementInternals
is definitely better than ElementSemantics
, +1 on that.
Would we put all of the properties on what I've called ElementSemantics
on ElementInternals
, or would we have a semantics
object hanging off ElementInternals
like I proposed (I referred to ElementInternals
as ElementConfiguration
as a placeholder in that doc, and in my comments above)?
@alice I think a flatten structure is just fine. HTMLElement
itself is mostly flatten anyways.
Extending from option 3, could the following help with performance?
static
list of features.What if elements could specify specific features to use in a static prop, so the engine doesn't unnecessarily have to pass every single feature into a callback even if an element won't use each feature?
F.e.
class Foo extends HTMLElement {
static get observedAttributes() { ... }
static get features() { return [ ... ] }
}
Then the features could do what they want: specify new callbacks, or provide certain instance props, etc.
Either it would be up to the feature how it is exposed, or perhaps they all get injected into a standard callback like option 3's createdCallback
(though I think a different name would be better because we already have constructor
).
As for the static list of features, if they were strings,
static get features() { return [ 'builtin-feature', 'user-feature' ] }
it would be easiest, but with the problem of name clashing (suppose we let anyone provide features, not built-in features):
static get features() { return [ BuiltinFeature, UserFeature ] }
References would avoid name clashing, and not require a registry:
But then, this seems like mixins!
Could mixins do the trick?
class Foo extends BuiltinFeature( UserFeature( HTMLElement ) ) {
As a real-world example, SkateJS is a web component library whose features are consumable as mixins, making them easy to mix and match.
ElementInternals
sounds good. If no one objects it, let's adopt it.
@alice @caridy I also suppose flatten structure. ElementInternals
interface has attributes/operations for various features such as accessibility, form controls, ...
@trusktr I'm not sure I understand what you wrote correctly. Probably such feature list isn't necessary because we assume a single ElementInternals
instance handles all features for the associated element?
Re: callback name
createdCallback
represents the timing when it is called, and doesn't represent the purpose. We may give more descriptive name like elementInternalsCreated
receiveElementInternals
.
@tkent-google
because we assume a single ElementInternals instance handles all features for the associated element?
That's what I was trying to avoid with the idea of a list or mixins, because otherwise it means ElementInternals
will include all possible features even if the features are not going to be used.
In Option 5 above, class-factory mixins provide a way to opt-in to using specific features (and avoid resource waste by creating the feature instances only when needed):
import UserFeature from 'npm-package'
const {BuiltinFeature1, BuiltinFeature2} = customElements.elementFeatures
class MyEl extends BuiltinFeature1( BuiltinFeature2( UserFeature( HTMLElement ) ) ) {
// ...
}
customElements.define('my-el', MyEl)
In that sample, the class will have only the features it has specified.
If the list gets long, then
const Features = BuiltinFeature1( BuiltinFeature2( UserFeature( HTMLElement ) ) )
class MyEl extends Features {
// ...
}
@alice An example of option 5 for element semantics would be like
// ...
const {withSemantics} = customElements.elementFeatures
class MyEl extends withSemantics( HTMLElement ) {
// ...
receiveSemantics( semantics ) {
const privates = elementPrivates.get(this);
privates.semantics = semantics;
}
}
customElements.define('my-el', MyEl)
where in this example, the receiveSemantics
callback will be called by the withSemantics
mixin implementation.
The withSemantics
implementation might call the receiveSemantics
method in its constructor
, for example.
The implementation of withSemantics
might look like the following (except it may not be JavaScript):
window.customElements.elementFeatures.withSemantics = function withSemantics(Base) {
return class WithSemantics extends Base {
constructor() {
super()
if (typeof this.receiveSemantics === 'function') {
const semantics = ___; // create the semantics for `this` element
this.receiveSemantics( semantics )
}
}
}
}
It could also be placed on window.elementFeatures
, but that creates a new window global. Seems like customElements
would be a good fit for this.
Another example could be observing children. A mixin could make this an opt-in feature:
const {withChildren} = customElements.elementFeatures
class MyEl extends withChildren( HTMLElement ) {
// This callback is provided by the `withChildren` mixin.
childrenChangedCallback() {
// Work with children here, don't worry about
// if children exist in `connectedCallback`.
// Not only will this fire whenever children change, but
// also once after `connectedCallback` has been called.
// Maybe children here are guaranteed to be already
// upgraded if they are custom elements?
}
}
@trusktr, with your ideas, can we realize APIs which are available only for custom element authors?
can we realize APIs which are available only for custom element authors
Are you wondering if we can allow the mixins to be used only with Element classes? If so, then yeah we can:
The mixin could check the base class to make sure it is an Element class, f.e., pseudo code of the native code in JS:
function withAwesomeness(Base = HTMLElement) {
if (!extendsFrom(Base, Element)) throw new TypeError('mixin can only be used on classes that extend from Element')
// continue, return class extends Base...
}
Or did you mean something else?
@trusktr this issue is about creating an API that can be used only by the custom element author, where consumers of the element can't access, influence or observe it. From that point of view, the question from @tkent-google is whether or not your mental model is considering that the primary driver? Seems to me that mixins are the wrong abstraction is you want to hide things from the consumer of the component.
If that’s the point of this issue, why not just make them private members on the base element classes? If this proposal waits on that to be approved and proceed? No need to reinvent the wheel.
@calebdwilliams where to store the reference of the instance generated by something like new ElementInternals(customElement)
is not the issue. You can use a weakmap, a symbol, a private field, etc. This is analog to what this.attachShadow({ mode: 'closed' })
does today where you're responsible for storing the internal reference to the shadowRoot reference if you want to keep it around. This issue is about how and when to create an instance of ElementInternals
, who can create it, and what are the semantics of it.
@caridy, I understand that, my thought was really to adopt that as a means of constructing those internals similar to the way a shadow root is attached. Something like this.#attachElementInternal('name')
. I believe the extending class should have access to that, right?
No @calebdwilliams, that's not how our private fields proposal works.
@caridy can you please identify exactly why @calebdwilliams' suggestion wouldnt work? because I honestly was thinking the same thing. I have been following the private members proposal, but probably not as closely as I should, so I'm sure you may be right. It would just be helpful to explain. Thanks!
Private fields from the private fields proposal are specified as hard privates:
It means that private fields are purely internal: no JS code outside of a class can detect or affect the existence, name, or value of any private field of instances of said class without directly inspecting the class's source, unless the class chooses to reveal them. (This includes subclasses and superclasses.)
More info here: https://github.com/tc39/proposal-class-fields/blob/master/PRIVATE_SYNTAX_FAQ.md#what-do-you-mean-by-encapsulation--hard-private
I asked a TAG review for Form Participation API including the third idea of this thread. When the review finish, let's follow the review feedbacks.
@tkent-google I'm still fuzzy about the createdCallback
and its semantics. Does it have different semantics as the constructor? Can an attribute be added there? Does any user-land code executes between the constructor and the createdCallback
? I suspect all answer will be "No".
If my assumptions are correct about the internals, they are never accessible from outside, and they will probably never dictate any state or any public API of the component. I believe we can just rely on the connectedCallback
. Adding the internals
argument to the connectedCallback
should be fine since it is just relaxing an existing API that doesn't have any argument.
@caridy I think connectedCallback
is a little weird. E.g., if I do const ce = new CustomElement()
, I wouldn't want to have to do treeItem.append(ce)
before being able to setup various aspects of its behavior.
I'm also not sure all your assumptions are necessarily correct. E.g., if we allow setting ARIA roles and states through it, I'd expect those to be observable if we also add an API for computed ARIA role or some such.
@annevk yeah, I thought about that, the analog case I found was getComputedStyle
, which is really useful only after insertion. The main reason being the fact that the semantics that you want for you component sometimes depend on the structure of the DOM, e.g.: are you focusabled?. Computed AOM could probably be the same.
Even if that would always hold (I don't think it does, the proposed computed ARIA role API would work for elements without being connected), it's not clear to me why that means it should be bound to insertion. Presumably if you get removed and then inserted again none of the state would change? Given that, it makes more sense to only hand out the state object once, at creation-time.
Does it have different semantics as the constructor? Can an attribute be added there?
That's a good point. We don't allow DOM mutation in a custom element constructor (See step 6.1.4-7 of create an element. If we kick the callback inside create an element steps, we should prevent the callback from DOM mutation in order to avoid breaking createElement() semantics.
it's not clear to me why that means it should be bound to insertion.
@annevk agreed. Maybe something more analog to attachShadow()
without the public getter is more suitable then. Something that you can optionally invoke during construction, e.g.:
class Foo extends HTMLElement {
constructor() {
super();
this._internals = this.createInternals();
}
}
With the same semantics as attachShadow()
, which means it can only be called once, and in the future we can add arguments to that API.
It just feel to me that the createCallback() is a misstep, from many angles, including the pedagogical one, how to teach people that this callback is kinda special?
If we kick the callback inside create an element steps, we should prevent the callback from DOM mutation in order to avoid breaking createElement() semantics.
@tkent-google yes, that will be weird, that callback is not really a callback from the point of view of the author, and it doesn't follow the other callbacks semantics either.
So that is the new Something()
proposal from above, with a slightly different API. It seems we could address the concerns about such an API (raised by @alice above) by a) limiting it to being invoked during construction only, so others cannot create one for you and b) making it a "mixin target" similar to the other proposals so you don't need a bunch of Something
s, just the one. It'd still be somethingInstance.ariaXXX
etc.
I think I wouldn't mind circling back to that (either API shape), given that additional callbacks do have some unwelcome complexities.
@annevk that's very interesting. I think I like that better, "restricting access to such thing during the construction phase", being completely optional, and mixin safe.
I still think that new Something(this)
is very weird, I prefer this.something()
to produce the stateful obj that represents the internals, and that can be accessed at any level in the prototype chain during construction only.
The problem with this.something()
is that anyone can call it. Since the point of this API is to provide new API surface only to custom element implementation, ideally, we wouldn't expose it to everyone else.
@rniwa if we only allow calling it during construction and throw otherwise, that's not really a problem, as constructors of normal elements don't run JavaScript that could invoke it.
@annevk yeah but exposing a method on Element which can't be invoked isn't great either since it pollutes the element's prototype.
Given the number of members we haven't been super concerned about that thus far, but yes. I think it's either that or an awkward possibly-hard-to-design second JavaScript invocation during construction (as a callback). Unless there's some way to expose an object in the constructor somehow?
I really don't think speccing or designing createdCallback would be that bad. Maybe @tkent-google can speak to the implementation? At this point that seems like the best path to me.
If we invoked the callback directly after step 6.1.3 of https://dom.spec.whatwg.org/#concept-create-element we'd maintain the invariant checks and not run JavaScript at a new unexpected point. It doesn't have "CEReaction timing", but that's fine.
That wouldn't run it when you do new XElement() directly. I think it needs to be in the the [HTMLConstructor] algorithm. I agree this wouldn't be a CE reaction.
new Something(this)
or some static method on Something
which throws when called outside the constructor would be probably fine.
I'd rather not have a new callback which is different from custom element reactions. Invoking JS from C++ is slower than invoking C++ functions from JS for various reasons, and this will be yet another JS object we need to [[Get]]
and store per every custom element interface.
Anther side effect of the new callback: the possibility to bypass internals defined during the inheritance. This might work fine for other callbacks, but for the definition of the internals, guaranteeing that the super class is defining the right internals seems to be very important, and the subclass should not have too much control other than overriding internals after the fact.
On the other hand, Something
constructor is guaranteed to be untouchable by the subclass, while this.something()
provides a little bit more flexibility, in case the subclass want to redefine that particular method for more control. It seems the best of both worlds.
@domenic the callback remind me a lot to the init()
method in the original Realm API, which was called by the Realm constructor algo, and many folks pushed back on that one for very similar reasons that those described here.
I was warming up to the something-that-throws-outside-the-constructor idea (my bikeshed was window.customElements.createInternals(this)
) but I can't figure out how to make it work. We have no ability to run code after element construction in the new XElement()
case. There the only code we run is inside [HTMLConstructor], i.e. the super()
call. So, assuming we use the super()
call to flip the flag saying "you can now use the thing", we have no way to later flip a flag that says "can no longer use the thing".
In other words, if we want new XElement()
to continue to work, I can't see how we'd be able to turn on access to the thing only inside constructors.
The following issues need APIs which should be used by custom element authors and should not be used by custom element users.
At the March F2F we came up with one idea. However using
ShadowRoot
for such APIs looks very weird to me and I'd like to discuss it again.Candidates:
ShadowRoot
Pros: Usually only custom element implementation knows the instance ofShadowRoot
which is used to implement the custom element. It's difficult for custom element users to get aShadowRoot
instance created by a custom element implementation [1] Cons:ShadowRoot
is an interface for tree-encapsulation. Adding features unrelated to tree-encapsulation looks like a design defect. We should not makeShadowRoot
a kitchen sink. Cons: Not all custom element implementations needShadowRoot
. For example, a checkbox custom element won't needShadowRoot
. Creating unnecessaryShadowRoot
for such APIs is not reasonable. Cons: If a custom element implementation uses noShadowRoot
, a custom element user can callelement.attachShadow()
to get aShadowRoot
instance, and thus get access to these private APIs.new Something(customElement)
It throws if users try to create it for the same element twice. It throws if the constructor is called with non-custom elements. The interfaceSomething
is called as ElementStates in #738, and called as HTMLElementPrimitives in #187. Pros:ShadowRoot
won't have unrelated APIs. Pros: Usually only custom element implementation knows the instance ofSomething
. It's difficult for custom element users to get aSomething
instance created by a custom element implementation [1] Cons: If a custom element implementation uses noSomething
, a custom element user can callnew Something(element)
successfully to get aSomething
instance.Deliver a
Something
instance by a custom element callback See https://github.com/w3c/webcomponents/issues/187#issuecomment-388740230 for more details. Pros:ShadowRoot
won't have unrelated APIs. Pros: Custom element users can not create aSomething
instance unlike other two candidates. Pros: It's difficult for custom element users to get aSomething
instance delivered to a custom element [1] Cons: UA implementation would need larger code for a new callback, compared to the other two candidates.IMO, the second one or the third one is much better than the first one.
@annevk @domenic @rniwa @trusktr What do you think?
[1] If a custom element implementation stores a
ShadowRoot
/Something
instance tothis.foo_
, a custom element user can get the instance by accessingyourElement.foo_
. There are some techniques to avoid such casual access.