team icon indicating copy to clipboard operation
team copied to clipboard

Request for library: slim web component library

Open fitzgen opened this issue 6 years ago • 21 comments

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.

fitzgen avatar Apr 25 '18 17:04 fitzgen

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

rail44 avatar May 22 '18 10:05 rail44

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.

fitzgen avatar May 22 '18 17:05 fitzgen

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.

rail44 avatar May 23 '18 03:05 rail44

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:

  • define expect a js_sys::Function as argument. How can I retreive the JS prototype of the class created in the first step?
  • If we want to use shadow DOM we need to call attach_shadow in the custom element constructor. I don't see how a struct can extend web_sys::Element nor web_sys::HTMLElement as rust do not have inheritance.

ctjhoa avatar Jan 26 '19 19:01 ctjhoa

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

fitzgen avatar Jan 28 '19 17:01 fitzgen

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

Pauan avatar Jan 31 '19 18:01 Pauan

@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();
    });
  }
}

fitzgen avatar Jan 31 '19 20:01 fitzgen

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.

Diggsey avatar Jan 31 '19 20:01 Diggsey

@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 avatar Jan 31 '19 20:01 Pauan

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.

Pauan avatar Jan 31 '19 20:01 Pauan

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 avatar Jan 31 '19 20:01 fitzgen

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

fitzgen avatar Jan 31 '19 20:01 fitzgen

@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 avatar Jan 31 '19 20:01 Pauan

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

Pauan avatar Jan 31 '19 20:01 Pauan

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.

  • First, it creates a web component which contains a rust object instance. This web component increments the rust model every 100ms.
  • This web component is then moved in the DOM every 2s.
  • Finally, after 3 moves this component is destroyed and another one is instantiated.

ctjhoa avatar Feb 02 '19 19:02 ctjhoa

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.

trusktr avatar May 25 '19 17:05 trusktr

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

olanod avatar Jun 06 '19 16:06 olanod

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

Pauan avatar Jun 06 '19 22:06 Pauan

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

sandreas avatar Aug 08 '19 04:08 sandreas

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!

eggyal avatar Aug 27 '19 13:08 eggyal

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

SephReed avatar Sep 15 '20 16:09 SephReed