Closed nikeokoronkwo closed 7 months ago
Thanks for the proposal! It's always cool to see people interested in expanding functionality.
A few notes:
I agree having to define a separate interop member that isn't tied to the type to get the constructor is a bit unfortunate, especially because it would require a rename via the @JS
annotation.
print(ArrayBuffer); // prints the constructor
Dart lowers this as a Type
, and we shouldn't change the meaning of that as it's not marked as an external
thing. That'd probably have some other downstream issues as well.
We could instead lower the external
constructor tear-off to be the JS constructor instead e.g. calling ArrayBuffer.new
would return the JS constructor. I'm not sure if you were actually referring to that and not the Type
with the above comment. This is sort of what we did with package:js
, but there are complications.
One is that you're not getting a Dart function back. When compiling to JS, calling the tear-off sort of works as intended because of the similarity between a compiled Dart function and a JS function, but this will certainly not work when compiling to Wasm. Another issue is that interop method/constructor invocations are transformed at the invocation site in order to account for optional parameters (the lack of an optional parameter is interpreted as not passing that optional rather than passing null
, which is how Dart members treat optional parameters). When these members are torn-off, we no longer know where the member is called. If we transformed the procedure instead of the invocation, now you have a discrepancy in optional parameter semantics between calling the member directly versus calling the tear-off.
That being said, maybe there's some way to declare something like:
external JSFunction get thisConstructor; // returns the constructor of the JS type
within an interop type. Although, this is only marginally better than declaring the top-level interop member to get the constructor. :) Definitely open to other ideas if we can think of any, though.
As you've noticed, we don't really have a good way to declare a custom element in Dart. Extension types definitely won't work because they don't lower to an actual class. In the past, users have used some wrapper classes to make this work in the JS compilers: https://github.com/dart-lang/sdk/issues/46248 and @donny-dont's package here: https://github.com/rampage-dart/rampage/tree/main.
Of course, if you're already declared the class in JS, defining it with Window.customElements
in Dart is just a matter of getting the JS constructor.
If you come up with a proposal to make custom elements work, please let us know!
Just a note that I've been playing with migrating to dart:js_interop
and doing Custom Elements. The wrapper solution does work perfectly fine. I haven't pushed anything because I'd like to generate all the extension type
bits running my WebIDL parser output through code_builder
. Might have a reason to make this more of an actual project instead of something I just futz with every once in awhile.
Oh I see, I forgot this used some JS code to define the class inline: https://github.com/rampage-dart/rampage/blob/main/html/lib/browser/custom_element.js. Then yeah, this should work with dart2wasm when this migrates to dart:js_interop
as well, cool!
I would really want to know how this can be implemented in dart:js_interop
@donny-dont, since one can't use the super
keyword in an extension type. How it would look...
Thanks for your notes so far.
I do have some ideas in mind @srujzs but before I get into that, just a few takeouts from what you've said:
In the past, users have used some wrapper classes to make this work in the JS compilers: https://github.com/dart-lang/sdk/issues/46248 and @donny-dont's package here: https://github.com/rampage-dart/rampage/tree/main.
I do like the implementation that the rampage package has been able to go with. I'll see if I can make a unified solution (i.e having everything together in one class than having the main class and an implementation class) from what is there. However, because of the new interop being worked on, I'm looking towards a solution using extension types.
That being said, maybe there's some way to declare something like:
external JSFunction get thisConstructor; // returns the constructor of the JS type
within an interop type.
This could help for most use cases, however I would want to ask if we could consider it being a static member. That way we do not need to call the constructor of the object (for cases like in html elements). However, static members aren't inherited.
Still looking at and towards other possible options, else we would probably have to go with using the new
method constructor.
It looks like if we don't need this to be static
, constructor
could get you what you want today already e.g. Array(5).constructor == Array
:
external JSFunction get constructor;
We wouldn't be able to do so for classes like HTMLElement
or any other classes that inherit HTMLElement
. What if we wanted to get the constructor for those classes?
void main() {
print(HTMLElement().constructor); // Error: Illegal Constructor
}
I'm not sure where you're getting the HTMLElement()
(there doesn't seem to be such a member in package:web
, so maybe dart:html
?) member from, but looking purely at JS, this is what I'm seeing:
document.createElement('html').constructor == HTMLHtmlElement; // true
edit: Oh, I see, it's an older version of package:web
. The illegal constructor
issue is separate from this. We were emitting constructors like external HTMLElement()
which are incorrect because new HTMLElement()
is not how you create elements. Doing so will give you the error you are seeing. Instead, we generated element constructors in https://github.com/dart-lang/web/commit/641a8dfe290dfd112a2fc75acb5631b26138f9cd using document.createElement
.
Oh I see now. My Bad.
Finally, what of in cases where an object may implement a JS Interop type, like DartArrayBuffer
implementing ArrayBuffer
?
extension type DartArrayBuffer._(JSObject _) implements ArrayBuffer {
// ...
}
Since constructor
is a property on the instance, it depends on the type of the underlying object and the Dart declaration doesn't affect that. If it was an ArrayBuffer
, then you'd get the JS ArrayBuffer
, and if it was a subtype, you'd get that instead.
I would really want to know how this can be implemented in
dart:js_interop
@donny-dont, since one can't use thesuper
keyword in an extension type. How it would look...
You create a extension type
on JSObject
that stores the Dart object. This would look like this.
/// A JavaScript object that wraps a Dart object.
extension type DartWrapper._(JSObject _) implements JSObject {
external JSBoxedDartObject? get dartObject;
external set dartObject(JSBoxedDartObject? dartObject);
}
You would then have a Dart object that stores a JSObject
which has a reference to the instance.
/// A bidirectional wrapper between Dart and JavaScript.
class DartJsWrapper implements JsWrapper {
/// Creates a [DartJsWrapper] around the [jsObject].
DartJsWrapper.fromJsObject(this.jsObject) {
final wrapper = jsObject as DartWrapper;
assert(
wrapper.dartObject == null,
'Another Dart Object is already attached to the JsObject',
);
wrapper.dartObject = this.toJSBox;
}
@override
final JSObject jsObject;
}
You then build the same hierarchy in Dart by descending from DartJsWrapper
. In 👇 its just extending from it directly but in a final form it would descend from Element
which descends from Node
which descends from EventTarget
which descends from DartJsWrapper
.
class HtmlElement extends DartJsWrapper {
HtmlElement.fromJsObject(super.jsObject) : super.fromJsObject();
void click() {
(jsObject as js.HtmlElement).click();
}
}
Then we need a Dart aware implementation of CustomElementRegistry.define
which is where the JavaScript comes in. I'm pretty sure this is similar to how Polymer.dart worked back in the day.
So 👇 's define
takes a constructor which will pop out a Dart object. This then creates an anonymous class in JavaScript that is the actual CustomElement. When one is constructed it calls construct
which creates a Dart object and puts it where expected in the DartWrapper
extension type.
class CustomElementInterop {
constructor() {
this.constructorCallbacks = {};
this.connectedCallback = (d) => { throw new Error('connectedCallback not set') };
this.disconnectedCallback = (d) => { throw new Error('disconnectedCallback not set') };
this.attributeChangedCallback = (d, attr, oldVal, newVal) => { throw new Error('attributeChangedCallback not set') };
}
define(name, construct, observed) {
let that = this;
customElements.define(name, class extends HTMLElement {
constructor() {
super();
this.dartObject = construct(this);
}
connectedCallback() {
that.connectedCallback(this.dartObject);
}
disconnectedCallback() {
that.disconnectedCallback(this.dartObject);
}
attributeChangedCallback(attr, oldVal, newVal) {
that.attributeChangedCallback(this.dartObject, attr, oldVal, newVal);
}
static get observedAttributes() { return observed; }
});
}
}
window.CustomElementInterop = CustomElementInterop;
On the Dart side we then create a CustomElement
descending from a HtmlElement
as expected. We then create a Dart wrapper of CustomElementInterop
, I'm not putting the extension type
part cause this is taking a bit 😄 and I'm sure you get it.
The CustomElementRegistry
's define
takes a constructor tear off and wraps it in function that produces the expected JSBoxedDartObject
. It calls into the actual defines. It also sets up callbacks that get invoked in the JS side when the element's events occur. This sends over the JsBoxedDartObject
to the Dart callback. It then unboxes it and calls the expected method on the Dart implementation which hooks in the lifecycle callbacks.
typedef CustomElementConstructor = HtmlElement Function(js.HtmlElement object);
class CustomElementRegistry {
CustomElementRegistry() : _registry = js.CustomElementInterop() {
_registry
..connectedCallback = _connectedCallback.toJS
..disconnectedCallback = _disconnectedCallback.toJS;
}
final js.CustomElementInterop _registry;
void define(
String name,
CustomElementConstructor constructor, [
List<String>? attributes,
]) {
js.JSBoxedDartObject? construct(js.HtmlElement jsObject) =>
constructor(jsObject).toJSBox;
_registry.define(name.toJS, construct.toJS, js.JSArray());
}
static void _connectedCallback(js.JSBoxedDartObject element) {
(element.toDart as CustomElement).connected();
}
static void _disconnectedCallback(js.JSBoxedDartObject element) {
(element.toDart as CustomElement).disconnected();
}
}
And then we wrap Document.createElement
and do a check to see if there's already a Dart object when it returns. For Custom Elements this will be the case and we can just return it. For others we'd wrap it in a Dart object so a <div>
would end up using a DivElement.fromJsObject
. The 👇 is just to get the gist and would be much larger in an actual implementation since it would need to look up the tags and act appropriately.
HtmlElement createElement(String name) {
final jsObject = js.document.createElement(name.toJS) as js.DartWrapper;
final dartObject = jsObject.dartObjectAs<HtmlElement>();
return dartObject ?? HtmlElement.fromJsObject(jsObject);
}
@srujzs Oh I see now. My Bad.
Well in that case, I don't think there is going to be any issue with getting constructors of JS interop types. We can use the constructor
property.
Since the remaining cases have to do with custom components, it would be much more fitting to redirect to a new proposal, because you can't really do document.createElement('my-element').constructor
when passing into the window.customElements.define
method because the element to be returned would be an instance of HTMLUnknownElement
.
I have been making some progress concerning creating custom components with dart:js_interop
(although it isn't exactly perfect :) ) https://github.com/quetzalframework/web-components.
@donny-dont Thanks for the explanation! Really Helpful. I will redirect this to the new proposal.
If there is anything else to discuss concerning this let me know. If not I'll have to close the issue, progress onto the Web Components proposal and redirect.
Synopsis
This proposal aims to suggest the possible addition of support for Dart-JavaScript Interoperability with JavaScript constructor methods and other related functionality.
Dart-JavaScript interop works for a good majority of use cases, such as for interoping with classes (via extension types) and functions. However, one feature of JavaScript that Dart doesn't have support for is being able to interop and emulate javascript constructors.
Overview
In JavaScript, if you called
ArrayBuffer
, it would return a function, representing the constructor function. If you calledArrayBuffer()
however (I'm aware there should be anew
keyword infront), it would return an objectAs of now, if we wanted to perform interop with the javascript
ArrayBuffer
class, we would have to pick between one of these: either to interop with the class object as an extension type, or to interop with the constructor as a function callIf we wanted to incorporate both, we would need to give separate names.
TThis proposal suggests incorporating an extension type into a single interface, where the object's constructor can be represented through an API call in the extension type. Although it would be preferable to simply use the object's name, an alternative solution could be considered.
I would be glad to enlighten more, but in order to not make this proposal too lengthy as it already is, I would respond to any questions concerning this in the comments (if any).
Importance
One of the main reasons behind this proposal has been for the adoption of Web Components (which will be discussed in more detail in a later proposal). I tried working on making Web Components on my own using current Dart interop, but it wasn't successful in any direction.
The best case I had was the fact that I wasn't able to use the
window.customElements.define
method because I couldn't obtain the constructor of the new class. I also couldn't callMyCustomElement()
, asHTMLElement
, as well as any objects that extend it, do not allow calls to their constructors directly.It also would not be possible to obtain the constructor for a class/extension type
A
made in Dart that extends/implementsB
that is in Dart but exported from JavaScript, sinceA
isn't defined in JavaScript.Additional Information/Closing
If the Dart team would be interested, I would be glad to join in helping to make this possible/contributing for this proposal. Web Components is another issue of it's own I guess, so I'd want to start with this one.