WICG / webcomponents

Web Components specifications
Other
4.36k stars 370 forks source link

How to define APIs only for custom element authors #758

Closed tkent-google closed 5 years ago

tkent-google commented 6 years ago

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:

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 to this.foo_, a custom element user can get the instance by accessing yourElement.foo_. There are some techniques to avoid such casual access.

caridy commented 6 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.

annevk commented 6 years ago

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.)

alice commented 6 years ago

I'm writing up the properties @caridy mentioned.

The design problem we're trying to solve there is:

ShadowRoot 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?

annevk commented 6 years ago

@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.

alice commented 6 years ago

@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):

trusktr commented 6 years ago

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?

alice commented 6 years ago

@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!

alice commented 6 years ago

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 MyThings keep being added, custom elements which were written before they existed run the risk of having MyThings being defined by the page instead. This may not be a concern, but it seems awkward to me.

trusktr commented 6 years ago

@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.

hayatoito commented 6 years ago

@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.

tkent-google commented 6 years ago

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:

Interface name

HTMLElementPrimitives (drop HTML?), ElementSemantics, etc. Please reply your ideas.

Callback

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.

tkent-google commented 6 years ago

@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.

annevk commented 6 years ago

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.

caridy commented 6 years ago

ElementInternals is definitely better than ElementSemantics, +1 on that.

alice commented 6 years ago

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)?

caridy commented 6 years ago

@alice I think a flatten structure is just fine. HTMLElement itself is mostly flatten anyways.

trusktr commented 6 years ago

Extending from option 3, could the following help with performance?

Option 4: 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!

Option 5: class-factory 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.

tkent-google commented 6 years ago

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.

trusktr commented 6 years ago

@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 {
  // ...
}
trusktr commented 6 years ago

@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.

trusktr commented 6 years ago

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?

  }

}
tkent-google commented 6 years ago

@trusktr, with your ideas, can we realize APIs which are available only for custom element authors?

trusktr commented 6 years ago

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?

caridy commented 6 years ago

@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.

calebdwilliams commented 6 years ago

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.

caridy commented 6 years ago

@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.

calebdwilliams commented 6 years ago

@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?

caridy commented 6 years ago

No @calebdwilliams, that's not how our private fields proposal works.

markcellus commented 6 years ago

@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!

caridy commented 6 years ago

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

tkent-google commented 6 years ago

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.

caridy commented 5 years ago

@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.

annevk commented 5 years ago

@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.

caridy commented 5 years ago

@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.

annevk commented 5 years ago

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.

tkent-google commented 5 years ago

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.

caridy commented 5 years ago

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.

annevk commented 5 years ago

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 Somethings, 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.

caridy commented 5 years ago

@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.

rniwa commented 5 years ago

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.

annevk commented 5 years ago

@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.

rniwa commented 5 years ago

@annevk yeah but exposing a method on Element which can't be invoked isn't great either since it pollutes the element's prototype.

annevk commented 5 years ago

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?

domenic commented 5 years ago

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.

annevk commented 5 years ago

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.

domenic commented 5 years ago

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.

rniwa commented 5 years ago

new Something(this) or some static method on Something which throws when called outside the constructor would be probably fine.

rniwa commented 5 years ago

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.

caridy commented 5 years ago

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.

domenic commented 5 years ago

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.