rustwasm / team

A point of coordination for all things Rust and WebAssembly
MIT License
1.45k stars 59 forks source link

Request for library: slim web component library #162

Open fitzgen opened 6 years ago

fitzgen commented 6 years ago

It would be super cool if someone wrote a slim library for writing web components and custom elements in rust!

I'm imagining the library would have a trait, and if I (the library user) implement that trait for my type, then the library wires up all the glue to turn that trait implementation into a web component.

A great way to integrate nicely with the JS ecosystem! A website could use these web components without even needing to know that it was implemented in rust and wasm.

A potential web component to implement with this library might be a graphing/charting library that has to churn through a lot of data before displaying it.

rail44 commented 6 years ago

https://github.com/rail44/squark I'm working and trying to resolve https://github.com/rustwasm/wasm-bindgen/issues/42 🤔

fitzgen commented 6 years ago

https://github.com/rail44/squark

Neat! Although I don't see any mention of web components, so I think it may be orthogonal to this issue...

I'm working and trying to resolve rustwasm/wasm-bindgen#42

Awesome! Do you have any particular questions or anything? Probably best to move this part of the discussion into that issue.

rail44 commented 6 years ago

I don't see any mention of web components

Oh, sorry 😅 Currently, I use stdweb for binding to web browser. I think, Importing WebIDL is necessary to provide more binding for browser world such as web component.

ctjhoa commented 5 years ago

Hi,

I want to give a try on this but I'm quickly stuck on few things. My first try is simple:

Here are my questions:

fitzgen commented 5 years ago

Hi @ctjhoa! Excited to see some movement here :)

The design that I think makes sense is to have the actual HTMLElement subclass be a JavaScript class that creates a the inner #[wasm_bindgen]-exposed Rust struct in the connectedCallback and then frees it in the disconnectedCallback. E.g. https://rustwasm.github.io/sfhtml5-rust-and-wasm/#74

This approach saves users from having to manage memory and lifetimes of objects themselves. It also side steps some of your questions above.

Also, the primary interface between a web-component and the outside world is the custom element's attributes. It would be A+ if we could provide a serde deserializer from attributes to rich rust types that the web component uses (or at minimum does things like parse integers from attribute value strings).

Are you available to come to the next WG meeting? If possible, it would be great to have some high-throughput design discussion on this stuff :) https://github.com/rustwasm/team/issues/252

Pauan commented 5 years ago

@fitzgen It's not feasible to use connectedCallback and disconnectedCallback to manage Rust lifetimes, because they aren't actually tied to the lifecycle of the DOM node, and both callbacks can be called multiple times:

https://codepen.io/Pauan/pen/961b58f8fc23677268ad11f37e3c6cc9

(Open the dev console and see the messages)

As you can see, every time a DOM element is inserted/removed, it fires the connectedCallback/disconnectedCallback.

This happens even when merely moving around the DOM node (without removing it).

Unfortunately I don't see a clean way to fix this. You mentioned (during the WG meeting) using setTimeout (or similar), which would technically work in some situations, but not others. It also feels very hacky.

So I think we're still stuck with manual memory management, manually calling a free method (or similar).

fitzgen commented 5 years ago

@Pauan, thanks for making an example and verifying this behavior!

I still think a delayed (and cancellable!) destruction is what we want here, because of the superior UX. Custom elements are supposed to "just" be another element, and they are supposed to completely encapsulate themselves without requiring users do anything that "normal" elements require.

I think using requestIdleCallback would actually fit this use case really well:

import { RustCustomElement } from "path/to/rust-custom-element";

class CustomElement extends HTMLElement {
  constructor() {
    this.inner = null;
    this.idleId = null;

    if (this.isConnected) {
      this.inner = new RustCustomElement(/* ... */);
    }
  }

  connectedCallback() {
    if (!this.inner) {
      window.cancelIdleCallback(this.idleId);
      this.idleId = null;
      this.inner = new RustCustomElement(/* ... */);
    }
  }

  disconnectedCallback() {
    this.idleId = window.requestIdleCallback(() => {
      const inner = this.inner;
      this.inner = null;
      inner.free();
    });
  }
}
Diggsey commented 5 years ago

The other option would be to require the state to be serializable. That way you can store all the state directly on the component (or in a WeakMap) and you don't actually need to free anything.

Pauan commented 5 years ago

@fitzgen I still think a delayed (and cancellable!) destruction is what we want here, because of the superior UX. Custom elements are supposed to "just" be another element, and they are supposed to completely encapsulate themselves without requiring users do anything that "normal" elements require.

I agree that's a good goal, I'm just not seeing a clean way to accomplish that.

If a user removes the custom element from the DOM, then waits for a bit (perhaps using setTimeout, or perhaps waiting for an event), and then re-inserts it into the DOM, then things will be broken since the Rust objects will have been freed.

An unusual case, sure, but it definitely can happen, and I can even imagine use-cases for it (e.g. a modal dialog which is only inserted into the DOM while the modal is open, and is otherwise detached from the DOM).

So proper support probably requires WeakRef or similar.

Having said that, we can totally experiment with custom elements even without WeakRef (just with the above caveat).

Pauan commented 5 years ago

Also, as for encapsulation, custom elements can actually have custom methods, which the consumer can then access:

class Foo extends HTMLElement {
    myMethod() {
        ...
    }
}

customElements.define("my-foo", Foo);

var foo = document.createElement("my-foo");

foo.myMethod();

So I don't think it's that unusual to call methods on custom elements. I imagine that's probably the preferred way to update the custom element's internal state from the outside.

