Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: allow subclasses of constructible built-ins to be constructed #125

Closed
domenic opened this issue May 20, 2016 · 8 comments
Closed

Comments

@domenic
Copy link
Member

domenic commented May 20, 2016

Originally: whatwg/html#1289

The problem

HTMLElement has a constructor, specifically designed so that you can do

class X extends HTMLElement {}
customElements.define("x-x", X);
new X();

However, our intention was that you should also be able to do

class Y extends HTMLParagraphElement {}
customElements.define("y-y", Y, { extends: "p" });
new Y();

But this currently fails since, per Web IDL, the first step of HTMLParagraphElement's constructor is to throw a TypeError.

Proposed solution

For interface objects without [Constructor], change the first step of their constructor to only throw if they are not a derived interface. Otherwise, do the equivalent of super(...arguments).

This means all subclasses of things that are constructible automatically get a pass-through constructor.

Subclasses of things without constructors also get a pass-through constructor, but since the superclass constructor throws, the subclass constructor will continue throwing.

As part of this, I'd update the specification for interface objects to have modern [[Construct]] and [[Call]] definitions instead of just [[Call]].

Repercussions

I'm not sure if there are any interface on the platform that currently have a constructor, but which have subclasses that are not constructible. If such interfaces exist, their subclasses would become constructible. This seems unlikely to be a problem even if such interfaces do exist, but it's hard to say for sure.


I'm happy to do a PR for this but would like some feedback on whether it will be accepted.

@bzbarsky
Copy link
Collaborator

Otherwise, do the equivalent of super(...arguments).

We have to be a bit careful defining this, so if you do new Foo and Foo inherits from Bar, the UA is allowed to throw an exception with text about Foo not being constructible, as opposed to Bar not being constructible. Note that examining new.target is not sufficient to generate some text, because you might have subclassed Foo. So we need some provision for this...

It might be simpler to just have IDL check whether the parent interface is in fact constructible instead of adding those provisions.

As part of this, I'd update the specification for interface objects to have modern [[Construct]] and [[Call]] definitions instead of just [[Call]].

Note the discussion towards the end of https://www.w3.org/Bugs/Public/show_bug.cgi?id=22808 (comment 17 and following). In particular, I'd think we want IDL constructors to just be built-in function objects, have the default [[Call]] and [[Construct]] of those, and examine new.target to see whether they were called as a constructor. Otherwise we have to redefine a bunch of stuff involving the script execution context stack and whatnot...

I'm not sure if there are any interface on the platform that currently have a constructor, but which have subclasses that are not constructible.

There totally are. Of the things Gecko implements, that would be the following:

  1. Event has the following non-constructible standardized subclasses: AudioProcessingEvent, BeforeUnloadEvent, MediaKeyError, MutationEvent (well, questionably standardized), OfflineAudioCompletionEvent, TimeEvent (again, questionably standardized).
  2. Text has CDATASection inheriting from it, though I think some people are trying to remove it as a thing.
  3. Animation has non-constructible CSSAnimation and CSSTransition inheriting from it.
  4. MediaStream has non-constructible CanvasCaptureMediaStream and LocalMediaStream.
  5. UIEvent has CompositionEvent, SVGZoomEvent
  6. There's whatever is going on with documents (e.g. is HTMLDocument a thing, and is it constructible?).
  7. DocumentFragment has non-constructible ShadowRoot inheriting from it.

