diff --git a/Cargo.lock b/Cargo.lock index 9baa156..ee77d44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ dependencies = [ [[package]] name = "bumpalo" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] @@ -82,7 +82,7 @@ dependencies = [ name = "dodrio" version = "0.1.0" dependencies = [ - "bumpalo 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "bumpalo 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "console_log 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "futures 0.1.25 (registry+https://github.com/rust-lang/crates.io-index)", "js-sys 0.3.11 (registry+https://github.com/rust-lang/crates.io-index)", @@ -128,6 +128,25 @@ dependencies = [ "web-sys 0.3.11 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "dodrio-todomvc" +version = "0.1.0" +dependencies = [ + "cfg-if 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", + "console_error_panic_hook 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", + "console_log 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", + "dodrio 0.1.0", + "futures 0.1.25 (registry+https://github.com/rust-lang/crates.io-index)", + "js-sys 0.3.11 (registry+https://github.com/rust-lang/crates.io-index)", + "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.87 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_json 1.0.38 (registry+https://github.com/rust-lang/crates.io-index)", + "wasm-bindgen 0.2.34 (registry+https://github.com/rust-lang/crates.io-index)", + "wasm-bindgen-futures 0.3.11 (registry+https://github.com/rust-lang/crates.io-index)", + "wasm-bindgen-test 0.2.34 (registry+https://github.com/rust-lang/crates.io-index)", + "web-sys 0.3.11 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "env_logger" version = "0.6.0" @@ -181,6 +200,11 @@ dependencies = [ "quick-error 1.2.2 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "itoa" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "js-sys" version = "0.3.11" @@ -280,11 +304,44 @@ name = "rustc-demangle" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "ryu" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "scoped-tls" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "serde" +version = "1.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "serde_derive 1.0.87 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "serde_derive" +version = "1.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 0.4.27 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 0.6.11 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 0.15.26 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "serde_json" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "itoa 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)", + "ryu 0.2.7 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.87 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "sourcefile" version = "0.1.4" @@ -520,7 +577,7 @@ dependencies = [ "checksum autocfg 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "a6d640bee2da49f60a4068a7fae53acde8982514ab7bae8b8cea9e88cbcfd799" "checksum backtrace 0.3.13 (registry+https://github.com/rust-lang/crates.io-index)" = "b5b493b66e03090ebc4343eb02f94ff944e0cbc9ac6571491d170ba026741eb5" "checksum backtrace-sys 0.1.28 (registry+https://github.com/rust-lang/crates.io-index)" = "797c830ac25ccc92a7f8a7b9862bde440715531514594a6154e3d4a54dd769b6" -"checksum bumpalo 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "bfa2dc7f1b2cc9e9b0d80ab7765699bd5314653930f7f54ba635a13bdd406165" +"checksum bumpalo 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "21ef2109240a377370f55ea3ef0b486b46d7b5c0f7455ab0ec676d73f875d58a" "checksum cc 1.0.29 (registry+https://github.com/rust-lang/crates.io-index)" = "4390a3b5f4f6bce9c1d0c00128379df433e53777fdd30e92f16a529332baec4e" "checksum cfg-if 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "082bb9b28e00d3c9d39cc03e64ce4cea0f1bb9b3fde493f0cbc008472d22bdf4" "checksum console_error_panic_hook 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "6c5dd2c094474ec60a6acaf31780af270275e3153bafff2db5995b715295762e" @@ -531,6 +588,7 @@ dependencies = [ "checksum futures 0.1.25 (registry+https://github.com/rust-lang/crates.io-index)" = "49e7653e374fe0d0c12de4250f0bdb60680b8c80eed558c5c7538eec9c89e21b" "checksum heck 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205" "checksum humantime 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "3ca7e5f2e110db35f93b837c81797f3714500b81d517bf20c431b16d3ca4f114" +"checksum itoa 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "1306f3464951f30e30d12373d31c79fbd52d236e5e896fd92f96ec7babbbe60b" "checksum js-sys 0.3.11 (registry+https://github.com/rust-lang/crates.io-index)" = "838d3c46e859486a1c7d24defa6fb2a4d4a7fc64ab79079a2b8dd6a92b4c327f" "checksum lazy_static 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a374c89b9db55895453a74c1e38861d9deec0b01b405a82516e9d5de4820dea1" "checksum libc 0.2.48 (registry+https://github.com/rust-lang/crates.io-index)" = "e962c7641008ac010fa60a7dfdc1712449f29c44ef2d4702394aea943ee75047" @@ -545,7 +603,11 @@ dependencies = [ "checksum regex 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "37e7cbbd370869ce2e8dff25c7018702d10b21a20ef7135316f8daecd6c25b7f" "checksum regex-syntax 0.6.5 (registry+https://github.com/rust-lang/crates.io-index)" = "8c2f35eedad5295fdf00a63d7d4b238135723f92b434ec06774dad15c7ab0861" "checksum rustc-demangle 0.1.13 (registry+https://github.com/rust-lang/crates.io-index)" = "adacaae16d02b6ec37fdc7acfcddf365978de76d1983d3ee22afc260e1ca9619" +"checksum ryu 0.2.7 (registry+https://github.com/rust-lang/crates.io-index)" = "eb9e9b8cde282a9fe6a42dd4681319bfb63f121b8a8ee9439c6f4107e58a46f7" "checksum scoped-tls 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "332ffa32bf586782a3efaeb58f127980944bbc8c4d6913a86107ac2a5ab24b28" +"checksum serde 1.0.87 (registry+https://github.com/rust-lang/crates.io-index)" = "2e20fde37801e83c891a2dc4ebd3b81f0da4d1fb67a9e0a2a3b921e2536a58ee" +"checksum serde_derive 1.0.87 (registry+https://github.com/rust-lang/crates.io-index)" = "633e97856567e518b59ffb2ad7c7a4fd4c5d91d9c7f32dd38a27b2bf7e8114ea" +"checksum serde_json 1.0.38 (registry+https://github.com/rust-lang/crates.io-index)" = "27dce848e7467aa0e2fcaf0a413641499c0b745452aaca1194d24dedde9e13c9" "checksum sourcefile 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "4bf77cb82ba8453b42b6ae1d692e4cdc92f9a47beaf89a847c8be83f4e328ad3" "checksum syn 0.15.26 (registry+https://github.com/rust-lang/crates.io-index)" = "f92e629aa1d9c827b2bb8297046c1ccffc57c99b947a680d3ccff1f136a3bee9" "checksum synstructure 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)" = "73687139bf99285483c96ac0add482c3776528beac1d97d444f6e91f203a2015" diff --git a/Cargo.toml b/Cargo.toml index 348badd..f0fcdaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,15 +8,15 @@ edition = "2018" default = [] [dependencies] -bumpalo = "2.0.0" -futures = "0.1" +bumpalo = "2.1.0" +futures = "0.1.25" wasm-bindgen = "0.2.34" -wasm-bindgen-futures = "0.3.10" -js-sys = "0.3.10" +wasm-bindgen-futures = "0.3.11" +js-sys = "0.3.11" log = "0.4.6" [dependencies.web-sys] -version = "0.3.10" +version = "0.3.11" features = [ "console", "Document", @@ -27,11 +27,11 @@ features = [ ] [dev-dependencies] -wasm-bindgen-test = "0.2" +wasm-bindgen-test = "0.2.34" console_log = "0.1.2" [dev-dependencies.web-sys] -version = "0.3.10" +version = "0.3.11" features = [ "Attr", "EventTarget", @@ -41,7 +41,8 @@ features = [ ] [profile.release] -# Tell `rustc` to optimize for small code size. +incremental = false +lto = true opt-level = "s" [workspace] @@ -49,4 +50,5 @@ members = [ "./examples/counter", "./examples/hello-world", "./examples/input-form", + "./examples/todomvc", ] diff --git a/examples/counter/Cargo.toml b/examples/counter/Cargo.toml index 8caa1a1..2939dce 100644 --- a/examples/counter/Cargo.toml +++ b/examples/counter/Cargo.toml @@ -5,19 +5,19 @@ authors = ["Nick Fitzgerald "] edition = "2018" [lib] -crate-type = ["cdylib", "rlib"] +crate-type = ["cdylib"] [features] [dependencies] -console_error_panic_hook = "0.1.1" +console_error_panic_hook = "0.1.5" console_log = "0.1.2" dodrio = { path = "../.." } log = "0.4.6" -wasm-bindgen = "0.2" +wasm-bindgen = "0.2.34" [dependencies.web-sys] -version = "0.3.8" +version = "0.3.11" features = [ "console", "Document", @@ -30,4 +30,4 @@ features = [ ] [dev-dependencies] -wasm-bindgen-test = "0.2" +wasm-bindgen-test = "0.2.34" diff --git a/examples/counter/README.md b/examples/counter/README.md index 1d32866..8701b81 100644 --- a/examples/counter/README.md +++ b/examples/counter/README.md @@ -1,29 +1,21 @@ -# 🦀🕸 `rust-webpack-template` +# Counter -> **Kickstart your Rust, WebAssembly, and Webpack project!** +A counter that can be incremented and decremented. -This template is designed for creating monorepo-style Web applications with -Rust-generated WebAssembly and Webpack without publishing your wasm to NPM. +## Source -* Want to create and publish NPM packages with Rust and WebAssembly? [Check out - `wasm-pack-template`.](https://github.com/rustwasm/wasm-pack-template) +See `src/lib.rs`. -## 🔋 Batteries Included +## Build -This template comes pre-configured with all the boilerplate for compiling Rust -to WebAssembly and hooking into a Webpack build pipeline. - -* `npm run start` -- Serve the project locally for development at - `http://localhost:8080`. - -* `npm run build` -- Bundle the project (in production mode). - -## 🚴 Using This Template +``` +wasm-pack build --target no-modules +``` -First, [install `wasm-pack`!](https://rustwasm.github.io/wasm-pack/installer/) +## Serve -Then, use `npm init` to clone this template: +Use any HTTP server, for example: -```sh -npm init rust-webpack my-app +``` +python -m SimpleHTTPServer ``` diff --git a/examples/counter/src/lib.rs b/examples/counter/src/lib.rs index 74eb459..c299801 100644 --- a/examples/counter/src/lib.rs +++ b/examples/counter/src/lib.rs @@ -3,30 +3,37 @@ use dodrio::{on, Node, Render}; use log::*; use wasm_bindgen::prelude::*; +/// A counter that can be incremented and decrmented! struct Counter { - val: isize, + count: isize, } impl Counter { + /// Construct a new, zeroed counter. fn new() -> Counter { - Counter { val: 0 } + Counter { count: 0 } } + /// Increment this counter's count. fn increment(&mut self) { - self.val += 1; + self.count += 1; } + /// Decrement this counter's count. fn decrement(&mut self) { - self.val -= 1; + self.count -= 1; } } +// The `Render` implementation for `Counter`s displays the current count and has +// buttons to increment and decrement the count. impl Render for Counter { fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> dodrio::Node<'bump> where 'a: 'bump, { - let val = bumpalo::format!(in bump, "{}", self.val); + // Stringify the count as a bump-allocated string. + let count = bumpalo::format!(in bump, "{}", self.count); Node::element( bump, @@ -38,17 +45,26 @@ impl Render for Counter { bump, "button", [on(bump, "click", |root, vdom, _event| { - root.unwrap_mut::().increment(); + // Cast the root render component to a `Counter`, since + // we know that's what it is. + let counter = root.unwrap_mut::(); + + // Increment the counter. + counter.increment(); + + // Since the count has updated, we should re-render the + // counter on the next animation frame. vdom.schedule_render(); })], [], [Node::text("+")], ), - Node::text(val.into_bump_str()), + Node::text(count.into_bump_str()), Node::element( bump, "button", [on(bump, "click", |root, vdom, _event| { + // Same as above, but decrementing instead of incrementing. root.unwrap_mut::().decrement(); vdom.schedule_render(); })], @@ -62,9 +78,11 @@ impl Render for Counter { #[wasm_bindgen(start)] pub fn run() { + // Initialize debug logging for if/when things go wrong. console_error_panic_hook::set_once(); console_log::init_with_level(Level::Trace).expect("should initialize logging OK"); + // Get the document's ``. let window = web_sys::window().unwrap(); let document = window.document().unwrap(); let body = document.body().unwrap(); diff --git a/examples/hello-world/Cargo.toml b/examples/hello-world/Cargo.toml index bb95290..c90399a 100644 --- a/examples/hello-world/Cargo.toml +++ b/examples/hello-world/Cargo.toml @@ -5,17 +5,17 @@ authors = ["Nick Fitzgerald "] edition = "2018" [lib] -crate-type = ["cdylib", "rlib"] +crate-type = ["cdylib"] [features] [dependencies] dodrio = { path = "../.." } -wasm-bindgen = "0.2" -console_error_panic_hook = "0.1.1" +wasm-bindgen = "0.2.34" +console_error_panic_hook = "0.1.5" [dependencies.web-sys] -version = "0.3.8" +version = "0.3.11" features = [ "Document", "HtmlElement", @@ -24,4 +24,4 @@ features = [ ] [dev-dependencies] -wasm-bindgen-test = "0.2" +wasm-bindgen-test = "0.2.34" diff --git a/examples/hello-world/README.md b/examples/hello-world/README.md index 1d32866..40d52d0 100644 --- a/examples/hello-world/README.md +++ b/examples/hello-world/README.md @@ -1,29 +1,21 @@ -# 🦀🕸 `rust-webpack-template` +# Hello World -> **Kickstart your Rust, WebAssembly, and Webpack project!** +The most basic example! -This template is designed for creating monorepo-style Web applications with -Rust-generated WebAssembly and Webpack without publishing your wasm to NPM. +## Source -* Want to create and publish NPM packages with Rust and WebAssembly? [Check out - `wasm-pack-template`.](https://github.com/rustwasm/wasm-pack-template) +See `src/lib.rs`. -## 🔋 Batteries Included +## Build -This template comes pre-configured with all the boilerplate for compiling Rust -to WebAssembly and hooking into a Webpack build pipeline. - -* `npm run start` -- Serve the project locally for development at - `http://localhost:8080`. - -* `npm run build` -- Bundle the project (in production mode). - -## 🚴 Using This Template +``` +wasm-pack build --target no-modules +``` -First, [install `wasm-pack`!](https://rustwasm.github.io/wasm-pack/installer/) +## Serve -Then, use `npm init` to clone this template: +Use any HTTP server, for example: -```sh -npm init rust-webpack my-app +``` +python -m SimpleHTTPServer ``` diff --git a/examples/hello-world/src/lib.rs b/examples/hello-world/src/lib.rs index d51f76f..b869539 100644 --- a/examples/hello-world/src/lib.rs +++ b/examples/hello-world/src/lib.rs @@ -2,16 +2,14 @@ use dodrio::bumpalo::Bump; use dodrio::{Node, Render, Vdom}; use wasm_bindgen::prelude::*; +/// A rendering component that displays a greeting. struct Hello<'who> { + /// Who to greet. who: &'who str, } -impl<'who> Hello<'who> { - fn new(who: &str) -> Hello { - Hello { who } - } -} - +// The `Render` implementation describes how to render a `Hello` component into +// HTML. impl<'who> Render for Hello<'who> { fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> where @@ -19,9 +17,13 @@ impl<'who> Render for Hello<'who> { { Node::element( bump, + // The element's tag name. In this case a `

` element. "p", + // Event listeners. Empty in this case. [], + // Attributes. Again, empty in this case. [], + // Child nodes. [Node::text("Hello, "), Node::text(self.who), Node::text("!")], ) } @@ -29,14 +31,16 @@ impl<'who> Render for Hello<'who> { #[wasm_bindgen(start)] pub fn run() { + // Set up the panic hook for debugging when things go wrong. console_error_panic_hook::set_once(); + // Grab the document's ``. let window = web_sys::window().unwrap_throw(); let document = window.document().unwrap_throw(); let body = document.body().unwrap_throw(); // Create a new `Hello` render component. - let component = Hello::new("World"); + let component = Hello { who: "World" }; // Create a virtual DOM and mount it and the `Hello` render component to the // ``. diff --git a/examples/input-form/Cargo.toml b/examples/input-form/Cargo.toml index 708ba79..8590ed9 100644 --- a/examples/input-form/Cargo.toml +++ b/examples/input-form/Cargo.toml @@ -5,17 +5,17 @@ authors = ["Nick Fitzgerald "] edition = "2018" [lib] -crate-type = ["cdylib", "rlib"] +crate-type = ["cdylib"] [features] [dependencies] dodrio = { path = "../.." } -wasm-bindgen = "0.2" -console_error_panic_hook = "0.1.1" +wasm-bindgen = "0.2.34" +console_error_panic_hook = "0.1.5" [dependencies.web-sys] -version = "0.3.8" +version = "0.3.11" features = [ "console", "Document", @@ -29,4 +29,4 @@ features = [ ] [dev-dependencies] -wasm-bindgen-test = "0.2" +wasm-bindgen-test = "0.2.34" diff --git a/examples/input-form/README.md b/examples/input-form/README.md index 1d32866..573f06f 100644 --- a/examples/input-form/README.md +++ b/examples/input-form/README.md @@ -1,29 +1,22 @@ -# 🦀🕸 `rust-webpack-template` +# Input Forms -> **Kickstart your Rust, WebAssembly, and Webpack project!** +Renders a greeting to a user-supplied person via an `` value. The +greeting is live-updated with new inputs. -This template is designed for creating monorepo-style Web applications with -Rust-generated WebAssembly and Webpack without publishing your wasm to NPM. +## Source -* Want to create and publish NPM packages with Rust and WebAssembly? [Check out - `wasm-pack-template`.](https://github.com/rustwasm/wasm-pack-template) +See `src/lib.rs`. -## 🔋 Batteries Included +## Build -This template comes pre-configured with all the boilerplate for compiling Rust -to WebAssembly and hooking into a Webpack build pipeline. - -* `npm run start` -- Serve the project locally for development at - `http://localhost:8080`. - -* `npm run build` -- Bundle the project (in production mode). - -## 🚴 Using This Template +``` +wasm-pack build --target no-modules +``` -First, [install `wasm-pack`!](https://rustwasm.github.io/wasm-pack/installer/) +## Serve -Then, use `npm init` to clone this template: +Use any HTTP server, for example: -```sh -npm init rust-webpack my-app +``` +python -m SimpleHTTPServer ``` diff --git a/examples/input-form/src/lib.rs b/examples/input-form/src/lib.rs index 877bfda..9c700ef 100644 --- a/examples/input-form/src/lib.rs +++ b/examples/input-form/src/lib.rs @@ -3,21 +3,27 @@ use dodrio::{on, Attribute, Node, Render}; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; +/// Say hello to someone. struct SayHelloTo { + /// Who to say hello to. who: String, } impl SayHelloTo { + /// Construct a new `SayHelloTo` component. fn new>(who: S) -> SayHelloTo { let who = who.into(); SayHelloTo { who } } + /// Update who to say hello to. fn set_who(&mut self, who: String) { self.who = who; } } +// The `Render` implementation has a text `` and a `

` that shows a +// greeting to the ``'s value. impl Render for SayHelloTo { fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> dodrio::Node<'bump> where @@ -27,15 +33,21 @@ impl Render for SayHelloTo { bump, "input", [on(bump, "input", |root, vdom, event| { + // If the event's target is our input... let input = match event .target() .and_then(|t| t.dyn_into::().ok()) { - Some(input) => input, None => return, + Some(input) => input, }; - root.unwrap_mut::().set_who(input.value()); + // ...then get its value and update who we are greeting. + let value = input.value(); + let hello = root.unwrap_mut::(); + hello.set_who(value); + + // Finally, re-render the component on the next animation frame. vdom.schedule_render(); })], [ @@ -60,12 +72,15 @@ impl Render for SayHelloTo { #[wasm_bindgen(start)] pub fn run() { + // Initialize debugging for when/if something goes wrong. console_error_panic_hook::set_once(); + // Get the document's ``. let window = web_sys::window().unwrap(); let document = window.document().unwrap(); let body = document.body().unwrap(); + // Construct a new `SayHelloTo` rendering component. let say_hello = SayHelloTo::new("World"); // Mount the component to the ``. diff --git a/examples/todomvc/Cargo.toml b/examples/todomvc/Cargo.toml new file mode 100644 index 0000000..2187054 --- /dev/null +++ b/examples/todomvc/Cargo.toml @@ -0,0 +1,48 @@ +[package] +authors = ["Nick Fitzgerald "] +edition = "2018" +name = "dodrio-todomvc" +version = "0.1.0" + +[dependencies] +cfg-if = "0.1.6" +dodrio = { path = "../.." } +futures = "0.1.25" +js-sys = "0.3.11" +serde = { features = ["derive"], version = "1.0.87" } +serde_json = "1.0.38" +wasm-bindgen = "0.2.34" +wasm-bindgen-futures = "0.3.11" + +# Optional dependencies for logging. +console_error_panic_hook = { optional = true, version = "0.1.5" } +console_log = { optional = true, version = "0.1.2" } +log = { optional = true, version = "0.4.6" } + +[dependencies.web-sys] +version = "0.3.11" +features = [ + "Document", + "Event", + "EventTarget", + "HtmlElement", + "HtmlInputElement", + "KeyboardEvent", + "Location", + "Storage", + "Node", + "Window", +] + +[dev-dependencies] +wasm-bindgen-test = "0.2.34" + +[features] +logging = [ + "console_log", + "console_error_panic_hook", + "log", +] + +[lib] +crate-type = ["cdylib"] diff --git a/examples/todomvc/README.md b/examples/todomvc/README.md new file mode 100644 index 0000000..0f63c67 --- /dev/null +++ b/examples/todomvc/README.md @@ -0,0 +1,33 @@ +# TodoMVC + +`dodrio` implementation of the popular [TodoMVC](http://todomvc.com/) app. It +correctly and completely fulfills [the +specification](https://github.com/tastejs/todomvc/blob/master/app-spec.md) to +the best of my knowledge. + +## Source + +There are a number of modules in this `dodrio` implementation of TodoMVC. The +most important are: + +* `src/lib.rs`: The entry point to the application. +* `src/todos.rs`: Definition of `Todos` model and its rendering. +* `src/todo.rs`: Definition of `Todo` model and its rendering. +* `src/controller.rs`: The controller handles UI interactions and translates + them into updates on the model. Finally, it triggers re-rendering after those + updates. +* `src/router.rs`: A simple URL hash-based router. + +## Build + +``` +wasm-pack build --target no-modules +``` + +## Serve + +Use any HTTP server, for example: + +``` +python -m SimpleHTTPServer +``` diff --git a/examples/todomvc/index.html b/examples/todomvc/index.html new file mode 100644 index 0000000..89131e4 --- /dev/null +++ b/examples/todomvc/index.html @@ -0,0 +1,21 @@ + + + + + + dodrio • TodoMVC + + + + +
+
+
+

Double-click to edit a todo

+
+ + + + diff --git a/examples/todomvc/src/controller.rs b/examples/todomvc/src/controller.rs new file mode 100644 index 0000000..a347afa --- /dev/null +++ b/examples/todomvc/src/controller.rs @@ -0,0 +1,140 @@ +//! The controller handles UI events, translates them into updates on the model, +//! and schedules re-renders. + +use crate::todo::{Todo, TodoActions}; +use crate::todos::{Todos, TodosActions}; +use crate::visibility::Visibility; +use dodrio::{RootRender, VdomWeak}; +use serde::{Deserialize, Serialize}; +use std::ops::{Deref, DerefMut}; + +/// The controller for the TodoMVC app. +/// +/// This `Controller` struct is never actually instantiated. It is only used for +/// its `*Actions` trait implementations, none of which take a `self` parameter. +/// +/// One could imagine alternative controller implementations with `*Actions` +/// trait implementations for (e.g.) testing that will assert various expected +/// action methods are called after rendering todo items and sending DOM events. +#[derive(Default, Deserialize, Serialize)] +pub struct Controller; + +impl TodosActions for Controller { + fn toggle_all(root: &mut dyn RootRender, vdom: VdomWeak) { + let mut todos = AutoCommitTodos::new(root, vdom); + let all_complete = todos.todos().iter().all(|t| t.is_complete()); + for t in todos.todos_mut() { + t.set_complete(!all_complete); + } + } + + fn update_draft(root: &mut dyn RootRender, vdom: VdomWeak, draft: String) { + let mut todos = AutoCommitTodos::new(root, vdom); + todos.set_draft(draft); + } + + fn finish_draft(root: &mut dyn RootRender, vdom: VdomWeak) { + let mut todos = AutoCommitTodos::new(root, vdom); + let title = todos.take_draft(); + let title = title.trim(); + if !title.is_empty() { + let id = todos.todos().len(); + let new = Todo::new(id, title); + todos.add_todo(new); + } + } + + fn change_visibility(root: &mut dyn RootRender, vdom: VdomWeak, vis: Visibility) { + let mut todos = AutoCommitTodos::new(root, vdom); + todos.set_visibility(vis); + } + + fn delete_completed(root: &mut dyn RootRender, vdom: VdomWeak) { + let mut todos = AutoCommitTodos::new(root, vdom); + todos.delete_completed(); + } +} + +impl TodoActions for Controller { + fn toggle_completed(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) { + let mut todos = AutoCommitTodos::new(root, vdom); + let t = &mut todos.todos_mut()[id]; + let completed = t.is_complete(); + t.set_complete(!completed); + } + + fn delete(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) { + let mut todos = AutoCommitTodos::new(root, vdom); + todos.delete_todo(id); + } + + fn begin_editing(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) { + let mut todos = AutoCommitTodos::new(root, vdom); + let t = &mut todos.todos_mut()[id]; + let desc = t.title().to_string(); + t.set_edits(Some(desc)); + } + + fn update_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize, edits: String) { + let mut todos = AutoCommitTodos::new(root, vdom); + let t = &mut todos.todos_mut()[id]; + t.set_edits(Some(edits)); + } + + fn finish_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) { + let mut todos = AutoCommitTodos::new(root, vdom); + let t = &mut todos.todos_mut()[id]; + if let Some(edits) = t.take_edits() { + let edits = edits.trim(); + if edits.is_empty() { + todos.delete_todo(id); + } else { + t.set_title(edits); + } + } + } + + fn cancel_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) { + let mut todos = AutoCommitTodos::new(root, vdom); + let t = &mut todos.todos_mut()[id]; + let _ = t.take_edits(); + } +} + +/// An RAII type that dereferences to a `Todos` and once it is dropped, saves +/// the (presumably just modified) todos to local storage, and schedules a new +/// `dodrio` render. +pub struct AutoCommitTodos<'a> { + todos: &'a mut Todos, + vdom: VdomWeak, +} + +impl AutoCommitTodos<'_> { + /// Construct a new `AutoCommitTodos` from the root rendering component and + /// `vdom` handle. + pub fn new(root: &mut dyn RootRender, vdom: VdomWeak) -> AutoCommitTodos { + let todos = root.unwrap_mut::(); + AutoCommitTodos { todos, vdom } + } +} + +impl Drop for AutoCommitTodos<'_> { + fn drop(&mut self) { + self.todos.save_to_local_storage(); + self.vdom.schedule_render(); + } +} + +impl Deref for AutoCommitTodos<'_> { + type Target = Todos; + + fn deref(&self) -> &Todos { + &self.todos + } +} + +impl DerefMut for AutoCommitTodos<'_> { + fn deref_mut(&mut self) -> &mut Todos { + &mut self.todos + } +} diff --git a/examples/todomvc/src/keys.rs b/examples/todomvc/src/keys.rs new file mode 100644 index 0000000..0f4d7aa --- /dev/null +++ b/examples/todomvc/src/keys.rs @@ -0,0 +1,7 @@ +//! Constants for `KeyboardEvent::key_code`.` + +/// The key code for the enter key. +pub const ENTER: u32 = 13; + +/// The key code for the escape key. +pub const ESCAPE: u32 = 27; diff --git a/examples/todomvc/src/lib.rs b/examples/todomvc/src/lib.rs new file mode 100755 index 0000000..2fea19a --- /dev/null +++ b/examples/todomvc/src/lib.rs @@ -0,0 +1,60 @@ +//! TodoMVC implemented in `dodrio`! + +#![deny(missing_docs)] + +pub mod controller; +pub mod keys; +pub mod router; +pub mod todo; +pub mod todos; +pub mod utils; +pub mod visibility; + +use controller::Controller; +use dodrio::Vdom; +use todos::Todos; +use wasm_bindgen::prelude::*; + +/// Run the TodoMVC app! +/// +/// Since this is marked `#[wasm_bindgen(start)]` it is automatically invoked +/// once the wasm module instantiated on the Web page. +#[wasm_bindgen(start)] +pub fn run() -> Result<(), JsValue> { + // Set up the logging for debugging if/when things go wrong. + init_logging(); + + // Grab the todo app container. + let document = utils::document(); + let container = document + .query_selector(".todoapp")? + .ok_or_else(|| js_sys::Error::new("could not find `.todoapp` container"))?; + + // Create a new `Todos` render component. + let todos = Todos::::new(); + + // Create a virtual DOM and mount it and the `Todos` render component. + let vdom = Vdom::new(&container, todos); + + // Start the URL router. + router::start(vdom.weak()); + + // Run the virtual DOM forever and don't unmount it. + vdom.forget(); + + Ok(()) +} + +cfg_if::cfg_if! { + if #[cfg(feature = "logging")] { + fn init_logging() { + console_error_panic_hook::set_once(); + console_log::init_with_level(log::Level::Trace) + .expect_throw("should initialize logging OK"); + } + } else { + fn init_logging() { + // Do nothing. + } + } +} diff --git a/examples/todomvc/src/router.rs b/examples/todomvc/src/router.rs new file mode 100644 index 0000000..f3242c4 --- /dev/null +++ b/examples/todomvc/src/router.rs @@ -0,0 +1,66 @@ +//! A simple `#`-fragment router. + +use crate::todos::Todos; +use crate::utils; +use crate::visibility::Visibility; +use dodrio::VdomWeak; +use futures::prelude::*; +use std::str::FromStr; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; + +/// Start the router. +pub fn start(vdom: VdomWeak) { + // Callback fired whenever the URL's hash fragment changes. Keeps the root + // todos collection's visibility in sync with the `#` fragment. + let on_hash_change = move || { + let new_vis = utils::hash() + .and_then(|hash| { + if hash.starts_with("#/") { + Visibility::from_str(&hash[2..]).ok() + } else { + None + } + }) + .unwrap_or_else(|| { + // If we couldn't parse a visibility, make sure we canonicalize + // it back to our default hash. + let v = Visibility::default(); + utils::set_hash(&format!("#/{}", v)); + v + }); + + wasm_bindgen_futures::spawn_local( + vdom.with_component({ + let vdom = vdom.clone(); + move |root| { + let todos = root.unwrap_mut::(); + // If the todos' visibility already matches the event's + // visibility, then there is nothing to do (ha). If they + // don't match, then we need to update the todos' visibility + // and re-render. + if todos.visibility() != new_vis { + todos.set_visibility(new_vis); + vdom.schedule_render(); + } + } + }) + .map_err(|_| ()), + ); + }; + + // Call it once to handle the initial `#` fragment. + on_hash_change(); + + // Now listen for hash changes forever. + // + // Note that if we ever intended to unmount our todos app, we would want to + // provide a method for removing this router's event listener and cleaning + // up after ourselves. + let on_hash_change = Closure::wrap(Box::new(on_hash_change) as Box); + let window = utils::window(); + window + .add_event_listener_with_callback("hashchange", on_hash_change.as_ref().unchecked_ref()) + .unwrap_throw(); + on_hash_change.forget(); +} diff --git a/examples/todomvc/src/todo.rs b/examples/todomvc/src/todo.rs new file mode 100644 index 0000000..3176210 --- /dev/null +++ b/examples/todomvc/src/todo.rs @@ -0,0 +1,229 @@ +//! Type definition and `dodrio::Render` implementation for a single todo item. + +use crate::keys; +use dodrio::{bumpalo::Bump, on, Attribute, Node, Render, RootRender, VdomWeak}; +use serde::{Deserialize, Serialize}; +use std::marker::PhantomData; +use wasm_bindgen::{prelude::*, JsCast}; + +/// A single todo item. +#[derive(Serialize, Deserialize)] +pub struct Todo { + id: usize, + title: String, + completed: bool, + + #[serde(skip)] + edits: Option, + + #[serde(skip)] + _controller: PhantomData, +} + +/// Actions on a single todo item that can be triggered from the UI. +pub trait TodoActions { + /// Toggle the completion state of the todo item with the given id. + fn toggle_completed(root: &mut dyn RootRender, vdom: VdomWeak, id: usize); + + /// Delete the todo item with the given id. + fn delete(root: &mut dyn RootRender, vdom: VdomWeak, id: usize); + + /// Begin editing the todo item with the given id. + fn begin_editing(root: &mut dyn RootRender, vdom: VdomWeak, id: usize); + + /// Update the edits for the todo with the given id. + fn update_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize, edits: String); + + /// Finish editing the todo with the given id. + fn finish_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize); + + /// Cancel editing the todo with the given id. + fn cancel_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize); +} + +impl Todo { + /// Construct a new `Todo` with the given identifier and title. + pub fn new>(id: usize, title: S) -> Self { + let title = title.into(); + let completed = false; + let edits = None; + Todo { + id, + title, + completed, + edits, + _controller: PhantomData, + } + } + + /// Set this todo item's id. + pub fn set_id(&mut self, id: usize) { + self.id = id; + } + + /// Is this `Todo` complete? + pub fn is_complete(&self) -> bool { + self.completed + } + + /// Mark the `Todo` as complete or not. + pub fn set_complete(&mut self, to: bool) { + self.completed = to; + } + + /// Get this todo's title. + pub fn title(&self) -> &str { + &self.title + } + + /// Set this todo item's title. + pub fn set_title>(&mut self, title: S) { + self.title = title.into(); + } + + /// Set the edits for this todo. + pub fn set_edits>(&mut self, edits: Option) { + self.edits = edits.map(Into::into); + } + + /// Take this todo's edits, leaving `None` in their place. + pub fn take_edits(&mut self) -> Option { + self.edits.take() + } +} + +impl Render for Todo { + fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> dodrio::Node<'bump> + where + 'a: 'bump, + { + use dodrio::bumpalo::{self, collections::String}; + + let mut class = String::new_in(bump); + if self.completed { + class.push_str("completed "); + } + if self.edits.is_some() { + class.push_str("editing"); + } + + let id = self.id; + let title = self.edits.as_ref().unwrap_or(&self.title); + let elem_id = bumpalo::format!(in bump, "todo-{}", id); + + let mut input_attrs = bumpalo::vec![ + in bump; + Attribute { + name: "class", + value: "toggle", + }, + Attribute { + name: "type", + value: "checkbox", + } + ]; + if self.completed { + input_attrs.push(Attribute { + name: "checked", + value: "", + }); + } + let input_attrs = input_attrs.into_bump_slice(); + + Node::element( + bump, + "li", + [], + [Attribute { + name: "class", + value: class.into_bump_str(), + }], + [ + Node::element( + bump, + "div", + [], + [Attribute { + name: "class", + value: "view", + }], + [ + Node::element( + bump, + "input", + [on(bump, "click", move |root, vdom, _event| { + C::toggle_completed(root, vdom, id); + })], + input_attrs, + [], + ), + Node::element( + bump, + "label", + [on(bump, "dblclick", move |root, vdom, _event| { + C::begin_editing(root, vdom, id); + })], + [], + [Node::text(title)], + ), + Node::element( + bump, + "button", + [on(bump, "click", move |root, vdom, _event| { + C::delete(root, vdom, id); + })], + [Attribute { + name: "class", + value: "destroy", + }], + [], + ), + ], + ), + Node::element( + bump, + "input", + [ + on(bump, "input", move |root, vdom, event| { + let input = event + .target() + .unwrap_throw() + .unchecked_into::(); + C::update_edits(root, vdom, id, input.value()); + }), + on(bump, "blur", move |root, vdom, _event| { + C::finish_edits(root, vdom, id); + }), + on(bump, "keydown", move |root, vdom, event| { + let event = event.unchecked_into::(); + match event.key_code() { + keys::ENTER => C::finish_edits(root, vdom, id), + keys::ESCAPE => C::cancel_edits(root, vdom, id), + _ => {} + } + }), + ], + [ + Attribute { + name: "class", + value: "edit", + }, + Attribute { + name: "value", + value: title, + }, + Attribute { + name: "name", + value: "title", + }, + Attribute { + name: "id", + value: elem_id.into_bump_str(), + }, + ], + [], + ), + ], + ) + } +} diff --git a/examples/todomvc/src/todos.rs b/examples/todomvc/src/todos.rs new file mode 100644 index 0000000..6e87072 --- /dev/null +++ b/examples/todomvc/src/todos.rs @@ -0,0 +1,450 @@ +//! Type definitions and `dodrio::Render` implementation for a collection of +//! todo items. + +use crate::controller::Controller; +use crate::todo::{Todo, TodoActions}; +use crate::visibility::Visibility; +use crate::{keys, utils}; +use dodrio::{ + bumpalo::{self, Bump}, + on, Attribute, Node, Render, RootRender, VdomWeak, +}; +use serde::{Deserialize, Serialize}; +use std::marker::PhantomData; +use std::mem; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; + +/// A collection of todos. +#[derive(Default, Serialize, Deserialize)] +#[serde(rename = "todos-dodrio", bound = "")] +pub struct Todos { + todos: Vec>, + + #[serde(skip)] + draft: String, + + #[serde(skip)] + visibility: Visibility, + + #[serde(skip)] + _controller: PhantomData, +} + +/// Actions for `Todos` that can be triggered by UI interactions. +pub trait TodosActions: TodoActions { + /// Toggle the completion state of all todo items. + fn toggle_all(root: &mut dyn RootRender, vdom: VdomWeak); + + /// Update the draft todo item's text. + fn update_draft(root: &mut dyn RootRender, vdom: VdomWeak, draft: String); + + /// Finish the current draft todo item and add it to the collection of + /// todos. + fn finish_draft(root: &mut dyn RootRender, vdom: VdomWeak); + + /// Change the todo item visibility filtering to the given `Visibility`. + fn change_visibility(root: &mut dyn RootRender, vdom: VdomWeak, vis: Visibility); + + /// Delete all completed todo items. + fn delete_completed(root: &mut dyn RootRender, vdom: VdomWeak); +} + +impl Todos { + /// Construct a new todos set. + /// + /// If an existing set is available in local storage, then us that, + /// otherwise create a new set. + pub fn new() -> Self + where + C: Default, + { + Self::from_local_storage().unwrap_or_default() + } + + /// Deserialize a set of todos from local storage. + pub fn from_local_storage() -> Option { + utils::local_storage() + .get("todomvc-dodrio") + .ok() + .and_then(|opt| opt) + .and_then(|json| serde_json::from_str(&json).ok()) + } + + /// Serialize this set of todos to local storage. + pub fn save_to_local_storage(&self) { + let serialized = serde_json::to_string(self).unwrap_throw(); + utils::local_storage() + .set("todomvc-dodrio", &serialized) + .unwrap_throw(); + } + + /// Add a new todo item to this collection. + pub fn add_todo(&mut self, todo: Todo) { + self.todos.push(todo); + } + + /// Delete the todo with the given id. + pub fn delete_todo(&mut self, id: usize) { + self.todos.remove(id); + self.fix_ids(); + } + + /// Delete all completed todo items. + pub fn delete_completed(&mut self) { + self.todos.retain(|t| !t.is_complete()); + self.fix_ids(); + } + + // Fix all todo identifiers so that they match their index once again. + fn fix_ids(&mut self) { + for (id, todo) in self.todos.iter_mut().enumerate() { + todo.set_id(id); + } + } + + /// Get a shared slice of the underlying set of todo items. + pub fn todos(&self) -> &[Todo] { + &self.todos + } + + /// Get an exclusive slice of the underlying set of todo items. + pub fn todos_mut(&mut self) -> &mut [Todo] { + &mut self.todos + } + + /// Set the draft todo item text. + pub fn set_draft>(&mut self, draft: S) { + self.draft = draft.into(); + } + + /// Take the current draft text and replace it with an empty string. + pub fn take_draft(&mut self) -> String { + mem::replace(&mut self.draft, String::new()) + } + + /// Get the current visibility for these todos. + pub fn visibility(&self) -> Visibility { + self.visibility + } + + /// Set the visibility for these todoS. + pub fn set_visibility(&mut self, vis: Visibility) { + self.visibility = vis; + } +} + +/// Rendering helpers. +impl Todos { + fn header<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> + where + 'a: 'bump, + { + Node::element( + bump, + "header", + [], + [Attribute { + name: "class", + value: "header", + }], + [ + Node::element(bump, "h1", [], [], [Node::text("todos")]), + Node::element( + bump, + "input", + [ + on(bump, "input", |root, vdom, event| { + let input = event + .target() + .unwrap_throw() + .unchecked_into::(); + C::update_draft(root, vdom, input.value()); + }), + on(bump, "keydown", |root, vdom, event| { + let event = event.unchecked_into::(); + if event.key_code() == keys::ENTER { + C::finish_draft(root, vdom); + } + }), + ], + [ + Attribute { + name: "class", + value: "new-todo", + }, + Attribute { + name: "placeholder", + value: "What needs to be done?", + }, + Attribute { + name: "autofocus", + value: "", + }, + Attribute { + name: "value", + value: &self.draft, + }, + ], + [], + ), + ], + ) + } + + fn todos_list<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> + where + 'a: 'bump, + { + use dodrio::bumpalo::collections::Vec; + + let mut todos = Vec::new_in(bump); + todos.extend( + self.todos + .iter() + .filter(|t| match self.visibility { + Visibility::All => true, + Visibility::Active => !t.is_complete(), + Visibility::Completed => t.is_complete(), + }) + .map(|t| t.render(bump)), + ); + let todos = todos.into_bump_slice(); + + let mut input_attrs = bumpalo::vec![ + in bump; + Attribute { + name: "class", + value: "toggle-all", + }, + Attribute { + name: "id", + value: "toggle-all", + }, + Attribute { + name: "type", + value: "checkbox", + }, + Attribute { + name: "name", + value: "toggle", + } + ]; + if self.todos.iter().all(|t| t.is_complete()) { + input_attrs.push(Attribute { + name: "checked", + value: "", + }); + } + let input_attrs = input_attrs.into_bump_slice(); + + Node::element( + bump, + "section", + [], + [ + Attribute { + name: "class", + value: "main", + }, + Attribute { + name: "visibility", + value: if self.todos.is_empty() { + "hidden" + } else { + "visible" + }, + }, + ], + [ + Node::element( + bump, + "input", + [on(bump, "click", |root, vdom, _event| { + C::toggle_all(root, vdom); + })], + input_attrs, + [], + ), + Node::element( + bump, + "label", + [], + [Attribute { + name: "for", + value: "toggle-all", + }], + [Node::text("Mark all as complete")], + ), + Node::element( + bump, + "ul", + [], + [Attribute { + name: "class", + value: "todo-list", + }], + todos, + ), + ], + ) + } + + fn footer<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> + where + 'a: 'bump, + { + let completed_count = self.todos.iter().filter(|t| t.is_complete()).count(); + let incomplete_count = self.todos.len() - completed_count; + let items_left = if incomplete_count == 1 { + " item left" + } else { + " items left" + }; + let incomplete_count = bumpalo::format!(in bump, "{}", incomplete_count); + + let attrs = if self.todos.is_empty() { + &bump.alloc([ + Attribute { + name: "class", + value: "footer", + }, + Attribute { + name: "hidden", + value: "", + }, + ])[..] + } else { + &bump.alloc([Attribute { + name: "class", + value: "footer", + }])[..] + }; + + let clear_completed_text = bumpalo::format!( + in bump, + "Clear completed ({})", + self.todos.iter().filter(|t| t.is_complete()).count() + ); + + Node::element( + bump, + "footer", + [], + attrs, + [ + Node::element( + bump, + "span", + [], + [Attribute { + name: "class", + value: "todo-count", + }], + [ + Node::element( + bump, + "strong", + [], + [], + [Node::text(incomplete_count.into_bump_str())], + ), + Node::text(items_left), + ], + ), + Node::element( + bump, + "ul", + [], + [Attribute { + name: "class", + value: "filters", + }], + [ + self.visibility_swap(bump, "#/", Visibility::All), + self.visibility_swap(bump, "#/active", Visibility::Active), + self.visibility_swap(bump, "#/completed", Visibility::Completed), + ], + ), + Node::element( + bump, + "button", + [on(bump, "click", |root, vdom, _event| { + C::delete_completed(root, vdom); + })], + { + let mut attrs = bumpalo::vec![ + in bump; + Attribute { + name: "class", + value: "clear-completed", + } + ]; + if self.todos.iter().all(|t| !t.is_complete()) { + attrs.push(Attribute { + name: "hidden", + value: "", + }); + } + attrs + }, + [Node::text(clear_completed_text.into_bump_str())], + ), + ], + ) + } + + fn visibility_swap<'a, 'bump>( + &'a self, + bump: &'bump Bump, + url: &'static str, + target_vis: Visibility, + ) -> Node<'bump> + where + 'a: 'bump, + { + Node::element( + bump, + "li", + [on(bump, "click", move |root, vdom, _event| { + C::change_visibility(root, vdom, target_vis); + })], + [], + [Node::element( + bump, + "a", + [], + [ + Attribute { + name: "href", + value: url, + }, + Attribute { + name: "class", + value: if self.visibility == target_vis { + "selected" + } else { + "" + }, + }, + ], + [Node::text(target_vis.label())], + )], + ) + } +} + +impl Render for Todos { + fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> + where + 'a: 'bump, + { + Node::element( + bump, + "div", + [], + [], + [self.header(bump), self.todos_list(bump), self.footer(bump)], + ) + } +} diff --git a/examples/todomvc/src/utils.rs b/examples/todomvc/src/utils.rs new file mode 100644 index 0000000..0eaced2 --- /dev/null +++ b/examples/todomvc/src/utils.rs @@ -0,0 +1,32 @@ +//! Small utility functions. + +use wasm_bindgen::UnwrapThrowExt; + +/// Get the top-level window. +pub fn window() -> web_sys::Window { + web_sys::window().unwrap_throw() +} + +/// Get the current location hash, if any. +pub fn hash() -> Option { + window() + .location() + .hash() + .ok() + .and_then(|h| if h.is_empty() { None } else { Some(h) }) +} + +/// Set the current location hash. +pub fn set_hash(hash: &str) { + window().location().set_hash(hash).unwrap_throw(); +} + +/// Get the top-level document. +pub fn document() -> web_sys::Document { + window().document().unwrap_throw() +} + +/// Get the top-level window's local storage. +pub fn local_storage() -> web_sys::Storage { + window().local_storage().unwrap_throw().unwrap_throw() +} diff --git a/examples/todomvc/src/visibility.rs b/examples/todomvc/src/visibility.rs new file mode 100644 index 0000000..ad53c14 --- /dev/null +++ b/examples/todomvc/src/visibility.rs @@ -0,0 +1,51 @@ +//! Visibility filtering. + +use std::fmt; +use std::str::FromStr; + +/// The visibility filtering for todo items. +#[derive(Clone, Copy, Eq, PartialEq)] +pub enum Visibility { + /// Show all todos. + All, + /// Show only active, incomplete todos. + Active, + /// Show only inactive, completed todos. + Completed, +} + +impl Default for Visibility { + fn default() -> Visibility { + Visibility::All + } +} + +impl FromStr for Visibility { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "all" => Ok(Visibility::All), + "active" => Ok(Visibility::Active), + "completed" => Ok(Visibility::Completed), + _ => Err(()), + } + } +} + +impl fmt::Display for Visibility { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.label().to_lowercase()) + } +} + +impl Visibility { + /// Get a string label for this visibility. + pub fn label(self) -> &'static str { + match self { + Visibility::All => "All", + Visibility::Active => "Active", + Visibility::Completed => "Completed", + } + } +} diff --git a/js/change-list.js b/js/change-list.js index 834b0c2..c6a3f95 100644 --- a/js/change-list.js +++ b/js/change-list.js @@ -49,7 +49,20 @@ const OP_TABLE = [ const pointer2 = mem32[i++]; const length2 = mem32[i++]; const value = string(mem8, pointer2, length2); - top(changeList.stack).setAttribute(name, value); + const node = top(changeList.stack); + node.setAttribute(name, value); + + // Some attributes are "volatile" and don't work through `setAttribute`. + if (name === "value") { + node.value = value; + } + if (name === "checked") { + node.checked = true; + } + if (name === "selected") { + node.selected = true; + } + return i; }, @@ -58,7 +71,20 @@ const OP_TABLE = [ const pointer = mem32[i++]; const length = mem32[i++]; const name = string(mem8, pointer, length); - top(changeList.stack).removeAttribute(name); + const node = top(changeList.stack); + node.removeAttribute(name); + + // Some attributes are "volatile" and don't work through `removeAttribute`. + if (name === "value") { + node.value = null; + } + if (name === "checked") { + node.checked = false; + } + if (name === "selected") { + node.selected = false; + } + return i; }, diff --git a/src/cached.rs b/src/cached.rs index 87bbf7f..3a82c33 100644 --- a/src/cached.rs +++ b/src/cached.rs @@ -13,6 +13,7 @@ use std::ops::{Deref, DerefMut}; /// A renderable that supports caching for when rendering is expensive but can /// generate the same DOM tree. +#[derive(Debug)] pub struct Cached { inner: R, bump: bumpalo::Bump, diff --git a/src/change_list.rs b/src/change_list.rs index 887b3e7..799e1c5 100644 --- a/src/change_list.rs +++ b/src/change_list.rs @@ -100,7 +100,8 @@ impl ChangeList { } #[repr(u32)] -pub enum ChangeDiscriminant { +#[derive(Clone, Copy, Debug)] +enum ChangeDiscriminant { /// Immediates: `(pointer, length)` /// /// Stack: `[... TextNode] -> [... TextNode]` diff --git a/src/lib.rs b/src/lib.rs index c43fc7f..68048a6 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,15 +54,11 @@ //! } //! } //! ``` +#![deny(missing_docs, missing_debug_implementations)] // Re-export the `bumpalo` crate. pub use bumpalo; -use bumpalo::Bump; -use std::any::Any; -use std::rc::Rc; -use wasm_bindgen::UnwrapThrowExt; - // Only `pub` so that the wasm-bindgen bindings work. #[doc(hidden)] pub mod change_list; @@ -70,111 +66,11 @@ pub mod change_list; mod cached; mod events; mod node; +mod render; mod vdom; // Re-export items at the top level. pub use self::cached::Cached; pub use self::node::{on, Attribute, ElementNode, Listener, ListenerCallback, Node, TextNode}; +pub use self::render::{Render, RootRender}; pub use self::vdom::{Vdom, VdomWeak}; - -pub trait Render { - fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> - where - 'a: 'bump; -} - -impl<'r, R> Render for &'r R -where - R: Render, -{ - fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> - where - 'a: 'bump, - { - (**self).render(bump) - } -} - -impl Render for Rc -where - R: Render, -{ - fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> - where - 'a: 'bump, - { - (**self).render(bump) - } -} - -/// A `RootRender` is a render component that can be the root rendering component -/// mounted to a virtual DOM. -/// -/// In addition to rendering, it must also be `'static` so that it can be owned -/// by the virtual DOM and `Any` so that it can be downcast to its concrete type -/// by event listener callbacks. -/// -/// You do not need to implement this trait by hand: there is a blanket -/// implementation for all `Render` types that fulfill the `RootRender` -/// requirements. -pub trait RootRender: Any + Render { - /// Get this `&RootRender` trait object as an `&Any` trait object reference. - fn as_any(&self) -> &Any; - - /// Get this `&mut RootRender` trait object as an `&mut Any` trait object - /// reference. - fn as_any_mut(&mut self) -> &mut Any; -} - -impl RootRender for T { - fn as_any(&self) -> &Any { - self - } - - fn as_any_mut(&mut self) -> &mut Any { - self - } -} - -impl dyn RootRender { - /// Downcast this shared `&dyn RootRender` trait object reference to its - /// underlying concrete type. - /// - /// # Panics - /// - /// Panics if this virtual DOM's root rendering component is not an `R` - /// instance. - pub fn unwrap_ref(&self) -> &R { - self.as_any() - .downcast_ref::() - .expect_throw("bad `RootRender::unwrap_ref` call") - } - - /// Downcast this exclusive `&mut dyn RootRender` trait object reference to - /// its underlying concrete type. - /// - /// # Panics - /// - /// Panics if this virtual DOM's root rendering component is not an `R` - /// instance. - pub fn unwrap_mut(&mut self) -> &mut R { - self.as_any_mut() - .downcast_mut::() - .expect_throw("bad `RootRender::unwrap_ref` call") - } -} - -#[cfg(test)] -mod tests { - #[test] - fn render_is_object_safe() { - #[allow(dead_code)] - fn takes_dyn_render(_: &dyn super::Render) {} - } - - #[test] - fn root_render_is_object_safe() { - #[allow(dead_code)] - fn takes_dyn_render(_: &dyn super::RootRender) {} - } -} diff --git a/src/node.rs b/src/node.rs index 0728109..fa2cff4 100644 --- a/src/node.rs +++ b/src/node.rs @@ -48,9 +48,13 @@ pub struct Listener<'a> { pub callback: ListenerCallback<'a>, } +/// An attribute on a DOM node, such as `id="my-thing"` or +/// `href="https://example.com"`. #[derive(Clone, Debug)] pub struct Attribute<'a> { + /// The attribute name, such as `id`. pub name: &'a str, + /// The attribute value, such as `"my-thing"`. pub value: &'a str, } @@ -66,6 +70,20 @@ impl fmt::Debug for Listener<'_> { } } +impl<'a> Attribute<'a> { + /// Certain attributes are considered "volatile" and can change via user + /// input that we can't see when diffing against the old virtual DOM. For + /// these attributes, we want to always re-set the attribute on the physical + /// DOM node, even if the old and new virtual DOM nodes have the same value. + #[inline] + pub(crate) fn is_volatile(&self) -> bool { + match self.name { + "value" | "checked" | "selected" => true, + _ => false, + } + } +} + impl<'a> Node<'a> { /// Construct a new element node with the given tag name and children. #[inline] diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..8765e93 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,145 @@ +use crate::Node; +use bumpalo::Bump; +use std::any::Any; +use std::rc::Rc; +use wasm_bindgen::UnwrapThrowExt; + +/// A trait for any component that can be rendered to HTML. +/// +/// Takes a shared reference to `self` and generates the virtual DOM that +/// represents its rendered HTML. +/// +/// ## `Bump` Allocation +/// +/// `Render` implementations can use the provided `Bump` for very fast +/// allocation for anything that needs to be allocated during rendering. +/// +/// ## The `'a: 'bump` Lifetime Bound +/// +/// The `'a: 'bump` bounds enforce that `self` outlives the given bump +/// allocator. This means that if `self` contains a string, the string does not +/// need to be copied into the output `Node` and can be used by reference +/// instead (i.e. it prevents accidentally using the string after its been +/// freed). The `'a: 'bump` bound also enables abstractions like +/// `dodrio::Cached` that can re-use cached `Node`s across `render`s without +/// copying them. +/// +/// ## Example +/// +/// ```no_run +/// use dodrio::{bumpalo::Bump, Node, Render}; +/// +/// pub struct MyComponent; +/// +/// impl Render for MyComponent { +/// fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> +/// where +/// 'a: 'bump +/// { +/// Node::text("This is my component rendered!") +/// } +/// } +/// ``` +pub trait Render { + /// Render `self` as a virtual DOM. Use the given `Bump` for temporary + /// allocations. + fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> + where + 'a: 'bump; +} + +impl<'r, R> Render for &'r R +where + R: Render, +{ + fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> + where + 'a: 'bump, + { + (**self).render(bump) + } +} + +impl Render for Rc +where + R: Render, +{ + fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> + where + 'a: 'bump, + { + (**self).render(bump) + } +} + +/// A `RootRender` is a render component that can be the root rendering component +/// mounted to a virtual DOM. +/// +/// In addition to rendering, it must also be `'static` so that it can be owned +/// by the virtual DOM and `Any` so that it can be downcast to its concrete type +/// by event listener callbacks. +/// +/// You do not need to implement this trait by hand: there is a blanket +/// implementation for all `Render` types that fulfill the `RootRender` +/// requirements. +pub trait RootRender: Any + Render { + /// Get this `&RootRender` trait object as an `&Any` trait object reference. + fn as_any(&self) -> &Any; + + /// Get this `&mut RootRender` trait object as an `&mut Any` trait object + /// reference. + fn as_any_mut(&mut self) -> &mut Any; +} + +impl RootRender for T { + fn as_any(&self) -> &Any { + self + } + + fn as_any_mut(&mut self) -> &mut Any { + self + } +} + +impl dyn RootRender { + /// Downcast this shared `&dyn RootRender` trait object reference to its + /// underlying concrete type. + /// + /// # Panics + /// + /// Panics if this virtual DOM's root rendering component is not an `R` + /// instance. + pub fn unwrap_ref(&self) -> &R { + self.as_any() + .downcast_ref::() + .expect_throw("bad `RootRender::unwrap_ref` call") + } + + /// Downcast this exclusive `&mut dyn RootRender` trait object reference to + /// its underlying concrete type. + /// + /// # Panics + /// + /// Panics if this virtual DOM's root rendering component is not an `R` + /// instance. + pub fn unwrap_mut(&mut self) -> &mut R { + self.as_any_mut() + .downcast_mut::() + .expect_throw("bad `RootRender::unwrap_ref` call") + } +} + +#[cfg(test)] +mod tests { + #[test] + fn render_is_object_safe() { + #[allow(dead_code)] + fn takes_dyn_render(_: &dyn super::Render) {} + } + + #[test] + fn root_render_is_object_safe() { + #[allow(dead_code)] + fn takes_dyn_render(_: &dyn super::RootRender) {} + } +} diff --git a/src/vdom.rs b/src/vdom.rs index 9232a54..09b3610 100644 --- a/src/vdom.rs +++ b/src/vdom.rs @@ -22,6 +22,7 @@ use wasm_bindgen_futures::JsFuture; /// removed. To keep it mounted forever, use the `Vdom::forget` method. #[must_use = "A `Vdom` will only keep rendering and listening to events while it has not been \ dropped. If you want a `Vdom` to run forever, call `Vdom::forget`."] +#[derive(Debug)] pub struct Vdom { inner: Rc, } @@ -363,17 +364,22 @@ impl VdomInnerExclusive { // Do O(n^2) passes to add/update and remove attributes, since // there are almost always very few attributes. 'outer: for new_attr in new { - for old_attr in old { - if old_attr.name == new_attr.name { - if old_attr.value != new_attr.value { - self.change_list - .emit_set_attribute(new_attr.name, new_attr.value); + if new_attr.is_volatile() { + self.change_list + .emit_set_attribute(new_attr.name, new_attr.value); + } else { + for old_attr in old { + if old_attr.name == new_attr.name { + if old_attr.value != new_attr.value { + self.change_list + .emit_set_attribute(new_attr.name, new_attr.value); + } + continue 'outer; } - continue 'outer; } + self.change_list + .emit_set_attribute(new_attr.name, new_attr.value); } - self.change_list - .emit_set_attribute(new_attr.name, new_attr.value); } 'outer2: for old_attr in old {