In addition, because JS doesn't have finalizers, I think there will be custom elements (written entirely in JS) which require a free method (or similar), for the sake of cleaning up event listeners (and other resources which can't be claimed by the JS garbage collector).

So overall I don't think it's that unusual to have a free method for custom elements, but I guess we should wait and see how the ecosystem evolves.

fitzgen commented 5 years ago

If a user removes the custom element from the DOM, then waits for a bit (perhaps using setTimeout, or perhaps waiting for an event), and then re-inserts it into the DOM, then things will be broken since the Rust objects will have been freed.

The reinsertion will trigger a new Rust object to be created, so this won't result in a bug. See the if (!this.inner) check in the connectedCallback in the JS snippet in my previous comment.

Yes, there can be multiple Rust objects used across the lifetime of the JS custom element: the idea is that creating and destroying the inner Rust thing on every attach or detach is the baseline, and the delay is an optimization to cut down on thrashing when just moving the element instead of removing it.

Unless I am misunderstanding what you are saying?

fitzgen commented 5 years ago

So I don't think it's that unusual to call methods on custom elements. I imagine that's probably the preferred way to update the custom element's internal state from the outside.

I think the usual way is via setting attributes (eg same as value, min, and max for <input type="range"/>) unless there are Other Reasons where that doesn't make sense.

Ultimately, yes we want finalizers, and we can actually polyfill it now. Which is something we need to get published...

Pauan commented 5 years ago

@fitzgen Ah, okay, I had (incorrectly) assumed there would be a 1:1 relationship between the Rust struct and the custom element.

If you instead make it N:1 then yeah, it can just dynamically create/destroy the Rust objects on demand. I don't think that'll work for every use case, but it should work for most.

I'm a bit concerned about the user's expectations though, I think other users will also expect a 1:1 relationship.

Pauan commented 5 years ago

As for attributes, (at least in HTML) those are primarily used for static pre-initialized things. For dynamic things, users instead use setters/methods like foo.min = 5. The same goes for adding event listeners (and other things).

Since custom elements can listen to attribute changes, they can respond to dynamic attribute changes, but using foo.setAttribute("bar", "qux") seems rather inconvenient and unidiomatic compared to foo.bar = "qux" (or similar).

Since getters/setters/methods are so common with regular DOM elements, I expect them to be similarly common with custom elements (I don't have any statistics or experience to back that up, though).

ctjhoa commented 5 years ago

I've made a proof of concept https://github.com/ctjhoa/rust-web-component It uses requestIdleCallback to free rust resources. I've tried to use other techniques with WeakMap & WeakSet without success.

So what's going on in this project.

trusktr commented 5 years ago

Seems like any wasm module just needs a JS custom element glue class to call into the module (to trigger the lifecycle hooks). Even if wasm gets ability to reference DOM elements in the future, there will be no way to avoid the JS glue class (to pass into customElements.define()), unless the Custom Elements API evolves to accept wasm modules and not just JS classes.

olanod commented 5 years ago

Can't you create the "class" from rust creating a function object changing the prototype, etc, etc?

Pauan commented 5 years ago

@olanod That still requires JS glue to create the function object (and change the prototype).

Secondly, as far as I know, it's not possible to use ES5 classes for custom elements:

function Foo() {
    console.log("HI");
}

Foo.prototype = Object.create(HTMLElement.prototype);

customElements.define("my-foo", Foo);

// Errors
var x = document.createElement("my-foo");

This is because ES5 classes cannot inherit from special built-ins like Array, RegExp, Error, or HTMLElement.

But ES6 classes were specifically designed so that they can inherit from built-ins. So ES6 classes aren't just a bit of syntax sugar, they actually have new behavior.


However, even given the above, we only need a single JS function to create all of the classes (this was mentioned by @trusktr ):

export function make_custom_element(parent, observedAttributes, connectedCallback, disconnectedCallback, adoptedCallback, attributeChangedCallback) {
    return class extends parent {
        static get observedAttributes() { return observedAttributes; }

        connectedCallback() {
            connectedCallback();
        }

        disconnectedCallback() {
            disconnectedCallback();
        }

        adoptedCallback() {
            adoptedCallback();
        }

        attributeChangedCallback(name, oldValue, newValue) {
            attributeChangedCallback(name, oldValue, newValue);
        }
    };
}

Now wasm can just call the make_custom_element function.

In fact, it should be possible to do that right now, no changes needed to wasm-bindgen.

sandreas commented 5 years ago

These might be interesting, if not already known: https://github.com/webcomponents/polyfills/tree/master/packages/custom-elements

https://github.com/cyco/WebFun/blob/26aa79ab14e05dd0a3d84e0c43d01fe0c0255512/src/ui/babel-html-element.ts

eggyal commented 5 years ago

With https://github.com/rustwasm/wasm-bindgen/pull/1737, it's possible to do:

#[wasm_bindgen(prototype=web_sys::HtmlElement)]
struct MyCustomElement {}

#[wasm_bindgen]
impl MyCustomElement {
    #[wasm_bindgen(constructor)]
    fn new() -> WasmType<MyCustomElement> {
        instantiate! { MyCustomElement{} }
    }
}

// ...

web_sys::window()
    .unwrap()
    .custom_elements()
    .define("my-custom-element", &js_sys::JsFunction::of::<MyCustomElement>())?;

// ...

Any thoughts or input into that PR and/or the (early draft) RFC for which it's a prototype would be greatly appreciated!

SephReed commented 4 years ago

@eggyal Oh no! What happened with your draft?

What would it take to get this train moving again? I could really use the ability to extend and implement AudioWorkletProcessor in Rust.