diff --git a/benchmarks/Cargo.toml b/benchmarks/Cargo.toml index 15016ae7d..e3c86a1eb 100644 --- a/benchmarks/Cargo.toml +++ b/benchmarks/Cargo.toml @@ -3,8 +3,26 @@ name = "benchmarks" version = "0.1.0" edition = "2021" -[dev-dependencies] +[dependencies] leptos = { path = "../leptos", default-features = false, features = ["ssr"] } sycamore = { version = "0.8", features = ["ssr"] } -yew = { git = "https://github.com/yewstack/yew", features = ["ssr", "tokio"] } -tokio-test = "0.4" \ No newline at end of file +yew = { git = "https://github.com/yewstack/yew", features = ["ssr"] } +tokio-test = "0.4" +miniserde = "0.1" +gloo = "0.8" +uuid = { version = "1", features = ["serde", "v4", "wasm-bindgen"] } +wasm-bindgen = "0.2" +log = "0.4" +strum = "0.24" +strum_macros = "0.24" +serde = { version = "1", features = ["derive", "rc"]} +serde_json = "1" + +[dependencies.web-sys] +version = "0.3" +features = [ + "Window", + "Document", + "HtmlElement", + "HtmlInputElement" +] \ No newline at end of file diff --git a/benchmarks/src/lib.rs b/benchmarks/src/lib.rs index cebbdf0e7..2d6802282 100644 --- a/benchmarks/src/lib.rs +++ b/benchmarks/src/lib.rs @@ -1,322 +1,7 @@ #![feature(test)] extern crate test; -/* -mod reactive { - use test::Bencher; - use std::{cell::Cell, rc::Rc}; - - #[bench] - fn leptos_create_1000_signals(b: &mut Bencher) { - use leptos::{create_isomorphic_effect, create_memo, create_scope, create_signal}; - - b.iter(|| { - create_scope(|cx| { - let acc = Rc::new(Cell::new(0)); - let sigs = (0..1000).map(|n| create_signal(cx, n)).collect::>(); - let reads = sigs.iter().map(|(r, _)| *r).collect::>(); - let writes = sigs.iter().map(|(_, w)| *w).collect::>(); - let memo = create_memo(cx, move |_| reads.iter().map(|r| r.get()).sum::()); - assert_eq!(memo(), 499500); - }) - .dispose() - }); - } - - #[bench] - fn leptos_create_and_update_1000_signals(b: &mut Bencher) { - use leptos::{create_isomorphic_effect, create_memo, create_scope, create_signal}; - - b.iter(|| { - create_scope(|cx| { - let acc = Rc::new(Cell::new(0)); - let sigs = (0..1000).map(|n| create_signal(cx, n)).collect::>(); - let reads = sigs.iter().map(|(r, _)| *r).collect::>(); - let writes = sigs.iter().map(|(_, w)| *w).collect::>(); - let memo = create_memo(cx, move |_| reads.iter().map(|r| r.get()).sum::()); - assert_eq!(memo(), 499500); - create_isomorphic_effect(cx, { - let acc = Rc::clone(&acc); - move |_| { - acc.set(memo()); - } - }); - assert_eq!(acc.get(), 499500); - - writes[1].update(|n| *n += 1); - writes[10].update(|n| *n += 1); - writes[100].update(|n| *n += 1); - - assert_eq!(acc.get(), 499503); - assert_eq!(memo(), 499503); - }) - .dispose() - }); - } - - #[bench] - fn leptos_create_and_dispose_1000_scopes(b: &mut Bencher) { - use leptos::{create_isomorphic_effect, create_scope, create_signal}; - - b.iter(|| { - let acc = Rc::new(Cell::new(0)); - let disposers = (0..1000) - .map(|_| { - create_scope({ - let acc = Rc::clone(&acc); - move |cx| { - let (r, w) = create_signal(cx, 0); - create_isomorphic_effect(cx, { - move |_| { - acc.set(r()); - } - }); - w.update(|n| *n += 1); - } - }) - }) - .collect::>(); - for disposer in disposers { - disposer.dispose(); - } - }); - } - - #[bench] - fn sycamore_create_1000_signals(b: &mut Bencher) { - use sycamore::reactive::{create_effect, create_memo, create_scope, create_signal}; - - b.iter(|| { - let d = create_scope(|cx| { - let acc = Rc::new(Cell::new(0)); - let sigs = Rc::new((0..1000).map(|n| create_signal(cx, n)).collect::>()); - let memo = create_memo(cx, { - let sigs = Rc::clone(&sigs); - move || sigs.iter().map(|r| *r.get()).sum::() - }); - assert_eq!(*memo.get(), 499500); - }); - unsafe { d.dispose() }; - }); - } - - #[bench] - fn sycamore_create_and_update_1000_signals(b: &mut Bencher) { - use sycamore::reactive::{create_effect, create_memo, create_scope, create_signal}; - - b.iter(|| { - let d = create_scope(|cx| { - let acc = Rc::new(Cell::new(0)); - let sigs = Rc::new((0..1000).map(|n| create_signal(cx, n)).collect::>()); - let memo = create_memo(cx, { - let sigs = Rc::clone(&sigs); - move || sigs.iter().map(|r| *r.get()).sum::() - }); - assert_eq!(*memo.get(), 499500); - create_effect(cx, { - let acc = Rc::clone(&acc); - move || { - acc.set(*memo.get()); - } - }); - assert_eq!(acc.get(), 499500); - - sigs[1].set(*sigs[1].get() + 1); - sigs[10].set(*sigs[10].get() + 1); - sigs[100].set(*sigs[100].get() + 1); - - assert_eq!(acc.get(), 499503); - assert_eq!(*memo.get(), 499503); - }); - unsafe { d.dispose() }; - }); - } - - #[bench] - fn sycamore_create_and_dispose_1000_scopes(b: &mut Bencher) { - use sycamore::reactive::{create_effect, create_scope, create_signal}; - - b.iter(|| { - let acc = Rc::new(Cell::new(0)); - let disposers = (0..1000) - .map(|_| { - create_scope({ - let acc = Rc::clone(&acc); - move |cx| { - let s = create_signal(cx, 0); - create_effect(cx, { - move || { - acc.set(*s.get()); - } - }); - s.set(*s.get() + 1); - } - }) - }) - .collect::>(); - for disposer in disposers { - unsafe { - disposer.dispose(); - } - } - }); - } -} */ - -mod ssr { - use test::Bencher; - - #[bench] - fn leptos_ssr_bench(b: &mut Bencher) { - use leptos::*; - - b.iter(|| { - _ = create_scope(|cx| { - #[component] - fn Counter(cx: Scope, initial: i32) -> Element { - let (value, set_value) = create_signal(cx, initial); - view! { - cx, -
- - "Value: " {move || value().to_string()} "!" - -
- } - } - - let rendered = view! { - cx, -
-

"Welcome to our benchmark page."

-

"Here's some introductory text."

- - - -
- }; - - assert_eq!( - rendered, - "

Welcome to our benchmark page.

Here's some introductory text.

Value: 1!
Value: 2!
Value: 3!
" - ); - }); - }); - } - - #[bench] - fn sycamore_ssr_bench(b: &mut Bencher) { - use sycamore::*; - use sycamore::prelude::*; - - b.iter(|| { - _ = create_scope(|cx| { - #[derive(Prop)] - struct CounterProps { - initial: i32 - } - - - #[component] - fn Counter(cx: Scope, props: CounterProps) -> View { - let value = create_signal(cx, props.initial); - view! { - cx, - div { - button(on:click=|_| value.set(*value.get() - 1)) { - "-1" - } - span { - "Value: " - (value.get().to_string()) - "!" - } - button(on:click=|_| value.set(*value.get() + 1)) { - "+1" - } - } - } - } - - let rendered = render_to_string(|cx| view! { - cx, - main { - h1 { - "Welcome to our benchmark page." - } - p { - "Here's some introductory text." - } - Counter(initial = 1) - Counter(initial = 2) - Counter(initial = 3) - } - }); - - assert_eq!( - rendered, - "

Welcome to our benchmark page.

Here's some introductory text.

Value: 1!
Value: 2!
Value: 3!
" - ); - }); - }); - } - - #[bench] - fn yew_ssr_bench(b: &mut Bencher) { - use yew::prelude::*; - use yew::ServerRenderer; - - b.iter(|| { - #[derive(Properties, PartialEq, Eq, Debug)] - struct CounterProps { - initial: i32 - } - - #[function_component(Counter)] - fn counter(props: &CounterProps) -> Html { - let state = use_state(|| props.initial); - - let incr_counter = { - let state = state.clone(); - Callback::from(move |_| state.set(&*state + 1)) - }; - - let decr_counter = { - let state = state.clone(); - Callback::from(move |_| state.set(&*state - 1)) - }; - - html! { -
-

{"Welcome to our benchmark page."}

-

{"Here's some introductory text."}

- -

{"Value: "} {*state} {"!"}

- -
- } - } - - #[function_component] - fn App() -> Html { - html! { -
- - - -
- } - } - - tokio_test::block_on(async { - let renderer = ServerRenderer::::new(); - let rendered = renderer.render().await; - assert_eq!( - rendered, - "

Welcome to our benchmark page.

Here's some introductory text.

Value: 1!

Welcome to our benchmark page.

Here's some introductory text.

Value: 2!

Welcome to our benchmark page.

Here's some introductory text.

Value: 3!

" - ); - }); - }); - } -} \ No newline at end of file +mod reactive; +mod ssr; +mod todomvc; diff --git a/benchmarks/src/reactive.rs b/benchmarks/src/reactive.rs new file mode 100644 index 000000000..38aeb6852 --- /dev/null +++ b/benchmarks/src/reactive.rs @@ -0,0 +1,159 @@ +use test::Bencher; + +use std::{cell::Cell, rc::Rc}; + +#[bench] +fn leptos_create_1000_signals(b: &mut Bencher) { + use leptos::{create_isomorphic_effect, create_memo, create_scope, create_signal}; + + b.iter(|| { + create_scope(|cx| { + let acc = Rc::new(Cell::new(0)); + let sigs = (0..1000).map(|n| create_signal(cx, n)).collect::>(); + let reads = sigs.iter().map(|(r, _)| *r).collect::>(); + let writes = sigs.iter().map(|(_, w)| *w).collect::>(); + let memo = create_memo(cx, move |_| reads.iter().map(|r| r.get()).sum::()); + assert_eq!(memo(), 499500); + }) + .dispose() + }); +} + +#[bench] +fn leptos_create_and_update_1000_signals(b: &mut Bencher) { + use leptos::{create_isomorphic_effect, create_memo, create_scope, create_signal}; + + b.iter(|| { + create_scope(|cx| { + let acc = Rc::new(Cell::new(0)); + let sigs = (0..1000).map(|n| create_signal(cx, n)).collect::>(); + let reads = sigs.iter().map(|(r, _)| *r).collect::>(); + let writes = sigs.iter().map(|(_, w)| *w).collect::>(); + let memo = create_memo(cx, move |_| reads.iter().map(|r| r.get()).sum::()); + assert_eq!(memo(), 499500); + create_isomorphic_effect(cx, { + let acc = Rc::clone(&acc); + move |_| { + acc.set(memo()); + } + }); + assert_eq!(acc.get(), 499500); + + writes[1].update(|n| *n += 1); + writes[10].update(|n| *n += 1); + writes[100].update(|n| *n += 1); + + assert_eq!(acc.get(), 499503); + assert_eq!(memo(), 499503); + }) + .dispose() + }); +} + +#[bench] +fn leptos_create_and_dispose_1000_scopes(b: &mut Bencher) { + use leptos::{create_isomorphic_effect, create_scope, create_signal}; + + b.iter(|| { + let acc = Rc::new(Cell::new(0)); + let disposers = (0..1000) + .map(|_| { + create_scope({ + let acc = Rc::clone(&acc); + move |cx| { + let (r, w) = create_signal(cx, 0); + create_isomorphic_effect(cx, { + move |_| { + acc.set(r()); + } + }); + w.update(|n| *n += 1); + } + }) + }) + .collect::>(); + for disposer in disposers { + disposer.dispose(); + } + }); +} + +#[bench] +fn sycamore_create_1000_signals(b: &mut Bencher) { + use sycamore::reactive::{create_effect, create_memo, create_scope, create_signal}; + + b.iter(|| { + let d = create_scope(|cx| { + let acc = Rc::new(Cell::new(0)); + let sigs = Rc::new((0..1000).map(|n| create_signal(cx, n)).collect::>()); + let memo = create_memo(cx, { + let sigs = Rc::clone(&sigs); + move || sigs.iter().map(|r| *r.get()).sum::() + }); + assert_eq!(*memo.get(), 499500); + }); + unsafe { d.dispose() }; + }); +} + +#[bench] +fn sycamore_create_and_update_1000_signals(b: &mut Bencher) { + use sycamore::reactive::{create_effect, create_memo, create_scope, create_signal}; + + b.iter(|| { + let d = create_scope(|cx| { + let acc = Rc::new(Cell::new(0)); + let sigs = Rc::new((0..1000).map(|n| create_signal(cx, n)).collect::>()); + let memo = create_memo(cx, { + let sigs = Rc::clone(&sigs); + move || sigs.iter().map(|r| *r.get()).sum::() + }); + assert_eq!(*memo.get(), 499500); + create_effect(cx, { + let acc = Rc::clone(&acc); + move || { + acc.set(*memo.get()); + } + }); + assert_eq!(acc.get(), 499500); + + sigs[1].set(*sigs[1].get() + 1); + sigs[10].set(*sigs[10].get() + 1); + sigs[100].set(*sigs[100].get() + 1); + + assert_eq!(acc.get(), 499503); + assert_eq!(*memo.get(), 499503); + }); + unsafe { d.dispose() }; + }); +} + +#[bench] +fn sycamore_create_and_dispose_1000_scopes(b: &mut Bencher) { + use sycamore::reactive::{create_effect, create_scope, create_signal}; + + b.iter(|| { + let acc = Rc::new(Cell::new(0)); + let disposers = (0..1000) + .map(|_| { + create_scope({ + let acc = Rc::clone(&acc); + move |cx| { + let s = create_signal(cx, 0); + create_effect(cx, { + move || { + acc.set(*s.get()); + } + }); + s.set(*s.get() + 1); + } + }) + }) + .collect::>(); + for disposer in disposers { + unsafe { + disposer.dispose(); + } + } + }); +} diff --git a/benchmarks/src/ssr.rs b/benchmarks/src/ssr.rs new file mode 100644 index 000000000..13cd15db7 --- /dev/null +++ b/benchmarks/src/ssr.rs @@ -0,0 +1,154 @@ +use test::Bencher; + +#[bench] +fn leptos_ssr_bench(b: &mut Bencher) { + use leptos::*; + + b.iter(|| { + _ = create_scope(|cx| { + #[component] + fn Counter(cx: Scope, initial: i32) -> Element { + let (value, set_value) = create_signal(cx, initial); + view! { + cx, +
+ + "Value: " {move || value().to_string()} "!" + +
+ } + } + + let rendered = view! { + cx, +
+

"Welcome to our benchmark page."

+

"Here's some introductory text."

+ + + +
+ }; + + assert_eq!( + rendered, + "

Welcome to our benchmark page.

Here's some introductory text.

Value: 1!
Value: 2!
Value: 3!
" + ); + }); + }); +} + +#[bench] +fn sycamore_ssr_bench(b: &mut Bencher) { + use sycamore::*; + use sycamore::prelude::*; + + b.iter(|| { + _ = create_scope(|cx| { + #[derive(Prop)] + struct CounterProps { + initial: i32 + } + + + #[component] + fn Counter(cx: Scope, props: CounterProps) -> View { + let value = create_signal(cx, props.initial); + view! { + cx, + div { + button(on:click=|_| value.set(*value.get() - 1)) { + "-1" + } + span { + "Value: " + (value.get().to_string()) + "!" + } + button(on:click=|_| value.set(*value.get() + 1)) { + "+1" + } + } + } + } + + let rendered = render_to_string(|cx| view! { + cx, + main { + h1 { + "Welcome to our benchmark page." + } + p { + "Here's some introductory text." + } + Counter(initial = 1) + Counter(initial = 2) + Counter(initial = 3) + } + }); + + assert_eq!( + rendered, + "

Welcome to our benchmark page.

Here's some introductory text.

Value: 1!
Value: 2!
Value: 3!
" + ); + }); + }); +} + +#[bench] +fn yew_ssr_bench(b: &mut Bencher) { + use yew::prelude::*; + use yew::ServerRenderer; + + b.iter(|| { + #[derive(Properties, PartialEq, Eq, Debug)] + struct CounterProps { + initial: i32 + } + + #[function_component(Counter)] + fn counter(props: &CounterProps) -> Html { + let state = use_state(|| props.initial); + + let incr_counter = { + let state = state.clone(); + Callback::from(move |_| state.set(&*state + 1)) + }; + + let decr_counter = { + let state = state.clone(); + Callback::from(move |_| state.set(&*state - 1)) + }; + + html! { +
+

{"Welcome to our benchmark page."}

+

{"Here's some introductory text."}

+ +

{"Value: "} {*state} {"!"}

+ +
+ } + } + + #[function_component] + fn App() -> Html { + html! { +
+ + + +
+ } + } + + tokio_test::block_on(async { + let renderer = ServerRenderer::::new(); + let rendered = renderer.render().await; + assert_eq!( + rendered, + "

Welcome to our benchmark page.

Here's some introductory text.

Value: 1!

Welcome to our benchmark page.

Here's some introductory text.

Value: 2!

Welcome to our benchmark page.

Here's some introductory text.

Value: 3!

" + ); + }); + }); +} diff --git a/benchmarks/src/todomvc/leptos.rs b/benchmarks/src/todomvc/leptos.rs new file mode 100644 index 000000000..dcc6ed80f --- /dev/null +++ b/benchmarks/src/todomvc/leptos.rs @@ -0,0 +1,324 @@ +use leptos::*; +use miniserde::*; +use web_sys::HtmlInputElement; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Todos(pub Vec); + +const STORAGE_KEY: &str = "todos-leptos"; + +impl Todos { + pub fn new(cx: Scope) -> Self { + Self(vec![]) + } + + pub fn new_with_1000(cx: Scope) -> Self { + let todos = (0..1000) + .map(|id| Todo::new(cx, id, format!("Todo #{id}"))) + .collect(); + Self(todos) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn add(&mut self, todo: Todo) { + self.0.push(todo); + } + + pub fn remove(&mut self, id: usize) { + self.0.retain(|todo| todo.id != id); + } + + pub fn remaining(&self) -> usize { + self.0.iter().filter(|todo| !(todo.completed)()).count() + } + + pub fn completed(&self) -> usize { + self.0.iter().filter(|todo| (todo.completed)()).count() + } + + pub fn toggle_all(&self) { + // if all are complete, mark them all active instead + if self.remaining() == 0 { + for todo in &self.0 { + if todo.completed.get() { + (todo.set_completed)(false); + } + } + } + // otherwise, mark them all complete + else { + for todo in &self.0 { + (todo.set_completed)(true); + } + } + } + + fn clear_completed(&mut self) { + self.0.retain(|todo| !todo.completed.get()); + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct Todo { + pub id: usize, + pub title: ReadSignal, + pub set_title: WriteSignal, + pub completed: ReadSignal, + pub set_completed: WriteSignal, +} + +impl Todo { + pub fn new(cx: Scope, id: usize, title: String) -> Self { + Self::new_with_completed(cx, id, title, false) + } + + pub fn new_with_completed(cx: Scope, id: usize, title: String, completed: bool) -> Self { + let (title, set_title) = create_signal(cx, title); + let (completed, set_completed) = create_signal(cx, completed); + Self { + id, + title, + set_title, + completed, + set_completed, + } + } + + pub fn toggle(&self) { + self.set_completed + .update(|completed| *completed = !*completed); + } +} + +const ESCAPE_KEY: u32 = 27; +const ENTER_KEY: u32 = 13; + +#[component] +pub fn TodoMVC(cx: Scope, todos: Todos) -> Element { + let mut next_id = todos + .0 + .iter() + .map(|todo| todo.id) + .max() + .map(|last| last + 1) + .unwrap_or(0); + + let (todos, set_todos) = create_signal(cx, todos); + provide_context(cx, set_todos); + + let (mode, set_mode) = create_signal(cx, Mode::All); + window_event_listener("hashchange", move |_| { + let new_mode = location_hash().map(|hash| route(&hash)).unwrap_or_default(); + set_mode(new_mode); + }); + + let add_todo = move |ev: web_sys::Event| { + let target = event_target::(&ev); + ev.stop_propagation(); + let key_code = ev.unchecked_ref::().key_code(); + if key_code == ENTER_KEY { + let title = event_target_value(&ev); + let title = title.trim(); + if !title.is_empty() { + let new = Todo::new(cx, next_id, title.to_string()); + set_todos.update(|t| t.add(new)); + next_id += 1; + target.set_value(""); + } + } + }; + + let filtered_todos = create_memo::>(cx, move |_| { + todos.with(|todos| match mode.get() { + Mode::All => todos.0.to_vec(), + Mode::Active => todos + .0 + .iter() + .filter(|todo| !todo.completed.get()) + .cloned() + .collect(), + Mode::Completed => todos + .0 + .iter() + .filter(|todo| todo.completed.get()) + .cloned() + .collect(), + }) + }); + + // effect to serialize to JSON + // this does reactive reads, so it will automatically serialize on any relevant change + create_effect(cx, move |_| { + if let Ok(Some(storage)) = window().local_storage() { + let objs = todos + .get() + .0 + .iter() + .map(TodoSerialized::from) + .collect::>(); + let json = json::to_string(&objs); + if storage.set_item(STORAGE_KEY, &json).is_err() { + log::error!("error while trying to set item in localStorage"); + } + } + }); + + view! { cx, +
+
+
+

"todos"

+ +
+
+ 0)} + on:input=move |_| set_todos.update(|t| t.toggle_all()) + /> + +
    + + {move |cx, todo: &Todo| view! { cx, }} + +
+
+
+ + {move || todos.with(|t| t.remaining().to_string())} + {move || if todos.with(|t| t.remaining()) == 1 { + " item" + } else { + " items" + }} + " left" + + + +
+
+ +
+ } +} + +#[component] +pub fn Todo(cx: Scope, todo: Todo) -> Element { + let (editing, set_editing) = create_signal(cx, false); + let set_todos = use_context::>(cx).unwrap(); + let input: Element; + + let save = move |value: &str| { + let value = value.trim(); + if value.is_empty() { + set_todos.update(|t| t.remove(todo.id)); + } else { + (todo.set_title)(value.to_string()); + } + set_editing(false); + }; + + let tpl = view! { cx, +
  • +
    + + +
    + {move || editing().then(|| view! { cx, + ().key_code(); + if key_code == ENTER_KEY { + save(&event_target_value(&ev)); + } else if key_code == ESCAPE_KEY { + set_editing(false); + } + }} + /> + }) + } +
  • + }; + + tpl +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Active, + Completed, + All, +} + +impl Default for Mode { + fn default() -> Self { + Mode::All + } +} + +pub fn route(hash: &str) -> Mode { + match hash { + "/active" => Mode::Active, + "/completed" => Mode::Completed, + _ => Mode::All, + } +} + +#[derive(Serialize, Deserialize)] +pub struct TodoSerialized { + pub id: usize, + pub title: String, + pub completed: bool, +} + +impl TodoSerialized { + pub fn into_todo(self, cx: Scope) -> Todo { + Todo::new_with_completed(cx, self.id, self.title, self.completed) + } +} + +impl From<&Todo> for TodoSerialized { + fn from(todo: &Todo) -> Self { + Self { + id: todo.id, + title: todo.title.get(), + completed: (todo.completed)(), + } + } +} diff --git a/benchmarks/src/todomvc/mod.rs b/benchmarks/src/todomvc/mod.rs new file mode 100644 index 000000000..a3e2becef --- /dev/null +++ b/benchmarks/src/todomvc/mod.rs @@ -0,0 +1,109 @@ +use test::Bencher; + +mod leptos; +mod sycamore; +mod yew; + +#[bench] +fn leptos_todomvc_ssr(b: &mut Bencher) { + use self::leptos::*; + use ::leptos::*; + + b.iter(|| { + _ = create_scope(|cx| { + let rendered = view! { + cx, + + }; + + assert!(rendered.len() > 1); + }); + }); +} + +#[bench] +fn sycamore_todomvc_ssr(b: &mut Bencher) { + use self::sycamore::*; + use ::sycamore::prelude::*; + use ::sycamore::*; + + b.iter(|| { + _ = create_scope(|cx| { + let rendered = render_to_string(|cx| { + view! { + cx, + App() + } + }); + + assert!(rendered.len() > 1); + }); + }); +} + +#[bench] +fn yew_todomvc_ssr(b: &mut Bencher) { + use self::yew::*; + use ::yew::prelude::*; + use ::yew::ServerRenderer; + + b.iter(|| { + tokio_test::block_on(async { + let renderer = ServerRenderer::::new(); + let rendered = renderer.render().await; + assert!(rendered.len() > 1); + }); + }); +} + +#[bench] +fn leptos_todomvc_ssr_with_1000(b: &mut Bencher) { + use self::leptos::*; + use ::leptos::*; + + b.iter(|| { + _ = create_scope(|cx| { + let rendered = view! { + cx, + + }; + + assert!(rendered.len() > 1); + }); + }); +} + +#[bench] +fn sycamore_todomvc_ssr_with_1000(b: &mut Bencher) { + use self::sycamore::*; + use ::sycamore::prelude::*; + use ::sycamore::*; + + b.iter(|| { + _ = create_scope(|cx| { + let rendered = render_to_string(|cx| { + view! { + cx, + AppWith1000() + } + }); + + assert!(rendered.len() > 1); + }); + }); +} + +#[bench] +fn yew_todomvc_ssr_with_1000(b: &mut Bencher) { + use self::yew::*; + use ::yew::prelude::*; + use ::yew::ServerRenderer; + + b.iter(|| { + tokio_test::block_on(async { + let renderer = ServerRenderer::::new(); + let rendered = renderer.render().await; + assert!(rendered.len() > 1); + }); + }); +} diff --git a/benchmarks/src/todomvc/sycamore.rs b/benchmarks/src/todomvc/sycamore.rs new file mode 100644 index 000000000..4747fdada --- /dev/null +++ b/benchmarks/src/todomvc/sycamore.rs @@ -0,0 +1,438 @@ +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; +use uuid::Uuid; +use wasm_bindgen::JsCast; +use web_sys::{Event, HtmlInputElement, KeyboardEvent}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub struct Todo { + title: String, + completed: bool, + id: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Filter { + All, + Active, + Completed, +} + +impl Default for Filter { + fn default() -> Self { + Self::All + } +} + +impl Filter { + fn url(self) -> &'static str { + match self { + Filter::All => "#", + Filter::Active => "#/active", + Filter::Completed => "#/completed", + } + } + + fn get_filter_from_hash() -> Self { + let hash = web_sys::window().unwrap().location().hash().unwrap(); + + match hash.as_str() { + "#/active" => Filter::Active, + "#/completed" => Filter::Completed, + _ => Filter::All, + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct AppState { + pub todos: RcSignal>>, + pub filter: RcSignal, +} + +impl AppState { + fn add_todo(&self, title: String, id: usize) { + self.todos.modify().push(create_rc_signal(Todo { + title, + completed: false, + id, + })) + } + + fn remove_todo(&self, id: usize) { + self.todos.modify().retain(|todo| todo.get().id != id); + } + + fn todos_left(&self) -> usize { + self.todos.get().iter().fold( + 0, + |acc, todo| if todo.get().completed { acc } else { acc + 1 }, + ) + } + + fn toggle_complete_all(&self) { + if self.todos_left() == 0 { + // make all todos active + for todo in self.todos.get().iter() { + if todo.get().completed { + todo.set(Todo { + completed: false, + ..todo.get().as_ref().clone() + }) + } + } + } else { + // make all todos completed + for todo in self.todos.get().iter() { + if !todo.get().completed { + todo.set(Todo { + completed: true, + ..todo.get().as_ref().clone() + }) + } + } + } + } + + fn clear_completed(&self) { + self.todos.modify().retain(|todo| !todo.get().completed); + } +} + +const KEY: &str = "todos-sycamore"; + +#[component] +pub fn App(cx: Scope) -> View { + // Initialize application state + let todos = create_rc_signal(Vec::new()); + let app_state = AppState { + todos, + filter: create_rc_signal(Filter::All), + }; + provide_context(cx, app_state); + + view! { cx, + div(class="todomvc-wrapper") { + section(class="todoapp") { + Header {} + List {} + Footer {} + } + Copyright {} + } + } +} + +#[component] +pub fn AppWith1000(cx: Scope) -> View { + // Initialize application state + let todos = (0..1000) + .map(|id| { + create_rc_signal(Todo { + title: format!("Todo #{id}"), + completed: false, + id, + }) + }) + .collect(); + let todos = create_rc_signal(todos); + let app_state = AppState { + todos, + filter: create_rc_signal(Filter::All), + }; + provide_context(cx, app_state); + + view! { cx, + div(class="todomvc-wrapper") { + section(class="todoapp") { + Header {} + List {} + Footer {} + } + Copyright {} + } + } +} + +#[component] +pub fn Copyright(cx: Scope) -> View { + view! { cx, + footer(class="info") { + p { "Double click to edit a todo" } + p { + "Created by " + a(href="https://github.com/lukechu10", target="_blank") { "lukechu10" } + } + p { + "Part of " + a(href="http://todomvc.com") { "TodoMVC" } + } + } + } +} + +#[component] +pub fn Header(cx: Scope) -> View { + let app_state = use_context::(cx); + let value = create_signal(cx, String::new()); + let input_ref = create_node_ref(cx); + + let handle_submit = |event: Event| { + let event: KeyboardEvent = event.unchecked_into(); + + if event.key() == "Enter" { + let mut task = value.get().as_ref().clone(); + task = task.trim().to_string(); + + if !task.is_empty() { + app_state.add_todo(task, 0); + value.set("".to_string()); + input_ref + .get::() + .unchecked_into::() + .set_value(""); + } + } + }; + + view! { cx, + header(class="header") { + h1 { "todos" } + input(ref=input_ref, + class="new-todo", + placeholder="What needs to be done?", + bind:value=value, + on:keyup=handle_submit, + ) + } + } +} + +#[component(inline_props)] +pub fn Item(cx: Scope, todo: RcSignal) -> View { + let app_state = use_context::(cx); + // Make `todo` live as long as the scope. + let todo = create_ref(cx, todo); + + let title = || todo.get().title.clone(); + let completed = create_selector(cx, || todo.get().completed); + let id = todo.get().id; + + let editing = create_signal(cx, false); + let input_ref = create_node_ref(cx); + let value = create_signal(cx, "".to_string()); + + let handle_input = |event: Event| { + let target: HtmlInputElement = event.target().unwrap().unchecked_into(); + value.set(target.value()); + }; + + let toggle_completed = |_| { + todo.set(Todo { + completed: !todo.get().completed, + ..todo.get().as_ref().clone() + }); + }; + + let handle_dblclick = move |_| { + editing.set(true); + input_ref + .get::() + .unchecked_into::() + .focus() + .unwrap(); + value.set(title()); + }; + + let handle_blur = move || { + editing.set(false); + + let mut value = value.get().as_ref().clone(); + value = value.trim().to_string(); + + if value.is_empty() { + app_state.remove_todo(id); + } else { + todo.set(Todo { + title: value, + ..todo.get().as_ref().clone() + }) + } + }; + + let handle_submit = move |event: Event| { + let event: KeyboardEvent = event.unchecked_into(); + match event.key().as_str() { + "Enter" => handle_blur(), + "Escape" => { + input_ref + .get::() + .unchecked_into::() + .set_value(&title()); + editing.set(false); + } + _ => {} + } + }; + + let handle_destroy = move |_| { + app_state.remove_todo(id); + }; + + // We need a separate signal for checked because clicking the checkbox will detach the binding + // between the attribute and the view. + let checked = create_signal(cx, false); + create_effect(cx, || { + // Calling checked.set will also update the `checked` property on the input element. + checked.set(*completed.get()) + }); + + let class = || { + format!( + "{} {}", + if *completed.get() { "completed" } else { "" }, + if *editing.get() { "editing" } else { "" } + ) + }; + + view! { cx, + li(class=class()) { + div(class="view") { + input( + class="toggle", + type="checkbox", + on:input=toggle_completed, + bind:checked=checked + ) + label(on:dblclick=handle_dblclick) { + (title()) + } + button(class="destroy", on:click=handle_destroy) + } + + (if *editing.get() { + view! { cx, + input(ref=input_ref, + class="edit", + prop:value=&todo.get().title, + on:blur=move |_| handle_blur(), + on:keyup=handle_submit, + on:input=handle_input, + ) + } + } else { + View::empty() + }) + } + } +} + +#[component] +pub fn List(cx: Scope) -> View { + let app_state = use_context::(cx); + let todos_left = create_selector(cx, || app_state.todos_left()); + + let filtered_todos = create_memo(cx, || { + app_state + .todos + .get() + .iter() + .filter(|todo| match *app_state.filter.get() { + Filter::All => true, + Filter::Active => !todo.get().completed, + Filter::Completed => todo.get().completed, + }) + .cloned() + .collect::>() + }); + + // We need a separate signal for checked because clicking the checkbox will detach the binding + // between the attribute and the view. + let checked = create_signal(cx, false); + create_effect(cx, || { + // Calling checked.set will also update the `checked` property on the input element. + checked.set(*todos_left.get() == 0) + }); + + view! { cx, + section(class="main") { + input( + id="toggle-all", + class="toggle-all", + type="checkbox", + readonly=true, + bind:checked=checked, + on:input=|_| app_state.toggle_complete_all() + ) + label(for="toggle-all") + + ul(class="todo-list") { + Keyed( + iterable=filtered_todos, + view=|cx, todo| view! { cx, + Item(todo=todo) + }, + key=|todo| todo.get().id, + ) + } + } + } +} + +#[component(inline_props)] +pub fn TodoFilter(cx: Scope, filter: Filter) -> View { + let app_state = use_context::(cx); + let selected = move || filter == *app_state.filter.get(); + let set_filter = |filter| app_state.filter.set(filter); + + view! { cx, + li { + a( + class=if selected() { "selected" } else { "" }, + href=filter.url(), + on:click=move |_| set_filter(filter), + ) { + (format!("{filter:?}")) + } + } + } +} + +#[component] +pub fn Footer(cx: Scope) -> View { + let app_state = use_context::(cx); + + let items_text = || match app_state.todos_left() { + 1 => "item", + _ => "items", + }; + + let has_completed_todos = + create_selector(cx, || app_state.todos_left() < app_state.todos.get().len()); + + let handle_clear_completed = |_| app_state.clear_completed(); + + view! { cx, + footer(class="footer") { + span(class="todo-count") { + strong { (app_state.todos_left()) } + span { " " (items_text()) " left" } + } + ul(class="filters") { + TodoFilter(filter=Filter::All) + TodoFilter(filter=Filter::Active) + TodoFilter(filter=Filter::Completed) + } + + (if *has_completed_todos.get() { + view! { cx, + button(class="clear-completed", on:click=handle_clear_completed) { + "Clear completed" + } + } + } else { + view! { cx, } + }) + } + } +} diff --git a/benchmarks/src/todomvc/yew.rs b/benchmarks/src/todomvc/yew.rs new file mode 100644 index 000000000..4237f603b --- /dev/null +++ b/benchmarks/src/todomvc/yew.rs @@ -0,0 +1,619 @@ +use gloo::storage::{LocalStorage, Storage}; +use strum::IntoEnumIterator; +use web_sys::HtmlInputElement as InputElement; +use yew::events::{FocusEvent, KeyboardEvent}; +use yew::html::Scope; +use yew::{classes, html, Classes, Component, Context, Html, NodeRef, TargetCast}; + +const KEY: &str = "yew.todomvc.self"; + +pub enum Msg { + Add(String), + Edit((usize, String)), + Remove(usize), + SetFilter(Filter), + ToggleAll, + ToggleEdit(usize), + Toggle(usize), + ClearCompleted, + Focus, +} + +pub struct App { + state: State, + focus_ref: NodeRef, +} + +impl Component for App { + type Message = Msg; + type Properties = (); + + fn create(_ctx: &Context) -> Self { + let entries = vec![]; //LocalStorage::get(KEY).unwrap_or_else(|_| Vec::new()); + let state = State { + entries, + filter: Filter::All, + edit_value: "".into(), + }; + let focus_ref = NodeRef::default(); + Self { state, focus_ref } + } + + fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { + match msg { + Msg::Add(description) => { + if !description.is_empty() { + let entry = Entry { + description: description.trim().to_string(), + completed: false, + editing: false, + }; + self.state.entries.push(entry); + } + } + Msg::Edit((idx, edit_value)) => { + self.state.complete_edit(idx, edit_value.trim().to_string()); + self.state.edit_value = "".to_string(); + } + Msg::Remove(idx) => { + self.state.remove(idx); + } + Msg::SetFilter(filter) => { + self.state.filter = filter; + } + Msg::ToggleEdit(idx) => { + let entry = self + .state + .entries + .iter() + .filter(|e| self.state.filter.fits(e)) + .nth(idx) + .unwrap(); + self.state.edit_value = entry.description.clone(); + self.state.clear_all_edit(); + self.state.toggle_edit(idx); + } + Msg::ToggleAll => { + let status = !self.state.is_all_completed(); + self.state.toggle_all(status); + } + Msg::Toggle(idx) => { + self.state.toggle(idx); + } + Msg::ClearCompleted => { + self.state.clear_completed(); + } + Msg::Focus => { + if let Some(input) = self.focus_ref.cast::() { + input.focus().unwrap(); + } + } + } + LocalStorage::set(KEY, &self.state.entries).expect("failed to set"); + true + } + + fn view(&self, ctx: &Context) -> Html { + let hidden_class = if self.state.entries.is_empty() { + "hidden" + } else { + "" + }; + html! { +
    +
    +
    +

    { "todos" }

    + { self.view_input(ctx.link()) } +
    +
    + +
    +
    + + { self.state.total() } + { " item(s) left" } + +
      + { for Filter::iter().map(|flt| self.view_filter(flt, ctx.link())) } +
    + +
    +
    + +
    + } + } +} + +impl App { + fn view_filter(&self, filter: Filter, link: &Scope) -> Html { + let cls = if self.state.filter == filter { + "selected" + } else { + "not-selected" + }; + html! { +
  • + + { filter } + +
  • + } + } + + fn view_input(&self, link: &Scope) -> Html { + let onkeypress = link.batch_callback(|e: KeyboardEvent| { + if e.key() == "Enter" { + let input: InputElement = e.target_unchecked_into(); + let value = input.value(); + input.set_value(""); + Some(Msg::Add(value)) + } else { + None + } + }); + html! { + // You can use standard Rust comments. One line: + //
  • + + /* Or multiline: +
      +
    • +
    + */ + } + } + + fn view_entry(&self, (idx, entry): (usize, &Entry), link: &Scope) -> Html { + let mut class = Classes::from("todo"); + if entry.editing { + class.push(" editing"); + } + if entry.completed { + class.push(" completed"); + } + html! { +
  • +
    + + +
    + { self.view_entry_edit_input((idx, entry), link) } +
  • + } + } + + fn view_entry_edit_input(&self, (idx, entry): (usize, &Entry), link: &Scope) -> Html { + let edit = move |input: InputElement| { + let value = input.value(); + input.set_value(""); + Msg::Edit((idx, value)) + }; + + let onblur = link.callback(move |e: FocusEvent| edit(e.target_unchecked_into())); + + let onkeypress = link.batch_callback(move |e: KeyboardEvent| { + (e.key() == "Enter").then(|| edit(e.target_unchecked_into())) + }); + + if entry.editing { + html! { + + } + } else { + html! { } + } + } +} + +pub struct AppWith1000 { + state: State, + focus_ref: NodeRef, +} + +impl Component for AppWith1000 { + type Message = Msg; + type Properties = (); + + fn create(_ctx: &Context) -> Self { + let entries = (0..1000) + .map(|id| Entry { + description: format!("Todo #{id}"), + completed: false, + editing: false, + }) + .collect(); + let state = State { + entries, + filter: Filter::All, + edit_value: "".into(), + }; + let focus_ref = NodeRef::default(); + Self { state, focus_ref } + } + + fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { + match msg { + Msg::Add(description) => { + if !description.is_empty() { + let entry = Entry { + description: description.trim().to_string(), + completed: false, + editing: false, + }; + self.state.entries.push(entry); + } + } + Msg::Edit((idx, edit_value)) => { + self.state.complete_edit(idx, edit_value.trim().to_string()); + self.state.edit_value = "".to_string(); + } + Msg::Remove(idx) => { + self.state.remove(idx); + } + Msg::SetFilter(filter) => { + self.state.filter = filter; + } + Msg::ToggleEdit(idx) => { + let entry = self + .state + .entries + .iter() + .filter(|e| self.state.filter.fits(e)) + .nth(idx) + .unwrap(); + self.state.edit_value = entry.description.clone(); + self.state.clear_all_edit(); + self.state.toggle_edit(idx); + } + Msg::ToggleAll => { + let status = !self.state.is_all_completed(); + self.state.toggle_all(status); + } + Msg::Toggle(idx) => { + self.state.toggle(idx); + } + Msg::ClearCompleted => { + self.state.clear_completed(); + } + Msg::Focus => { + if let Some(input) = self.focus_ref.cast::() { + input.focus().unwrap(); + } + } + } + LocalStorage::set(KEY, &self.state.entries).expect("failed to set"); + true + } + + fn view(&self, ctx: &Context) -> Html { + let hidden_class = if self.state.entries.is_empty() { + "hidden" + } else { + "" + }; + html! { +
    +
    +
    +

    { "todos" }

    + { self.view_input(ctx.link()) } +
    +
    + +
    +
    + + { self.state.total() } + { " item(s) left" } + +
      + { for Filter::iter().map(|flt| self.view_filter(flt, ctx.link())) } +
    + +
    +
    + +
    + } + } +} + +impl AppWith1000 { + fn view_filter(&self, filter: Filter, link: &Scope) -> Html { + let cls = if self.state.filter == filter { + "selected" + } else { + "not-selected" + }; + html! { +
  • + + { filter } + +
  • + } + } + + fn view_input(&self, link: &Scope) -> Html { + let onkeypress = link.batch_callback(|e: KeyboardEvent| { + if e.key() == "Enter" { + let input: InputElement = e.target_unchecked_into(); + let value = input.value(); + input.set_value(""); + Some(Msg::Add(value)) + } else { + None + } + }); + html! { + // You can use standard Rust comments. One line: + //
  • + + /* Or multiline: +
      +
    • +
    + */ + } + } + + fn view_entry(&self, (idx, entry): (usize, &Entry), link: &Scope) -> Html { + let mut class = Classes::from("todo"); + if entry.editing { + class.push(" editing"); + } + if entry.completed { + class.push(" completed"); + } + html! { +
  • +
    + + +
    + { self.view_entry_edit_input((idx, entry), link) } +
  • + } + } + + fn view_entry_edit_input(&self, (idx, entry): (usize, &Entry), link: &Scope) -> Html { + let edit = move |input: InputElement| { + let value = input.value(); + input.set_value(""); + Msg::Edit((idx, value)) + }; + + let onblur = link.callback(move |e: FocusEvent| edit(e.target_unchecked_into())); + + let onkeypress = link.batch_callback(move |e: KeyboardEvent| { + (e.key() == "Enter").then(|| edit(e.target_unchecked_into())) + }); + + if entry.editing { + html! { + + } + } else { + html! { } + } + } +} + +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumIter}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct State { + pub entries: Vec, + pub filter: Filter, + pub edit_value: String, +} + +impl State { + pub fn total(&self) -> usize { + self.entries.len() + } + + pub fn total_completed(&self) -> usize { + self.entries + .iter() + .filter(|e| Filter::Completed.fits(e)) + .count() + } + + pub fn is_all_completed(&self) -> bool { + let mut filtered_iter = self + .entries + .iter() + .filter(|e| self.filter.fits(e)) + .peekable(); + + if filtered_iter.peek().is_none() { + return false; + } + + filtered_iter.all(|e| e.completed) + } + + pub fn clear_completed(&mut self) { + let entries = self + .entries + .drain(..) + .filter(|e| Filter::Active.fits(e)) + .collect(); + self.entries = entries; + } + + pub fn toggle(&mut self, idx: usize) { + let filter = self.filter; + let entry = self + .entries + .iter_mut() + .filter(|e| filter.fits(e)) + .nth(idx) + .unwrap(); + entry.completed = !entry.completed; + } + + pub fn toggle_all(&mut self, value: bool) { + for entry in &mut self.entries { + if self.filter.fits(entry) { + entry.completed = value; + } + } + } + + pub fn toggle_edit(&mut self, idx: usize) { + let filter = self.filter; + let entry = self + .entries + .iter_mut() + .filter(|e| filter.fits(e)) + .nth(idx) + .unwrap(); + entry.editing = !entry.editing; + } + + pub fn clear_all_edit(&mut self) { + for entry in &mut self.entries { + entry.editing = false; + } + } + + pub fn complete_edit(&mut self, idx: usize, val: String) { + if val.is_empty() { + self.remove(idx); + } else { + let filter = self.filter; + let entry = self + .entries + .iter_mut() + .filter(|e| filter.fits(e)) + .nth(idx) + .unwrap(); + entry.description = val; + entry.editing = !entry.editing; + } + } + + pub fn remove(&mut self, idx: usize) { + let idx = { + let entries = self + .entries + .iter() + .enumerate() + .filter(|&(_, e)| self.filter.fits(e)) + .collect::>(); + let &(idx, _) = entries.get(idx).unwrap(); + idx + }; + self.entries.remove(idx); + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Entry { + pub description: String, + pub completed: bool, + pub editing: bool, +} + +#[derive(Clone, Copy, Debug, EnumIter, Display, PartialEq, Serialize, Deserialize, Eq)] +pub enum Filter { + All, + Active, + Completed, +} +impl Filter { + pub fn fits(&self, entry: &Entry) -> bool { + match *self { + Filter::All => true, + Filter::Active => !entry.completed, + Filter::Completed => entry.completed, + } + } + + pub fn as_href(&self) -> &'static str { + match self { + Filter::All => "#/", + Filter::Active => "#/active", + Filter::Completed => "#/completed", + } + } +}