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 for new implementation of leptos_dom #116

Closed
jquesada2016 opened this issue Nov 24, 2022 · 4 comments
Closed

Proposal for new implementation of leptos_dom #116

jquesada2016 opened this issue Nov 24, 2022 · 4 comments
Labels

Comments

@jquesada2016
Copy link
Contributor

As promised, here is a little writeup. This is just the theoretical principals. I can make an RFC detailing how it would all work together and create a crate on my github for an implementation reference if you would like to hear more.

Disclaimer:

The code presented here is mostly pseudo code, so don't expect
it to compile as-is.

I had done some research on efficient ways of working with the DOM for
the purpose of a reactive web framework. I will resume the technique,
algorithm, and reasoning below.

When working with the DOM, there is surprisingly little work that needs
to be done in order to create a flexible and fast model for representing
application state. This is because the DOM already does all of the work for us.
We just need to exploit that within our respective paradigm, FRP, in our case.

Within FRP, we have values that asynchronously
change over time. These are called signals. The goal is to leverage the DOM without
duplicating it in a VDOM. We have two simple notions of an element,
static and dynamic. Static nodes are nodes which never change, their value
is either known at compile time, or can be evaluated at runtime, but once
rendered, it's content will never change. For example:

<h1>Hello</h1>

or

<button onclick="handler">{value.to_string()}</h1>

Dynamic nodes, however, do change during runtime. For example,

<h1>{title.subscribe_to_changes()}</h1>

In this previous case, the text content of the <h1> tag will change when
the value of title changes. This is known as reactivity. Reactivity can
be split into boundaries, where the thing that will be changing, will have
it's own reactive area for it to change into.

Most modern web frameworks have the notion of components, that is a collection
of HTML markup and logic that comprises a more abstract piece of UI or application
logic. For example:

let button = view! { cx, <button class="btn btn-primary">{label}</button> };

Creating basic elements and rendering them to the DOM is trivial. Let's
say I want to represent the following markup in Rust:

<h1>Hello, World!</h1>

This can be achieved with the following Rust code:

let h1 = document().create_element("h1");

h1.set_inner_text("Hello, World!");

document().body().append_child(h1);

Rendering static nodes is trivial; simply create the nodes, populate their
content with text or other nodes, and move on to the next one. The tricky bit
is working with dynamic data.

I will define one simple core component which is needed to represent all other
reactive elements, I will call it DynChild. DynChild can have children that
is intended to be atomically changed when a signal changes. Let's take
our previous dynamic example, it can be represented with DynChild as follows:

<h1>
  <DynChild signal=title_signal>
    {|title: String| title}
  </DynChild>
</h1>

So far, so good. Now, how can we create a reactive abstraction which
will let us update the contents of the <h1> tag with doing as little
work as possible? Try this:

<h1>
  <!-- <DynChild> -->
  title text goes here
  <!-- </DynChild> -->
</h1>

If we fence the reactive child within "opening" and "closing" comment nodes,
we will be able to insert and remove nodes by using only at most two DOM operations;
append_child and before_with_node_1, and better yet, with a guaranteed insertion
time of O(1).

Let's get into a bit more detail. For this, we'll need to introduce a few
new types.

struct Element(web_sys::Node);
struct Text(web_sys::Node);
struct Comment(web_sys::Node);

enum NodeKind {
  Element(Element),
  Text(text),
  Component {
    opening: Comment,
    children: Vec<NodeKind>,
    closing: Comment,
  }
}

For simplicity, children are not mentioned for Element, only for Component,
as this is the part which interests us in the context of reactivity.

To remove any node from the DOM, it's as simple as:

impl Drop for Element {
  fn drop(&mut self) {
    self.0.unchecked_ref::<web_sys::Element>().remove();
  }
}

Here is where the optimization comes into play for inserting dynamic children;
remember how I mentioned that DynChild was the only component required to
implement all reactivity? When we want to insert new nodes to replace the
old ones, just do these two steps

  1. Drop the old children
  2. For each new children, just call closing.before_with_node_1(child_node).

That is, insert the nodes relative to the component boundaries, and you have
your own little isolated reactive context for children! In this example,
technically you only need the closing comment node, but you actually need
both in order to best represent another component built on DynChild, Each,
which can be made also into a O(n) insertion for each element changed, where
n is the number of changes in the list. This, however, is outside the scope
of this writeup.

@jquesada2016
Copy link
Contributor Author

I think this issue should be renamed, I just don't know to what lol. I'll leave it up to you @gbj.

Also, in my research I also had found a very simple and fast technique for hydration.

@gbj gbj added the design label Nov 24, 2022
@gbj
Copy link
Collaborator

gbj commented Nov 24, 2022

I think the name is fine, and I think this is very exciting.

Short summary of my thoughts: if you're willing, I'd love to see you implement it so we can benchmark it against the current renderer.