I think the biggest problem is the behavior when one of those constructors is invoked. Say someone does new ShadowRoot. If this just forwards to DocumentFragment you get back an object with the following behaviors as far as I can tell:

  • Object.prototype.toString.call() claims "[object ShadowRoot]" (because that's how @@toStringTag works, sorry.
  • The object is branded as a DocumentFragment but not a ShadowRoot (because the branding is done at allocation time and the allocation happens in the DocumentFragment constructor).
  • Doing for (far i in obj) { console.log(i, obj[i]); } throws, because the accessor props expect actual ShadowRoot branding.

These problems come up in HTML too. If you do class Foo extends HTMLParagraphElement and then new Foo do you get an element with localName set to "p"? If so, I guess that's because the HTMLElement constructor does the whole element registry thing and you must have registered your thing with the right info for that to work. But it won't work without that bit...

I think the right answer here is probably to do something specific to elements, because they have this weird setup where the base class constructor creates subclasses based on some sort of out of band information. For things that don't do that, passthrough is not the right behavior.

@domenic
Copy link
Member Author

domenic commented May 20, 2016

The object is branded as a DocumentFragment but not a ShadowRoot (because the branding is done at allocation time and the allocation happens in the DocumentFragment constructor).

But how does this work for subclasses that are constructible? E.g. if I do new CustomEvent, allocation happens in Event. How did it get the CustomEvent brand?

It seems to me like new ShadowRoot() should result in an object branded as both a DocumentFragment and a ShadowRoot, the same as CustomEvent. Maybe that means my proposal is not just a pass-through constructor, but a pass through that also adds a brand? (It's not really defined where the branding happens right now...)

I think the right answer here is probably to do something specific to elements, because they have this weird setup where the base class constructor creates subclasses based on some sort of out of band information. For things that don't do that, passthrough is not the right behavior.

I am in general convinced, but am curious about your thoughts on the above question first.

@bzbarsky
Copy link
Collaborator

No, if you do new CustomEvent the allocation happens in CustomEvent.

@bzbarsky
Copy link
Collaborator

In particular, CustomEvent and Event need to be able to allocate things of different sizes, because CustomEvent has additional internal slots. If you have a model where internal slots can be arbitrarily added to objects, you can delegate and then add post-hoc, I guess, but then that needs to be very explicitly spelled out in the actual constructor algorithms: they need to add those internal slots to the objects they create.

@bzbarsky
Copy link
Collaborator

Sorry I keep coming back to this.. I think the key property is this: whatever adds the CustomEvent brand to the object must also ensure that it has the relevant internal slots for a CustomEvent. This cannot be done by the Event constructor in general, I don't think, but it could be done, conceptually, by delegating to the Event constructor and then adding the internal slots and branding to the returned object. This is OK, at least, as long as you KNOW the super constructor does not allow the object to escape in any way....

@domenic
Copy link
Member Author

domenic commented May 20, 2016

No, if you do new CustomEvent the allocation happens in CustomEvent.

Oh, I see, I was assuming a normal ES model where subclasses called super(), and the base-most constructor uses new.target to determine what to allocate.

But I guess that's not how platform interfaces work. It sounds like they do a single allocation (and branding process) in their most-derived constructor, and only run that constructor's logic, not any base class constructors.

That latter model probably makes more sense when you have a hierarchy of exotic objects. ES in general doesn't seem to have that; the closest it has is GeneratorFunction deriving from Function but it looks like FunctionAllocate (i.e. what does the allocation inside the Function constructor) has hard-coded knowledge of generator functions baked in to it. This is somewhat predictable as ES seems to make an implicit assumption that all of an object's slots are fixed at creation time.

With this in mind I agree that we should do something specific to elements. Although, now I'm not sure what. I was thinking of just specifying a [PassThroughConstructor], but I guess that's not really what we want. We basically want to define that every HTML element class uses the same algorithm, which is currently only encoded in HTMLElement.

Maybe I'll define [HTMLElementConstructor] in HTML, which says "the [[Construct]] behavior for this interface is as follows: ... algorithm currently only in HTMLElement goes here ..."

@bzbarsky
Copy link
Collaborator

I was assuming a normal ES model where subclasses called super(), and the base-most constructor uses new.target to determine what to allocate.

That's not at all how things work at all in ES6 for builtins. The Array constructor uses ArrayCreate no matter what new.target is. Most other ES6 builtin constructors (Map, Promise, etc) use OrdinaryCreateFromConstructor but importantly pass it the list of internal slots to allocate. In fact, all of the ES builtins are designed such that the list of internal slots is available at allocation time last I checked. In none of those cases is the allocation behavior affected by the value of new.target; all that affects is the prototype of the object that gets created.

It sounds like they do a single allocation (and branding process) in their most-derived constructor, and only run that constructor's logic, not any base class constructors.

Going back to ES builtins, the one case where there is inheritance is typed arrays. The way these are done in ES6 is that all the typed array classes have the same set of internal slots and the typed array constructors do in fact delegate to their proto (note, dynamically; I'm not sure whether you were proposing dynamic or static delegation in the HTML element constructors). Anyway, these all invoke %TypedArray% which starts at new.target and walks its proto chain looking for one of the typed array constructors. If it finds one, it uses that to decide which sort of typed array to allocate. The actual allocation is again done via IntegerIndexedObjectCreate (passing in all the internal slot names, etc) and the type is just stored in an internal slot.

There are no examples of ES6 builtins where builtin A subclasses builtin B and the set of internal slots differs between them, so we don't have a great guide to go on...

Anyway, there are two ways to model this. One way is that creating an object requires its full set of internal slots, and then the creation has to be done by something that has all that information. In practice, that's the most-derived builtin constructor. This is the general approach ES6 takes, and the way actual implementations work, I believe. The other way is that you can dynamically add internal slots. As a specification device, this works ok: you call your super constructor, it adds whatever internal slots and branding, then you add your own internal slots and your own branding. The key is that we never have branding and internal slots diverge. This doesn't match how implementations work, but may make specification easier. The danger is if this setup allows creation of specifications that are not exactly implementable, due to the conceptual mismatch. But I think it's hard to do that in this case, as long as you don't add internal slots in weird conditional ways...

Maybe I'll define [HTMLElementConstructor] in HTML, which says "the [[Construct]] behavior for this interface is as follows: ... algorithm currently only in HTMLElement goes here ..."

That might actually make sense, yes. But, again, we want this to be the built-in function object steps and use the normal built-in function object [[Construct]], I think.

domenic added a commit to whatwg/html that referenced this issue Jun 8, 2016
Previously, only HTMLElement itself ran the algorithm necessary to allow
subclassing for the purpose of custom elements. This prevented most
customized built-in elements from working correctly. Here we introduce
the [HTMLConstructor] IDL extended attribute, in order to automatically
install the correct constructor behavior for all HTML element
constructors.

Fixes #1289. See also whatwg/webidl#125 where
an alternate solution was discussed and rejected before arriving at the
solution contained here.
@domenic
Copy link
Member Author

domenic commented Jun 8, 2016

Closing this in favor of the [HTMLConstructor] solution in whatwg/html#1404. Thanks for talking me away from my weird idea, @bzbarsky!

@domenic domenic closed this as completed Jun 8, 2016
annevk pushed a commit to whatwg/html that referenced this issue Jun 12, 2016
Previously, only HTMLElement itself ran the algorithm necessary to allow
subclassing for the purpose of custom elements. This prevented most
customized built-in elements from working correctly. Here we introduce
the [HTMLConstructor] IDL extended attribute, in order to automatically
install the correct constructor behavior for all HTML element
constructors.

Fixes #1289. See also whatwg/webidl#125 where
an alternate solution was discussed and rejected before arriving at the
solution contained here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

2 participants