Pauan / rust-dominator

Zero-cost ultra-high-performance declarative DOM library using FRP signals for Rust!
MIT License
967 stars 62 forks source link

Some questions #6

Open rsaccon opened 5 years ago

rsaccon commented 5 years ago

While doing research for adding spring animations to a seed-based app, I discovered this project. I am totally blown away by the signal-based concept and all the advanced and highly performant stuff packed into this framework. Especially the animations example! Here some questions which piled up, while digging into rust-dominator and rust-signals:

If I understand properly, rust-signals is conceptually like Erlang message passing. Or Actor frameworks like Rust actix, but more lightweight and specifically optimized for your primary use case rust-dominator. Please correct me if I am wrong.

I read the reusable component issue, where you mentioned queues. If I understand properly, queues are a way to make the whole thing look like an Elm (or seed) approach where all state updates are done in an update function, so the view contains less state update logic. @Pauan Are you still planning to add this to rust-dominator? Are there disadvantages for such a Queue approach like elimination of the rust-signal performance gain over Elm-like approaches ? Could push-to-queue and direct-signal-to-model (as we have it now) both be used together ? I imagine for things things like animations, which have their own internal state, one does not want to propagate things up to the the message queue.

One more specific question about dom.StyleSheetBuilder. Where is that getting used ? In the examples, style changes are directly applied to dom HTML elements (if I understood properly). But the code from the StyleSheetBuilder suggests to me that this is for collecting styles from the element definitions in the view and building a stylesheet out of it, for injection into the HTML page.

What is the roadmap for rust-dominator ? It is based on stdweb, but since wasm-bindgen became usable, it seems to me that all new stuff in wasm space is based on wasm-bindgen. Is there any technical reason preventing rust-dominator from being based on wasm-bindgen ?

Pauan commented 5 years ago

I really appreciate the interest in dominator! I'm impressed you managed to figure things out despite the lack of docs and tutorial (which I am working on).

If I understand properly, rust-signals is conceptually like Erlang message passing. Or Actor frameworks like Rust actix, but more lightweight.

I don't have any experience with Erlang/actor style message passing, but I don't think they're related.

Instead, Signals are based on "functional reactive programming", which is used in Haskell and Elm.

Signals can be used for things like distributed systems, but they accomplish that in a very different way from Erlang.

It's best not to think of Signals as "sending messages", instead it's best to think of them as more like a mutable variable: they have a current value, and that value can then change to something else.

Unlike mutable variables, you can be efficiently notified when a Signal changes, allowing for changes to automatically propagate throughout your app.

So you set up your app as a network of these Signals, and then data flows through the Signals automatically (and very efficiently).

Rather than using messages, instead you just use raw data (such as integers, strings, etc.), just like how you would with mutable variables.

And unlike Erlang message queues, Signals do not guarantee that every change is observed, and it is possible for changes to occur out of order.

That might sound scary, but it's actually completely fine! You just have to treat Signals as being like mutable variables, rather than treating them like event listeners or message queues.

You can read more about that in this thread: https://github.com/Pauan/rust-signals/issues/1#issuecomment-432915099

specifically optimized for your primary use case rust-dominator

It is true that I designed rust-signals alongside rust-dominator.

It is also true that rust-dominator was the primary motivation for rust-signals.

However, rust-signals was carefully designed to be general purpose, and it can be used for many things.

I read the reusable component issue, where you mentioned queues. If I understand properly, queues are a way to make the whole thing look like an Elm (or seed) approach where all state updates are done in an update function, so the view contains less state update logic.

That is correct. Though I had imagined that it could be used for a variety of purposes: an entire app driven by queues, a single component driven by queues, etc.

Are you still planning to add this to rust-dominator?

I haven't put any thought or effort into it. I'm not against it, I just haven't had a personal need for it. Using Mutable has worked out really well on all of the projects I've done.

Things have improved a lot since the "Reusable components" issue. So most of the pain points and difficulties have already been fixed.

So I'm personally in favor of extremely light-weight components (basically just a struct) which use Mutable internally.

Here's an example:

  1. I have a top-level State struct which has a render method.

  2. It contains a Game struct which has its own render method. Storing a struct inside of a struct is how state composition happens.

  3. Similarly, Game contains other structs such as Tiles, etc. The hierarchy can be as deep as you want.

  4. The State::render method calls the Game::render method. This is how view composition happens.

  5. The Game struct can expose various public methods, which can then be called by various State methods. This is how behavior composition happens.

  6. The State methods can be called by events (and those methods will then update internal state, call various Game methods, etc.).

And... that's pretty much it. A component is just a Rust struct. It contains internal state in fields. It contains methods for updating that state. You store a struct inside of another struct to compose them. You call the render method to compose another component's view inside of your component. You call methods from events to update things.

Everything is just completely ordinary Rust techniques, there's no need for anything fancy like queues. This keeps everything light-weight, fast, and easy to understand.

The only difficulty is when it comes to events: sometimes a component needs to pass information upwards in the hierarchy (e.g. a child component notifying a parent component that something has happened).

In that case you have a few options:

The third option is the most interesting, so let's see how that would look like:

#[derive(Clone, Copy)]
pub enum Event {
    LeftClick,
    RightClick,
}

pub struct Child {
    events: Queue<Event>,
}

impl Child {
    pub fn new() -> Arc<Self> {
        Arc::new(Self {
            events: Queue::new(),
        })
    }

    pub fn events(&self) -> impl Stream<Item = Event> {
        self.events.stream()
    }

    pub fn render(state: Arc<Self>) -> Dom {
        html!("div", {
            .event(clone!(state => move |_: ClickEvent| {
                state.events.push(Event::LeftClick);
            }))

            .event(clone!(state => move |_: ContextMenuEvent| {
                state.events.push(Event::RightClick);
            }))
        })
    }
}

pub struct Parent {
    child: Arc<Child>,
}

impl Parent {
    pub fn new() -> Arc<Self> {
        Arc::new(Self {
            child: Child::new(),
        })
    }

    pub fn render(state: Arc<Self>) -> Dom {
        html!("div", {
            .future(state.child.events().for_each(|event| {
                match event {
                    Event::LeftClick => {
                        ...
                    },
                    Event::RightClick => {
                        ...
                    },
                }

                async {}
            }))

            .children(&mut [
                Child::render(state.child.clone()),
            ])
        })
    }
}

Basically, it uses the same structure described above (structs + normal methods), except the Child now has an events() method, which returns a Stream of Event. The Parent can then use .future + for_each to listen for those events.

However, I think even this is too heavy-weight, and I would personally just do this:

#[derive(Clone, Copy)]
pub enum Event {
    LeftClick,
    RightClick,
}

pub struct Child {

}

impl Child {
    pub fn new() -> Arc<Self> {
        Arc::new(Self {

        })
    }

    pub fn render<F>(state: Arc<Self>, on_event: F) -> Dom where F: FnMut(Event) {
        let events = EventDispatcher::new(on_event);

        html!("div", {
            .event(clone!(events => move |_: ClickEvent| {
                events.send(Event::LeftClick);
            }))

            .event(clone!(events => move |_: ContextMenuEvent| {
                events.send(Event::RightClick);
            }))
        })
    }
}

pub struct Parent {
    child: Arc<Child>,
}

impl Parent {
    pub fn new() -> Arc<Self> {
        Arc::new(Self {
            child: Child::new(),
        })
    }

    pub fn render(state: Arc<Self>) -> Dom {
        html!("div", {
            .children(&mut [
                Child::render(state.child.clone(), move |event| {
                    match event {
                        Event::LeftClick => {
                            ...
                        },
                        Event::RightClick => {
                            ...
                        },
                    }
                }),
            ])
        })
    }
}

In this case the Parent just passes in a closure to the Child::render method. Internally it uses an EventDispatcher to allow it to call the closure from multiple different events.

EventDispatcher would be defined like this:

#[derive(Debug, Clone)]
pub struct EventDispatcher<F> {
    listener: Arc<Mutex<F>>,
}

impl<F> EventDispatcher<F> {
    pub fn new(listener: F) -> Self {
        Self {
            listener: Arc::new(Mutex::new(listener)),
        }
    }
}

impl<A, F> EventDispatcher<F> where F: FnMut(A) {
    pub fn send(&self, event: A) {
        let mut listener = self.listener.lock().unwrap();
        listener(event);
    }
}

This is pretty much the fastest and most light-weight way to handle this problem. It's also very natural, from a Rust perspective.

Are there disadvantages for such a Queue approach like elimination of the rust-signal performance gain over Elm-like approaches ?

There could be! It depends on how you use the queues. If you use queues to update Mutables, then there will only be a small constant performance loss, so it should still be very fast.

But if you use queues to drive the rendering, then yeah you basically get the Elm/yew/seed model, which is inherently slow (since it either has to do DOM diffing, value diffing, or both).

Could push-to-queue and direct-signal-to-model (as we have it now) both be used together ? I imagine for things things like animations, which have their own internal state, one does not want to propagate things up to the the message queue.

Yeah, that was the original idea (discussed in the "Reusable components" issue): using a queue to manage updates, but internally it would be mutating a bunch of Mutables (or MutableAnimations). So integrating the two is definitely possible.

But I've now come to the conclusion that queues are unnecessary and just add bloat. I'm open to changing my mind, but I haven't needed queues so far.

One more specific question about dom.StyleSheetBuilder. Where is that getting used ?

It's used by the stylesheet! macro.

You use it like this:

stylesheet!("div", {
    .style("background-color", "green")
});

That's equivalent to this CSS file:

div {
    background-color: green;
}

Basically, it allows you to create "raw" stylesheets, where you can specify the CSS selector.

It's rarely used (since class! is much better). The primary reason to use it is to change the styles of html and body, like this:

stylesheet!("html, body", {
    .style("width", "100%")
    .style("height", "100%")
    .style("margin", "0px")
    .style("padding", "0px")
});

Or you can use it to apply styles to every element in the page:

stylesheet!("*", {
    .style("font-family", "sans-serif")
    .style("font-size", "13px")
});

In the examples, style changes are directly applied to dom HTML elements (if I understood properly).

That depends. If you use .style or .style_signal, then indeed it applies CSS styles directly to the DOM element.

However, you can also use .class and .class_signal, as shown in the counter example.

The way that it works is that when you use class! it creates a <style> element, and creates a new unique class name, and then injects the CSS styles into the <style>.

In other words, if you do this:

static ref FOO_CLASS: String = class! {
    .style("background-color", "green")
};

Then it basically generates this:

<style>
.__class_0__ {
    background-color: green;
}
</style>

(Technically it doesn't actually serialize the CSS styles, instead it just uses insertRule. But the end result is the same.)

And then when you use .class(&*FOO_CLASS), it just adds __class_0__ to the className of the DOM element.

This is much more efficient than individually setting styles on the DOM element, but it has the same effect.

This is a really awesome technique which almost no DOM frameworks use (I don't know why, it has no downsides).

What is the roadmap for rust-dominator ?

It's feature-complete, just needs some docs and tutorial.

I recently made a short overview post here: https://github.com/rustwasm/team/issues/243

Not really a roadmap, but it does give some high-level information.

It is based on stdweb, but since wasm-bindgen became usable, it seems to me that all new stuff in wasm space is based on wasm-bindgen.

Yeah, that's mostly because the Rust Wasm WG has always pushed heavily for wasm-bindgen.

I think it's a shame that stdweb has gotten so little credit, since it's always been far ahead of wasm-bindgen (and still is).

But eventually wasm-bindgen will catch up, and I think that's a good thing in the long run.

Is there any technical reason preventing rust-dominator from being based on wasm-bindgen ?

Yeah, a bunch. wasm-bindgen is very different in features and API, so it'll take some work to port it over (and probably some changes to wasm-bindgen).

But I am very interested in porting dominator to work with wasm-bindgen, it's currently my number 1 priority.

rsaccon commented 5 years ago

Thanks a lot for the detailed answer, it is very helpful. I started to port over an app I started with yew and seed. And I will try to add spring animations.

One thing I noticed when trying to build a dominator app from scratch, just by declaring dependencies as in the counter example: the app does not compile (on nightly 2018-12-18). I had to copy the Cargo.lock from that counter example, and then things worked as expected. However I am relatively new to Rust and I might just not know yet enough for everything going smooth ...

Anyway, thanks again and I look forward for dominator working with wasm-bindgen.

Pauan commented 5 years ago

And I will try to add spring animations.

Let me know how that goes. If you want, I can add it to dominator, since I want dominator to be quite full-featured.

One thing I noticed when trying to build a dominator app from scratch, just by declaring dependencies as in the counter example: the app does not compile (on nightly 2018-12-18).

What are the errors that you're getting?

There's been some very recent changes on nightly that broken Futures and Signals, so I've been working on fixing those.

In general nightly has breaking changes on a pretty regular basis.

Pauan commented 5 years ago

I've fixed both futures-signals and dominator to work with the latest Nightly.

Please upgrade (using cargo update) and then try again.

If you still run into problems, make sure you're on the latest Nightly (using rustup update to update).

rsaccon commented 5 years ago

@Pauan I tried to use the EventDispatcher you showed above, but it does not compile, and I am a newbie at troubleshooting Rust generics . Here is the error I get:

error[E0207]: the type parameter `A` is not constrained by the impl trait, self type, or predicates
  --> my-app/src/event_dispatcher.rs:16:6
   |
16 | impl<A, F> EventDispatcher<F> where F: FnMut(A) {
   |      ^ unconstrained type parameter
Pauan commented 5 years ago

@rsaccon That's weird, almost feels like a bug in Rust. In any case, here's the fixed version:

use std::sync::{Arc, Mutex};
use core::marker::PhantomData;

#[derive(Debug, Clone)]
pub struct EventDispatcher<A, F> {
    listener: Arc<Mutex<F>>,
    argument: PhantomData<A>,
}

impl<A, F> EventDispatcher<A, F> where F: FnMut(A) {
    #[inline]
    pub fn new(listener: F) -> Self {
        Self {
            listener: Arc::new(Mutex::new(listener)),
            argument: PhantomData,
        }
    }

    #[inline]
    pub fn send(&self, event: A) {
        let listener = &mut *self.listener.lock().unwrap();
        listener(event);
    }
}
rsaccon commented 5 years ago

Thanks a lot, EventDispatcher compiles now, but unfortunately now I got a compile error at the Child, when sending the events:

error[E0277]: the trait bound `F: std::clone::Clone` is not satisfied
  --> myapp/src/my_child.rs:73:22
   |
73 |                 .event(clone!(events => move |_: ClickEvent| {
   |  ______________________^
74 | |                 events.send(Event::LeftClick);
75 | |               }))
   | |________________^ the trait `std::clone::Clone` is not implemented for `F`
   |
   = help: consider adding a `where F: std::clone::Clone` bound
   = note: required because of the requirements on the impl of `std::clone::Clone` for `event_dispatcher::EventDispatcher<state::Event, F>`
   = note: required by `std::clone::Clone::clone`
   = note: this error originates in a macro outside of the current crate (in Nightly builds, run with -Z external-macro-backtrace for more info)

Unless you can spot immediately what is still missing, I will set up later an example repo, just containing this component composition example.

Pauan commented 5 years ago

Oops, yeah, I forgot that derive(Clone) doesn't interact well with Arc/Rc and type bounds. Here's the (hopefully final) version:

use std::sync::{Arc, Mutex};
use core::marker::PhantomData;

#[derive(Debug)]
pub struct EventDispatcher<A, F> {
    listener: Arc<Mutex<F>>,
    argument: PhantomData<A>,
}

impl<A, F> Clone for EventDispatcher<A, F> {
    #[inline]
    fn clone(&self) -> Self {
        EventDispatcher {
            listener: self.listener.clone(),
            argument: self.argument,
        }
    }
}

impl<A, F> EventDispatcher<A, F> where F: FnMut(A) {
    #[inline]
    pub fn new(listener: F) -> Self {
        Self {
            listener: Arc::new(Mutex::new(listener)),
            argument: PhantomData,
        }
    }

    #[inline]
    pub fn send(&self, event: A) {
        let listener = &mut *self.listener.lock().unwrap();
        listener(event);
    }
}
Pauan commented 5 years ago

It might look like a lot of code, but the Rust compiler will optimize it heavily.

The argument field won't exist at all, and the EventDispatcher struct also won't exist at all.

So it'll just be an Arc<Mutex<F>> which is ref-counted and locked, and that's it. It's about as lightweight as it can be (while still allowing FnMut).

rsaccon commented 5 years ago

Thanks a lot. Very demanding, this Rust type system ! It still did not compile, but that was an easy one, I changed the signature of the render function in Child from:

pub fn render<F>(state: Arc<Self>, on_event: F) -> Dom where F: FnMut(Event) {

to

pub fn render<F>(state: Arc<Self>, on_event: F) -> Dom where F: FnMut(Event) + 'static {

and then this Event Dispatcher example finally worked. Gonna play with this now ...

Pauan commented 5 years ago

Very demanding, this Rust type system !

It is at first, but there's good reasons for it, and you get used to it pretty fast.

I'm happy to answer any questions, or give advice.

It still did not compile, but that was an easy one

Oops, I always forget the 'static (thankfully the error message is helpful enough that I can always fix it right away).

rsaccon commented 5 years ago

Next problem I ran into, when trying to use Events where the enum has variants with fields, e.g.:

#[derive(Clone, Copy)]
pub enum Event {
    SimpleEvent,
    StringEvent(String),
}

then I get this error:

error[E0204]: the trait `Copy` may not be implemented for this type

I tried to declare in the EventDispatcher the generic Event Type A as A: Copy, but still got the same error.

Pauan commented 5 years ago

Copy means that it simply copies the bits in memory (this is generally quite fast). However, not all types support that. String is one of the types which doesn't support Copy.

You can read the stdlib documentation, which has a pretty excellent explanation of how Copy works.

You can fix it by simply removing the Copy from the derive, then it'll work.

rsaccon commented 5 years ago

Thanks a lot. At least for now, all works as expected.

nothingIsSomething commented 2 years ago

I have learned a lot about signals by reading this thread, the 6 steps in which you detailed the pattern has helped me to understand a lot and like how it looks from a functional perspective.

I've been curious to see the examples of the game, to my bad luck the links are broken :( , do you have some simple examples? or do you still have the game examples?

I remember my failed attempt to make a game with rxjs lol, too much lag.

I've been reading the code of dominator but I still don't understand how the children observe the signal and react to it, I know they update the state but it's still not clear to me.

I am a bit confused on how to call the signals, I understand that it is in the render method, if I build a game the events would be the signals and I would have to spawn them in the render method, and if the child needs to tell something to the parent, the parent would have to pass a closure to the child.

An example would help me a lot to understand more, thanks for creating such a powerful library!

this is a pseudocode I wrote to try to understand the pattern, I just need a functional but simple example..

...
//render method
async fn render(state: Rc<Self>){
    //create signals / observers / events
    let obs_a_future = state.behavior_jump.signal().for_each(|value| {
        println!("Observer A {}", value);

        //update state
        state.move_hair.set(true); 

        state.y_direction.set(2000);
        state.score.set(899)

        //call child method
        state.print_is_jumping();  //the parent method print_is_jumping will call the child method.

        async {}
    }).await;

    let obs_b_future = state.behavior_run.signal().for_each(|value| {
        println!("Observer B {}", value);

        state.speed.set(100);
        async {}
    }).await;

    tokio::spawn(obs_a_future);
    tokio::spawn(obs_b_future);

    let height =  state.player.height.get();
    println!("state height: {:?} ", height);

    //closure or hook. how?
    // Child::render(state.player.clone(), move |event| {
    //     match event {
    //         Event::??? => {
    //             ...
    //         },
    //         Event::??? => {
    //             ...
    //         },
    //     }
    // }),

   state.child.render(); //or a parent method that calls the child render method?
}

fn main(){
    let state = State::new();
    State::render(state);
}
Pauan commented 2 years ago

@nothingIsSomething Sorry for the delay, I missed this comment.

I've been curious to see the examples of the game, to my bad luck the links are broken :( , do you have some simple examples? or do you still have the game examples?

Sorry, no, the repo doesn't exist anymore. The best examples are from dominator, especially todomvc.

But it was just a simple game that uses svg! + standard dominator methods, since the game was written entirely in dominator.

I've been reading the code of dominator but I still don't understand how the children observe the signal and react to it, I know they update the state but it's still not clear to me.

Well you don't really need to know how it works in order to use it, but this is the code for child_signal, and this is the code for children_signal_vec.

They just use for_each + spawn_local to listen to changes and then update the DOM and internal dominator state. The various other methods like attr_signal and style_signal also use for_each + spawn_local, it's the standard way to spawn a Signal.

The Dom type has a Callbacks field, which contains a Vec<dyn Discard>. When dominator spawns a Signal it returns a Discard which can be used to cancel the Signal. That Discard is then pushed into the Callbacks.

When the Dom is removed, it will loop over all of the Discards which are inside of the Callbacks and will discard them. This cancels all of the Signals which are attached to that Dom and cleans everything up so that there isn't any memory leak.

As an extra optimization, for static Dom (meaning a Dom that was inserted with child or children) it will just append the child's Callbacks into the parent. So instead of managing each child individually, it can manage them as a group, which is more efficient. But that's just an optimization, it doesn't affect the behavior.

I am a bit confused on how to call the signals, I understand that it is in the render method, if I build a game the events would be the signals and I would have to spawn them in the render method, and if the child needs to tell something to the parent, the parent would have to pass a closure to the child.

If you're creating a non-DOM game, then you'll have to spawn the Signals yourself using for_each + spawn, just like how dominator does it.

In that case it's up to you to create the game framework however you want. Maybe your game would use a tree format (similar to dominator), or maybe it would use ECS, or something else.