Longer list of thoughts:

  • Just so you know: I have no sentimental attachment to the current leptos_dom, which is basically a bad port of SolidJS's renderer. So my feelings won't be hurt if we throw it out.
  • Glad you mentioned hydration as it's very important to keep this in mind when thinking about rendering. (Hydration is the default use case for a lot of people now)
  • The current renderer is very fast, but as you're well aware my implementation is pretty fragile. If you can make it more robust with your approach without it being meaningfully slower I'd adopt it immediately. (Most of what is fragile in the current approach is that it's based on using a single comment marker node rather than a pair wrapping each reactive "hole," so most bugs have been about correctly generating the code to traverse the DOM and pick up the right nodes. Wrapping everything in two may simply be the right approach.)
  • I'm sure you're thinking about rolling in Only elements are allowed at the top level of view! {} macro #55 on this. I've been doing some work on that too but can put it on hold for now if you want to work on this, to save myself what may be wasted effort.
  • Also good to be thinking of Each. I've been thinking it would be useful to have the map_keyed function, which powers the current <For/> component, actually output a set of VecDiffs (like the one's in Dominator/the rust-signals crate's MutableVec type). I don't want people to have to use a special data structure, but we can diff Vecs very efficiently; it's just the DOM operations to reconcile them that are slow.

So yeah, basically I think this is an exciting idea and would be very happy to see it. I assume the best thing to do is for you to implement it we can try it out. If you only want to work on the rendering part and leave the macro stuff to me, that's fine... If you come up with a manual/verbose version, I'm sure I can use syn-rsx to make a nice macro to do it.

  • As I mentioned on Discord, <template> elements with static HTML strings are the fastest way to create DOM nodes. The only complex part about this is iterating over the template HTML to "pick up" the wrapping component nodes

Here's a hypothetical approach to use template nodes. Feel completely free to ignore it

let (name, set_name) = create_signal(cx, "Bob".to_string());
let (age, set_age) = create_signal(cx, 42);

// simplified output of the view macro
let template_str = "<h1><!></><!></></h1>";

// this is actually stored in a thread-local variable
// so we only create the template node once, lazily, per application
// then clone it every time we need it
let template = document.create_element("template").unchecked_into::<HtmlTemplateElement>();
template.set_inner_html(template_str);
let root = template.content().clone_node_with_deep(true);

// get the next start and end comments, starting from 
let (name_start, name_end) = next_dyn_child(template_str); 
let (age_start, age_end) = next_dyn_child(name_start); 

insert_dyn_child_between(name_start, name_end, move || name.get());
insert_dyn_child_between(age_start, age_end, move || age.get().to_string());

@jquesada2016
Copy link
Contributor Author

Yay! Thanks! I'll definitely start working on it, hopefully today. I do need help with one thing, though...as you know, in computer science, there are only two hard things; cache invalidation, and naming things...what should I name it in the meantime...?

I'm sure you're thinking about rolling in #55 on this. I've been doing some work on that too but can put it on hold for now if you want to work on this, to save myself what may be wasted effort.

Sure! There are a few design approaches that can be taken, but a basic proof of concept renderer can be made in less than 500 LoC (I've made way too many to know lol), so it shouldn't be too long before we can start evaluating this approach vs the current one, and thus, the issue shouldn't be stalled for too long

Also good to be thinking of Each. I've been thinking it would be useful to have the map_keyed function, which powers the current component, actually output a set of VecDiffs (like the one's in Dominator/the rust-signals crate's MutableVec type). I don't want people to have to use a special data structure, but we can diff Vecs very efficiently; it's just the DOM operations to reconcile them that are slow.

Yes! As a matter of fact, the approach for the Each component is entirely based on diffing the values and creating a command queue which is used to minimally touch the DOM.

If you come up with a manual/verbose version, I'm sure I can use syn-rsx to make a nice macro to do it.

I'll actually start off with a builder API, as it's the easiest to adapt to a macro, as opposed to the other way around, imho.

Here's a hypothetical approach to use template nodes. Feel completely free to ignore it

I will not ignore it! I will, however, not try to implement this for the initial evaluation, simply because I know adding this after the fact will be trivial, but will allow me to deliver something sooner if I skip this complexity for now. The reason I think this is because, let's say I have an API that looks something like this:

let btn: HtmlElement<Button> = button(cx).class("btn btn-primary");

can later be collected into a <template> via macro magic via the following:

let btn = HtmlElement<Button> = HtmlElement::from_template::<Button>("<button class=r#"btn btn-primary""#);

or something similar...

P.S.
I'm the original author of the Sycamore builder API, so I was thinking of making the API similar to that implementation. It's not the builder API that currently exists, as it was made slightly less flexible than my original PR, in order to better align with their macro, but if you wanted to take a look at it and suggest changes, that would be great!

@gbj
Copy link
Collaborator

gbj commented Dec 29, 2022

Merged!

@gbj gbj closed this as completed Dec 29, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants