diff --git a/examples/async/.gitignore b/examples/async/.gitignore new file mode 100644 index 0000000..c41f5e3 --- /dev/null +++ b/examples/async/.gitignore @@ -0,0 +1,5 @@ +node_modules +/dist/js +/target +/wasm-pack.log +/yarn-error.log diff --git a/examples/async/Cargo.toml b/examples/async/Cargo.toml new file mode 100644 index 0000000..e477cd2 --- /dev/null +++ b/examples/async/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "async" +version = "0.1.0" +description = "Async example using dominator" +authors = ["Pauan "] +categories = ["wasm"] +readme = "README.md" +license = "MIT" +edition = "2018" + +[profile.release] +lto = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +console_error_panic_hook = "0.1.5" +dominator = "0.5.0" +futures = "0.3.4" +futures-signals = "0.3.0" +wasm-bindgen-futures = "0.4.9" +wasm-bindgen = "0.2.48" +gloo-timers = { version = "0.2.1", features = ["futures"] } +js-sys = "0.3.36" +serde_json = "1.0.10" +serde_derive = "1.0.27" +serde = "1.0.27" +lazy_static = "1.0.0" + +[dependencies.web-sys] +version = "0.3.22" +features = [ +"console", + "AbortController", + "AbortSignal", + "Headers", + "Response", + "RequestInit", + "Window", +] diff --git a/examples/async/README.md b/examples/async/README.md new file mode 100644 index 0000000..080be86 --- /dev/null +++ b/examples/async/README.md @@ -0,0 +1,12 @@ +## How to install + +```sh +yarn install +``` + +## How to build + +```sh +# Builds the project and places it into the `dist` folder. +yarn run build +``` diff --git a/examples/async/dist/index.html b/examples/async/dist/index.html new file mode 100644 index 0000000..36daaea --- /dev/null +++ b/examples/async/dist/index.html @@ -0,0 +1,10 @@ + + + + + rust-dominator • Async + + + + + diff --git a/examples/async/package.json b/examples/async/package.json new file mode 100644 index 0000000..aa1b6a0 --- /dev/null +++ b/examples/async/package.json @@ -0,0 +1,14 @@ +{ + "private": true, + "author": "Pauan ", + "name": "async", + "version": "0.1.0", + "scripts": { + "build": "rimraf dist/js && rollup --config" + }, + "devDependencies": { + "@wasm-tool/rollup-plugin-rust": "^1.0.0", + "rimraf": "^3.0.2", + "rollup": "^1.31.0" + } +} diff --git a/examples/async/rollup.config.js b/examples/async/rollup.config.js new file mode 100644 index 0000000..69be1b7 --- /dev/null +++ b/examples/async/rollup.config.js @@ -0,0 +1,17 @@ +import rust from "@wasm-tool/rollup-plugin-rust"; + +export default { + input: { + index: "./Cargo.toml", + }, + output: { + dir: "dist/js", + format: "iife", + sourcemap: true, + }, + plugins: [ + rust({ + serverPath: "js/", + }), + ], +}; diff --git a/examples/async/src/lib.rs b/examples/async/src/lib.rs new file mode 100644 index 0000000..5eb7137 --- /dev/null +++ b/examples/async/src/lib.rs @@ -0,0 +1,151 @@ +use std::rc::Rc; +use wasm_bindgen::prelude::*; +use gloo_timers::future::TimeoutFuture; +use serde_derive::{Serialize, Deserialize}; +use futures_signals::signal::{Mutable, not}; +use dominator::{html, class, events, clone, with_node, Dom}; +use web_sys::HtmlInputElement; +use lazy_static::lazy_static; +use util::*; + +mod util; + + +#[derive(Debug, Serialize, Deserialize)] +struct User { + login: String, + id: u32, + node_id: String, + avatar_url: String, + gravatar_id: String, + url: String, + html_url: String, + followers_url: String, + following_url: String, + gists_url: String, + starred_url: String, + subscriptions_url: String, + repos_url: String, + events_url: String, + received_events_url: String, + #[serde(rename = "type")] + type_: String, + site_admin: bool, + name: Option, + company: Option, + blog: String, + location: Option, + email: Option, + hireable: Option, + bio: Option, + public_repos: u32, + public_gists: u32, + followers: u32, + following: u32, + created_at: String, + updated_at: String, +} + +impl User { + async fn fetch(user: &str) -> Result { + let user = fetch_github(&format!("https://api.github.com/users/{}", user)).await?; + Ok(serde_json::from_str::(&user).unwrap_throw()) + } +} + + +struct App { + user: Mutable>, + input: Mutable, + loader: AsyncLoader, +} + +impl App { + fn new(name: &str, user: Option) -> Rc { + Rc::new(Self { + user: Mutable::new(user), + input: Mutable::new(name.to_string()), + loader: AsyncLoader::new(), + }) + } + + fn render(app: Rc) -> Dom { + lazy_static! { + static ref APP: String = class! { + .style("white-space", "pre") + }; + } + + html!("div", { + .class(&*APP) + + .children(&mut [ + html!("input" => HtmlInputElement, { + .property_signal("value", app.input.signal_cloned()) + + .with_node!(element => { + .event(clone!(app => move |_: events::Input| { + app.input.set(element.value()); + })) + }) + }), + + html!("button", { + .text("Lookup user") + + .event(clone!(app => move |_: events::Click| { + let input = app.input.lock_ref(); + + if *input == "" { + app.user.set(None); + + } else { + let input = input.to_string(); + + app.loader.load(clone!(app => async move { + // Simulate a slow network + TimeoutFuture::new(5_000).await; + + let user = User::fetch(&input).await.ok(); + app.user.set(user); + })); + } + })) + }), + + html!("button", { + .text("Cancel") + + .event(clone!(app => move |_: events::Click| { + app.loader.cancel(); + })) + }), + + html!("div", { + .visible_signal(app.loader.is_loading()) + .text("LOADING") + }), + + html!("div", { + .visible_signal(not(app.loader.is_loading())) + + .text_signal(app.user.signal_ref(|user| format!("{:#?}", user))) + }), + ]) + }) + } +} + + +#[wasm_bindgen(start)] +pub async fn main_js() -> Result<(), JsValue> { + console_error_panic_hook::set_once(); + + let user = User::fetch("Pauan").await.ok(); + + let app = App::new("Pauan", user); + + dominator::append_dom(&dominator::body(), App::render(app)); + + Ok(()) +} diff --git a/examples/async/src/util.rs b/examples/async/src/util.rs new file mode 100644 index 0000000..1ea1dff --- /dev/null +++ b/examples/async/src/util.rs @@ -0,0 +1,137 @@ +use std::future::Future; +use std::sync::atomic::{AtomicUsize, Ordering}; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use futures_signals::signal::{Signal, Mutable}; +use wasm_bindgen_futures::{JsFuture, spawn_local}; +use futures::future::{abortable, AbortHandle}; +use js_sys::Error; +use web_sys::{window, Response, RequestInit, Headers, AbortController, AbortSignal}; + + +struct AsyncState { + id: usize, + handle: AbortHandle, +} + +impl AsyncState { + fn new(handle: AbortHandle) -> Self { + static ID: AtomicUsize = AtomicUsize::new(0); + + let id = ID.fetch_add(1, Ordering::SeqCst); + + Self { id, handle } + } +} + +pub struct AsyncLoader { + loading: Mutable>, +} + +impl AsyncLoader { + pub fn new() -> Self { + Self { + loading: Mutable::new(None), + } + } + + pub fn cancel(&self) { + self.replace(None); + } + + fn replace(&self, value: Option) { + let mut loading = self.loading.lock_mut(); + + if let Some(state) = loading.as_mut() { + state.handle.abort(); + } + + *loading = value; + } + + pub fn load(&self, fut: F) where F: Future + 'static { + let (fut, handle) = abortable(fut); + + let state = AsyncState::new(handle); + let id = state.id; + + self.replace(Some(state)); + + let loading = self.loading.clone(); + + spawn_local(async move { + match fut.await { + Ok(()) => { + let mut loading = loading.lock_mut(); + + if let Some(current_id) = loading.as_ref().map(|x| x.id) { + // If it hasn't been overwritten with a new state... + if current_id == id { + *loading = None; + } + } + }, + // It was already cancelled + Err(_) => {}, + } + }); + } + + pub fn is_loading(&self) -> impl Signal { + self.loading.signal_ref(|x| x.is_some()) + } +} + + +struct Abort { + controller: AbortController, +} + +impl Abort { + fn new() -> Result { + Ok(Self { + controller: AbortController::new()?, + }) + } + + fn signal(&self) -> AbortSignal { + self.controller.signal() + } +} + +impl Drop for Abort { + fn drop(&mut self) { + self.controller.abort(); + } +} + +pub async fn fetch_github(url: &str) -> Result { + let abort = Abort::new()?; + + let headers = Headers::new()?; + headers.set("Accept", "application/vnd.github.v3+json")?; + + let future = window() + .unwrap_throw() + .fetch_with_str_and_init( + url, + RequestInit::new() + .headers(&headers) + .signal(Some(&abort.signal())), + ); + + let response = JsFuture::from(future) + .await? + .unchecked_into::(); + + if !response.ok() { + return Err(Error::new("Fetch failed").into()); + } + + let value = JsFuture::from(response.text()?) + .await? + .as_string() + .unwrap_throw(); + + Ok(value) +}