dart-lang / web

Lightweight browser API bindings built around JS static interop.
https://pub.dev/packages/web
BSD 3-Clause "New" or "Revised" License
135 stars 23 forks source link

Proposal: Add Support for JS Constructor Interoperability #222

Closed nikeokoronkwo closed 7 months ago

nikeokoronkwo commented 7 months ago

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 called ArrayBuffer() however (I'm aware there should be a new keyword infront), it would return an object

typeof ArrayBuffer; // "function"
typeof new ArrayBuffer(); // "object"

As 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 call

import 'dart:js_interop';
// Either
@JS("ArrayBuffer")
external JSFunction get ArrayBuffer;

// Or
extension type ArrayBuffer._(JSObject _) implements JSObject {
  // methods and fields...
}

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

extension type ArrayBuffer._(JSObject _) implements JSObject {
  // methods and fields...
}

void main() {
  print(ArrayBuffer); // prints the constructor
  print(ArrayBuffer()); // prints object
}

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.

// imports defined

extension type MyCustomElement._(JSObject _) implements HTMLElement {
  // EDIT: Forgot you can't call `super` in an extension type 
  // Another issue facing the implementation of Web Components with the next-gen JS Interop
}

void main() {
  // ?? - No constructor possible
  window.customElements.define("my-element", /* ?? */);
}

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 call MyCustomElement(), as HTMLElement, 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/implements B that is in Dart but exported from JavaScript, since A isn't defined in JavaScript.

/// `B` represents the Dart API for the JavaScript `C` object
@JS("C")
extension type B._(JSObject _) implements JSObject {
  external B();
  // more code...
}

// If we wanted the constructor for JavaScript's `C` object, we would need to do something like this
@JS("C")
external JSFunction get cConstructor;

// `A` is now an extension type of underlying type `B` and implements `B`
extension type A._(B _) implements B {
  A(): _ = B();
  // more code...
}

void main() {
  print(cConstructor); // Constructor of `C`
  print(B()); // Object of `C`
  print(A()); // Object of `A`
  // Cannot get constructor of A

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.

srujzs commented 7 months ago

Thanks for the proposal! It's always cool to see people interested in expanding functionality.

A few notes:

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!

donny-dont commented 7 months ago

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.

srujzs commented 7 months ago

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!

nikeokoronkwo commented 7 months ago

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

nikeokoronkwo commented 7 months ago

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.

srujzs commented 7 months ago

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;
nikeokoronkwo commented 7 months ago

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
}
srujzs commented 7 months ago

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.

nikeokoronkwo commented 7 months ago

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 {
  // ...
}
srujzs commented 7 months ago

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.

donny-dont commented 7 months ago

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

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);
}
nikeokoronkwo commented 7 months ago

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